Step nesting, promises, and dependencies
Steps can be nested inside other steps to help modularize automation code. Furthermore, steps can be linked to each other via simple input-output assignments to pass data around and to ensure steps are executed in the correct order.
Create a Python module called square.py
with the following content:
from freyja import Step, Automation, Input, Output
class Square(Step):
num = Input(int)
squared = Output(int)
def execute(self):
self.squared = self.num * self.num
class SquareOfSquare(Step):
num = Input(int)
def execute(self):
square1 = Square(name_="Square1", num=self.num)
square2 = Square(name_="Square2", num=square1.squared)
print(f"Result is {square2.squared}")
if __name__ == "__main__":
Automation(SquareOfSquare).run()
The first step Square
computes the square of an integer input and provides it as an output. The second step SquareOfSquare
uses two successive instances of Square
to compute the square of square for its input. The result is then printed in the console.
Before we run this example, let's clarify a few important concepts.
Step outputs and promises
In the above example, step Square
has a single output named squared
. Inside the execute()
function of SquareOfSquare
we can access this output simply by using the standard dot notation on the step instance (square1.squared
). So far this is not different from how you would access an instance variable of any regular Python class.
Step outputs are special instance variables though. When accessing a step output, what is returned is not the actual value of an output but its promise (also known as future). A promise is a special type that yields its value only when needed (for example if you print the value to the console).
For all other purposes, you are handed just a proxy object whose actual value remains unknown until the execute function of this output's step assigns a value to it. Because steps execute asynchronously, actual output values might not yet be known when the output is accessed.
The use of promises allows the Python interpreter to step over the square1.squared
statement without knowing its actual value at this point, and to continue building the execution graph to parallelize step execution. We will come back to this later.
Step dependencies
To establish a dependency between two steps, the output of one step (or more precisely the promise of the output of one step) is simply assigned to the input of another.
In the above example, the statement num=square1.squared
declares that square2
depends on square1
and therefore square2
cannot be executed before square1
.
The visual analogy to the statement above is a simple linear workflow like the following:
Other than in regular Python function calls, input arguments for steps are passed by-value and not by-reference.
This means that each step operates on its private (deep) copy of input values. Consequently, there are no side-effects on other steps when changing the value of an input inside a step, for example adding or removing an input list element.
However, this also results in a higher memory footprint, which might be noticeable if your automation passes very large input values (tens of kilobytes) between many steps (hundreds or thousands).
Step naming
Each step instantiated within the same parent step must carry a unique name. In this example, the first step is named "Square1" and the second step is named "Square2". Assigning unique names to steps is important to uniquely identify steps during execution.
Step names must be unique only within its parent step. It is OK to assign steps identical names as long as they are instantiated within different parent steps.
If you provide the name of a step as its initial argument, you can omit the argument's name.
So instead of writing:
square1 = Square(name_="Square1", num=self.num)
you can just write:
square1 = Square("Square1", num=self.num)
Step name arguments can be omitted entirely if you only have a single step instance of a given type. In this case, the step's class name is automatically assigned as the instance name.
If you see an error message similar to "ValueError: Step with name 'main.Square' already exists." you know that you have more than one unnamed step instance and you have to assign a unique step name manually.
Running the example
When executed, the above example should produce an output like this:
$ python square.py run --num 3
2019-03-18 17:14:35,154 INFO [ freyja.log: 68]: (MainThread ) Logging configured
2019-03-18 17:14:35,154 INFO [freyja.graph: 362]: (MainThread ) Process: 17354
2019-03-18 17:14:35,155 INFO [freyja.graph: 489]: (MainThread ) Instantiating Step <SquareOfSquare "main">
2019-03-18 17:14:35,155 INFO [freyja.graph: 217]: (MainThread ) Step <SquareOfSquare ("main")> queued for execution
2019-03-18 17:14:35,155 INFO [freyja.graph: 648]: (main ) Initiating execution for for: Step <SquareOfSquare ("main")>
2019-03-18 17:14:35,156 INFO [freyja.graph: 658]: (main ) Execution started for: Step <SquareOfSquare ("main")>
2019-03-18 17:14:35,156 INFO [freyja.graph: 664]: (main ) RUNNING: main
2019-03-18 17:14:35,156 INFO [freyja.graph: 489]: (main ) Instantiating Step <Square "Square1">
2019-03-18 17:14:35,156 INFO [freyja.graph: 217]: (main ) Step <Square ("main.Square1")> queued for execution
2019-03-18 17:14:35,156 INFO [freyja.graph: 489]: (main ) Instantiating Step <Square "Square2">
2019-03-18 17:14:35,157 INFO [freyja.graph: 648]: (main.Square1) Initiating execution for for: Step <Square ("main.Square1")>
2019-03-18 17:14:35,157 INFO [freyja.graph: 217]: (main ) Step <Square ("main.Square2")> queued for execution
2019-03-18 17:14:35,157 INFO [freyja.graph: 658]: (main.Square1) Execution started for: Step <Square ("main.Square1")>
2019-03-18 17:14:35,158 INFO [freyja.graph: 664]: (main.Square1) RUNNING: Square1
2019-03-18 17:14:35,158 INFO [freyja.graph: 690]: (main.Square1) Execution finished for: Step <Square ("main.Square1")>
2019-03-18 17:14:35,158 INFO [freyja.graph: 648]: (main.Square2) Initiating execution for for: Step <Square ("main.Square2")>
2019-03-18 17:14:35,159 INFO [freyja.graph: 658]: (main.Square2) Execution started for: Step <Square ("main.Square2")>
2019-03-18 17:14:35,159 INFO [freyja.graph: 664]: (main.Square2) RUNNING: Square2
2019-03-18 17:14:35,159 INFO [freyja.graph: 690]: (main.Square2) Execution finished for: Step <Square ("main.Square2")>
Result is 81
2019-03-18 17:14:35,159 INFO [freyja.graph: 690]: (main ) Execution finished for: Step <SquareOfSquare ("main")>
2019-03-18 17:14:35,160 INFO [freyja.graph: 114]: (Executor-main) Executor done
2019-03-18 17:14:35,160 INFO [freyja.graph: 406]: (MainThread )
-----------------------------------------------------------------------
Execution summary:
Steps instantiated: 3
Steps incomplete: 0
Steps executed: 3
Steps failed: 0
-----------------------------------------------------------------------
The execution summary tells us that there is a total of three executed steps: step "main", step "main.Square1", and step "main.Square2". Note how the step hierarchy is reflected in the naming of steps.
Another close look into the execution log above reveals that both steps are instantiated before they are executed! This is the effect of promises and lazy evaluation, as mentioned above, and it will become handy later to parallelize execution when we write automations that contain loops.
As final result we get (3^2)^2 = 81.
Updated about 4 years ago