Quickstart¶
Welcome to a short guide to working with porchlight! This tutorial will step through the basics of installing and using porchlight. If you are looking for more advanced examples, see the examples available in the porchlight github repository.
Requirements¶
porchlight requires Python version 3.9 or higher.
Note: If you download the repository you’ll notice that requirements.txt is not empty; these requirements are for building these docs, and are not used by the main porchlight library.
Installation¶
For help installing Python 3.9 or above please see their respective instructions:
You can install porchlight using pip:
pip install porchlight
Once porchlight has installed, you’re ready to start writing code. If you’d like, this guide can be followed line-for-line in an interactive python environment! To get started, just import the library:
import porchlight
Type annotations and porchlight¶
Within porchlight, type annotation are allowed and encouraged. Generally,
save for a few very special cases, [1] you can ignore
type annotations when writing your code. porchlight, via the Door class in
particular, will note type annotations if they are present and otherwise will
ignore them.
Creating a Neighborhood object¶
The Neighborhood object collects various
functions, extracts information about the function from existing metadata
(using the inspect module
in the CPython standard library) and the source code itself. Adding a function
to a Neighborhood is as straightforward as passing the function’s name,
whether defined locally or otherwise:
def my_function(x: int, z: int = 0) -> int:
'''This is a simple equation, but we want to return
a named variable.
'''
y = x ** 2 + z
return y
neighborhood = porchlight.Neighborhood() # Instantiates the object.
neighborhood.add_function(my_function)
At this point, porchlight will parse the function and store metadata about it. The str representation of Neighborhood contains most of the data in a very dense format:
print(neighborhood)
Neighborhood(doors={'my_function': Door(name=my_function, base_function=<function my_function at 0x1...F>, arguments={}, return_vals=[['y']])}, params={'y': Param(name=y, value=<porchlight.param.Empty object at 0x1...F>, constant=False, type=<class 'porchlight.param.Empty'>)}, call_order=['my_function'])
A few things are now kept track of by the Neighborhood automatically:
The function arguments, now tracked as
Paramobjects. The default values found were saved (in our case, it foundz = 0), and any parameters not yet assigned a value have been given theEmptyvalue.Function return variables. We’ll explore this in more detail later, but one important note here: the return variable names are critically important to keep consistent!
Right now, our Neighborhood is a fully-fledged, if tiny, model. Let’s set our
variables and run it!
neighborhood.set_param('x', 2)
neighborhood.set_param('z', 0)
neighborhood.run_step()
print(neighborhood)
Neighborhood(doors={'my_function': Door(name=my_function, base_function=<function my_function at 0x1...f>, arguments={'x': <class 'int'>, 'z': <class 'int'>}, return_vals=[['y']])}, params={'x': Param(name=x, value=2, constant=False, type=<class 'int'>), 'z': Param(name=z, value=0, constant=False, type=<class 'int'>), 'y': Param(name=y, value=4, constant=False, type=<class 'int'>)}, call_order=['my_function'])
run_step() executes all
functions that have been added to our Neighborhood object. The object passes
the parameters with names matching the arguments in my_function, and
stores my_function’s output in the parameter for y.
All of this could be accomplished in a few lines of code without any imports,
obviously. We could manage our own x, y, and z in a
heartbeat, and all porchlight really did was what we could do with
something as simple as y = my_function(2, 0). Let’s add another
function to our neighborhood and call
run_step()
def my_new_function(y, z):
z += y // 2
return z
neighborhood.add_function(my_new_function)
# Let's run Neighborhood.run_step() a few times and see how the system
# evolves by printing out the parameters.
for i in range(5):
neighborhood.run_step()
x = neighborhood.get_value('x')
y = neighborhood.get_value('y')
z = neighborhood.get_value('z')
print(f"{i}) {x = }, {y = }, {z = }")
0) x = 2, y = 4, z = 2
1) x = 2, y = 6, z = 5
2) x = 2, y = 9, z = 9
3) x = 2, y = 13, z = 15
4) x = 2, y = 19, z = 24
We are now running a system of two functions that share variables. As we step forward, the functions are called sequentially and the parameters are updated directly.
Behind the scenes, our Neighborhood object has generated a number of Door
objects and Param objects that hold onto metadata our Neighborhood can use
to know when and what to run, check, and modify. To really leverage
porchlight, we’ll need to get to know these objects a bit better on their
own.
By default, the functions are called sequentially in the order they were added
to the Neighborhood. To re-arrange them,
order_doors() takes a list of
the Door names and will modify the call order appropriately. This does
require the list to have each Door name, spelled correctly.
Param objects¶
Param objects manage the memory being passed between functions in our
Neighborhood object.
These are pretty simple objects, and making them is also simple:
pm = porchlight.Param("x", "hello")
Param(name=x, value=hello, constant=False, type=<class 'str'>)
To access the data of a Param, you need to get its .value
attribute. To change the value, we can update the value directly using
something like
pm.value = "world"
print(pm)
Param(name=x, value=world, constant=False, type=<class 'str'>)
We can also specify that parameters should be constant, or change parameters to become constant.
my_constant = porchlight.Param("y", 42.0, constant=True)
pm.constant = True
try:
pm.value = 10
except Exception as e:
# Writing out the error and its message.
print(f"Got {type(e)}: {e}")
Got <class 'porchlight.param.ParameterError'>: Parameter x is not mutable.
This is great for keeping parameters that should stay constant for a specific
scenario constant. Keep in mind that Param implemented like this is not a
true constant, like const in other programming languages. The data could
still be modified as a side effect of functions.
Within our Neighborhood, we’ve already seen param basics. We can add our own
params or modify the existing ones in a few different ways, the safest of which
is to use set_param(), which we
used above.
Now, let’s turn to how our Neighborhood re-interprets our function
definitions to know what to pass to them.
Door objects¶
A Door is an object that contains metadata about how to call a function and
what it might return.
Because we can return quite a few things, including evaluated expressions in
the return statement, Door objects will not consider outputs that are not
valid Python variable names.
my_door = porchlight.Door(my_door_to_be)
print(my_door)
Door(name=my_door_to_be, base_function=<function my_door_to_be at 0x1...h>, arguments={'x': <class'porchlight.param.Empty'>}, return_vals=[['z']])
So far, we’ve defined the functions we’ll be working with ourselves, but what
if we want to include a function from a library or source with incompatible
definitions? With our Door we can map function argument/return value names to
match our needs. In our example, say we are given the following function from a
coworker that they want integrated into the model we already have:
def coworker_function(a, b=0):
b = b + a // b
a = a + 1
return a, b
We would need to write our own function that converts our x and y into
a and b if we wanted to pass a representation of this function to
Door like we have been. We can pass the keyword argument
argument_mapping when we initialize our door, though, to signal that
these variables should be treated like they have different names.
my_coworker_door = porchlight.Door(
coworker_function, argument_mapping={'x': 'a', 'y': 'b'}
)
Now, our Door my_coworker_door will take x and y as
arguments, and Neighborhood objects can recognize this. Let’s add our new
door to our neighborhood.
neighborhood.add_door(my_coworker_door)
for i in range(5, 10):
neighborhood.run_step()
x = neighborhood.get_value("x")
y = neighborhood.get_value("y")
z = neighborhood.get_value("z")
print(f"{i}) {x = }, {y = }, {z = }")
5) x = 3, y = 29, z = 38
6) x = 4, y = 48, z = 61
7) x = 5, y = 78, z = 99
8) x = 6, y = 125, z = 161
9) x = 7, y = 198, z = 259
And now, with just 2 lines of code, we’ve fully integrated our coworker’s code
into our own! You could keep doing this with as many functions as you’d like;
as long as they’re convertible to a Door, our Neighborhood will keep
accepting new functions.
This is the end of the interactive portion of the Quickstart Guide. With the knowledge you have now, you are more than capable of doing some pretty exciting things with porchlight! That said, keep in mind there is more to the package, as well as some nuances.
Closing Nuances¶
Neighborhoodobjects will execute their functions sequentially, in the order they are added. if you’d like to re-order the functions before execution, seeorder_doors().porchlight is under active development. The current development strategy will not include a dedicated stable branch until v1.0.0. That means that you need to be cautious with versions before v1.0.0, and some changes may break your code. You can generally assume that increments of 0.0.1 are non-breaking. 0.1.0 increments may be breaking, check the appropriate Release Notes.