diff --git a/backend/fluksio/sdk/__init__.py b/backend/fluksio/sdk/__init__.py index b845c6b..5e0cf6b 100644 --- a/backend/fluksio/sdk/__init__.py +++ b/backend/fluksio/sdk/__init__.py @@ -60,6 +60,9 @@ DTYPES = frozenset( ITEM_TYPES = frozenset({"float", "int", "str", "bool", "json", "record"}) #: Settings the engine reads itself, mirroring ``nodes.base.RESERVED_SETTINGS``. RESERVED_SETTINGS = frozenset({"synchronous"}) +#: Names ``_shim`` needs inside the body it generates: ``settings`` would collide +#: with its ``**settings``, ``_impl`` with the function it imports. +SHIM_RESERVED = frozenset({"settings", "_impl"}) #: Mirrors ``fluksio.flow.schemas.NAME_PATTERN``. NAME_PATTERN = re.compile(r"^[a-z][a-z0-9_]*$") #: First line of every generated node body. Its absence is how ``sync`` knows a @@ -495,6 +498,11 @@ def _check(spec: NodeSpec, mode: str) -> dict[str, Any]: p.kind is inspect.Parameter.VAR_KEYWORD for p in parameters.values() ) ports = {port.port for port in spec.requires} + for name in sorted(ports & SHIM_RESERVED): + raise SyncError( + f"{where}: '{name}' is a name the generated node body keeps for " + "itself — rename the port" + ) params: dict[str, Any] = {} for name, parameter in parameters.items(): @@ -772,6 +780,10 @@ def _shim(spec: NodeSpec) -> str: The store therefore still holds a complete, runnable definition — the body simply happens to be generated, which is why it says so and says where the real thing is. + + The import is aliased because ``process``'s parameters are the node's port + names, and a port may be named after the very function it feeds — which + would otherwise shadow the import inside the body. """ _refuse_main(spec) fn = spec.fn @@ -780,7 +792,7 @@ def _shim(spec: NodeSpec) -> str: ports = [port.port for port in spec.requires] signature = ", ".join(ports + ["**settings"]) arguments = ", ".join([f"{port}={port}" for port in ports] + ["**settings"]) - call = f"{fn.__name__}({arguments})" + call = f"_impl({arguments})" lines = [ f"{MARKER} from {where} — edit that file instead", @@ -798,7 +810,7 @@ def _shim(spec: NodeSpec) -> str: "", ] lines += [ - f"from {fn.__module__} import {fn.__name__}", + f"from {fn.__module__} import {fn.__name__} as _impl", "", "", f"def process({signature}):", diff --git a/backend/tests/sdk/test_build.py b/backend/tests/sdk/test_build.py index 834d91a..a800217 100644 --- a/backend/tests/sdk/test_build.py +++ b/backend/tests/sdk/test_build.py @@ -187,15 +187,15 @@ def test_shim_imports_rather_than_copies(): shims = a_flow().shims() assert shims["prepare"].startswith(MARKER) - assert "from tests.sdk.test_build import prepare" in shims["prepare"] + assert "from tests.sdk.test_build import prepare as _impl" in shims["prepare"] assert "def process(**settings):" in shims["prepare"] - assert "return prepare(**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 fit(dataset=dataset, lr=lr, **settings))" + "return (yield from _impl(dataset=dataset, lr=lr, **settings))" in a_flow().shims()["fit"] ) @@ -221,7 +221,31 @@ def test_a_generator_shim_publishes_both_the_stream_and_the_result(): def test_shim_of_a_single_port_wraps_the_bare_return(): - assert "return {'score': evaluate(weights=weights" in a_flow().shims()["evaluate"] + 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"], "", "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():