The generated node body imported the function under its own name and then gave `process` the ports as parameters, so a port named like its function shadowed the import and the call became a value calling itself — `TypeError: 'str' object is not callable`, with the downstream node's missing arguments as the knock-on. The import is aliased now. Only the two names the body needs itself, `settings` and `_impl`, are refused at sync. Every shim's text moves once, so the next sync reports every code-defined node updated and each cached node misses a single time. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TXQv6KNyyvY7Z1etYTUUAd
367 lines
12 KiB
Python
367 lines
12 KiB
Python
"""What the decorators declare, and what the engine would be given."""
|
|
|
|
import pytest
|
|
|
|
from fluksio.flow.schemas import FlowDef
|
|
from fluksio.sdk import MARKER, Flow, Port, SyncError, node, use
|
|
|
|
# The functions live here rather than in each test: a node's module is what the
|
|
# generated body imports, and `__main__` is refused at sync for that reason.
|
|
|
|
|
|
@node(provides=[Port("dataset", "artifact"), Port("rows", "int")])
|
|
def prepare(limit=8):
|
|
return {"dataset": {"digest": "sha256:x", "size": 1}, "rows": limit}
|
|
|
|
|
|
@node(
|
|
requires=["dataset", Port("lr", "float")],
|
|
provides=[Port("loss", "float", stream=True), Port("weights", "artifact")],
|
|
device="gpu",
|
|
device_policy="prefer",
|
|
)
|
|
def fit(dataset, lr, epochs=3):
|
|
for step in range(epochs):
|
|
yield {"loss": 1.0 / (step + 1)}
|
|
return {"weights": {"digest": "sha256:y", "size": 1}}
|
|
|
|
|
|
@node(requires=["weights"], provides=Port("score", "float"))
|
|
def evaluate(weights):
|
|
return 0.5
|
|
|
|
|
|
@node(requires=["dataset"], provides=[Port("augmented", "artifact")])
|
|
def augment(dataset):
|
|
return {"augmented": dataset}
|
|
|
|
|
|
def a_flow(name="train", **kwargs):
|
|
options = {
|
|
"nodes": [prepare, fit, evaluate],
|
|
"inputs": [Port("lr", "float", initial=0.01)],
|
|
"outputs": ["score"],
|
|
}
|
|
options.update(kwargs)
|
|
return Flow(name, **options)
|
|
|
|
|
|
def test_document_is_a_flow_the_engine_accepts():
|
|
document = a_flow().document({"kind": "python", "repo": "/r", "commit": "abc"})
|
|
definition = FlowDef.model_validate(document)
|
|
|
|
assert definition.mode == "batch"
|
|
assert [n.id for n in definition.nodes] == ["prepare", "fit", "evaluate"]
|
|
assert definition.origin is not None
|
|
assert definition.origin.commit == "abc"
|
|
assert [i.spec.name for i in definition.inputs] == ["lr"]
|
|
assert definition.outputs == ["score"]
|
|
|
|
|
|
def test_defaults_become_settings_and_ports_do_not():
|
|
definition = FlowDef.model_validate(a_flow().document())
|
|
nodes = {n.id: n for n in definition.nodes}
|
|
|
|
assert nodes["prepare"].params == {"limit": 8}
|
|
# `dataset` and `lr` are ports, so they are not settings as well.
|
|
assert nodes["fit"].params == {"epochs": 3}
|
|
assert {p.port for p in nodes["fit"].requires} == {"dataset", "lr"}
|
|
|
|
|
|
def test_device_travels_to_the_node():
|
|
definition = FlowDef.model_validate(a_flow().document())
|
|
node_def = next(n for n in definition.nodes if n.id == "fit")
|
|
|
|
assert node_def.device == "gpu"
|
|
assert node_def.device_policy == "prefer"
|
|
|
|
|
|
def test_a_required_port_takes_the_type_it_is_provided_as():
|
|
"""`requires=["dataset"]` is not a second declaration of its type."""
|
|
definition = FlowDef.model_validate(a_flow().document())
|
|
node_def = next(n for n in definition.nodes if n.id == "fit")
|
|
|
|
assert next(p for p in node_def.requires if p.port == "dataset").dtype == "artifact"
|
|
|
|
|
|
def test_use_rewires_and_reconfigures_one_flow_only():
|
|
train = a_flow()
|
|
finetune = Flow(
|
|
"finetune",
|
|
nodes=[prepare, augment, use(fit, wire={"dataset": "augmented"}, epochs=9)],
|
|
inputs=[Port("lr", "float")],
|
|
)
|
|
|
|
theirs = next(n for n in finetune.document()["nodes"] if n["id"] == "fit")
|
|
ours = next(n for n in train.document()["nodes"] if n["id"] == "fit")
|
|
assert [p["name"] for p in theirs["requires"]] == ["augmented", "lr"]
|
|
assert theirs["params"] == {"epochs": 9}
|
|
# The same function in another flow is untouched by that.
|
|
assert [p["name"] for p in ours["requires"]] == ["dataset", "lr"]
|
|
assert ours["params"] == {"epochs": 3}
|
|
|
|
|
|
def unknown_port(a):
|
|
return a
|
|
|
|
|
|
def two_arguments(a, b):
|
|
return a
|
|
|
|
|
|
def one_default(a=1):
|
|
return a
|
|
|
|
|
|
def a_generator():
|
|
yield {"x": 1}
|
|
|
|
|
|
def reads_dataset(dataset):
|
|
return dataset
|
|
|
|
|
|
def test_a_port_with_no_parameter_is_refused():
|
|
with pytest.raises(SyncError, match="no parameter to arrive in"):
|
|
node(requires=["nope"])(unknown_port)
|
|
|
|
|
|
def test_a_parameter_that_is_neither_port_nor_setting_is_refused():
|
|
with pytest.raises(SyncError, match="neither a port nor a setting"):
|
|
node(requires=["a"])(two_arguments)
|
|
|
|
|
|
def test_a_name_the_store_would_refuse_is_refused_here():
|
|
with pytest.raises(SyncError, match="node id"):
|
|
node(requires=["a"], id="Trainer")(one_default)
|
|
|
|
|
|
def test_a_generator_cannot_use_the_bare_return_shorthand():
|
|
with pytest.raises(SyncError, match="yields"):
|
|
node(provides=Port("x", "float"))(a_generator)
|
|
|
|
|
|
def test_a_setting_that_is_also_a_port_is_refused():
|
|
with pytest.raises(SyncError, match="both a port and a setting"):
|
|
Flow("clash", nodes=[use(prepare, limit=2), _sets("dataset")])
|
|
|
|
|
|
def test_a_setting_that_is_not_a_parameter_is_refused():
|
|
with pytest.raises(SyncError, match="not a parameter"):
|
|
Flow("stray", nodes=[use(prepare, nonesuch=1)])
|
|
|
|
|
|
def _sets(name):
|
|
"""A node whose settings clash with its own port, built for the test."""
|
|
fn = node(requires=["dataset"], settings={name: 1})
|
|
return fn(reads_dataset)
|
|
|
|
|
|
def test_a_message_nobody_provides_is_refused():
|
|
orphan = node(requires=["missing"], id="orphan")(reads_dataset_missing)
|
|
with pytest.raises(SyncError, match="which no node in it provides"):
|
|
Flow("broken", nodes=[orphan])
|
|
|
|
|
|
def reads_dataset_missing(missing):
|
|
return missing
|
|
|
|
|
|
def test_an_output_nobody_produces_is_refused():
|
|
with pytest.raises(SyncError, match="not produced by any"):
|
|
a_flow("typo", outputs=["scoer"])
|
|
|
|
|
|
def test_two_nodes_of_one_name_are_refused():
|
|
with pytest.raises(SyncError, match="tell them apart"):
|
|
Flow("twice", nodes=[prepare, prepare])
|
|
|
|
|
|
def test_a_type_both_sides_disagree_on_is_refused():
|
|
wrong = node(requires=[Port("dataset", "float")], id="wrong")(reads_dataset)
|
|
with pytest.raises(SyncError, match="provided as artifact"):
|
|
Flow("mistyped", nodes=[prepare, wrong])
|
|
|
|
|
|
def test_shim_imports_rather_than_copies():
|
|
shims = a_flow().shims()
|
|
|
|
assert shims["prepare"].startswith(MARKER)
|
|
assert "from tests.sdk.test_build import prepare as _impl" in shims["prepare"]
|
|
assert "def process(**settings):" in shims["prepare"]
|
|
assert "return _impl(**settings)" in shims["prepare"]
|
|
|
|
|
|
def test_shim_of_a_generator_delegates_and_keeps_its_return_value():
|
|
"""A bare `yield from` streams but drops what the generator returns."""
|
|
assert (
|
|
"return (yield from _impl(dataset=dataset, lr=lr, **settings))"
|
|
in a_flow().shims()["fit"]
|
|
)
|
|
|
|
|
|
def test_a_generator_shim_publishes_both_the_stream_and_the_result():
|
|
"""Driven the way the worker drives it: every yield, then the return."""
|
|
namespace: dict = {}
|
|
exec(compile(a_flow().shims()["fit"], "<shim>", "exec"), namespace)
|
|
|
|
generator = namespace["process"](dataset=None, lr=0.5, epochs=2)
|
|
streamed = []
|
|
result = None
|
|
while True:
|
|
try:
|
|
streamed.append(next(generator))
|
|
except StopIteration as stop:
|
|
result = stop.value
|
|
break
|
|
|
|
assert [step["loss"] for step in streamed] == [1.0, 0.5]
|
|
# A bare `yield from` would stream the same and lose this.
|
|
assert result == {"weights": {"digest": "sha256:y", "size": 1}}
|
|
|
|
|
|
def test_shim_of_a_single_port_wraps_the_bare_return():
|
|
assert "return {'score': _impl(weights=weights" in a_flow().shims()["evaluate"]
|
|
|
|
|
|
@node(requires=["scale"], provides=Port("scaled", "float"))
|
|
def scale(scale):
|
|
return scale * 2.0
|
|
|
|
|
|
def test_a_port_named_like_its_function_still_calls_the_function():
|
|
"""The body imports under an alias, so the port cannot shadow the function."""
|
|
flow = Flow("collide", nodes=[scale], inputs=[Port("scale", "float", initial=1.0)])
|
|
namespace: dict = {}
|
|
exec(compile(flow.shims()["scale"], "<shim>", "exec"), namespace)
|
|
|
|
assert namespace["process"](scale=2.0) == {"scaled": 4.0}
|
|
|
|
|
|
def reads_settings(settings):
|
|
return settings
|
|
|
|
|
|
def test_a_port_the_generated_body_reserves_is_refused():
|
|
refused = node(requires=["settings"], provides=Port("out", "float"))(reads_settings)
|
|
with pytest.raises(SyncError, match="keeps for itself"):
|
|
Flow("reserved", nodes=[refused], inputs=[Port("settings", "float")])
|
|
|
|
|
|
def test_every_shim_compiles_and_defines_process():
|
|
for code in a_flow().shims().values():
|
|
namespace: dict = {}
|
|
exec(compile(code, "<shim>", "exec"), namespace)
|
|
assert callable(namespace["process"])
|
|
|
|
|
|
def test_the_decorators_leave_the_function_alone():
|
|
"""The point of the whole feature: it is still your code."""
|
|
assert prepare(limit=2)["rows"] == 2
|
|
assert [step["loss"] for step in fit(None, 0.5, epochs=2)] == [1.0, 0.5]
|
|
assert evaluate(None) == 0.5
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# What a function emits against what it declares
|
|
#
|
|
# The engine refuses an undeclared key at the first yield, which is right but
|
|
# late — a sweep can be an hour in. A literal one is a typo, and a typo is
|
|
# readable from the source.
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
def trains_with_a_typo(steps=3):
|
|
for _ in range(steps):
|
|
yield {"lss": 0.5}
|
|
return {"final_loss": 0.5}
|
|
|
|
|
|
def trains(steps=3):
|
|
for _ in range(steps):
|
|
yield {"loss": 0.5}
|
|
return {"final_loss": 0.5}
|
|
|
|
|
|
def emits_a_typo(steps=3):
|
|
import fluksio
|
|
|
|
fluksio.emit(lss=0.5)
|
|
return {"final_loss": 0.5}
|
|
|
|
|
|
def yields_a_name_it_computes(steps=3):
|
|
for index in range(steps):
|
|
yield {f"loss_{index}": 0.5}
|
|
return {"final_loss": 0.5}
|
|
|
|
|
|
def trains_beside_a_helper(steps=3):
|
|
def every_pair():
|
|
yield {"internal": 1}
|
|
|
|
for _ in range(steps):
|
|
yield {"loss": 0.5}
|
|
return {"final_loss": 0.5}
|
|
|
|
|
|
def test_a_yielded_key_no_port_declares_is_refused():
|
|
with pytest.raises(SyncError, match="lss"):
|
|
node(provides=[Port("loss", "float"), Port("final_loss", "float")])(
|
|
trains_with_a_typo
|
|
)
|
|
|
|
|
|
def test_an_emitted_key_no_port_declares_is_refused():
|
|
with pytest.raises(SyncError, match="lss"):
|
|
node(provides=[Port("final_loss", "float")])(emits_a_typo)
|
|
|
|
|
|
def test_declared_keys_pass():
|
|
assert node(provides=[Port("loss", "float"), Port("final_loss", "float")])(trains)
|
|
|
|
|
|
def test_a_key_the_code_computes_is_left_to_the_engine():
|
|
"""Only literals are readable here; the rest is checked where it runs."""
|
|
assert node(provides=[Port("final_loss", "float")])(yields_a_name_it_computes)
|
|
|
|
|
|
def test_a_helper_defined_inside_the_node_is_not_the_nodes_ports():
|
|
assert node(provides=[Port("loss", "float"), Port("final_loss", "float")])(
|
|
trains_beside_a_helper
|
|
)
|
|
|
|
|
|
def test_a_negative_timeout_is_refused():
|
|
with pytest.raises(SyncError, match="0 or more"):
|
|
node(requires=["a"], timeout=-1)(one_default)
|
|
|
|
|
|
def test_a_zero_timeout_means_no_limit():
|
|
decorated = node(requires=["a"], timeout=0)(one_default)
|
|
assert decorated.__fluksio__.timeout == 0
|
|
|
|
|
|
def test_a_node_in_a_script_run_directly_is_refused_at_sync_not_at_import():
|
|
"""A study module has to be runnable as a script for a self-check.
|
|
|
|
The refusal belongs where the body is generated: the decorator hands the
|
|
function back untouched, so `python study.py` declares its nodes, calls
|
|
them and checks itself. What cannot be done is syncing them, because
|
|
nothing could import `__main__`.
|
|
"""
|
|
|
|
def probe(rows=1):
|
|
return {"score": float(rows)}
|
|
|
|
probe.__module__ = "__main__"
|
|
decorated = node(provides=[Port("score", "float")])(probe)
|
|
# Declared and callable, which is the whole point of running the file.
|
|
assert decorated(rows=2) == {"score": 2.0}
|
|
|
|
flow = Flow("mainflow", nodes=[decorated], outputs=["score"])
|
|
with pytest.raises(SyncError, match="run directly"):
|
|
flow.document()
|
|
with pytest.raises(SyncError, match="run directly"):
|
|
flow.shims()
|