Nodes¶
The basic elements of a noob tube are nodes.
Nodes are just normal python functions or classes - they do not require any special noob-specific syntax in order to be used in a tube.
Each node has a set of slots and signals[1]:
Slots define the names of events that a node accepts as inputs
Signals define the names of events that a node emits
flowchart LR
signal_a@{ shape: sm-circ }
signal_b@{ shape: sm-circ }
slot_a@{ shape: sm-circ }
slot_b@{ shape: sm-circ }
node@{ shape: rounded }
signal_a -- "signal_a" --> node
signal_b -- "signal_b" --> node
node -- "slot_a" --> slot_a
node -- "slot_b" --> slot_b
Function Nodes¶
The simplest nodes are pure functions.
The function parameters and their types define the node’s slots, and the return value annotation defines its signals.
For example, this function:
def concat(left: str, right: str) -> str:
return left + right
has the node structure
flowchart LR
left@{ shape: sm-circ }
right@{ shape: sm-circ }
value@{ shape: sm-circ }
node@{ shape: rounded, label: "concat" }
left -- "left" --> node
right -- "right" --> node
node -- "value" --> value
The special signal name value is used whenever a node doesn’t annotate its return type with a name.
Named Signals¶
To give the signal a name, we can use the special annotation Name :
from typing import Annotated as A
from noob import Name
def concat(left: str, right: str) -> A[str, Name("catted")]:
return left + right
flowchart LR
left@{ shape: sm-circ }
right@{ shape: sm-circ }
value@{ shape: sm-circ }
node@{ shape: rounded, label: "concat" }
left -- "left" --> node
right -- "right" --> node
node -- "catted" --> value
Multiple Signals¶
Multiple signals can be emitted by returning a tuple:
def concount(left: str, right: str) -> tuple[
A[str, Name("catted")],
A[int, Name("count")]
]:
catted = left + right
return catted, len(catted)
flowchart LR
left@{ shape: sm-circ }
right@{ shape: sm-circ }
catted@{ shape: sm-circ }
count@{ shape: sm-circ }
node@{ shape: rounded, label: "concount" }
left -- "left" --> node
right -- "right" --> node
node -- "catted" --> catted
node -- "count" --> count
See also Optional Events for how to emit events for only one (or zero) signals
and how Nones are handled.
Positional Slots¶
Positional-only arguments are named with integers that indicate their position,
for example a function using args could accept any (contiguous) set of integer-named events:
def concat_all(*args) -> str:
return ''.join(args)
flowchart LR
zero@{ shape: sm-circ }
one@{ shape: sm-circ }
n@{ shape: sm-circ }
value@{ shape: sm-circ }
node@{ shape: rounded, label: "concat_all" }
zero -- "0" --> node
one -- "1" --> node
n -- "...{n}" --> node
node -- "value" --> value
Class Nodes¶
Some nodes are stateful! Some nodes are classes!
The simplest class node is a bare python class with a process method -
no inheritance needed:
class RollingSum:
def __init__(self, x: int = 0):
self.x = x
def process(self, value: int) -> int:
self.x += value
return self.x
This node would be initialized with some param or input in the tube specification
when starting to run the tube,
and then would have its process method called in each iteration of the tube.
Wrapper Nodes
When created in a tube, non-Node nodes are wrapped
as two special classes:
WrapClassNodefor classesWrapFuncNodefor functions
Non-process processing methods¶
If the class’s processing method doesnt happen to be named process,
it can be decorated with process_method():
from noob import process_method
class RollingSum:
def __init__(self, x: int = 0):
self.x = x
@process_method
def add(self, value: int) -> int:
self.x += value
return self.x
Class Lifespan Events¶
Subclassing the Node class gives the most control over how a node is used within a tube.
Specifically, nodes have two lifespan methods that allow them to perform some actions or change their state when runners stop and start processing events.
So, say you had some tube that you only ran sporadically but didn’t want to have to setup and teardown each time, you might make some database accessing node open a connection only when the tube is processing:
from noob import Node
class DBNode(Node):
def __init__(self, db_address):
self.db_address = db_address
self._connection = None
def init(self) -> None:
self._connection = SomeDBClass(self.db_address).connect()
def deinit(self) -> None:
self._connection.close()
def process(self, value: str) -> str:
return self._connection.exec("SELECT * FROM table where something = ?", value)
Subclassed nodes can also manipulate how they are interpreted by tubes,
e.g. by overriding their signals() property.
Generator Nodes¶
Generator functions can also be used as nodes. They can’t have any slots (for now), but can be used as sources when e.g. reading data from disk.
Generator nodes are given any configured params and then called once per round of processing, so e.g. one could cause output strings to become increasingly excited with a generator node like
def hype(starting_n: int = 0) -> A[str, Name("exclamation")]:
n = starting_n
while True:
n += 1
yield '!'*n
Which is wrapped and called roughly like
class HypeWrapper(Node):
def __init__(self, params: dict):
self._generator = hype(**params)
def process(self) -> A[str, Name("exclamation")]:
return next(self._generator)
Async Nodes¶
Todo
Don’t you worry, they are not supported yet, but we’ll implement async nodes :)