Refuse a __main__ node where its body is written, not at import

A module defining nodes could not be run directly: the decorator refused
`__main__` while the module body was still executing, so a `if __name__ ==
"__main__"` self-check beside the nodes was impossible and the checks had
to live in a separate pytest file. The refusal now fires where the
generated body is written — document() and shims(), both, since sync writes
the document first — and the decorator hands the function back as it always
did. Syncing a __main__-defined node is still refused, with the same words.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Hra4ndWMCLU5F3KjUuVAc
This commit is contained in:
2026-08-29 13:50:35 +02:00
co-authored by Claude Opus 5
parent cfb3941cf3
commit 81649dbfca
2 changed files with 42 additions and 6 deletions
+17 -5
View File
@@ -388,11 +388,6 @@ def _check_signature(spec: NodeSpec) -> None:
fn, where = spec.fn, f"node '{spec.id}'" fn, where = spec.fn, f"node '{spec.id}'"
if inspect.iscoroutinefunction(fn): if inspect.iscoroutinefunction(fn):
raise SyncError(f"{where}: async functions cannot be nodes") raise SyncError(f"{where}: async functions cannot be nodes")
if fn.__module__ == "__main__":
raise SyncError(
f"{where}: {fn.__name__}() is defined in a script run directly, so the "
"generated node could not import it — put it in an importable module"
)
generator = inspect.isgeneratorfunction(fn) generator = inspect.isgeneratorfunction(fn)
if generator and spec.single: if generator and spec.single:
raise SyncError( raise SyncError(
@@ -551,6 +546,21 @@ def _check(spec: NodeSpec, mode: str) -> dict[str, Any]:
} }
def _refuse_main(spec: NodeSpec) -> None:
"""A node the generated body would have no way to import.
Refused where the body is written rather than where the decorator is: a
module defining nodes is then still runnable as a script, which is what a
`__main__` self-check beside them needs.
"""
if spec.fn.__module__ == "__main__":
raise SyncError(
f"node '{spec.id}': {spec.fn.__name__}() is defined in a script run "
"directly, so the generated node could not import it — put it in an "
"importable module"
)
def _code_of(spec: NodeSpec) -> dict[str, Any]: def _code_of(spec: NodeSpec) -> dict[str, Any]:
"""What this node's function calls into, which its shim does not say. """What this node's function calls into, which its shim does not say.
@@ -559,6 +569,7 @@ def _code_of(spec: NodeSpec) -> dict[str, Any]:
side is the only one that can work it out at all: it has imported the side is the only one that can work it out at all: it has imported the
code, and the engine never does. code, and the engine never does.
""" """
_refuse_main(spec)
files = reached(spec.fn) files = reached(spec.fn)
return {"code_files": files, "code_digest": digest_of(files)} return {"code_files": files, "code_digest": digest_of(files)}
@@ -762,6 +773,7 @@ def _shim(spec: NodeSpec) -> str:
simply happens to be generated, which is why it says so and says where the simply happens to be generated, which is why it says so and says where the
real thing is. real thing is.
""" """
_refuse_main(spec)
fn = spec.fn fn = spec.fn
where = inspect.getsourcefile(fn) or fn.__module__ where = inspect.getsourcefile(fn) or fn.__module__
repo = import_root(fn) repo = import_root(fn)
+25 -1
View File
@@ -6,7 +6,7 @@ from fluksio.flow.schemas import FlowDef
from fluksio.sdk import MARKER, Flow, Port, SyncError, node, use 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 # The functions live here rather than in each test: a node's module is what the
# generated body imports, and `__main__` is refused for exactly that reason. # generated body imports, and `__main__` is refused at sync for that reason.
@node(provides=[Port("dataset", "artifact"), Port("rows", "int")]) @node(provides=[Port("dataset", "artifact"), Port("rows", "int")])
@@ -316,3 +316,27 @@ def test_a_negative_timeout_is_refused():
def test_a_zero_timeout_means_no_limit(): def test_a_zero_timeout_means_no_limit():
decorated = node(requires=["a"], timeout=0)(one_default) decorated = node(requires=["a"], timeout=0)(one_default)
assert decorated.__fluksio__.timeout == 0 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()