{"config":{"lang":["en"],"separator":"[\\s\\-]+","pipeline":["stopWordFilter"]},"docs":[{"location":"","title":"\u26d3\ufe0f dagpipe \u2014 Directed Acyclic Graph Execution for Deterministic State","text":"

dagpipe executes pipelines composed of nodes connected in a directed acyclic graph (DAG). Each node receives an immutable State and optionally produces derived states for downstream nodes. Execution is deterministic: the same input state and pipeline always produce the same output states and lineage.

Doc model: this wiki is written for humans \u2014 how\u2011to guides, examples, and testing recipes. The authoritative API contracts live in the code (GSDFC docstrings) and the machine\u2011readable bundle under docs/mcp/.

"},{"location":"#key-features","title":"\ud83d\ude80 Key Features","text":""},{"location":"#installation","title":"\ud83d\udce6 Installation","text":"

From your internal PyPI:

pip install --extra-index-url https://$PYPI_USERNAME:$PYPI_PASSWORD@pip.aetoskia.com/simple dagpipe\n

From local source:

pip install -e .\n
"},{"location":"#quick-start","title":"\u26a1 Quick Start","text":"
from dagpipe import Payload, Schema, State, Graph, Engine, Node\n\nclass HelloNode(Node):\n    id = \"hello\"\n\n    def resolve(self, state):\n        yield self.fork(state, payload_update={\"msg\": \"hello\"})\n\nclass MyState(State):\n    schema = Schema({\"msg\": str | None})\n\ngraph = Graph()\ngraph.add_root(HelloNode())\n\nengine = Engine(graph)\nresults = engine.run(MyState(payload=Payload({})))\n\nassert results[0].get(\"msg\") == \"hello\"\n
"},{"location":"#documentation-structure","title":"\ud83d\udcc1 Documentation Structure","text":"Section Description Overview Architecture, lifecycle, and execution model decision table Core Components Validated reference for the public API surface Use cases Step-by-step recipes, grouped by theme \u00b7 Getting Started \u00b7 01 \u2013 Basic linear pipeline Sequential multi-node pipeline \u00b7 02 \u2013 Graph execution Branching, merging, multiple roots \u00b7 03 \u2013 YAML pipelines Declarative pipelines via load_pipeline \u00b7 State & Data \u00b7 04 \u2013 Schema and payload Nested schemas and dot-path access \u00b7 05 \u2013 State fork and lineage Immutability, lineage, confidence \u00b7 Async & Steps \u00b7 06 \u2013 Async execution AsyncNode and run_async \u00b7 07 \u2013 Step-wise execution Resumable runs and progress hooks Best Practices Node design, state design, and gotchas Error Handling Library exceptions and where they surface Testing Mock-based quickstart"},{"location":"#related-resources","title":"\ud83d\udd17 Related Resources","text":"

\u00a9 Aetoskia Internal \u2014 dagpipe 0.0.1

"},{"location":"01_overview/","title":"\ud83d\uddfa\ufe0f Library Overview","text":"

dagpipe is a small execution framework that propagates immutable State objects through Node units connected in a directed acyclic graph (DAG). It is designed for deterministic, observable, and resumable data-processing pipelines.

"},{"location":"01_overview/#architecture","title":"\ud83c\udfd7\ufe0f Architecture","text":"

At runtime a pipeline is made of four cooperating layers:

\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502                       YAML (optional)                       \u2502\n\u2502     schema \u00b7 initial \u00b7 nodes \u00b7 graph  \u2192  load_pipeline()    \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n                               \u25bc\n\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502               Graph (execution topology)                    \u2502\n\u2502   roots \u00b7 add_edge(child) \u00b7 children() \u00b7 parents()          \u2502\n\u2502   acyclic \u2014 validated on every mutation                     \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n                               \u25bc\n\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502               Engine (orchestration)                        \u2502\n\u2502   run / run_async / run_steps / run_steps_async             \u2502\n\u2502   mode: linear sequence  OR  graph BFS                      \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n                               \u25bc\n\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502       State \u00b7 Payload \u00b7 Schema (data plane)                 \u2502\n\u2502   immutable State \u00b7 fork() \u00b7 dot-path payload \u00b7 validation  \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n
"},{"location":"01_overview/#lifecycle-of-a-run","title":"\ud83d\udd04 Lifecycle of a run","text":"Step Who What happens 1 You Define Node subclasses and a State subclass bound to a Schema. 2 You Build a Graph (order + add_edge) or a linear Sequence[Node]. 3 You Construct Engine(nodes_or_graph). 4 You Create a root State (MyState(payload=Payload(...))) \u2014 validated here. 5 Engine Executes roots first, then fans State out along edges (graph mode) or feeds each sequence element (linear mode). 6 Engine Collects terminal states \u2014 states from nodes with no children (or the last step in linear mode).

Nodes that yield no states prune the branch \u2014 see use case 02.

"},{"location":"01_overview/#which-execution-model-should-you-use","title":"\u2696\ufe0f Which execution model should you use?","text":"Model Construct Use when Runs Linear Engine([a, b, c]) A fixed pipeline of steps, no branches run Graph Engine(Graph) Branching, merging, multiple roots run Async Engine + AsyncNode subclasses I/O-bound steps (HTTP, DB, files) run_async Steps Engine.run_steps(...) Progress bars, resume-after-interrupt run_steps

A linear sequence is equivalent to a chain graph: each step receives every state its predecessor produced. Graph mode gives you explicit fan-out and fan-in.

"},{"location":"01_overview/#read-next","title":"\ud83d\udcda Read Next","text":""},{"location":"02_components/","title":"\ud83e\udde9 Core Components","text":"

This page is the validated reference for the public API surface. For step-by-step recipes see the use cases index. For exact signatures and docstrings, see the library reference (docs/lib) or the MCP bundle (docs/mcp).

"},{"location":"02_components/#execution-core","title":"\u26a1 Execution Core","text":""},{"location":"02_components/#node","title":"Node","text":"

Base class for all execution nodes. It is an abstract base class: subclasses implement resolve() and declare an id (dotted snake_case).

from dagpipe import Node, State\n\nclass Increment(Node):\n    id = \"math.increment\"\n\n    def resolve(self, state: State):\n        yield self.fork(state, payload_update={\"value\": state.get(\"value\") + 1})\n

Key facts:

"},{"location":"02_components/#asyncnode","title":"AsyncNode","text":"

Base class for asynchronous nodes. Subclasses implement resolve_async() (an async generator yielding State objects). See use case 06.

from dagpipe import AsyncNode\n\nclass FetchRemote(AsyncNode):\n    id = \"web.fetch\"\n\n    async def resolve_async(self, state: State):\n        data = await fetch(state.get(\"url\"))\n        yield self.fork(state, payload_update={\"body\": data})\n

A sync engine that hits an AsyncNode treats it as a no-op (yields no states). Use Engine.run_async when the graph contains AsyncNodes.

"},{"location":"02_components/#graph","title":"Graph","text":"

DAG topology container. Stores connectivity only \u2014 it never executes nodes.

from dagpipe import Graph\n\ngraph = Graph()\ngraph.add_root(a)          # node with no parents\ngraph.add_edge(a, b)       # b is a child of a\ngraph.add_edge(a, c)\ngraph.children(a)          # (b, c)\ngraph.parents(b)           # (a,)\ngraph.roots()              # nodes with no incoming edges\ngraph.nodes()              # all registered nodes\n
"},{"location":"02_components/#engine","title":"Engine","text":"

Orchestrator that runs a linear Sequence[Node] or a Graph.

from dagpipe import Engine\n\nengine = Engine(graph)                # or Engine([n1, n2, n3])\nresults = engine.run(root_state)      # list[State] of terminal states\nresults_async = await engine.run_async(root_state)\n
"},{"location":"02_components/#state-data","title":"\ud83e\uddca State & Data","text":""},{"location":"02_components/#state","title":"State","text":"

Immutable execution snapshot at one point in traversal. Subclass and bind a schema:

from dagpipe import State, Schema\n\nclass MyState(State):\n    schema = Schema({\"value\": int, \"label\": str | None})\n
"},{"location":"02_components/#payload","title":"Payload","text":"

Immutable hierarchical container with dot-path access.

payload = Payload({\"user\": {\"address\": {\"city\": \"Mumbai\"}}})\npayload.get(\"user.address.city\")            # 'Mumbai'\npayload.has(\"user.address.zip\")             # False\npayload.update({\"user.address.zip\": 400001})  # new Payload, original untouched\npayload.keys()                              # ('user',)\npayload.as_dict()                           # read-only view of underlying mapping\n
"},{"location":"02_components/#schema","title":"Schema","text":"

Immutable hierarchical validation tree. Leaf nodes are types or PEP-604 unions; nested Schema instances describe nested structure.

AddressSchema = Schema({\"city\": str, \"zip\": int | None})\nUserSchema = Schema({\"name\": str, \"address\": AddressSchema})\n\nuser_payload = Payload({\"name\": \"Ada\", \"address\": {\"city\": \"London\"}})\nUserSchema.validate_payload(user_payload)   # no-op on success\nUserSchema.validate_update({\"address.city\": \"Paris\"})\n
"},{"location":"02_components/#schemaerror","title":"SchemaError","text":"

Raised when payload data violates the declared schema: invalid structure, undefined path, or invalid type. See Error Handling.

"},{"location":"02_components/#declarative-pipelines","title":"\ud83d\udcdc Declarative Pipelines","text":""},{"location":"02_components/#pipeline","title":"Pipeline","text":"

Dataclass wrapping engine, state_cls, and initial_payload. Executes with run(payload_override=None) and returns terminal states.

"},{"location":"02_components/#load_pipelinepath","title":"load_pipeline(path)","text":"

Factory that builds a Pipeline from one YAML file:

version: 1\nschema:\n  value: int\ninitial:\n  value: 1\nnodes:\n  step1:\n    class: mymod.Increment\ngraph:\n  roots:\n    - step1\n

See use case 03 for the full walkthrough.

"},{"location":"02_components/#progress-types","title":"\ud83d\udd01 Progress Types","text":"

Both are produced by Engine.run_steps / run_steps_async \u2014 see use case 07.

"},{"location":"02_components/#read-next","title":"\ud83d\udcda Read Next","text":""},{"location":"04_best_practices/","title":"\ud83e\uddf9 Best Practices","text":"

Conventions adopted across dagpipe pipelines. Follow these to keep graphs deterministic, observable, and debuggable.

"},{"location":"04_best_practices/#node-design","title":"\ud83e\uddf1 Node design","text":""},{"location":"04_best_practices/#state-schema-design","title":"\ud83c\udfaf State & schema design","text":""},{"location":"04_best_practices/#graph-patterns","title":"\ud83d\udd00 Graph patterns","text":""},{"location":"04_best_practices/#async-guidance","title":"\u26a1 Async guidance","text":""},{"location":"04_best_practices/#step-wise-guidance","title":"\ud83e\ude9c Step-wise guidance","text":""},{"location":"04_best_practices/#testability","title":"\ud83e\uddea Testability","text":""},{"location":"04_best_practices/#anti-patterns","title":"\u274c Anti-patterns","text":"Pattern Why it's wrong Mutating state.payload or state.metadata Breaks immutability; shared across branches Building nodes with heavy __init__ Skips singleton reuse; couples pipeline to instance state Logging inside resolve Hard to test; pollutes output. Use on_step hooks instead Reusing one Graph across concurrent runs Assumes nodes are stateless \u2014 they must be"},{"location":"04_best_practices/#read-next","title":"\ud83d\udcda Read Next","text":""},{"location":"06_error_handling/","title":"\u26a0\ufe0f Error Handling","text":"

dagpipe raises a small, predictable set of exceptions. Know when they surface and how to react.

"},{"location":"06_error_handling/#exception-reference","title":"\ud83d\udccb Exception reference","text":"Exception Raised when Where SchemaError Payload violates the declared schema State.__post_init__, Schema.validate_payload, Schema.validate_update, Schema._walk, Schema._check_type, Schema._validate_path TypeError resolve()/resolve_async() yields a non-State Node.run, AsyncNode.run_async TypeError Engine(...) receives a non-Node element or a non-Sequence/Graph Engine.__init__ TypeError Engine.run/run_async receives a non-State root Engine.run, Engine.run_async TypeError Graph.add_edge/add_root receives a non-Node Graph.add_edge, Graph.add_root TypeError A YAML node class path is not a Node subclass _load_nodes ValueError Adding an edge would create a cycle (incl. self-cycle) Graph.add_edge ValueError Node.id is not valid dotted snake_case Node.clean_id_and_name RuntimeError Engine mode is corrupt (should never happen) Engine.run"},{"location":"06_error_handling/#schema-failures","title":"\ud83e\uddca Schema failures","text":"
from dagpipe import Payload, Schema, SchemaError, State\n\nclass UserState(State):\n    schema = Schema({\"name\": str, \"zip\": int | None})\n\ntry:\n    UserState(payload=Payload({\"name\": 42, \"zip\": \"abc\"}))\nexcept SchemaError as e:\n    print(e)   # Path 'name' must be str\n

Guidance:

"},{"location":"06_error_handling/#cycle-failures","title":"\ud83d\udd01 Cycle failures","text":"
from dagpipe import Graph\n\ng = Graph()\ng.add_edge(a, b)\ntry:\n    g.add_edge(b, a)     # would create a cycle\nexcept ValueError as e:\n    print(e)             # Adding edge b \u2192 a would create cycle\n

Cycles (including a \u2192 a) always raise immediately at wiring time, never at execution. Treat them as a design error: redraw the topology.

"},{"location":"06_error_handling/#bad-node-output","title":"\ud83e\uddec Bad node output","text":"
class BadNode(Node):\n    id = \"bad.output\"\n\n    def resolve(self, state):\n        yield \"not a state\"    # \u2190 not a State\n\nengine = Engine([BadNode()])\ntry:\n    engine.run(root)\nexcept TypeError as e:\n    print(e)   # bad.output.resolve must yield State, got <class 'str'>\n

This check is applied per yielded object, so one bad element in a generator fails the whole run.

"},{"location":"06_error_handling/#step-wise-status-not-errors","title":"\ud83e\ude9c Step-wise status, not errors","text":"

run_steps doesn't raise when a node produces no output \u2014 it reports completed=False:

for step in engine.run_steps(root):\n    if not step.completed:\n        log.info(\"%s produced no state (branch pruned)\", step.node_id)\n

Treat completed=False as a signal, not an exception. See use case 07.

"},{"location":"06_error_handling/#handling-strategies","title":"\ud83d\udca1 Handling strategies","text":""},{"location":"06_error_handling/#read-next","title":"\ud83d\udcda Read Next","text":""},{"location":"07_testing_example/","title":"\ud83e\uddea Testing Example","text":"

dagpipe's own test suite runs without any external services \u2014 nodes are the only things you need to fake. The pattern: small schema/state factories, then assert on terminal states and history.

"},{"location":"07_testing_example/#setup-state-factories","title":"\ud83c\udfaf Setup: state factories","text":"

Import from dagpipe and declare tiny schemas/states, exactly like the project's conftest.py:

# conftest.py\nimport pytest\nfrom dagpipe import Payload, Schema, State\n\nTestSchema = Schema({\"value\": object})\nItemSchema = Schema({\n    \"raw\": object,\n    \"entity\": Schema({\"candidate\": str | None, \"name\": str | None}),\n    \"tags\": list | None,\n})\n\nclass TestState(State):\n    schema = TestSchema\n\nclass ItemState(State):\n    schema = ItemSchema\n\n@pytest.fixture\ndef make_state():\n    def _make(value=1):\n        return TestState(payload=Payload({\"value\": value}))\n    return _make\n\n@pytest.fixture\ndef make_item_state():\n    def _make(**payload):\n        base = {\"raw\": object(), \"tags\": []}\n        base.update(payload)\n        return ItemState(payload=Payload(base))\n    return _make\n
"},{"location":"07_testing_example/#a-node-to-test","title":"\ud83c\udfd7\ufe0f A node to test","text":"
from dagpipe import Node\n\nclass Increment(Node):\n    id = \"test.increment\"\n\n    def resolve(self, state):\n        yield self.fork(state, payload_update={\"value\": state.get(\"value\") + 1})\n
"},{"location":"07_testing_example/#single-node-test","title":"\u2705 Single-node test","text":"
def test_increment(make_state):\n    node = Increment()\n    (result,) = node.run(make_state(value=1))\n\n    assert result.get(\"value\") == 2\n    assert result.depth == 1\n    assert result.history == (\"test.increment\",)\n
"},{"location":"07_testing_example/#graph-behavior-test","title":"\ud83d\udd00 Graph behavior test","text":"
from dagpipe import Engine, Graph\n\ndef test_branching_and_merge(make_item_state):\n    class Extract(Node):\n        id = \"entity.extract\"\n        def resolve(self, state):\n            yield self.fork(state, payload_update={\"entity.candidate\": \"amzn\"})\n            yield self.fork(state, payload_update={\"entity.candidate\": \"flipkart\"})\n\n    class Resolve(Node):\n        id = \"entity.resolve\"\n        def resolve(self, state):\n            if state.get(\"entity.candidate\"):\n                yield self.fork(state, payload_update={\n                    \"entity.name\": state.get(\"entity.candidate\").upper()})\n\n    graph = Graph()\n    graph.add_edge(Extract(), Resolve())\n\n    results = Engine(graph).run(make_item_state())\n    names = sorted(s.get(\"entity.name\") for s in results)\n\n    assert names == [\"AMZN\", \"FLIPKART\"]\n    assert all(len(s.history) == 2 for s in results)   # extract \u2192 resolve\n
"},{"location":"07_testing_example/#error-path-tests","title":"\u26a0\ufe0f Error-path tests","text":"
import pytest\nfrom dagpipe import Payload, Schema, SchemaError, State\n\ndef test_invalid_payload_raises():\n    class S(State):\n        schema = Schema({\"name\": str})\n\n    with pytest.raises(SchemaError, match=\"must be str\"):\n        S(payload=Payload({\"name\": 42}))\n\ndef test_bad_yield_raises_type_error(make_state):\n    class Bad(Node):\n        id = \"bad.output\"\n        def resolve(self, state):\n            yield \"nope\"\n\n    with pytest.raises(TypeError, match=\"must yield State\"):\n        Bad().run(make_state())\n
"},{"location":"07_testing_example/#testing-yaml-pipelines-offline","title":"\ud83e\uddf1 Testing YAML pipelines offline","text":"

Use a tiny importable node module + tmp_path, as the integration suite does:

def test_yaml_pipeline(tmp_path, monkeypatch):\n    module_dir = tmp_path / \"testnodes\"\n    module_dir.mkdir()\n    (module_dir / \"__init__.py\").write_text(\"\"\"\nfrom dagpipe.node import Node\n\nclass Increment(Node):\n    id = \"test.increment\"\n    def resolve(self, state):\n        yield self.fork(state, payload_update={\"value\": state.get(\"value\") + 1})\n\"\"\")\n\n    monkeypatch.syspath_prepend(tmp_path)\n\n    yaml_file = tmp_path / \"p.yaml\"\n    yaml_file.write_text(\"\"\"\nschema:\n  value: int\ninitial:\n  value: 1\nnodes:\n  inc:\n    class: testnodes.Increment\ngraph:\n  roots:\n    - inc\n\"\"\")\n\n    from dagpipe import load_pipeline\n    results = load_pipeline(yaml_file).run()\n\n    assert results[0].get(\"value\") == 2\n
"},{"location":"07_testing_example/#tips","title":"\ud83d\udca1 Tips","text":"
pytest -q\n
"},{"location":"07_testing_example/#read-next","title":"\ud83d\udcda Read Next","text":""},{"location":"03_use_cases/01_basic_linear_pipeline/","title":"01 \u2013 Basic Linear Pipeline","text":"

A fixed sequence of steps: each step receives every state the previous step produced, and the last step's states are the terminal results.

"},{"location":"03_use_cases/01_basic_linear_pipeline/#goal","title":"\ud83c\udfaf Goal","text":"

Normalize and sum a list of numbers with a three-node chain: Clean \u2192 Sum \u2192 Announce.

"},{"location":"03_use_cases/01_basic_linear_pipeline/#nodes","title":"\ud83e\uddf1 Nodes","text":"
from dagpipe import Node, State\n\nclass Clean(Node):\n    id = \"math.clean\"\n\n    def resolve(self, state: State):\n        numbers = [n for n in state.get(\"numbers\", []) if isinstance(n, (int, float))]\n        yield self.fork(state, payload_update={\"numbers\": numbers})\n\nclass Sum(Node):\n    id = \"math.sum\"\n\n    def resolve(self, state: State):\n        yield self.fork(\n            state,\n            payload_update={\"total\": sum(state.get(\"numbers\", []))},\n        )\n\nclass Announce(Node):\n    id = \"math.announce\"\n\n    def resolve(self, state: State):\n        print(f\"total={state.get('total')}\")\n        yield self.fork(state, payload_update={\"announced\": True})\n
"},{"location":"03_use_cases/01_basic_linear_pipeline/#run-it","title":"\ud83c\udfc3 Run it","text":"
from dagpipe import Engine, Payload, Schema, State\n\nclass CalcState(State):\n    schema = Schema({\n        \"numbers\": list,\n        \"total\": int | float | None,\n        \"announced\": bool | None,\n    })\n\nengine = Engine([Clean(), Sum(), Announce()])\nresults = engine.run(CalcState(payload=Payload({\"numbers\": [1, \"x\", 2, 3.5]})))\n\nstate = results[0]\nassert state.get(\"numbers\") == [1, 2, 3.5]\nassert state.get(\"total\") == 6.5\nassert state.get(\"announced\") is True\nassert len(state.history) == 3      # clean \u2192 sum \u2192 announce\nassert state.depth == 3\n
"},{"location":"03_use_cases/01_basic_linear_pipeline/#what-just-happened","title":"\ud83d\udd0d What just happened","text":"

If any node yields zero states, the engine breaks and returns an empty list \u2014 that is how a pipeline can terminate early.

"},{"location":"03_use_cases/01_basic_linear_pipeline/#tips","title":"\ud83d\udca1 Tips","text":""},{"location":"03_use_cases/01_basic_linear_pipeline/#related","title":"\ud83d\udcda Related","text":""},{"location":"03_use_cases/02_graph_execution/","title":"02 \u2013 Graph Execution","text":"

A DAG lets states fan out (one node, many children) and merge (many nodes, one child). Both are plain State flow \u2014 the engine handles the plumbing.

"},{"location":"03_use_cases/02_graph_execution/#goal","title":"\ud83c\udfaf Goal","text":"

Resolve an entity candidate into a name through two alternative resolvers, then tag the result \u2014 while a Direct path bypasses resolution entirely.

\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510      \u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\n\u2502 entity.extract  \u2502 \u2500\u2500\u252c\u2500\u2500\u25b6 resolve_amzn \u2500\u2500\u2510\n\u2502                 \u2502   \u2502  \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2510\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518   \u2502                     \u251c\u2500\u2500\u25b6 entity.tag\n\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510   \u2502  \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502\n\u2502 entity.direct   \u2502 \u2500\u2500\u253c\u2500\u2500\u25b6 entity.direct  \u2500\u2524 (no children \u2192 terminal)\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518   \u2502        \u2502              \n\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510   \u2502        \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n\u2502 entity.kill     \u2502 \u2500\u2500\u2518  (yields nothing \u2192 branch pruned)\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n
"},{"location":"03_use_cases/02_graph_execution/#nodes","title":"\ud83e\uddf1 Nodes","text":"
from dagpipe import Graph, Node\n\nclass ExtractCandidate(Node):\n    id = \"entity.extract\"\n\n    def resolve(self, state):\n        # fan-out: two candidate states for one input\n        yield self.fork(state, payload_update={\"entity.candidate\": \"amzn\"})\n        yield self.fork(state, payload_update={\"entity.candidate\": \"flipkart\"})\n\nclass ResolveAmazon(Node):\n    id = \"entity.resolve_amzn\"\n\n    def resolve(self, state):\n        if state.get(\"entity.candidate\") == \"amzn\":\n            yield self.fork(state, payload_update={\"entity.name\": \"Amazon\"})\n\nclass ResolveFlipkart(Node):\n    id = \"entity.resolve_fk\"\n\n    def resolve(self, state):\n        if state.get(\"entity.candidate\") == \"flipkart\":\n            yield self.fork(state, payload_update={\"entity.name\": \"Flipkart\"})\n\nclass TagNode(Node):\n    id = \"entity.tag\"\n\n    def resolve(self, state):\n        # only runs when a name was resolved\n        if state.get(\"entity.name\"):\n            tags = state.get(\"tags\") or []\n            yield self.fork(state, payload_update={\"tags\": tags + [\"resolved\"]})\n\nclass DirectNode(Node):\n    id = \"entity.direct\"\n\n    def resolve(self, state):\n        yield self.fork(state, payload_update={\"entity.name\": \"Direct\"})\n\nclass KillNode(Node):\n    id = \"entity.kill\"\n\n    def resolve(self, state):\n        return ()   # branch pruned: no states, no children visited\n
"},{"location":"03_use_cases/02_graph_execution/#wire-the-graph","title":"\ud83c\udfd7\ufe0f Wire the graph","text":"
graph = Graph()\ngraph.add_edge(ExtractCandidate(), ResolveAmazon())\ngraph.add_edge(ExtractCandidate(), ResolveFlipkart())\ngraph.add_edge(ResolveAmazon(), TagNode())\ngraph.add_edge(ResolveFlipkart(), TagNode())\ngraph.add_root(DirectNode())\ngraph.add_root(KillNode())\n
"},{"location":"03_use_cases/02_graph_execution/#run-it","title":"\ud83c\udfc3 Run it","text":"
from dagpipe import Engine, Payload, Schema, State\n\nclass ItemState(State):\n    schema = Schema({\n        \"raw\": object,\n        \"entity\": Schema({\"candidate\": str | None, \"name\": str | None}),\n        \"tags\": list | None,\n    })\n\nroot = ItemState(payload=Payload({\"raw\": object(), \"tags\": []}))\nresults = Engine(graph).run(root)\n\nnames = sorted(s.get(\"entity.name\") for s in results)\nassert names == [\"Amazon\", \"Direct\", \"Flipkart\"]\n\ntagged = {s.get(\"entity.name\"): s.get(\"tags\") for s in results}\nassert tagged[\"Amazon\"] == [\"resolved\"]\nassert tagged[\"Flipkart\"] == [\"resolved\"]\nassert tagged[\"Direct\"] is None     # direct path never reached tag\n
"},{"location":"03_use_cases/02_graph_execution/#what-just-happened","title":"\ud83d\udd0d What just happened","text":""},{"location":"03_use_cases/02_graph_execution/#tips","title":"\ud83d\udca1 Tips","text":""},{"location":"03_use_cases/02_graph_execution/#related","title":"\ud83d\udcda Related","text":""},{"location":"03_use_cases/03_yaml_pipeline/","title":"03 \u2013 YAML Pipelines","text":"

load_pipeline() builds a complete pipeline \u2014 schema, state subclass, nodes, graph, engine, and initial payload \u2014 from one declarative YAML file.

"},{"location":"03_use_cases/03_yaml_pipeline/#goal","title":"\ud83c\udfaf Goal","text":"

Recreate the entity-resolution DAG from use case 02 purely in YAML.

"},{"location":"03_use_cases/03_yaml_pipeline/#the-yaml-file","title":"\ud83d\udcc4 The YAML file","text":"
version: 1\n\nschema:\n  raw: object\n  tags: list | None\n  entity:\n    candidate: str | None\n    name: str | None\n\ninitial:\n  raw: {}\n\nnodes:\n  extract:\n    class: testnodes.ExtractCandidate\n  resolve_amzn:\n    class: testnodes.ResolveAmazon\n  resolve_fk:\n    class: testnodes.ResolveFlipkart\n  tag:\n    class: testnodes.TagNode\n  direct:\n    class: testnodes.DirectNode\n  kill:\n    class: testnodes.KillNode\n\ngraph:\n  roots:\n    - extract\n    - direct\n    - kill\n  edges:\n    extract:\n      - resolve_amzn\n      - resolve_fk\n    resolve_amzn:\n      - tag\n    resolve_fk:\n      - tag\n

Node class paths are fully qualified (module.ClassName). The modules are imported with importlib at load time, so they must be importable from your runtime environment.

"},{"location":"03_use_cases/03_yaml_pipeline/#run-it","title":"\ud83c\udfc3 Run it","text":"
from dagpipe import load_pipeline\n\npipeline = load_pipeline(\"pipeline.yaml\")\nresults = pipeline.run()\n\nnames = sorted(s.get(\"entity.name\") for s in results)\nassert names == [\"Amazon\", \"Direct\", \"Flipkart\"]\n
"},{"location":"03_use_cases/03_yaml_pipeline/#schema-short-hand","title":"\ud83d\udd0d Schema short-hand","text":"

Leaf values in schema: map to Python types:

YAML Python str str int / float int / float bool bool dict / list dict / list bytes bytes object object None NoneType str | None str \\| None (PEP-604 union)

Nested schema blocks become nested Schema instances.

"},{"location":"03_use_cases/03_yaml_pipeline/#overriding-the-initial-payload","title":"\ud83d\udd0d Overriding the initial payload","text":"

Pipeline.run(payload_override=None) merges override values on top of initial: using Payload.update \u2014 the root payload is the override, not the default:

results = pipeline.run(payload_override={\"raw\": \"custom\"})\n
"},{"location":"03_use_cases/03_yaml_pipeline/#tips","title":"\ud83d\udca1 Tips","text":""},{"location":"03_use_cases/03_yaml_pipeline/#related","title":"\ud83d\udcda Related","text":""},{"location":"03_use_cases/04_schema_and_payload/","title":"04 \u2013 Schema and Payload","text":"

Schema declares the allowed shape of a Payload, and is enforced whenever a State is constructed or forked.

"},{"location":"03_use_cases/04_schema_and_payload/#goal","title":"\ud83c\udfaf Goal","text":"

Validate a document with a nested address object, using unions for optional fields.

"},{"location":"03_use_cases/04_schema_and_payload/#the-schema","title":"\ud83e\uddf1 The schema","text":"
from dagpipe import Payload, Schema\n\nAddressSchema = Schema({\n    \"city\": str,\n    \"zip\": int | None,\n})\n\nUserSchema = Schema({\n    \"name\": str,\n    \"address\": AddressSchema,\n})\n\nDeepItemSchema = Schema({\n    \"raw\": object,\n    \"user\": UserSchema,\n})\n

Outer keys inside a Schema tree point at one of:

"},{"location":"03_use_cases/04_schema_and_payload/#valid-payloads","title":"\u2705 Valid payloads","text":"
ok = Payload({\n    \"raw\": {\"anything\": \"goes\"},\n    \"user\": {\n        \"name\": \"John\",\n        \"address\": {\"city\": \"Mumbai\", \"zip\": 400001},\n    },\n})\nDeepItemSchema.validate_payload(ok)   # no-op\n
"},{"location":"03_use_cases/04_schema_and_payload/#violations","title":"\ud83d\udca5 Violations","text":"

validate_payload raises SchemaError for any of:

Case Example Error Undefined key \"phone\" not declared Invalid path 'phone' not defined in schema Wrong scalar type \"name\": 42 Path 'user.name' must be str Union violation \"zip\": \"abc\" Path 'user.address.zip' must be one of (int, NoneType) Non-container for nested schema \"user\": \"joe\" Path 'user' must be a container
from dagpipe import SchemaError\n\ntry:\n    DeepItemSchema.validate_payload(\n        Payload({\"raw\": {}, \"user\": {\"name\": 42, \"address\": {\"city\": \"M\"}}})\n    )\nexcept SchemaError as e:\n    print(e)   # Path 'user.name' must be str\n
"},{"location":"03_use_cases/04_schema_and_payload/#updates-are-validated-too","title":"\ud83d\udd0d Updates are validated too","text":"

State.fork(payload_update=...) calls validate_update, which checks that every dot-path in the update is declared \u2014 before anything is copied:

UserSchema.validate_update({\"address.city\": \"Paris\"})   # OK\nUserSchema.validate_update({\"address.country\": \"FR\"})   # SchemaError: not in schema\n
"},{"location":"03_use_cases/04_schema_and_payload/#dot-path-access","title":"\ud83d\udd0d Dot-path access","text":"

Payload gives typed, immutable access to nested values:

p = Payload({\"user\": {\"address\": {\"city\": \"Mumbai\", \"zip\": None}}})\n\np.get(\"user.address.city\")     # 'Mumbai'\np.get(\"user.address.zip\")      # None\np.has(\"user.address.zip\")      # True\np.has(\"user.phone\")            # False\np.update({\"user.phone\": \"123\"})  # Schema may reject this on the State level!\n

Payload.update itself is schema-unaware \u2014 the shape check belongs to the State/Schema layer. Use State.fork when you want validation.

"},{"location":"03_use_cases/04_schema_and_payload/#tips","title":"\ud83d\udca1 Tips","text":""},{"location":"03_use_cases/04_schema_and_payload/#related","title":"\ud83d\udcda Related","text":""},{"location":"03_use_cases/05_state_fork_and_lineage/","title":"05 \u2013 State Fork and Lineage","text":"

State is immutable: nodes never modify it. They produce new states via fork(), which carries lineage, confidence, and history forward.

"},{"location":"03_use_cases/05_state_fork_and_lineage/#goal","title":"\ud83c\udfaf Goal","text":"

Walk a branch tree and inspect how states are related, how confidence compounds, and how history records the exact node path taken.

"},{"location":"03_use_cases/05_state_fork_and_lineage/#setup","title":"\ud83e\uddf1 Setup","text":"
from dagpipe import Payload, Schema, State, Node\n\nclass ScoreState(State):\n    schema = Schema({\"score\": int | float, \"label\": str | None})\n\nclass AddBonus(Node):\n    id = \"score.bonus\"\n\n    def resolve(self, state):\n        yield self.fork(state, confidence_delta=0.1,\n                        payload_update={\"score\": state.get(\"score\") + 10})\n

Node.fork passes node_id=self.id, so history records the point of derivation automatically.

"},{"location":"03_use_cases/05_state_fork_and_lineage/#forking","title":"\ud83d\udd0d Forking","text":"
root = ScoreState(payload=Payload({\"score\": 50}))\n\ns1 = root.fork(\n    payload_update={\"score\": 60},\n    confidence_delta=0.1,\n    node_id=\"score.bonus\",\n    metadata_update={\"source\": \"manual\"},\n)\n\ns2 = s1.fork(payload_update={\"label\": \"high\"})\n
Field root s1 s2 depth 0 1 2 score 50 60 60 confidence 1.0 1.1 1.1 history () ('score.bonus',) ('score.bonus',) parent None root s1"},{"location":"03_use_cases/05_state_fork_and_lineage/#lineage","title":"\ud83d\udd0d Lineage","text":"

lineage() walks parents back to the root, root-first:

states = s2.lineage()\nassert states == (root, s1, s2)\nassert states[0] is root\nassert states[-1] is s2\n
"},{"location":"03_use_cases/05_state_fork_and_lineage/#immutability-guarantees","title":"\ud83d\udd0d Immutability guarantees","text":"
assert root.get(\"label\") is None       # s2's update didn't touch root\nassert s1.get(\"label\") is None         # fork never mutates its input\nassert root.confidence == 1.0          # confidence travels forward only\nassert root is s1.parent               # parent is by reference\n
"},{"location":"03_use_cases/05_state_fork_and_lineage/#metadata-confidence","title":"\ud83d\udd0d Metadata & confidence","text":""},{"location":"03_use_cases/05_state_fork_and_lineage/#tips","title":"\ud83d\udca1 Tips","text":""},{"location":"03_use_cases/05_state_fork_and_lineage/#related","title":"\ud83d\udcda Related","text":""},{"location":"03_use_cases/06_async_execution/","title":"06 \u2013 Async Execution","text":"

AsyncNode subclasses run I/O-bound steps (HTTP, DB, file reads) via Engine.run_async. Sync and async nodes can be mixed in the same graph \u2014 the engine dispatches each node to the right path automatically.

"},{"location":"03_use_cases/06_async_execution/#goal","title":"\ud83c\udfaf Goal","text":"

Fetch a document from a remote service, then run a synchronous post-processor.

import asyncio\nfrom dagpipe import AsyncNode, Node, Schema, State, Payload, Graph, Engine\n\nclass FetchDoc(AsyncNode):\n    id = \"web.fetch\"\n\n    async def resolve_async(self, state: State):\n        body = await fetch_body(state.get(\"url\"))     # your async I/O\n        yield self.fork(state, payload_update={\"body\": body, \"fetched\": True})\n\nclass Summarize(Node):\n    id = \"text.summarize\"\n\n    def resolve(self, state: State):\n        words = len(state.get(\"body\", \"\").split())\n        yield self.fork(state, payload_update={\"word_count\": words})\n\nclass DocState(State):\n    schema = Schema({\n        \"url\": str,\n        \"body\": str | None,\n        \"fetched\": bool | None,\n        \"word_count\": int | None,\n    })\n
"},{"location":"03_use_cases/06_async_execution/#run-it","title":"\ud83c\udfc3 Run it","text":"
async def main():\n    graph = Graph()\n    graph.add_edge(FetchDoc(), Summarize())\n\n    engine = Engine(graph)\n    results = await engine.run_async(DocState(payload=Payload({\"url\": \"https://example.com\"})))\n\n    assert results[0].get(\"fetched\") is True\n    assert results[0].get(\"word_count\") > 0\n\nasyncio.run(main())\n
"},{"location":"03_use_cases/06_async_execution/#how-dispatch-works","title":"\ud83d\udd0d How dispatch works","text":"

Engine.run_async checks each node at execution time:

node is AsyncNode ? \u2192 await node.run_async(state)   # resolve_async()\n                    \u2192 node.run(state)               # resolve()\n
"},{"location":"03_use_cases/06_async_execution/#async-step-wise-variant","title":"\ud83d\udd0d Async step-wise variant","text":"

run_steps_async pairs with async nodes for progress + resume:

async for step in engine.run_steps_async(root):\n    print(step.index, step.node_id, len(step.states), step.completed)\n
"},{"location":"03_use_cases/06_async_execution/#tips","title":"\ud83d\udca1 Tips","text":""},{"location":"03_use_cases/06_async_execution/#related","title":"\ud83d\udcda Related","text":""},{"location":"03_use_cases/07_step_execution/","title":"07 \u2013 Step-wise Execution","text":"

Engine.run_steps runs a pipeline one step at a time, yielding a StepResult per executed node. It supports resume (skip already-done steps) and progress hooks (report started / completed / skipped).

"},{"location":"03_use_cases/07_step_execution/#goal","title":"\ud83c\udfaf Goal","text":"

Run a three-node pipeline, observe progress, and resume a partial run.

"},{"location":"03_use_cases/07_step_execution/#setup","title":"\ud83e\uddf1 Setup","text":"
from dagpipe import Engine, Node, Payload, Schema, State\n\nclass StepA(Node):\n    id = \"qa.step_a\"\n    def resolve(self, state):\n        yield self.fork(state, payload_update={\"a\": True})\n\nclass StepB(Node):\n    id = \"qa.step_b\"\n    def resolve(self, state):\n        if not state.get(\"a\"):\n            return                       # prune branch if precondition unmet\n        yield self.fork(state, payload_update={\"b\": True})\n\nclass StepC(Node):\n    id = \"qa.step_c\"\n    def resolve(self, state):\n        yield self.fork(state, payload_update={\"c\": True})\n\nclass QState(State):\n    schema = Schema({\"a\": bool | None, \"b\": bool | None, \"c\": bool | None})\n\nengine = Engine([StepA(), StepB(), StepC()])\nroot = QState(payload=Payload({}))\n
"},{"location":"03_use_cases/07_step_execution/#step-by-step","title":"\ud83c\udfc3 Step by step","text":"
for step in engine.run_steps(root):\n    print(f\"#{step.index} {step.node_id:10} completed={step.completed} states={len(step.states)}\")\n

Output:

#0 qa.step_a completed=True states=1\n#1 qa.step_b completed=True states=1\n#2 qa.step_c completed=True states=1\n

Each StepResult exposes:

Field Meaning index 0-based step ordinal (resume_from is relative to this) node_id The node that ran in this step states States produced by this step completed True if any state was produced"},{"location":"03_use_cases/07_step_execution/#resume-after-interruption","title":"\ud83d\udd0d Resume after interruption","text":"

If you already processed #0 and #1, skip ahead:

for step in engine.run_steps(root, resume_from=2):\n    print(step.node_id)      # only qa.step_c\n
"},{"location":"03_use_cases/07_step_execution/#progress-hooks","title":"\ud83d\udd0d Progress hooks","text":"

Pass on_step (or set it at engine construction) to be notified per step:

engine = Engine([StepA(), StepB(), StepC()],\n                on_step=lambda step, status, msg: print(step, status))\n\nfor step in engine.run_steps(root):\n    ...\n

Output:

qa.step_a started\nqa.step_a completed\nqa.step_b started\nqa.step_b completed\n...\n

A ProgressMessage can be passed as the third argument for richer progress (lines, blocks, count, unit, error, ...). Use as_dict() to surface it in your logging.

The async variant run_steps_async mirrors this API with on_step: AsyncStepHook \u2014 see use case 06.

"},{"location":"03_use_cases/07_step_execution/#step-ordering-in-graph-mode","title":"\ud83d\udd0d Step ordering in graph mode","text":"

In graph mode, run_steps derives a deterministic topological order (DFS post-order, roots first), one StepResult per node. A node that yields no states marks that step completed=False and the branch stops there.

"},{"location":"03_use_cases/07_step_execution/#tips","title":"\ud83d\udca1 Tips","text":""},{"location":"03_use_cases/07_step_execution/#related","title":"\ud83d\udcda Related","text":""}]}