Developing a New Blueprint
Blueprints are at the core of how HELIX generates a build. A properly written Blueprint is generic, reusable, and flexible - designed to work well with many or few Components in many different configurations. Good Blueprints do not define program behavior other than some minimal control flow to set up callsites - that is left to the Components integrated with a Blueprint in a build.
Most developers will not need to write a Blueprint for HELIX - making use of the existing Blueprints built in to HELIX is sufficient for most usecases. However, HELIX is flexible enough to support practically any programming language and any build system - developers simply need to write a Blueprint for their target platform. To demonstrate this, this tutorial will guide you through the process of writing a new Blueprint to support Python.
Prior to following this tutorial, you’ll need to have a python package set up to extend HELIX (see Building a Python Package).
Writing the Blueprint
Similar to Components and Transforms, Blueprints are simply Python classes that
implement the Blueprint
interface.
Let’s start by adding a blueprints
directory to our Python package with a
python
module for our new Blueprint. The package directory structure should
look like the following:
.
├── helix_example/
│ ├── blueprints/
│ │ ├── python.py
│ │ ├── __init__.py
│ ├── __init__.py
└── setup.py
Inside of python.py
we’ll create a simple Blueprint by subclassing
Blueprint
:
# python.py
import os
from helix import blueprint
from helix import utils
class ExamplePythonBlueprint(blueprint.Blueprint):
"""An example Python blueprint."""
name = "example-python"
verbose_name = "Example Python Blueprint"
type = "python"
version = "1.0.0"
description = "A simple Python blueprint"
CALLSITE_STARTUP = "startup"
"""Called at program startup.
Calls at this callsite are called once and expected to return.
"""
callsites = [CALLSITE_STARTUP]
TEMPLATE = """${functions}
if __name__ == "__main__":
${startup}
"""
def filename(self, directory):
"""Generate a build file name in the given directory.
Args:
directory (str): The path to the build directory.
Returns:
The file path of the build file.
"""
return os.path.join(directory, "{}.py".format(self.build_name))
def generate(self, directory):
functions = "\n".join(self.functions)
startup = self.calls.pop(self.CALLSITE_STARTUP, [])
startup = "\n ".join(startup) or "pass"
source = utils.substitute(self.TEMPLATE, functions=functions, startup=startup)
with open(self.filename(directory), "w") as f:
f.write(source)
def compile(self, directory, options):
"""Nothing to do here.
Python is an interpreted language, so we don't really need to do
anything in the ``compile()`` step. We still need to pass the build
artifacts to the output, however.
"""
return [self.filename(directory)]
Blueprints have three main components:
1. A set of callsites
- where
components may register calls to functions that they define.
2. A generate
method which
generates source code from the collection of Components provided, using the
functions
and
calls
properties of the Blueprint
class. These properties aggregate functions and calls provided by all of the
included Components. Source code is written to the given directory path and a
list of source files is returned.
3. A compile
method which compiles
the given directory of source files and returns a list of build artifacts. In
this case, since Python is not compiled, this simply returns the path to the
generated Python file.
This simple Python Blueprint defines a single callsite called startup
and
generates a single python file in the target directory with all included
functions and calls to startup
functions in __main__
.
Note that the Blueprint does not need to be concerned with how or when Transforms are applied. HELIX will apply Transforms automatically during build based on their type - Blueprints simply need to know how to generate valid source code from Components and compile that source code into build artifacts.
Note
It’s generally good practice to define callsite names as constants on
the Blueprint class for easier use by Components (e.g., CALLSITE_STARTUP
).
Registering the Blueprint
Similar to Components and Transforms, Blueprints must be added to the
entrypoint group helix.blueprints
in our Python package’s setup.py
.
Make the following change to setup.py
:
# setup.py
...
entry_points={
...
"helix.blueprints": [
"example-python = helix_example.blueprints.python:ExamplePythonBlueprint"
]
...
}
...
Note
Similar to Components and Transforms, the name
property of our
new Blueprint 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 Blueprint is registered with the HELIX CLI:
Available Blueprints
...
Example Python Blueprint (1.0.0) [example-python]
...
Finally, build an empty example-python
Blueprint to make sure that it
works:
helix build blueprint example-python ./example
Take a look at the generated Python script - it’s not particularly interesting right now but we’ll add a Component for our new Blueprint next.
Writing a Component for the Blueprint
Let’s create a minimal Component to test our new Blueprint in much the same way we created our first Component in Writing Your First Component.
First, let’s add a new module to the components
directory of our Python
package called python
to house our new Component. The package directory
structure should look like the following:
.
├── helix_example/
│ ├── components/
│ │ ├── example.py
│ │ ├── python.py
│ │ ├── __init__.py
│ ├── __init__.py
└── setup.py
Inside of python.py
we’ll create a simple Component:
# python.py
from helix import component
class ExamplePythonComponent(component.Component):
"""An example Python component."""
name = "example-python-component"
verbose_name = "Example Python Component"
type = "example"
version = "1.0.0"
description = "An example Python component"
date = "2020-10-20 12:00:00.000000"
tags = (("group", "example"),)
blueprints = ["example-python"]
functions = [
"""def ${example}():
print("hello world")
"""
]
calls = {"startup": ["${example}()"]}
globals = ["example"]
This is a very simple Component that defines one function that prints “hello
world” and registers a call to it at the startup
callsite.
Next, we need to register the new Component with the helix.components
entrypoint group. Make the following change to setup.py
:
# setup.py
...
entry_points={
...
"helix.components": [
...
"example-python-component = helix_example.components.python:ExamplePythonComponent",
...
]
...
}
...
To update the entrypoint list, resintall the Python package (even if you installed it in editable mode):
pip install .
Check that the new Component is registerd with the HELIX CLI:
helix list
The output should include the new Component:
Available Components:
...
Example Python Component (1.0.0) [example-python-component]
...
Now we can test our new Blueprint with the new Component:
helix build blueprint example-python ./example -c example-python-component
The generated Python script should simply print “hello world” and exit.
Adding Another Callsite
Blueprints are not limited to exposing only a single, trivial callsite.
Blueprints can evoke very sophistocated behavior from their Components by
exposing multiple different types of callsites. To demonstrate this, let’s add
another callsite to our Blueprint called loop
which is called repeatedly
inside of a loop defined in the Blueprint.
Make the following changes to the Blueprint:
# blueprints/python.py
...
Class ExamplePythonBlueprint(blueprint.Blueprint):
...
CALLSITE_LOOP = "loop"
"""Called every five seconds, indefinitely.
Calls this callsite repeatedly, inside of a loop, until the program is
terminated.
"""
callsites = [CALLSITE_STARTUP, CALLSITE_LOOP]
...
TEMPLATE = """import time
${functions}
if __name__ == "__main__":
${startup}
while True:
${loop}
time.sleep(5)
"""
def generate(self, directory):
...
loop = self.calls.pop(self.CALLSITE_LOOP, [])
loop = "\n ".join(loop) or "break"
...
source = utils.substitute(
self.TEMPLATE, functions=functions, startup=startup, loop=loop
)
...
Note that we’ve chosen to set the loop
template parameter to break
if
no calls are registered at that callsite. This makes our Blueprint more
flexible - if no calls are registered for the loop
callsite the Blueprint
will simply break out of its infinte loop.
Next, let’s update the simple testing Component for this Blueprint to make use of the new callsite. Make the following changes to the Component:
# components/python.py
...
class ExamplePythonComponent(component.Component):
...
functions = [
...
"""from datetime import datetime
def ${now}():
print(datetime.now())
""",
...
]
...
calls = {
...
"loop": ["${now}()"],
...
}
...
globals = ["example", "now"]
This adds a single function which prints the current date and time and adds a
call at the loop
callsite to that new function.
After reinstalling the python package (if not installed in editable mode), we can now create a new build with our updated Blueprint and Component:
helix build blueprint example-python ./example -c example-python-component
You should now have a Python script that prints “hello world” once and then repeatedly prints the current time every five seconds indefinitely.
Note
Blueprint Flexibility: When developing new Blueprints, it can be tempting to add a lot of project structure and even some core program functionality to Blueprints by implementing various callsites. A best practice is to limit the functionality inside of a Blueprint to only control flow and ensure that all callsites are optional. Remember: callsites must be able to support zero or more calls from components. A generic Blueprint is a reusble Blueprint.
Adding Dependencies
Specifying Blueprint dependencies can be done in the same way as specifying Component dependencies (see Adding Dependencies).
Testing the Blueprint
Writing unit tests for Transforms can be done in the same way as writing unit tests for Components (see Testing the Component).