A wheel whose top-level module is `app` collides with anything else in a user's venv, so the package that is about to be published takes the name it is published under. Only the Python package moves; the repo, the Docker WORKDIR and the compose project keep theirs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
156 lines
4.7 KiB
Python
156 lines
4.7 KiB
Python
"""State carried between runs, without a state API.
|
|
|
|
Logic nodes are pure functions of their inputs, so an accumulator keeps its
|
|
running value in a message it both reads and writes. That is the sanctioned
|
|
shape, and these tests pin what it means.
|
|
"""
|
|
|
|
from fluksio.flow.messages import DType, MessageSpec
|
|
from fluksio.flow.nodes import Node
|
|
from fluksio.flow.pipeline import Pipeline
|
|
from fluksio.flow.state import MemoryState
|
|
|
|
|
|
def counter() -> Node:
|
|
"""Adds each reading to a total it keeps in its own output message."""
|
|
|
|
def process(reading, params, total=0.0):
|
|
return {"total": total + reading}
|
|
|
|
node = Node(
|
|
f=process,
|
|
requires=[
|
|
MessageSpec(name="reading", port="reading", dtype=DType.FLOAT),
|
|
MessageSpec(name="total", port="total", dtype=DType.FLOAT),
|
|
],
|
|
provides=[MessageSpec(name="total", port="total", dtype=DType.FLOAT)],
|
|
name="accumulate",
|
|
)
|
|
node.assign_flow("f", "accumulate")
|
|
return node
|
|
|
|
|
|
def source() -> Node:
|
|
def process(params):
|
|
return {}
|
|
|
|
node = Node(
|
|
f=process,
|
|
provides=[MessageSpec(name="reading", port="reading", dtype=DType.FLOAT)],
|
|
name="sensor",
|
|
)
|
|
node.assign_flow("f", "sensor")
|
|
return node
|
|
|
|
|
|
def test_a_node_reading_what_it_writes_does_not_depend_on_itself():
|
|
node = counter()
|
|
pipeline = Pipeline(nodes=[node])
|
|
|
|
assert pipeline.dependencies[node] == frozenset()
|
|
# Nor is it downstream of itself, so publishing does not re-run it.
|
|
assert node not in pipeline.edges[node]
|
|
assert pipeline.validate({"f.reading": True, "f.total": True}) == []
|
|
|
|
|
|
def test_the_running_total_survives_between_runs():
|
|
state = MemoryState()
|
|
node = counter()
|
|
pipeline = Pipeline(
|
|
nodes=[node],
|
|
state=state,
|
|
initial_values={"f.reading": 0.0, "f.total": 0.0},
|
|
)
|
|
|
|
for reading in (2.0, 3.0, 5.0):
|
|
state["f.reading"] = reading
|
|
pipeline.run()
|
|
|
|
assert state["f.total"] == 10.0
|
|
|
|
|
|
def test_a_self_loop_without_a_starting_value_is_reported():
|
|
"""It could never run: the value it waits for is the one it writes."""
|
|
node = counter()
|
|
pipeline = Pipeline(nodes=[node, source()])
|
|
|
|
issues = pipeline.validate({"f.reading": True})
|
|
|
|
assert [i.code for i in issues] == ["self_loop_needs_initial"]
|
|
assert issues[0].message_name == "f.total"
|
|
|
|
# Declaring it as a flow input with a value settles it.
|
|
assert pipeline.validate({"f.reading": True, "f.total": True}) == []
|
|
|
|
|
|
def test_a_non_triggering_input_makes_a_two_node_loop_legal():
|
|
"""A→B→A is a cycle only while both edges wake their consumer.
|
|
|
|
Marking the back edge non-triggering says what is actually meant: B's
|
|
result is state A reads on its next run, not something that runs A.
|
|
"""
|
|
|
|
def forward(params, value=0.0):
|
|
return {"echo": value}
|
|
|
|
def back(echo, params):
|
|
return {"value": echo + 1.0}
|
|
|
|
a = Node(
|
|
f=forward,
|
|
# The back edge: read when a runs, never the reason it runs.
|
|
requires=[
|
|
MessageSpec(name="value", port="value", dtype=DType.FLOAT, trigger=False)
|
|
],
|
|
provides=[MessageSpec(name="echo", port="echo", dtype=DType.FLOAT)],
|
|
name="a",
|
|
)
|
|
b = Node(
|
|
f=back,
|
|
requires=[MessageSpec(name="echo", port="echo", dtype=DType.FLOAT)],
|
|
provides=[MessageSpec(name="value", port="value", dtype=DType.FLOAT)],
|
|
name="b",
|
|
)
|
|
a.assign_flow("f", "a")
|
|
b.assign_flow("f", "b")
|
|
|
|
state = MemoryState()
|
|
pipeline = Pipeline(nodes=[a, b], state=state, initial_values={"f.value": 1.0})
|
|
|
|
assert pipeline.dependencies[a] == frozenset()
|
|
assert pipeline.dependencies[b] == frozenset({a})
|
|
# Kahn sees no loop, so nothing is reported as cyclic.
|
|
assert [i.code for i in pipeline.validate({"f.value": True})] == []
|
|
|
|
pipeline.run()
|
|
assert state["f.echo"] == 1.0
|
|
assert state["f.value"] == 2.0
|
|
|
|
# The next run reads what b wrote, which is the point of the back edge.
|
|
pipeline.run()
|
|
assert state["f.echo"] == 2.0
|
|
assert state["f.value"] == 3.0
|
|
|
|
|
|
def test_a_non_triggering_input_never_holds_a_node_back():
|
|
"""Absent, it is simply left out of the call rather than awaited."""
|
|
|
|
def process(params, seen=None):
|
|
return {"out": 1.0 if seen is None else 2.0}
|
|
|
|
node = Node(
|
|
f=process,
|
|
requires=[
|
|
MessageSpec(name="seen", port="seen", dtype=DType.FLOAT, trigger=False)
|
|
],
|
|
provides=[MessageSpec(name="out", port="out", dtype=DType.FLOAT)],
|
|
name="n",
|
|
)
|
|
node.assign_flow("f", "n")
|
|
|
|
state = MemoryState()
|
|
pipeline = Pipeline(nodes=[node], state=state)
|
|
|
|
pipeline.run()
|
|
assert state["f.out"] == 1.0
|