Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions src/flowrep/compiler/flow_control.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,14 @@ def emit_flow_control(
for port in node.outputs
if (label, port) in required_by_handle
}
# Allocate output symbols for all outputs, using required names where available.
# A flow-control node's output port is always named after an enclosing *symbol* the
# author wrote -- an accumulator, a branch assignment, a loop variable -- so the port
# name is a pin, not a hint. `reserve` rather than `fresh`: minting `b_0` for a symbol
# the source calls `b` would break the round trip, and would collide with the same
# symbol pinned elsewhere (a `while` inside a `try` surfaces one `b` through both).
out_syms: dict[str, str] = {}
for port in node.outputs:
name = required.get(port) or alloc.fresh(port)
name = required.get(port) or alloc.reserve(port)
produced[(label, port)] = name
out_syms[port] = name

Expand Down
68 changes: 43 additions & 25 deletions src/flowrep/compiler/function.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,51 +118,69 @@ def referenced_top_level_bindings(
yield info.module.split(".")[0]


def _inlined_loop_variables(recipe: workflow_recipe.WorkflowRecipe) -> set[str]:
"""Loop-variable names emitted into this workflow's own scope.

For-loop variables are pinned to body-port names and bypass the allocator, so
reserving them prevents allocator-minted symbols from shadowing them. Descends
through inlined flow-control bodies; stops at reference-free WorkflowRecipe peer
nodes (emitted as nested defs with their own scope) and referenced nodes.
def _inlined_pinned_symbols(recipe: workflow_recipe.WorkflowRecipe) -> set[str]:
"""Every name the emitter is *forced* to use as a symbol in this function's scope.

Python's flow control introduces no scope, so every inlined ``for``/``if``/``while``/
``try`` body shares one flat namespace with the enclosing ``def``. Most symbols in it
are the allocator's to choose, but some are pinned: round-tripping requires a symbol
to carry a port name verbatim, or re-parsing regenerates a different port. Pinned
names bypass the allocator, so it must learn them *up front* -- a pin emitted later
cannot retroactively protect a name the allocator already minted.

The pins are flow-control output ports, for-loop variables, for-body output ports, and
the input ports of flow-control nodes (which force the feeding sibling's output symbol
to match).

Descends through inlined flow-control bodies; stops at reference-free WorkflowRecipe
peer nodes (emitted as nested defs, with their own scope) and referenced nodes.
"""
names: set[str] = set()
names: set[str] = set(statements.flow_control_input_requirements(recipe).values())
for node in recipe.nodes.values():
_collect_loop_variables(node, names)
_collect_pinned_symbols(node, names)
return names


def _collect_loop_variables(
def _collect_pinned_symbols(
node: union_types.RecipeDiscrimination, names: set[str]
) -> None:
if not isinstance(node, flow_control.FLOW_CONTROL_TYPES):
return # atomic / referenced / reference-free workflow peer: not inlined here

# Every flow-control output port is named for an enclosing symbol, and
# `emit_flow_control` pins it to that name.
names.update(node.outputs)

if isinstance(node, for_recipe.ForEachRecipe):
names.update(node.iterated_ports)
_collect_loop_variables_from_body(node.body_node.recipe, names)
# `_emit_for_each` pins every body output to its port name.
names.update(node.body_node.recipe.outputs)
_collect_pinned_symbols_from_body(node.body_node.recipe, names)
elif isinstance(node, if_recipe.IfRecipe):
for body_case in node.cases:
_collect_loop_variables_from_body(body_case.body.recipe, names)
_collect_pinned_symbols_from_body(body_case.body.recipe, names)
if node.else_case is not None:
_collect_loop_variables_from_body(node.else_case.recipe, names)
_collect_pinned_symbols_from_body(node.else_case.recipe, names)
elif isinstance(node, try_recipe.TryRecipe):
_collect_loop_variables_from_body(node.try_node.recipe, names)
_collect_pinned_symbols_from_body(node.try_node.recipe, names)
for exception_case in node.exception_cases:
_collect_loop_variables_from_body(exception_case.body.recipe, names)
_collect_pinned_symbols_from_body(exception_case.body.recipe, names)
elif isinstance(node, while_recipe.WhileRecipe):
_collect_loop_variables_from_body(node.case.body.recipe, names)
# atomic / referenced / reference-free workflow peer node: not inlined here
_collect_pinned_symbols_from_body(node.case.body.recipe, names)


def _collect_loop_variables_from_body(
def _collect_pinned_symbols_from_body(
body: union_types.RecipeDiscrimination, names: set[str]
) -> None:
"""Recurse into an inlined body: a WorkflowRecipe's own nodes, or a nested
flow-control node. Both are emitted into the current scope."""
if isinstance(body, workflow_recipe.WorkflowRecipe):
names.update(statements.flow_control_input_requirements(body).values())
for node in body.nodes.values():
_collect_loop_variables(node, names)
_collect_pinned_symbols(node, names)
elif isinstance(body, flow_control.FLOW_CONTROL_TYPES):
_collect_loop_variables(body, names)
# single atomic body: no loop variables
_collect_pinned_symbols(body, names)
# single atomic body: nothing pinned beyond what the caller already reserved


def emit_nested_workflow_node(
Expand Down Expand Up @@ -319,10 +337,10 @@ def emit_workflow_function(
) -> FunctionBuilder:
alloc = NameAllocator()
in_syms = {port: alloc.reserve(port) for port in recipe.inputs}
# Loop variables are pinned to body-port names and bypass the allocator; reserve
# them so node-output symbols never collide with (and get shadowed by) them.
for loop_variable in _inlined_loop_variables(recipe):
alloc.reserve(loop_variable)
# Pinned symbols bypass the allocator, so it has to know them before it mints
# anything, or a fresh name can land on one and be silently overwritten.
for pinned in _inlined_pinned_symbols(recipe):
alloc.reserve(pinned)
params = _render_params(recipe, signature, emitter)
lines, out_syms = statements.emit_workflow_body(recipe, in_syms, {}, emitter, alloc)
# Output port names are pinned via the decorator (see FunctionBuilder.render),
Expand Down
61 changes: 58 additions & 3 deletions src/flowrep/compiler/statements.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from pyiron_snippets import versions

from flowrep import base_models, edge_models, subgraph_validation
from flowrep.compiler import flow_control, function
from flowrep.compiler import flow_control, function, sugar
from flowrep.prospective import (
atomic_recipe,
constant_recipe,
Expand Down Expand Up @@ -115,7 +115,7 @@ def _topological_nodes(
)


def _flow_control_input_requirements(
def flow_control_input_requirements(
recipe: workflow_recipe.WorkflowRecipe,
) -> dict[tuple[str, str], str]:
"""Required source-symbol names imposed by flow-control node inputs.
Expand Down Expand Up @@ -238,7 +238,7 @@ def resolve(source) -> str:
# stays inert and it is inlined in the topo loop below -- which is why the
# conflict branch immediately below remains unreachable in parser-produced
# recipes.
for handle, name in _flow_control_input_requirements(recipe).items():
for handle, name in flow_control_input_requirements(recipe).items():
if ( # pragma: no cover - twin of the output-edge guard above; only a
# hand-built recipe (one a parser never emits) can name a source for
# both a flow-control input and an output differently. See
Expand All @@ -252,6 +252,52 @@ def resolve(source) -> str:
)
required_by_handle[handle] = name

def _obj_source(
label: str,
) -> edge_models.SourceHandle | edge_models.InputSource | None:
target = edge_models.TargetHandle(node=label, port=sugar.OBJ_PORT)
return recipe.input_edges.get(target) or recipe.edges.get(target)

def _obj_is_symbolic(label: str) -> bool:
"""True if the getattr's object resolves to a symbol we can write '.' after.

An object fed by an *inlined* constant would sugar to nonsense (``5 .a``), so
such a node keeps the plain-call emission. The parser cannot produce one.
"""
source = _obj_source(label)
if source is None:
return False
if isinstance(source, edge_models.InputSource):
return True
peer = recipe.nodes[source.node]
if isinstance(peer, constant_recipe.ConstantRecipe):
return source.node in materialized_constants
return True

# A recognised getattr node is emitted as attribute syntax -- `sym = obj.attr`
# -- always as its own statement, never inlined into a consumer's argument list.
#
# Inlining would be prettier (`f(dc.a)`, `dc.a.val`) but it is not safe. The
# parser injects a getattr node together with a `ConstantRecipe` peer carrying the
# attribute name, and constant labels come from one shared `constant_N` counter
# over the whole workflow. Inlining moves a getattr's creation to its consumer's
# statement, which reorders that name-constant relative to every *other* constant
# in the workflow (e.g. a literal call argument) and permutes the counter. The
# recipe would still execute identically, but it would not re-parse to an equal
# one. Emitting one statement per getattr, in topological order, reproduces the
# parser's injection order exactly, so the round trip is exact.
#
# Materialising also gives the efficiency guarantee for free: one node, one
# statement, one symbol -- a getattr consumed by several nodes stays a single
# node on re-parse rather than being duplicated into each consumer.
sugared: dict[str, str] = {
label: attr_name
for label, node in recipe.nodes.items()
if sugar.is_std_getattr(node)
and (attr_name := sugar.attribute_name(label, recipe)) is not None
and _obj_is_symbolic(label)
}

for label in _topological_nodes(recipe):
node = recipe.nodes[label]
if isinstance(node, constant_recipe.ConstantRecipe):
Expand All @@ -265,6 +311,15 @@ def resolve(source) -> str:
lines.append(f"{name} = {repr(node.constant)}")
continue

if label in sugared:
name = required_by_handle.get((label, sugar.ATTR_PORT)) or alloc.fresh(
_output_name_suggestion(label, sugar.ATTR_PORT, 1)
)
produced[(label, sugar.ATTR_PORT)] = name
obj = resolve(_obj_source(label))
lines.append(f"{name} = {obj}.{sugared[label]}")
continue

call_path = node_call_path(node, label, emitter, alloc)
if call_path is not None:
lhs_syms = _allocate_outputs(
Expand Down
63 changes: 63 additions & 0 deletions src/flowrep/compiler/sugar.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
from __future__ import annotations

import keyword
from typing import Any, cast

from flowrep import edge_models
from flowrep.prospective import (
atomic_recipe,
constant_recipe,
std,
union_types,
workflow_recipe,
)

# `LabeledRecipe.recipe` is a discriminated union; the cast narrows it for mypy.
_GETATTR = cast(atomic_recipe.AtomicRecipe, std.getattr_.recipe)
_GETATTR_FQN = _GETATTR.reference.info.fully_qualified_name

# Port names are derived from the recipe, never spelled out: editing `std.getattr_`
# must not require editing strings anywhere else in the source.
OBJ_PORT, NAME_PORT = _GETATTR.inputs
(ATTR_PORT,) = _GETATTR.outputs


def is_std_getattr(node: union_types.RecipeDiscrimination) -> bool:
"""True if *node* is the standard-library attribute-access recipe.

Matched on the referenced function's fully qualified name rather than on
``VersionInfo`` equality, so a recipe serialised under a different flowrep
version is still recognised.
"""
return (
isinstance(node, atomic_recipe.AtomicRecipe)
and node.reference.info.fully_qualified_name == _GETATTR_FQN
and node.inputs == _GETATTR.inputs
and node.outputs == _GETATTR.outputs
)


def is_attribute_syntax(value: Any) -> bool:
"""True if *value* can be written after a ``.`` in Python source.

Deliberately laxer than :func:`base_models.is_valid_label`, which also
excludes the reserved port names ``inputs``/``outputs``. Those are perfectly
legal attributes, and ``dc.inputs`` must compile back to ``dc.inputs``.
"""
return (
isinstance(value, str) and value.isidentifier() and not keyword.iskeyword(value)
)


def attribute_name(label: str, recipe: workflow_recipe.WorkflowRecipe) -> str | None:
"""The identifier fed to *label*'s ``name`` port by a constant peer, else None."""
source = recipe.edges.get(edge_models.TargetHandle(node=label, port=NAME_PORT))
if source is None:
return None
peer = recipe.nodes.get(source.node)
if not isinstance(peer, constant_recipe.ConstantRecipe):
return None
constant = peer.constant
if isinstance(constant, str) and is_attribute_syntax(constant):
return constant
return None
Loading
Loading