diff --git a/backend/fluksio/sdk/__init__.py b/backend/fluksio/sdk/__init__.py index e74e5f6..b845c6b 100644 --- a/backend/fluksio/sdk/__init__.py +++ b/backend/fluksio/sdk/__init__.py @@ -388,11 +388,6 @@ def _check_signature(spec: NodeSpec) -> None: fn, where = spec.fn, f"node '{spec.id}'" if inspect.iscoroutinefunction(fn): 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) if generator and spec.single: 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]: """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 code, and the engine never does. """ + _refuse_main(spec) files = reached(spec.fn) 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 real thing is. """ + _refuse_main(spec) fn = spec.fn where = inspect.getsourcefile(fn) or fn.__module__ repo = import_root(fn) diff --git a/backend/tests/sdk/test_build.py b/backend/tests/sdk/test_build.py index 3f21d11..834d91a 100644 --- a/backend/tests/sdk/test_build.py +++ b/backend/tests/sdk/test_build.py @@ -6,7 +6,7 @@ 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 for exactly that reason. +# generated body imports, and `__main__` is refused at sync for that reason. @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(): 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()