Skip to content

Custom Nodes#

When the built-in node set doesn't cover a need, you can add a node to the catalog. A node is a Python class; its name is its kind on the wire, so every executing client (the iOS app) must implement a class of the same name.

Client implementation required

Defining a node in Python only adds its interface and serialization. For a procedure that uses it to run on device, you must also implement the matching Swift class and register it — see Implementing the iOS side.

The @make_procedure decorator#

Every node is a frozen, keyword-only dataclass produced by @make_procedure:

import capturegraph as cg


@cg.make_procedure
class RepeatString(cg.Procedure[cg.String]):
    """Repeat a string a fixed number of times."""

    text: str
    times: int

The return type is the type the node produces, written as cg.Procedure[T] where T is a CGType (cg.String, cg.Image, cg.Void, …). The class body is purely declarative — Python never executes the node, it validates and serializes it.

Field classification#

Fields are classified automatically by what they hold:

Field holds Becomes Example
a Procedure a graph input (data flow) image: cg.Procedure[cg.Image]
a list of Procedures substeps (each cg.Void) steps: list[cg.Procedure[cg.Void]]
anything else a plain JSON setting times: int
@cg.make_procedure
class ProcessImage(cg.Procedure[cg.Image]):
    input_image: cg.Procedure[cg.Image]   # input
    brightness: float                       # setting

Validation#

Add checks in __post_init__ (call super() first):

@cg.make_procedure
class RepeatString(cg.Procedure[cg.String]):
    text: str
    times: int

    def __post_init__(self) -> None:
        super().__post_init__()
        if self.times < 1:
            raise ValueError("times must be at least 1")

Pass-through types#

A node that forwards a value's type (like IfThenElse) is generic and uses forward_types, applied under @make_procedure. The first argument bounds the forwarded fields; the named fields must all produce the same type, which becomes the node's return type:

@cg.make_procedure
@cg.forward_types(cg.CGType, "input_value")
class PassThrough[T: cg.CGType](cg.Procedure[T]):
    input_value: cg.Procedure[T]

PassThrough(input_value=some_image_node) is then a Procedure[cg.Image].

Implementing the iOS side#

Every kind in the JSON must resolve to a Swift class with exactly the same name. To add a node end to end:

  1. Define the class in the right module under capturegraph-lib/capturegraph/procedures/nodes/{collection,process,destination}/.
  2. Import it into the root capturegraph/__init__.py (the single aggregation point), leaf-by-leaf as Name as Name, — the deeper procedures/__init__.py is documentation-only, and a class that never reaches the root is invisible to the node editor and the registry-sync tests.
  3. Create the matching Swift class under capturegraph-ios-client/.../Models-Procedures/ProcedureNodes/Nodes/.
  4. Add its .self entry to ProcedureNodeRegistry.swift.
  5. If it has UI, add a view under Views-Procedures/Nodes/.
  6. Run cd capturegraph-lib && python -m pytest — the registry-sync tests catch any drift between the Python exports and the Swift registry.

Note

Renaming a node is a breaking change on both sides: the Python __name__ and the Swift class name must change together, and previously exported JSON keeps the old kind.

See Also#