Let a port be named after the function it feeds

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
This commit is contained in:
2026-08-31 07:52:16 +02:00
co-authored by Claude Opus 5
parent c20f6a1b68
commit 4ac3de38e2
2 changed files with 42 additions and 6 deletions
+14 -2
View File
@@ -60,6 +60,9 @@ DTYPES = frozenset(
ITEM_TYPES = frozenset({"float", "int", "str", "bool", "json", "record"}) ITEM_TYPES = frozenset({"float", "int", "str", "bool", "json", "record"})
#: Settings the engine reads itself, mirroring ``nodes.base.RESERVED_SETTINGS``. #: Settings the engine reads itself, mirroring ``nodes.base.RESERVED_SETTINGS``.
RESERVED_SETTINGS = frozenset({"synchronous"}) 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``. #: Mirrors ``fluksio.flow.schemas.NAME_PATTERN``.
NAME_PATTERN = re.compile(r"^[a-z][a-z0-9_]*$") NAME_PATTERN = re.compile(r"^[a-z][a-z0-9_]*$")
#: First line of every generated node body. Its absence is how ``sync`` knows a #: 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() p.kind is inspect.Parameter.VAR_KEYWORD for p in parameters.values()
) )
ports = {port.port for port in spec.requires} 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] = {} params: dict[str, Any] = {}
for name, parameter in parameters.items(): 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 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 simply happens to be generated, which is why it says so and says where the
real thing is. 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) _refuse_main(spec)
fn = spec.fn fn = spec.fn
@@ -780,7 +792,7 @@ def _shim(spec: NodeSpec) -> str:
ports = [port.port for port in spec.requires] ports = [port.port for port in spec.requires]
signature = ", ".join(ports + ["**settings"]) signature = ", ".join(ports + ["**settings"])
arguments = ", ".join([f"{port}={port}" for port in ports] + ["**settings"]) arguments = ", ".join([f"{port}={port}" for port in ports] + ["**settings"])
call = f"{fn.__name__}({arguments})" call = f"_impl({arguments})"
lines = [ lines = [
f"{MARKER} from {where} — edit that file instead", f"{MARKER} from {where} — edit that file instead",
@@ -798,7 +810,7 @@ def _shim(spec: NodeSpec) -> str:
"", "",
] ]
lines += [ lines += [
f"from {fn.__module__} import {fn.__name__}", f"from {fn.__module__} import {fn.__name__} as _impl",
"", "",
"", "",
f"def process({signature}):", f"def process({signature}):",
+28 -4
View File
@@ -187,15 +187,15 @@ def test_shim_imports_rather_than_copies():
shims = a_flow().shims() shims = a_flow().shims()
assert shims["prepare"].startswith(MARKER) 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 "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(): def test_shim_of_a_generator_delegates_and_keeps_its_return_value():
"""A bare `yield from` streams but drops what the generator returns.""" """A bare `yield from` streams but drops what the generator returns."""
assert ( assert (
"return (yield from fit(dataset=dataset, lr=lr, **settings))" "return (yield from _impl(dataset=dataset, lr=lr, **settings))"
in a_flow().shims()["fit"] 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(): 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"], "<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(): def test_every_shim_compiles_and_defines_process():