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:

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 :)