Writing Your First Component
Building a Python Package
HELIX makes use of Python entrypoints to discover installed Blueprints, Components, and Transforms. Additional Blueprints, Components, and Transforms can be installed by bundling them in a Python package with an entrypoint in one of the following groups:
helix.blueprints
helix.components
helix.transforms
The name of the entrypoint should correspond with the name of the Blueprint, Component, or Transform, and the object reference should refer to the class of its implementation.
To start, create a basic python package named helix-example
by creating the
following directory structure:
.
├── helix_example/
│ ├── __init__.py
└── setup.py
The __init__.py
file should be blank and setup.py
should consist of the
following:
# setup.py
from setuptools import setup
from setuptools import find_packages
setup(
name="helix-example",
version="1.0.0",
author="Your Name Here",
author_email="you@your-domain",
description="An example external HELIX package",
url="http://your-domain",
packages=find_packages(),
python_requires=">=3.5",
install_requires=[],
include_package_data=True,
zip_safe=False,
entry_points={
"helix.blueprints": [],
"helix.components": [],
"helix.transforms": [],
"helix.tests": []
},
)
This is the basic layout of a Python package - in later sections, we will create Components and Transforms and register them as entrypoints. You can install the package with:
pip install .
Note
For ease of development, it can be useful to install the Python package in editable mode to avoid having to reinstall the package every time you make changes. You can do this by instead running:
pip install -e .
Writing the Component
Components are simply Python classes that implement the
Component
interface. To write a simple
component, all you need to do is subclass this base class and implement the
required abstract methods.
Let’s start by adding a components
directory to our Python package with an
example
module for our new Component. The package directory structure
should look like the following:
.
├── helix_example/
│ ├── components/
│ │ ├── example.py
│ │ ├── __init__.py
│ ├── __init__.py
└── setup.py
Inside of example.py
we’ll create a simple Component by subclassing
Component
:
# example.py
from helix import component
class ExampleComponent(component.Component):
"""A simple example component."""
name = "example-component"
verbose_name = "Example Component"
type = "example"
version = "1.0.0"
description = "A simple example component"
date = "2020-10-20 12:00:00.000000"
tags = (("group", "example"),)
blueprints = ["cmake-c", "cmake-cpp"]
functions = [r"""
#include <stdio.h>
void ${hello_world}() {
printf("hello world\n");
}
"""]
calls = {
"main": [
r'${hello_world}();'
]
}
globals = ["hello_world"]
We start by defining required metadata
(name
,
verbose_name
,
type
, etc.). Next, we need to define
which Blueprints this Component is designed to work with - since we’re writing
code that could be compiled as either C or C++ code, we support both
CMakeCBlueprint
and
CMakeCBlueprint
by name. Next, we
define a simple function hello_world
that simply prints "hello world"
by adding it to the the functions
list for the Component. Note that the function name is surrounded in template
parameters (${...}
). These template parameters tell the build system how
to finalize Components so that duplicate function names do create conflicts.
Any template parameters like these that need to be deduplicated by the build
system should be included in the
globals
property.
Finally, we’ll add a single call at the main
callsite (defined by the
cmake
Blueprints - see
helix.blueprints.CMakeCBlueprint.CALLSITE_MAIN
) which calls our
hello_world
function.
callsites
are defined by each
individual Blueprint and provide a way for Components to invoke their
functions. The cmake
Blueprints’ main
callsite, as the name suggests,
allows Components to call functions inside of the generated binary’s main
function. We can make use of this callsite by adding it to the
calls
property for the Component.
Note
Because the printf
function is a part of the stdio
library,
we have to add an include that references it. We can simply add this to our
function definition.
Our Component definition is now complete.
Registering the Component
To register the component so that HELIX can find it, we need to add an
entrypoint in the group helix.components
to our Python package’s
setup.py
. Make the following change to setup.py
:
# setup.py
...
entry_points={
...
"helix.components": [
"example-component = helix_example.components.example:ExampleComponent"
]
...
}
...
Note
The name
property of our new Component must match the name of
the entrypoint.
To update the entrypoint list, reinstall the Python package (even if you installed it in editable mode):
pip install .
Check that our new Component is registered with the HELIX CLI:
helix list
The output should include our new example Component:
Available Components:
...
Example Component (1.0.0) [example-component]
...
Finally, build a cmake-cpp
Blueprint with our Component to make sure that
it works:
helix build blueprint cmake-cpp ./example -c example-component
Run the generated artifact binary - it should simply print “hello world” and exit.
Note
While developing a new component, it can be useful to build in
verbose mode (-v/--verbose
) to see the full output of the build
commands to assist in debugging.
Adding Configuration Options
Configuration options may be specified for Components in the
options
property. Make the following
changes to the ExampleComponent
class to define an optional configuration
parameter message
which will be printed to the console:
# example.py
from helix import utils
...
class ExampleComponent(component.Component):
...
options = {"message": {"default": "hello world"}}
...
# The following lines may be removed:
# functions = [r"""
# #include <stdio.h>
# void ${hello_world}() {
# printf("hello world\n");
# }
# """]
# calls = {
# "main": [
# r'${hello_world}();'
# ]
# }
# globals = ["hello_world"]
TEMPLATE = r"""
#include <stdio.h>
void ${hello_world}() {
printf("${message}\n");
}
"""
def generate(self):
function = utils.substitute(self.TEMPLATE, message=self.configuration["message"])
self.functions = [function]
self.calls = {
"main": [
r'${hello_world}();'
]
}
self.globals = ["hello_world"]
Components can choose to define their
functions
,
calls
, and
globals
properties inside of a
generate
method. This method is run
after configuration parameters are parsed and these parameters are available in
the configuration
property as
a dict and can be used in the
generate
method as above.
Reinstall the Python package (if not installed in editable mode) and then create a new HELIX build, supplying the new configuration parameter:
helix build blueprint cmake-cpp ./example -c example-component:message="goodbye world"
Run the generated artifact binary - it should now print “goodbye world” and exit.
Using External Template Files
Once a Component becomes relatively complex, it can be a good idea to move the
templated function code belonging to the Component into its own file so that it
is easier to track changes and so that syntax highlighting can be enabled for
ease of development. HELIX includes a couple of utilities to help you do that.
In this section, we’ll move the source code for our ExampleComponent
to an
external example.c
file.
To start, we’ll need to configure our Python package so that it includes
non-python files when it is compressed into its distributable form. To do this,
add a file named MANIFEST.in
to the root of your python package with the
following contents:
# MANIFEST.in
recursive-include helix_example *.c
This tells the Python package manager that any files with the extension .c
should be included with the package.
Next, write create a file in the same directory as example.py
called
example.c
. The package directory structure should look like:
.
├── helix_example/
│ ├── components/
│ │ ├── example.py
│ │ ├── example.c
│ │ ├── __init__.py
│ ├── __init__.py
└── setup.py
Add the following content to example.c
:
// example.c
#include <stdio.h>
void ${example}() {
printf("${message}\n");
}
Finally, modify the ExampleComponent
class in example.py
as follows:
# example.py
class ExampleComponent(component.Component):
...
# The following lines may be removed:
# TEMPLATE = r"""
# #include <stdio.h>
#
# void ${hello_world}() {
# printf("${message}\n");
# }
# """
def generate(self):
...
template = utils.source(__name__, "example.c")
...
function = utils.substitute(template, message=formatted)
...
We make use of the source
function here to fetch
the source of the included template file, relative to the current package path.
You can now reinstall the package (if not installed in editable mode) and test these Component changes. The Component should function exactly the same, but the Python package is now a bit more maintainable.
Adding Dependencies
HELIX includes a dependency installation/management system for Blueprints,
Components, and Transforms for managing external dependencies that cannot be
installed with pip
. Lets add a simple apt
dependency to our Component -
cowsay
to improve the visual output of our printed message.
Note
From here on, this tutorial only works on a Linux platform. There are dependency types defined for Windows, however, and you can find examples of their use in HELIX source.
Add the following to the ExampleComponent
class:
# example.py
from helix import utils
...
class ExampleComponent(component.Component):
...
dependencies = [utils.LinuxAPTDependency("cowsay")]
...
def generate(self):
...
cowsay = utils.find("cowsay")
output, _ = utils.run(
"{} {}".format(cowsay, self.configuration["message"]), cwd="./"
)
formatted = repr(output.decode("utf-8")).replace("'", "")
...
function = utils.substitute(self.TEMPLATE, message=formatted)
Reinstall the Python package (if not in editable mode) and install dependencies for our Component:
helix dependencies component example-component
Note
You may need to run the above command as root/Administrator to successfully install dependencies.
Finally, build the cmake-cpp
Blueprint again with our updated Component:
helix build blueprint cmake-cpp ./example -c example-component
You should now get an output similar to the following when running the generated artifact binary:
_____________
< hello world >
-------------
\ ^__^
\ (oo)\_______
(__)\ )\/\
||----w |
|| ||
Note
It’s worth noting here that the binary generated by HELIX in this
example does not actually make use of cowsay
. Instead, cowsay
is
invoked during configuration of the Component, and the cowsay
string is
injected into the generated source code. A more advanced approach, left as
an exercise for the reader, would be to invoke cowsay
from the
generated artifact instead (e.g., with a Linux system
call written in
C/C++).
Testing the Component
HELIX includes some minimal utilities for testing Components with the
unittest
framework. To write a unit test for our Component, add the
following to the example.py
module:
# example.py
from helix import tests
...
class ExampleComponentTests(tests.UnitTestCase, tests.ComponentTestCaseMixin):
blueprint = "cmake-cpp"
component = "example-component"
This will create a couple of simple unit tests from
TestCaseMixin
.
Note
When developing Components, at a minimum it is recommended to define
the simple testing class above. This will introduce simple build tests as
well as a test that ensures that your Component’s templated globals are
configured correctly (for more details, see
TestCaseMixin
).
To register this unit test with HELIX, add an entrypoint to the helix.tests
group in the Python package’s setup.py
as follows:
# setup.py
...
entry_points={
...
"helix.tests": {
"example-component = helix_example.components.example:ExampleComponentTests"
}
...
}
...
Finally, to run unit tests for Blueprints, Components, and Transforms, run:
helix test unit