Hello world
Plugboard is built to help you with two things: defining process models, and executing those models. There are two main ways to interact with Plugboard: via the Python API; or, via the CLI using model definitions saved in yaml format. In this introductory tutorial we'll do both, before building up to more complex models in later tutorials.
Building models with the Python API
First we start by defining each Component
we want in our model. Components can have only inputs, only outputs, or both. To keep it simple we just have two components here, showing the most basic functionality. Each component has several methods which are called at different stages during model execution: init
for optional initialisation actions; step
to take a single step forward through time; run
to execute all steps; and destroy
for optional teardown actions.
Info
A model is made up of one or more components, though Plugboard really shines when you have many!
Defining components
Let's define two components, A
and B
. A
puts data into the model by generating numbers from a sequence. B
takes input from A
, doubles it, then saves it to a file. We build each component as a reusable Python class, implementing the main logic in a step
method.
import asyncio
import typing as _t
from plugboard.component import Component, IOController as IO
from plugboard.connector import AsyncioConnector
from plugboard.process import LocalProcess
from plugboard.schemas import ComponentArgsDict, ConnectorSpec
class A(Component):
io = IO(outputs=["out_1"]) # (1)!
def __init__(self, iters: int, **kwargs: _t.Unpack[ComponentArgsDict]) -> None:
super().__init__(**kwargs)
self._iters = iters
async def init(self) -> None:
self._seq = iter(range(self._iters)) # (2)!
async def step(self) -> None:
try:
self.out_1 = next(self._seq) # (3)!
except StopIteration:
await self.io.close() # (5)!
class B(Component):
io = IO(inputs=["in_1"])
def __init__(self, path: str, **kwargs: _t.Unpack[ComponentArgsDict]) -> None:
super().__init__(**kwargs)
self._path = path
async def init(self) -> None:
self._f = open(self._path, "w")
async def step(self) -> None:
out = 2 * self.in_1
self._f.write(f"{out}\n")
async def destroy(self) -> None:
self._f.close() # (4)!
- This is where we configure the inputs and outputs for each
Component
. init
gets called before we run the model. It is optional, and used for any setup required on theComponent
.step
gets called at each step forward throughout the model execution. This is where the main business logic must be defined.destroy
is optional, and can be used to clean up any resources used by theComponent
.A
is responsible for stopping the model when it is complete, which we can do by callingself.io.close()
.
Note
- Each component has an
io
attribute in its definition, where we specify the names of the inputs and outputs. In this simple example,A
has a single output, whileB
has a single input. - During the
step
method, the inputs and outputs are available as attributes: so assigning toself.out_1
lets us set the output ofA
, and readingself.in_1
allows us to read the input ofB
. - Notice how each component can have additional parameters in the
__init__
constructor, allowing us to give our components configurable settings like the output file path.
Setting up a Process
Now we take these components, connect them up as a Process
, and fire off the model. When instantiating each component we must provide a name
, which we can use to help define the connections between them. Using a ConnectorSpec
object, we tell Plugboard to connect the out_1
output of the a
component to the in_1
input of b
. Visually, the model will look like this:
graph LR;
A(a)-->B(b);
The rest of the code is boilerplate: calling run()
on the Process
object triggers all the components to start iterating through all their inputs until a termination condition is reached. We're using LocalProcess
here, because we are running this model locally with no parallel computation (will explore this in a later tutorial).
Simulations proceed in an event-driven manner: when inputs arrive, the components are triggered to step forward in time. The framework handles the details of the inter-component communication, you just need to specify the logic of your components, and the connections between them.
process = LocalProcess(
components=[A(name="a", iters=5), B(name="b", path="b.txt")],
connectors=[
AsyncioConnector(
spec=ConnectorSpec(source="a.out_1", target="b.in_1"),
)
],
)
async with process:
await process.run()
Executing pre-defined models on the CLI
In many cases, we want to define components once, with suitable parameters, and then use them repeatedly in different simulations. Plugboard enables this workflow with model specification files yaml format. Once the components have been defined, the simple model above can be represented with a yaml file like this:
plugboard:
process:
args:
components:
- type: hello_world.A # (1)!
args:
name: "a"
iters: 10 # (2)!
- type: hello_world.B
args:
name: "b"
path: "./b.txt"
connectors:
- source: "a.out_1"
target: "b.in_1"
- This identifies the
A
class within thehello_world
module. - The
iters
parameter is required for the component - try adjusting to change how long the model runs for.
Note
Notice how we use type
to tell Plugboard where our components are defined within Python code (within the hello_world
module). Creating models in yaml format like this also makes it easy to track and adjust their configurable parameters: try editing the file path or iters
parameter to change the behaviour of the model.
We can now run this model using the plugboard CLI with the command:
You should see that an output .txt
file has been created, showing the the model as run successfully. Congratulations - you have built and run your first Plugboard model!
In the following tutorials we will build up some more complex components and processes to demonstrate the power of the framework.