Docs / docs (push) Successful in 19s
Playwright Tests / test-playwright (1, 2) (push) Failing after 1m5s
Playwright Tests / test-playwright (2, 2) (push) Failing after 20s
pre-commit / pre-commit (push) Failing after 2m33s
Test Backend / test-backend (push) Successful in 2m7s
Compose Smoke Test / test-compose (push) Failing after 20s
Playwright Tests / merge-reports (push) Failing after 1m3s
Publish / publish (push) Failing after 12s
A code node in a batch run is now fingerprinted by its source, its raw settings and the values it reads — an artifact input counting as its digest, which is what the content addressing was always for. A run that finds the key restores what the earlier one returned and skips the node, recorded as `cached`. The run history is the cache: `run_node.outputs` beside the `cache_key` the schema already had, no second store. On for code nodes, never for the built-in and connector types that have side effects; off per node with `@node(cache=False)` and per run with `--no-cache`. Emissions are not replayed on a hit, so a cached training node returns its result without redrawing its curve. Recorded in NOTEPAD.md with the two other deliberate limits. `fluksio run --local` boots the real app in the command's own process and drives it through its ASGI interface behind the ordinary client, so a run no longer needs a `serve` terminal beside it — same data directory, same history, and the cache carries between the two. It always waits, because the engine it starts lives exactly as long as the command. Also: `fluksio sweep --param lr=0.1,0.01` for the product of the lists, `run --follow` for a run's numbers as they arrive, Ctrl-C cancelling a waited run rather than abandoning it, coloured statuses on a terminal, and `name` made optional on the metrics endpoint so a follower can ask for every series. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
668 lines
24 KiB
Python
668 lines
24 KiB
Python
"""Declare flows and nodes in the repository you already have.
|
|
|
|
A research repository keeps its functions where they are; the decorators here
|
|
say which of them are nodes and which nodes make up a flow. ``fluksio sync``
|
|
uploads the flow document and, per node, a generated import shim — so the flow
|
|
store still holds a complete, runnable, git-versioned definition, and the code
|
|
it imports stays yours.
|
|
|
|
Nothing here imports the engine. A ``from fluksio import node`` in a training
|
|
script must not drag FastAPI and SQLAlchemy in behind it, and the same names
|
|
have to resolve inside a worker, where ``fluksio`` is the reporter module.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import inspect
|
|
import json
|
|
import re
|
|
import warnings
|
|
from collections.abc import Callable, Sequence
|
|
from pathlib import Path
|
|
from typing import Any, TypeVar
|
|
|
|
__all__ = [
|
|
"FLOWS",
|
|
"Flow",
|
|
"Port",
|
|
"SyncError",
|
|
"build",
|
|
"node",
|
|
"shims",
|
|
"use",
|
|
]
|
|
|
|
F = TypeVar("F", bound=Callable[..., Any])
|
|
|
|
#: Mirrors :class:`fluksio.flow.messages.DType`. Mirrored rather than imported:
|
|
#: importing it would pull the engine into a research process.
|
|
DTYPES = frozenset(
|
|
{"float", "int", "str", "bool", "json", "series", "record", "list", "artifact"}
|
|
)
|
|
#: What a list may hold, mirroring ``messages._ITEM_TYPES``.
|
|
ITEM_TYPES = frozenset({"float", "int", "str", "bool", "json", "record"})
|
|
#: Settings the engine reads itself, mirroring ``nodes.base.RESERVED_SETTINGS``.
|
|
RESERVED_SETTINGS = frozenset({"synchronous"})
|
|
#: 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
|
|
#: node's source was edited on the canvas.
|
|
MARKER = "# generated by fluksio sync"
|
|
|
|
|
|
class SyncError(Exception):
|
|
"""A declaration the engine would refuse, caught where it was written."""
|
|
|
|
|
|
#: Every flow declared in the modules imported so far. ``fluksio sync`` reads it.
|
|
FLOWS: dict[str, Flow] = {}
|
|
|
|
|
|
def _name(value: str, what: str) -> str:
|
|
if not NAME_PATTERN.match(value):
|
|
raise SyncError(
|
|
f"{what} '{value}': use lowercase letters, digits and underscores, "
|
|
"starting with a letter"
|
|
)
|
|
return value
|
|
|
|
|
|
class Port:
|
|
"""One port of a node, and the message it binds to.
|
|
|
|
A thin mirror of :class:`fluksio.flow.messages.MessageSpec`. ``name`` is
|
|
the wiring — two nodes are connected because one provides the name another
|
|
requires — and ``port`` is what the function calls its parameter, which
|
|
defaults to the name.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
name: str,
|
|
dtype: str | None = None,
|
|
*,
|
|
port: str = "",
|
|
stream: bool = False,
|
|
initial: Any = None,
|
|
item: str | None = None,
|
|
interval: float = 0.0,
|
|
trigger: bool = True,
|
|
) -> None:
|
|
#: Whether the caller said the type or left it to be resolved from
|
|
#: whoever provides the message. A message has one type, declared where
|
|
#: it is produced; requiring it does not mean declaring it again.
|
|
self.explicit = dtype is not None
|
|
dtype = dtype or "float"
|
|
if dtype not in DTYPES:
|
|
raise SyncError(f"port '{name}': unknown type '{dtype}'")
|
|
if item is not None and item not in ITEM_TYPES:
|
|
raise SyncError(f"port '{name}': a list cannot hold '{item}' items")
|
|
if interval < 0:
|
|
raise SyncError(f"port '{name}': interval cannot be negative")
|
|
self.name = name
|
|
self.dtype = dtype
|
|
self.port = port or name.rsplit(".", 1)[-1]
|
|
self.stream = stream
|
|
self.initial = initial
|
|
self.item = item
|
|
self.interval = interval
|
|
self.trigger = trigger
|
|
if not self.port.isidentifier():
|
|
raise SyncError(
|
|
f"port '{name}': '{self.port}' is not a Python identifier, so no "
|
|
"parameter can carry it — pass port='...'"
|
|
)
|
|
|
|
def spec(self) -> dict[str, Any]:
|
|
"""The ``MessageSpec`` this port serialises to."""
|
|
out: dict[str, Any] = {
|
|
"name": self.name,
|
|
"port": self.port,
|
|
"dtype": self.dtype,
|
|
"interval": self.interval,
|
|
"trigger": self.trigger,
|
|
"stream": self.stream,
|
|
}
|
|
if self.item is not None:
|
|
out["item"] = self.item
|
|
return out
|
|
|
|
def replace(self, name: str = "") -> Port:
|
|
"""A copy of this port, optionally bound to a different message."""
|
|
copy = Port(
|
|
name or self.name,
|
|
self.dtype,
|
|
port=self.port,
|
|
stream=self.stream,
|
|
initial=self.initial,
|
|
item=self.item,
|
|
interval=self.interval,
|
|
trigger=self.trigger,
|
|
)
|
|
copy.explicit = self.explicit
|
|
return copy
|
|
|
|
def __repr__(self) -> str:
|
|
return f"Port({self.name!r}, {self.dtype!r})"
|
|
|
|
|
|
def _ports(value: Sequence[str | Port] | str | Port) -> tuple[list[Port], bool]:
|
|
"""Normalise a ports declaration; the flag says it was a single port.
|
|
|
|
A single ``Port`` rather than a list of one is the opt-in to a bare return:
|
|
the shim wraps the value in the dict the engine expects. It has to be
|
|
explicit, because an artifact reference is itself a dict and a dict whose
|
|
keys match no port is dropped in silence.
|
|
"""
|
|
if isinstance(value, (str, Port)):
|
|
return [value if isinstance(value, Port) else Port(value)], True
|
|
return [item if isinstance(item, Port) else Port(item) for item in value], False
|
|
|
|
|
|
class NodeSpec:
|
|
"""What ``@node`` recorded about a function."""
|
|
|
|
def __init__(
|
|
self,
|
|
fn: Callable[..., Any],
|
|
*,
|
|
id: str,
|
|
requires: list[Port],
|
|
provides: list[Port],
|
|
single: bool,
|
|
settings: dict[str, Any],
|
|
title: str,
|
|
timeout: float | None,
|
|
device: str | None,
|
|
device_policy: str,
|
|
cache: bool = True,
|
|
) -> None:
|
|
self.fn = fn
|
|
self.id = id
|
|
self.requires = requires
|
|
self.provides = provides
|
|
self.single = single
|
|
self.settings = settings
|
|
self.title = title
|
|
self.timeout = timeout
|
|
self.device = device
|
|
self.device_policy = device_policy
|
|
self.cache = cache
|
|
|
|
def rebind(
|
|
self, *, id: str = "", wire: dict[str, str] | None = None, **settings: Any
|
|
) -> NodeSpec:
|
|
"""A copy of this node wired and configured for one flow."""
|
|
wire = wire or {}
|
|
known = {port.port for port in self.requires} | {p.port for p in self.provides}
|
|
unknown = sorted(set(wire) - known)
|
|
if unknown:
|
|
raise SyncError(
|
|
f"node '{self.id}': wire={{'{unknown[0]}': ...}} names no port of it "
|
|
f"(it has {', '.join(sorted(known)) or 'none'})"
|
|
)
|
|
|
|
# Copied rather than shared: the same decorated function appears in
|
|
# several flows, and each resolves its own port types.
|
|
def rewire(ports: list[Port]) -> list[Port]:
|
|
return [port.replace(wire.get(port.port, "")) for port in ports]
|
|
|
|
return NodeSpec(
|
|
self.fn,
|
|
id=_name(id, "node id") if id else self.id,
|
|
requires=rewire(self.requires),
|
|
provides=rewire(self.provides),
|
|
single=self.single,
|
|
settings={**self.settings, **settings},
|
|
title=self.title,
|
|
timeout=self.timeout,
|
|
device=self.device,
|
|
device_policy=self.device_policy,
|
|
cache=self.cache,
|
|
)
|
|
|
|
|
|
class NodeUse:
|
|
"""One use of a decorated function in one flow — see :func:`use`."""
|
|
|
|
def __init__(
|
|
self,
|
|
fn: Callable[..., Any],
|
|
*,
|
|
id: str = "",
|
|
wire: dict[str, str] | None = None,
|
|
settings: dict[str, Any] | None = None,
|
|
) -> None:
|
|
self.fn = fn
|
|
self.id = id
|
|
self.wire = wire
|
|
self.settings = settings or {}
|
|
|
|
|
|
def node(
|
|
*,
|
|
requires: Sequence[str | Port] = (),
|
|
provides: Sequence[str | Port] | str | Port = (),
|
|
id: str = "",
|
|
settings: dict[str, Any] | None = None,
|
|
title: str = "",
|
|
timeout: float | None = None,
|
|
device: str | None = None,
|
|
device_policy: str = "require",
|
|
cache: bool = True,
|
|
) -> Callable[[F], F]:
|
|
"""Mark a function as a node, declaring its ports.
|
|
|
|
The function is returned untouched: it is still callable, testable and
|
|
importable as what it was. A bare string in ``requires`` or ``provides`` is
|
|
shorthand for a ``float`` port of that name.
|
|
|
|
Parameters with a default that are not ports become the node's settings, so
|
|
the canvas can tune them without editing code. ``device`` picks the worker
|
|
the code runs on — ``"gpu"``, say — and ``device_policy="prefer"`` runs it
|
|
locally when no such worker is attached rather than waiting for one.
|
|
|
|
A batch run skips this node when an earlier one already ran the same source
|
|
with the same settings and the same input values, and restores what it
|
|
returned. ``cache=False`` says not to: the answer can change on its own.
|
|
"""
|
|
if device_policy not in ("require", "prefer"):
|
|
raise SyncError("device_policy is 'require' or 'prefer'")
|
|
|
|
def decorate(fn: F) -> F:
|
|
required, _ = _ports(requires)
|
|
provided, single = _ports(provides)
|
|
spec = NodeSpec(
|
|
fn,
|
|
id=_name(id or fn.__name__, "node id"),
|
|
requires=required,
|
|
provides=provided,
|
|
single=single,
|
|
settings=dict(settings or {}),
|
|
title=title,
|
|
timeout=timeout,
|
|
device=device,
|
|
device_policy=device_policy,
|
|
cache=cache,
|
|
)
|
|
_check_signature(spec)
|
|
fn.__fluksio__ = spec # type: ignore[attr-defined]
|
|
return fn
|
|
|
|
return decorate
|
|
|
|
|
|
def use(
|
|
fn: Callable[..., Any],
|
|
*,
|
|
id: str = "",
|
|
wire: dict[str, str] | None = None,
|
|
**settings: Any,
|
|
) -> NodeUse:
|
|
"""The same node, wired or configured differently in this flow.
|
|
|
|
``wire={"dataset": "augmented"}`` binds the ``dataset`` port to another
|
|
message; keyword arguments override settings. Give ``id`` when one function
|
|
appears twice in one flow.
|
|
"""
|
|
return NodeUse(fn, id=id, wire=wire, settings=settings)
|
|
|
|
|
|
def _spec_of(entry: Callable[..., Any] | NodeUse) -> NodeSpec:
|
|
fn = entry.fn if isinstance(entry, NodeUse) else entry
|
|
spec = getattr(fn, "__fluksio__", None)
|
|
if not isinstance(spec, NodeSpec):
|
|
raise SyncError(
|
|
f"{getattr(fn, '__name__', fn)!r} is not a node — decorate it with @node()"
|
|
)
|
|
if isinstance(entry, NodeUse):
|
|
return spec.rebind(id=entry.id, wire=entry.wire, **entry.settings)
|
|
return spec.rebind()
|
|
|
|
|
|
def _check_signature(spec: NodeSpec) -> None:
|
|
"""Whether the declaration matches the function it is on.
|
|
|
|
Raised where the decorator is rather than where the flow is: this is a
|
|
disagreement between a function and its own ports, and the engine would
|
|
otherwise only find it when the node is first called.
|
|
"""
|
|
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(
|
|
f"{where}: {fn.__name__}() yields, so it says which port each value is "
|
|
f"on — pass provides=[Port(...)] rather than a single port"
|
|
)
|
|
|
|
signature = inspect.signature(fn)
|
|
parameters = signature.parameters
|
|
var_keyword = any(
|
|
p.kind is inspect.Parameter.VAR_KEYWORD for p in parameters.values()
|
|
)
|
|
ports = {port.port for port in spec.requires}
|
|
|
|
for port in ports:
|
|
if port not in parameters and not var_keyword:
|
|
raise SyncError(
|
|
f"{where}: port '{port}' has no parameter to arrive in — "
|
|
f"{fn.__name__}({', '.join(parameters)}) takes no such argument"
|
|
)
|
|
for name, parameter in parameters.items():
|
|
if parameter.kind is inspect.Parameter.VAR_POSITIONAL:
|
|
raise SyncError(f"{where}: *{name} cannot be filled from ports")
|
|
if parameter.kind is inspect.Parameter.VAR_KEYWORD or name in ports:
|
|
continue
|
|
if parameter.default is inspect.Parameter.empty:
|
|
raise SyncError(
|
|
f"{where}: parameter '{name}' is neither a port nor a setting — "
|
|
f"add Port('{name}', ...) to requires=, or give it a default"
|
|
)
|
|
|
|
|
|
def _check(spec: NodeSpec, mode: str) -> dict[str, Any]:
|
|
"""The node as stored, with the settings and wiring of one flow.
|
|
|
|
Mirrors ``FlowController._build_node`` and ``flow.runs.batch_issues``. Kept
|
|
apart from the signature check because ``use(fn, epochs=3)`` adds settings
|
|
per flow, so this is the first point at which they are all known.
|
|
"""
|
|
fn, where = spec.fn, f"node '{spec.id}'"
|
|
parameters = inspect.signature(fn).parameters
|
|
var_keyword = any(
|
|
p.kind is inspect.Parameter.VAR_KEYWORD for p in parameters.values()
|
|
)
|
|
ports = {port.port for port in spec.requires}
|
|
|
|
params: dict[str, Any] = {}
|
|
for name, parameter in parameters.items():
|
|
if name in ports or parameter.default is inspect.Parameter.empty:
|
|
continue
|
|
if name in RESERVED_SETTINGS:
|
|
continue
|
|
if _serialisable(parameter.default):
|
|
params[name] = parameter.default
|
|
for name, value in spec.settings.items():
|
|
if name in ports:
|
|
raise SyncError(
|
|
f"{where}: '{name}' is both a port and a setting — a node reads its "
|
|
"settings beside its ports, so the names have to differ"
|
|
)
|
|
if name in RESERVED_SETTINGS:
|
|
raise SyncError(f"{where}: '{name}' is a setting the engine reads itself")
|
|
if name not in parameters and not var_keyword:
|
|
raise SyncError(
|
|
f"{where}: setting '{name}' is not a parameter of {fn.__name__}()"
|
|
)
|
|
if not _serialisable(value):
|
|
raise SyncError(
|
|
f"{where}: setting '{name}' is not JSON — settings are stored with "
|
|
"the flow, so they have to survive a round trip"
|
|
)
|
|
params[name] = value
|
|
|
|
if mode == "batch":
|
|
for port in spec.requires + spec.provides:
|
|
if port.interval > 0 and not port.stream:
|
|
raise SyncError(
|
|
f"{where}: port '{port.port}' has interval={port.interval} but a "
|
|
"batch run delivers once — drop it, or declare stream=True"
|
|
)
|
|
|
|
return {
|
|
"id": spec.id,
|
|
"type": "python",
|
|
"title": spec.title,
|
|
"params": params,
|
|
"requires": [port.spec() for port in spec.requires],
|
|
"provides": [port.spec() for port in spec.provides],
|
|
"timeout": spec.timeout,
|
|
"device": spec.device,
|
|
"device_policy": spec.device_policy,
|
|
"cache": spec.cache,
|
|
}
|
|
|
|
|
|
def _serialisable(value: Any) -> bool:
|
|
try:
|
|
json.dumps(value)
|
|
except (TypeError, ValueError):
|
|
return False
|
|
return True
|
|
|
|
|
|
class Flow:
|
|
"""A flow: which nodes are in it, what it takes and what it reports."""
|
|
|
|
def __init__(
|
|
self,
|
|
name: str,
|
|
*,
|
|
nodes: Sequence[Callable[..., Any] | NodeUse],
|
|
mode: str = "batch",
|
|
inputs: Sequence[Port] = (),
|
|
outputs: Sequence[str] = (),
|
|
title: str = "",
|
|
) -> None:
|
|
"""Declare a flow over decorated functions, and register it for sync.
|
|
|
|
``nodes`` is the membership: any decorated function, from anywhere in
|
|
the repository. Wiring is not membership — nodes connect because one
|
|
provides a message another requires, never because one imported the
|
|
other. Wrap an entry in :func:`use` to rebind or reconfigure it here
|
|
without touching the other flows using it.
|
|
"""
|
|
if mode not in ("batch", "live"):
|
|
raise SyncError(f"flow '{name}': mode is 'batch' or 'live'")
|
|
self.name = _name(name, "flow name")
|
|
self.title = title
|
|
self.mode = mode
|
|
self.inputs = list(inputs)
|
|
self.outputs = list(outputs)
|
|
self.nodes = [_spec_of(entry) for entry in nodes]
|
|
self._resolve_types()
|
|
self._validate()
|
|
self._nodes = [_check(spec, mode) for spec in self.nodes]
|
|
self._register()
|
|
|
|
def _register(self) -> None:
|
|
"""Put this flow where `fluksio sync` will find it."""
|
|
existing = FLOWS.get(self.name)
|
|
if existing is not None and existing is not self:
|
|
warnings.warn(
|
|
f"two flows are called '{self.name}'; the later one wins, and only "
|
|
"it will be synced",
|
|
stacklevel=3,
|
|
)
|
|
FLOWS[self.name] = self
|
|
|
|
def _resolve_types(self) -> None:
|
|
"""Give every required port the type of the message it reads.
|
|
|
|
A message has one type, declared once by whoever produces it. Requiring
|
|
it is not a second declaration, so `requires=["dataset"]` takes the
|
|
artifact type from the node providing `dataset` rather than defaulting
|
|
to a float the engine would then refuse at run time. Spelling the type
|
|
out on both sides is still allowed — and a disagreement is an error,
|
|
because one of the two is wrong about what it is handling.
|
|
"""
|
|
declared: dict[str, Port] = {}
|
|
for port in self.inputs:
|
|
declared.setdefault(port.name, port)
|
|
for spec in self.nodes:
|
|
for port in spec.provides:
|
|
declared.setdefault(port.name, port)
|
|
for spec in self.nodes:
|
|
for port in spec.requires:
|
|
source = declared.get(port.name)
|
|
if source is None:
|
|
continue
|
|
if not port.explicit:
|
|
port.dtype = source.dtype
|
|
port.item = source.item
|
|
elif port.dtype != source.dtype:
|
|
raise SyncError(
|
|
f"flow '{self.name}': node '{spec.id}' reads '{port.name}' as "
|
|
f"{port.dtype}, but it is provided as {source.dtype}"
|
|
)
|
|
|
|
def _validate(self) -> None:
|
|
seen: set[str] = set()
|
|
for spec in self.nodes:
|
|
if spec.id in seen:
|
|
raise SyncError(
|
|
f"flow '{self.name}': two nodes are called '{spec.id}' — "
|
|
"pass use(fn, id='...') to tell them apart"
|
|
)
|
|
seen.add(spec.id)
|
|
|
|
produced: dict[str, list[str]] = {}
|
|
for spec in self.nodes:
|
|
for port in spec.provides:
|
|
produced.setdefault(port.name, []).append(spec.id)
|
|
for name, writers in produced.items():
|
|
if len(writers) > 1:
|
|
warnings.warn(
|
|
f"flow '{self.name}': {', '.join(writers)} all publish "
|
|
f"'{name}', so a reader gets whichever ran last",
|
|
stacklevel=4,
|
|
)
|
|
|
|
available = set(produced) | {port.name for port in self.inputs}
|
|
for spec in self.nodes:
|
|
for port in spec.requires:
|
|
if "." in port.name or port.name in available:
|
|
continue
|
|
raise SyncError(
|
|
f"flow '{self.name}': node '{spec.id}' requires '{port.name}', "
|
|
"which no node in it provides and no input declares"
|
|
)
|
|
for output in self.outputs:
|
|
if output not in available:
|
|
raise SyncError(
|
|
f"flow '{self.name}': output '{output}' is not produced by any "
|
|
"of its nodes"
|
|
)
|
|
|
|
def document(self, origin: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
"""The flow document to store, without a version — sync sets that."""
|
|
doc: dict[str, Any] = {
|
|
"name": self.name,
|
|
"title": self.title,
|
|
"mode": self.mode,
|
|
"nodes": self._nodes,
|
|
"inputs": [
|
|
{"spec": port.spec(), "initial": port.initial} for port in self.inputs
|
|
],
|
|
"outputs": self.outputs,
|
|
}
|
|
if origin is not None:
|
|
doc["origin"] = origin
|
|
return doc
|
|
|
|
def shims(self) -> dict[str, str]:
|
|
"""One generated node body per node, keyed by node id."""
|
|
return {spec.id: _shim(spec) for spec in self.nodes}
|
|
|
|
def submit(
|
|
self, *, seed: int | None = None, client: Any = None, **params: Any
|
|
) -> Any:
|
|
"""Start a run of this flow and hand back a handle to it."""
|
|
from fluksio.sdk.client import Client
|
|
|
|
return (client or Client()).submit(self.name, params, seed=seed)
|
|
|
|
def runs(self, limit: int = 20, client: Any = None) -> list[dict[str, Any]]:
|
|
"""This flow's runs, newest first."""
|
|
from fluksio.sdk.client import Client
|
|
|
|
rows: list[dict[str, Any]] = (client or Client()).runs(
|
|
flow=self.name, limit=limit
|
|
)
|
|
return rows
|
|
|
|
def __repr__(self) -> str:
|
|
return f"Flow({self.name!r}, nodes={[n.id for n in self.nodes]})"
|
|
|
|
|
|
def build(target: Flow, origin: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
"""The flow document for a flow — checked, without a version."""
|
|
return target.document(origin)
|
|
|
|
|
|
def shims(target: Flow) -> dict[str, str]:
|
|
"""The generated node bodies for a flow."""
|
|
return target.shims()
|
|
|
|
|
|
def import_root(fn: Callable[..., Any]) -> str:
|
|
"""The directory that has to be on the path for ``fn`` to be importable.
|
|
|
|
Its module's dotted name says how deep in a package it sits, so counting
|
|
the segments back up from the file lands on the directory the import
|
|
resolves against — which is not always the repository root, and is what a
|
|
generated shim actually needs.
|
|
"""
|
|
source = inspect.getsourcefile(fn)
|
|
if not source:
|
|
return ""
|
|
path = Path(source).resolve()
|
|
for _ in fn.__module__.split("."):
|
|
path = path.parent
|
|
return str(path)
|
|
|
|
|
|
def _shim(spec: NodeSpec) -> str:
|
|
"""The node body the store keeps: an import of the real function.
|
|
|
|
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.
|
|
"""
|
|
fn = spec.fn
|
|
where = inspect.getsourcefile(fn) or fn.__module__
|
|
repo = import_root(fn)
|
|
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})"
|
|
|
|
lines = [
|
|
f"{MARKER} from {where} — edit that file instead",
|
|
"import sys",
|
|
"",
|
|
]
|
|
if repo:
|
|
# ponytail: the repository is on the path because the shim puts it
|
|
# there. An editable install (`-e /repo` in the Modules manifest, or a
|
|
# VCS requirement on a remote worker) makes these three lines a no-op.
|
|
lines += [
|
|
f"_REPO = {repo!r}",
|
|
"if _REPO not in sys.path:",
|
|
" sys.path.insert(0, _REPO)",
|
|
"",
|
|
]
|
|
lines += [
|
|
f"from {fn.__module__} import {fn.__name__}",
|
|
"",
|
|
"",
|
|
f"def process({signature}):",
|
|
]
|
|
if inspect.isgeneratorfunction(fn):
|
|
# `return (yield from ...)`, not a bare `yield from`: the delegating
|
|
# form is what carries the generator's own return value out, and that
|
|
# return value is everything the node produces at the end.
|
|
lines.append(f" return (yield from {call})")
|
|
elif spec.single:
|
|
lines.append(f" return {{{spec.provides[0].port!r}: {call}}}")
|
|
else:
|
|
lines.append(f" return {call}")
|
|
return "\n".join(lines) + "\n"
|