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"})
#: 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}):",
+28 -4
View File
@@ -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"], "<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():