flowrep 0.5.1__tar.gz → 0.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. {flowrep-0.5.1 → flowrep-0.6.0}/PKG-INFO +5 -3
  2. {flowrep-0.5.1 → flowrep-0.6.0}/docs/README.md +2 -2
  3. {flowrep-0.5.1 → flowrep-0.6.0}/pyproject.toml +2 -2
  4. {flowrep-0.5.1 → flowrep-0.6.0}/src/flowrep/__init__.py +2 -1
  5. {flowrep-0.5.1 → flowrep-0.6.0}/src/flowrep/_version.py +2 -2
  6. {flowrep-0.5.1 → flowrep-0.6.0}/src/flowrep/api/__init__.py +2 -0
  7. {flowrep-0.5.1 → flowrep-0.6.0}/src/flowrep/api/schemas.py +3 -1
  8. {flowrep-0.5.1 → flowrep-0.6.0}/src/flowrep/api/tools.py +2 -0
  9. {flowrep-0.5.1 → flowrep-0.6.0}/src/flowrep/base_models.py +25 -0
  10. {flowrep-0.5.1 → flowrep-0.6.0}/src/flowrep/compiler/flow_control.py +13 -9
  11. {flowrep-0.5.1 → flowrep-0.6.0}/src/flowrep/compiler/function.py +43 -25
  12. {flowrep-0.5.1 → flowrep-0.6.0}/src/flowrep/compiler/statements.py +149 -6
  13. flowrep-0.6.0/src/flowrep/compiler/sugar.py +86 -0
  14. {flowrep-0.5.1 → flowrep-0.6.0}/src/flowrep/parsers/atomic_parser.py +93 -74
  15. flowrep-0.6.0/src/flowrep/parsers/attribute_parser.py +48 -0
  16. {flowrep-0.5.1 → flowrep-0.6.0}/src/flowrep/parsers/case_helpers.py +38 -8
  17. flowrep-0.6.0/src/flowrep/parsers/chain_parser.py +266 -0
  18. flowrep-0.6.0/src/flowrep/parsers/constant_parser.py +67 -0
  19. flowrep-0.6.0/src/flowrep/parsers/dataclass_parser.py +160 -0
  20. {flowrep-0.5.1 → flowrep-0.6.0}/src/flowrep/parsers/for_parser.py +100 -50
  21. {flowrep-0.5.1 → flowrep-0.6.0}/src/flowrep/parsers/if_parser.py +22 -10
  22. flowrep-0.6.0/src/flowrep/parsers/item_parser.py +73 -0
  23. {flowrep-0.5.1 → flowrep-0.6.0}/src/flowrep/parsers/label_helpers.py +6 -4
  24. flowrep-0.6.0/src/flowrep/parsers/parser_helpers.py +332 -0
  25. {flowrep-0.5.1 → flowrep-0.6.0}/src/flowrep/parsers/symbol_scope.py +136 -15
  26. {flowrep-0.5.1 → flowrep-0.6.0}/src/flowrep/parsers/try_parser.py +12 -9
  27. flowrep-0.6.0/src/flowrep/parsers/while_parser.py +177 -0
  28. {flowrep-0.5.1 → flowrep-0.6.0}/src/flowrep/parsers/workflow_parser.py +152 -40
  29. {flowrep-0.5.1 → flowrep-0.6.0}/src/flowrep/prospective/atomic_recipe.py +1 -28
  30. flowrep-0.6.0/src/flowrep/prospective/constant_recipe.py +95 -0
  31. {flowrep-0.5.1 → flowrep-0.6.0}/src/flowrep/prospective/for_recipe.py +3 -3
  32. {flowrep-0.5.1 → flowrep-0.6.0}/src/flowrep/prospective/helper_models.py +5 -5
  33. {flowrep-0.5.1 → flowrep-0.6.0}/src/flowrep/prospective/if_recipe.py +3 -3
  34. {flowrep-0.5.1 → flowrep-0.6.0}/src/flowrep/prospective/try_recipe.py +2 -2
  35. {flowrep-0.5.1 → flowrep-0.6.0}/src/flowrep/prospective/union_types.py +2 -0
  36. {flowrep-0.5.1 → flowrep-0.6.0}/src/flowrep/prospective/while_recipe.py +2 -2
  37. {flowrep-0.5.1 → flowrep-0.6.0}/src/flowrep/retrospective/datastructures.py +62 -37
  38. {flowrep-0.5.1 → flowrep-0.6.0}/src/flowrep/retrospective/storage.py +12 -3
  39. flowrep-0.6.0/src/flowrep/std.py +292 -0
  40. {flowrep-0.5.1 → flowrep-0.6.0}/src/flowrep/wfms.py +31 -35
  41. flowrep-0.5.1/src/flowrep/parsers/parser_helpers.py +0 -166
  42. flowrep-0.5.1/src/flowrep/parsers/while_parser.py +0 -116
  43. {flowrep-0.5.1 → flowrep-0.6.0}/.gitignore +0 -0
  44. {flowrep-0.5.1 → flowrep-0.6.0}/LICENSE +0 -0
  45. {flowrep-0.5.1 → flowrep-0.6.0}/src/flowrep/compiler/__init__.py +0 -0
  46. {flowrep-0.5.1 → flowrep-0.6.0}/src/flowrep/compiler/annotate.py +0 -0
  47. {flowrep-0.5.1 → flowrep-0.6.0}/src/flowrep/compiler/source.py +0 -0
  48. {flowrep-0.5.1 → flowrep-0.6.0}/src/flowrep/converters/__init__.py +0 -0
  49. {flowrep-0.5.1 → flowrep-0.6.0}/src/flowrep/converters/python_workflow_definition.py +0 -0
  50. {flowrep-0.5.1 → flowrep-0.6.0}/src/flowrep/edge_models.py +0 -0
  51. {flowrep-0.5.1 → flowrep-0.6.0}/src/flowrep/parsers/__init__.py +0 -0
  52. {flowrep-0.5.1 → flowrep-0.6.0}/src/flowrep/parsers/object_scope.py +0 -0
  53. {flowrep-0.5.1 → flowrep-0.6.0}/src/flowrep/parsers/parser_protocol.py +0 -0
  54. {flowrep-0.5.1 → flowrep-0.6.0}/src/flowrep/prospective/__init__.py +0 -0
  55. {flowrep-0.5.1 → flowrep-0.6.0}/src/flowrep/prospective/workflow_recipe.py +0 -0
  56. {flowrep-0.5.1 → flowrep-0.6.0}/src/flowrep/retrospective/__init__.py +0 -0
  57. {flowrep-0.5.1 → flowrep-0.6.0}/src/flowrep/retrospective/storage_widget.py +0 -0
  58. {flowrep-0.5.1 → flowrep-0.6.0}/src/flowrep/subgraph_validation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flowrep
3
- Version: 0.5.1
3
+ Version: 0.6.0
4
4
  Summary: flowrep - Your premier tool for workflow representations
5
5
  Project-URL: Homepage, https://pyiron.org/
6
6
  Project-URL: Documentation, https://flowrep.readthedocs.io
@@ -47,8 +47,10 @@ Classifier: Programming Language :: Python :: 3.13
47
47
  Classifier: Programming Language :: Python :: 3.14
48
48
  Classifier: Topic :: Scientific/Engineering
49
49
  Requires-Python: <3.15,>=3.11
50
+ Requires-Dist: packaging<=26.2,>=24.2
50
51
  Requires-Dist: pydantic<2.13.0,>=2.12.0
51
52
  Requires-Dist: pyiron-snippets<2.0.0,>=1.3.0
53
+ Requires-Dist: typing-extensions<4.17.0,>=4.14.1
52
54
  Provides-Extra: notebooks
53
55
  Requires-Dist: numpy==2.4.6; extra == 'notebooks'
54
56
  Provides-Extra: pwd
@@ -292,10 +294,10 @@ We can look at the template it will follow, e.g., by peeking at the part of the
292
294
  used for the "while" condition:
293
295
 
294
296
  ```python
295
- >>> recipe_json["nodes"]["double_until_0"]["nodes"]["while_0"]["case"]["condition"]["node"]["type"]
297
+ >>> recipe_json["nodes"]["double_until_0"]["nodes"]["while_0"]["case"]["condition"]["recipe"]["type"]
296
298
  'atomic'
297
299
 
298
- >>> recipe_json["nodes"]["double_until_0"]["nodes"]["while_0"]["case"]["condition"]["node"]["reference"]["info"]["qualname"]
300
+ >>> recipe_json["nodes"]["double_until_0"]["nodes"]["while_0"]["case"]["condition"]["recipe"]["reference"]["info"]["qualname"]
299
301
  'is_less_than_target'
300
302
 
301
303
  ```
@@ -230,10 +230,10 @@ We can look at the template it will follow, e.g., by peeking at the part of the
230
230
  used for the "while" condition:
231
231
 
232
232
  ```python
233
- >>> recipe_json["nodes"]["double_until_0"]["nodes"]["while_0"]["case"]["condition"]["node"]["type"]
233
+ >>> recipe_json["nodes"]["double_until_0"]["nodes"]["while_0"]["case"]["condition"]["recipe"]["type"]
234
234
  'atomic'
235
235
 
236
- >>> recipe_json["nodes"]["double_until_0"]["nodes"]["while_0"]["case"]["condition"]["node"]["reference"]["info"]["qualname"]
236
+ >>> recipe_json["nodes"]["double_until_0"]["nodes"]["while_0"]["case"]["condition"]["recipe"]["reference"]["info"]["qualname"]
237
237
  'is_less_than_target'
238
238
 
239
239
  ```
@@ -1,5 +1,5 @@
1
1
  [build-system]
2
- requires = [ "hatchling==1.30.1", "hatch-vcs==0.5.0",]
2
+ requires = [ "hatchling==1.31.0", "hatch-vcs==0.5.0",]
3
3
  build-backend = "hatchling.build"
4
4
 
5
5
  [project]
@@ -9,7 +9,7 @@ readme = "docs/README.md"
9
9
  keywords = [ "pyiron",]
10
10
  requires-python = ">=3.11, <3.15"
11
11
  classifiers = [ "Development Status :: 4 - Beta", "Topic :: Scientific/Engineering", "License :: OSI Approved :: BSD License", "Intended Audience :: Science/Research", "Operating System :: OS Independent", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14",]
12
- dependencies = [ "pydantic>=2.12.0,<2.13.0", "pyiron_snippets>=1.3.0,<2.0.0",]
12
+ dependencies = [ "packaging>=24.2,<=26.2", "pydantic>=2.12.0,<2.13.0", "pyiron_snippets>=1.3.0,<2.0.0", "typing-extensions>=4.14.1,<4.17.0",]
13
13
  dynamic = [ "version",]
14
14
  [[project.authors]]
15
15
  name = "Sam Waseda"
@@ -7,9 +7,10 @@ attribute onto a function at definition time, and parsers (`parse_atomic`,
7
7
  import importlib.metadata
8
8
 
9
9
  from flowrep.api import atomic as atomic
10
+ from flowrep.api import dataclass as dataclass
10
11
  from flowrep.api import parse_atomic as parse_atomic
11
- from flowrep.api import parse_workflow as parse_workflow
12
12
  from flowrep.api import schemas as schemas
13
+ from flowrep.api import std as std
13
14
  from flowrep.api import tools as tools
14
15
  from flowrep.api import workflow as workflow
15
16
 
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.5.1'
22
- __version_tuple__ = version_tuple = (0, 5, 1)
21
+ __version__ = version = '0.6.0'
22
+ __version_tuple__ = version_tuple = (0, 6, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -7,9 +7,11 @@ tools list is likely to be useful for all power users, while schemas are intende
7
7
  primarily benefit downstream developers (e.g. WfMS creators).
8
8
  """
9
9
 
10
+ import flowrep.std as std
10
11
  from flowrep.api import schemas as schemas
11
12
  from flowrep.api import tools as tools
12
13
  from flowrep.api.tools import atomic as atomic
14
+ from flowrep.api.tools import dataclass as dataclass
13
15
  from flowrep.api.tools import parse_atomic as parse_atomic
14
16
  from flowrep.api.tools import parse_workflow as parse_workflow
15
17
  from flowrep.api.tools import workflow as workflow
@@ -22,7 +22,8 @@ from flowrep.edge_models import OutputTarget as OutputTarget
22
22
  from flowrep.edge_models import SourceHandle as SourceHandle
23
23
  from flowrep.edge_models import TargetHandle as TargetHandle
24
24
  from flowrep.prospective.atomic_recipe import AtomicRecipe as AtomicRecipe
25
- from flowrep.prospective.atomic_recipe import UnpackMode as UnpackMode
25
+ from flowrep.prospective.constant_recipe import JSONABLE as JSONABLE
26
+ from flowrep.prospective.constant_recipe import ConstantRecipe as ConstantRecipe
26
27
  from flowrep.prospective.for_recipe import ForEachRecipe as ForEachRecipe
27
28
  from flowrep.prospective.helper_models import ConditionalCase as ConditionalCase
28
29
  from flowrep.prospective.helper_models import ExceptionCase as ExceptionCase
@@ -36,6 +37,7 @@ from flowrep.prospective.workflow_recipe import WorkflowRecipe as WorkflowRecipe
36
37
  from flowrep.retrospective.datastructures import NOT_DATA as NOT_DATA
37
38
  from flowrep.retrospective.datastructures import AtomicData as AtomicData
38
39
  from flowrep.retrospective.datastructures import CompositeData as CompositeData
40
+ from flowrep.retrospective.datastructures import ConstantData as ConstantData
39
41
  from flowrep.retrospective.datastructures import DagData as DagData
40
42
  from flowrep.retrospective.datastructures import FlowControlData as FlowControlData
41
43
  from flowrep.retrospective.datastructures import ForEachData as ForEachData
@@ -10,8 +10,10 @@ from flowrep.converters.python_workflow_definition import flowrep2pwd as flowrep
10
10
  from flowrep.converters.python_workflow_definition import pwd2flowrep as pwd2flowrep
11
11
  from flowrep.parsers.atomic_parser import atomic as atomic
12
12
  from flowrep.parsers.atomic_parser import parse_atomic as parse_atomic
13
+ from flowrep.parsers.dataclass_parser import dataclass as dataclass
13
14
  from flowrep.parsers.workflow_parser import parse_workflow as parse_workflow
14
15
  from flowrep.parsers.workflow_parser import workflow as workflow
16
+ from flowrep.prospective.constant_recipe import is_jsonable as is_jsonable
15
17
  from flowrep.retrospective.datastructures import recipe2data as recipe2data
16
18
  from flowrep.retrospective.storage import LexicalBagBrowser as LexicalBagBrowser
17
19
  from flowrep.wfms import run_recipe as run_recipe
@@ -18,6 +18,7 @@ class RecipeElementType(StrEnum):
18
18
  WHILE = "while"
19
19
  IF = "if"
20
20
  TRY = "try"
21
+ CONSTANT = "constant"
21
22
 
22
23
 
23
24
  class IOTypes(StrEnum):
@@ -61,6 +62,30 @@ def validate_unique(v: list[T], message: str | None = None) -> list[T]:
61
62
  return v
62
63
 
63
64
 
65
+ def ensure_class_signature_from_init(cls: type, context: str) -> None:
66
+ """Reject classes whose constructor signature comes from ``__new__``.
67
+
68
+ Flowrep derives a class's input parameter names and defaults from
69
+ ``inspect.signature(cls)`` but reads their annotations from
70
+ ``cls.__init__``. For classes that define their signature via ``__new__``
71
+ (e.g. :class:`typing.NamedTuple`, or any class overriding ``__new__``
72
+ without a corresponding ``__init__``), these two sources disagree and
73
+ annotations would silently be dropped. Fail loudly instead.
74
+
75
+ Args:
76
+ cls: The class being introspected.
77
+ context: A short label for the caller (e.g. ``"@atomic"`` or a fully
78
+ qualified name) used in the error message.
79
+ """
80
+ if cls.__init__ is object.__init__ and cls.__new__ is not object.__new__: # type: ignore[misc, comparison-overlap]
81
+ raise TypeError(
82
+ f"{context} cannot introspect {cls.__name__!r}: its constructor "
83
+ f"signature is defined via __new__ (e.g. NamedTuple or a custom "
84
+ f"__new__), so flowrep cannot read input annotations from __init__. "
85
+ f"Define an __init__ with annotated parameters instead."
86
+ )
87
+
88
+
64
89
  UniqueList = Annotated[list[T], pydantic.AfterValidator(validate_unique)]
65
90
  # We want the ordered property of a list -- especially during round-trip serialization
66
91
  # But also the "one of each element" property of a list
@@ -69,10 +69,14 @@ def emit_flow_control(
69
69
  for port in node.outputs
70
70
  if (label, port) in required_by_handle
71
71
  }
72
- # Allocate output symbols for all outputs, using required names where available.
72
+ # A flow-control node's output port is always named after an enclosing *symbol* the
73
+ # author wrote -- an accumulator, a branch assignment, a loop variable -- so the port
74
+ # name is a pin, not a hint. `reserve` rather than `fresh`: minting `b_0` for a symbol
75
+ # the source calls `b` would break the round trip, and would collide with the same
76
+ # symbol pinned elsewhere (a `while` inside a `try` surfaces one `b` through both).
73
77
  out_syms: dict[str, str] = {}
74
78
  for port in node.outputs:
75
- name = required.get(port) or alloc.fresh(port)
79
+ name = required.get(port) or alloc.reserve(port)
76
80
  produced[(label, port)] = name
77
81
  out_syms[port] = name
78
82
 
@@ -106,7 +110,7 @@ def _emit_for_each(
106
110
  lines = [f"{out_syms[port]} = []" for port in node.outputs]
107
111
 
108
112
  body_label = node.body_node.label
109
- body = node.body_node.node
113
+ body = node.body_node.recipe
110
114
 
111
115
  def collection_symbol(body_port: str) -> str:
112
116
  """Return the enclosing-scope symbol that feeds a body port."""
@@ -178,7 +182,7 @@ def _render_condition(
178
182
  alloc: function.NameAllocator,
179
183
  ) -> str:
180
184
  """Render a condition call as an inline expression (no assignment)."""
181
- cond_node = case_condition.node
185
+ cond_node = case_condition.recipe
182
186
  cond_label = case_condition.label
183
187
  call_path = statements.node_call_path(cond_node, cond_label, emitter, alloc)
184
188
  if call_path is None:
@@ -244,7 +248,7 @@ def _emit_if(
244
248
  lines.append(f"{keyword} {cond_expr}:")
245
249
  body = _emit_branch(
246
250
  case.body.label,
247
- case.body.node,
251
+ case.body.recipe,
248
252
  node,
249
253
  in_resolver,
250
254
  out_syms,
@@ -256,7 +260,7 @@ def _emit_if(
256
260
  lines.append("else:")
257
261
  body = _emit_branch(
258
262
  node.else_case.label,
259
- node.else_case.node,
263
+ node.else_case.recipe,
260
264
  node,
261
265
  in_resolver,
262
266
  out_syms,
@@ -290,7 +294,7 @@ def _emit_try(
290
294
  # try body
291
295
  try_lines = _emit_branch(
292
296
  node.try_node.label,
293
- node.try_node.node,
297
+ node.try_node.recipe,
294
298
  node,
295
299
  in_resolver,
296
300
  out_syms,
@@ -309,7 +313,7 @@ def _emit_try(
309
313
  lines.append(header)
310
314
  body = _emit_branch(
311
315
  case.body.label,
312
- case.body.node,
316
+ case.body.recipe,
313
317
  node,
314
318
  in_resolver,
315
319
  out_syms,
@@ -332,7 +336,7 @@ def _emit_while(
332
336
  cond_expr = _render_condition(case.condition, node, in_resolver, emitter, alloc)
333
337
 
334
338
  body_label = case.body.label
335
- body = case.body.node
339
+ body = case.body.recipe
336
340
 
337
341
  # Map body inputs to enclosing-scope symbols (unwired defaults are omitted).
338
342
  body_in_syms: dict[str, str] = {}
@@ -118,51 +118,69 @@ def referenced_top_level_bindings(
118
118
  yield info.module.split(".")[0]
119
119
 
120
120
 
121
- def _inlined_loop_variables(recipe: workflow_recipe.WorkflowRecipe) -> set[str]:
122
- """Loop-variable names emitted into this workflow's own scope.
123
-
124
- For-loop variables are pinned to body-port names and bypass the allocator, so
125
- reserving them prevents allocator-minted symbols from shadowing them. Descends
126
- through inlined flow-control bodies; stops at reference-free WorkflowRecipe peer
127
- nodes (emitted as nested defs with their own scope) and referenced nodes.
121
+ def _inlined_pinned_symbols(recipe: workflow_recipe.WorkflowRecipe) -> set[str]:
122
+ """Every name the emitter is *forced* to use as a symbol in this function's scope.
123
+
124
+ Python's flow control introduces no scope, so every inlined ``for``/``if``/``while``/
125
+ ``try`` body shares one flat namespace with the enclosing ``def``. Most symbols in it
126
+ are the allocator's to choose, but some are pinned: round-tripping requires a symbol
127
+ to carry a port name verbatim, or re-parsing regenerates a different port. Pinned
128
+ names bypass the allocator, so it must learn them *up front* -- a pin emitted later
129
+ cannot retroactively protect a name the allocator already minted.
130
+
131
+ The pins are flow-control output ports, for-loop variables, for-body output ports, and
132
+ the input ports of flow-control nodes (which force the feeding sibling's output symbol
133
+ to match).
134
+
135
+ Descends through inlined flow-control bodies; stops at reference-free WorkflowRecipe
136
+ peer nodes (emitted as nested defs, with their own scope) and referenced nodes.
128
137
  """
129
- names: set[str] = set()
138
+ names: set[str] = set(statements.flow_control_input_requirements(recipe).values())
130
139
  for node in recipe.nodes.values():
131
- _collect_loop_variables(node, names)
140
+ _collect_pinned_symbols(node, names)
132
141
  return names
133
142
 
134
143
 
135
- def _collect_loop_variables(
144
+ def _collect_pinned_symbols(
136
145
  node: union_types.RecipeDiscrimination, names: set[str]
137
146
  ) -> None:
147
+ if not isinstance(node, flow_control.FLOW_CONTROL_TYPES):
148
+ return # atomic / referenced / reference-free workflow peer: not inlined here
149
+
150
+ # Every flow-control output port is named for an enclosing symbol, and
151
+ # `emit_flow_control` pins it to that name.
152
+ names.update(node.outputs)
153
+
138
154
  if isinstance(node, for_recipe.ForEachRecipe):
139
155
  names.update(node.iterated_ports)
140
- _collect_loop_variables_from_body(node.body_node.node, names)
156
+ # `_emit_for_each` pins every body output to its port name.
157
+ names.update(node.body_node.recipe.outputs)
158
+ _collect_pinned_symbols_from_body(node.body_node.recipe, names)
141
159
  elif isinstance(node, if_recipe.IfRecipe):
142
160
  for body_case in node.cases:
143
- _collect_loop_variables_from_body(body_case.body.node, names)
161
+ _collect_pinned_symbols_from_body(body_case.body.recipe, names)
144
162
  if node.else_case is not None:
145
- _collect_loop_variables_from_body(node.else_case.node, names)
163
+ _collect_pinned_symbols_from_body(node.else_case.recipe, names)
146
164
  elif isinstance(node, try_recipe.TryRecipe):
147
- _collect_loop_variables_from_body(node.try_node.node, names)
165
+ _collect_pinned_symbols_from_body(node.try_node.recipe, names)
148
166
  for exception_case in node.exception_cases:
149
- _collect_loop_variables_from_body(exception_case.body.node, names)
167
+ _collect_pinned_symbols_from_body(exception_case.body.recipe, names)
150
168
  elif isinstance(node, while_recipe.WhileRecipe):
151
- _collect_loop_variables_from_body(node.case.body.node, names)
152
- # atomic / referenced / reference-free workflow peer node: not inlined here
169
+ _collect_pinned_symbols_from_body(node.case.body.recipe, names)
153
170
 
154
171
 
155
- def _collect_loop_variables_from_body(
172
+ def _collect_pinned_symbols_from_body(
156
173
  body: union_types.RecipeDiscrimination, names: set[str]
157
174
  ) -> None:
158
175
  """Recurse into an inlined body: a WorkflowRecipe's own nodes, or a nested
159
176
  flow-control node. Both are emitted into the current scope."""
160
177
  if isinstance(body, workflow_recipe.WorkflowRecipe):
178
+ names.update(statements.flow_control_input_requirements(body).values())
161
179
  for node in body.nodes.values():
162
- _collect_loop_variables(node, names)
180
+ _collect_pinned_symbols(node, names)
163
181
  elif isinstance(body, flow_control.FLOW_CONTROL_TYPES):
164
- _collect_loop_variables(body, names)
165
- # single atomic body: no loop variables
182
+ _collect_pinned_symbols(body, names)
183
+ # single atomic body: nothing pinned beyond what the caller already reserved
166
184
 
167
185
 
168
186
  def emit_nested_workflow_node(
@@ -319,10 +337,10 @@ def emit_workflow_function(
319
337
  ) -> FunctionBuilder:
320
338
  alloc = NameAllocator()
321
339
  in_syms = {port: alloc.reserve(port) for port in recipe.inputs}
322
- # Loop variables are pinned to body-port names and bypass the allocator; reserve
323
- # them so node-output symbols never collide with (and get shadowed by) them.
324
- for loop_variable in _inlined_loop_variables(recipe):
325
- alloc.reserve(loop_variable)
340
+ # Pinned symbols bypass the allocator, so it has to know them before it mints
341
+ # anything, or a fresh name can land on one and be silently overwritten.
342
+ for pinned in _inlined_pinned_symbols(recipe):
343
+ alloc.reserve(pinned)
326
344
  params = _render_params(recipe, signature, emitter)
327
345
  lines, out_syms = statements.emit_workflow_body(recipe, in_syms, {}, emitter, alloc)
328
346
  # Output port names are pinned via the decorator (see FunctionBuilder.render),
@@ -2,14 +2,15 @@ from __future__ import annotations
2
2
 
3
3
  import re
4
4
  from collections.abc import Callable
5
- from typing import TypeAlias, cast
5
+ from typing import Any, TypeAlias, cast
6
6
 
7
7
  from pyiron_snippets import versions
8
8
 
9
- from flowrep import base_models, edge_models, subgraph_validation
10
- from flowrep.compiler import flow_control, function
9
+ from flowrep import base_models, edge_models, std, subgraph_validation
10
+ from flowrep.compiler import flow_control, function, sugar
11
11
  from flowrep.prospective import (
12
12
  atomic_recipe,
13
+ constant_recipe,
13
14
  union_types,
14
15
  workflow_recipe,
15
16
  )
@@ -103,6 +104,23 @@ def _set_call_path_from_info(
103
104
  return call_path
104
105
 
105
106
 
107
+ def _identity_materialization(value: Any, emitter: function.Emitter) -> str:
108
+ """Render an ``identity`` call wrapping a constant that cannot be inlined.
109
+
110
+ A bare ``name = <literal>`` statement no longer re-parses (constants are only
111
+ valid as call arguments), so a constant feeding a workflow output or a bare
112
+ flow-control body is emitted as ``identity(x=<literal>)``. The
113
+ ``flowrep.prospective.std`` import is hoisted via ``_set_call_path_from_info``.
114
+ On re-parse this canonicalises to a std ``identity`` atomic node fed by an
115
+ inlined constant argument, which then round-trips exactly.
116
+ """
117
+ call_path = _set_call_path_from_info(
118
+ std.identity.flowrep_recipe.reference.info, # type: ignore[attr-defined]
119
+ emitter.module_imports,
120
+ )
121
+ return f"{call_path}(x={value!r})"
122
+
123
+
106
124
  def _topological_nodes(
107
125
  recipe: workflow_recipe.WorkflowRecipe,
108
126
  ) -> list[str]:
@@ -114,7 +132,7 @@ def _topological_nodes(
114
132
  )
115
133
 
116
134
 
117
- def _flow_control_input_requirements(
135
+ def flow_control_input_requirements(
118
136
  recipe: workflow_recipe.WorkflowRecipe,
119
137
  ) -> dict[tuple[str, str], str]:
120
138
  """Required source-symbol names imposed by flow-control node inputs.
@@ -192,9 +210,26 @@ def emit_workflow_body(
192
210
  produced: dict[tuple[str, str], str] = {}
193
211
  lines: list[str] = []
194
212
 
213
+ # Constants feeding a workflow output cannot be inlined (a bare `return <literal>`
214
+ # is not re-parseable, and a bare `sym = <literal>` statement is no longer
215
+ # re-parseable either), so they are wrapped as `sym = identity(x=<literal>)` below
216
+ # and resolved to that symbol everywhere.
217
+ materialized_constants = {
218
+ src.node
219
+ for src in recipe.output_edges.values()
220
+ if isinstance(src, edge_models.SourceHandle)
221
+ and isinstance(recipe.nodes.get(src.node), constant_recipe.ConstantRecipe)
222
+ }
223
+
195
224
  def resolve(source) -> str:
196
225
  if isinstance(source, edge_models.InputSource):
197
226
  return in_syms[source.port]
227
+ source_node = recipe.nodes[source.node]
228
+ if (
229
+ isinstance(source_node, constant_recipe.ConstantRecipe)
230
+ and source.node not in materialized_constants
231
+ ):
232
+ return repr(source_node.constant)
198
233
  return produced[(source.node, source.port)]
199
234
 
200
235
  # Map (node, port) -> required symbol name for outputs the parent pins.
@@ -214,8 +249,14 @@ def emit_workflow_body(
214
249
 
215
250
  # Flow-control nodes derive their input port names from the enclosing symbols
216
251
  # feeding them, so each such source must be named after the port for the port
217
- # names (and while-loop reassignments) to round-trip.
218
- for handle, name in _flow_control_input_requirements(recipe).items():
252
+ # names (and while-loop reassignments) to round-trip. A constant peer feeding a
253
+ # flow-control input is normal now (a literal condition argument injects
254
+ # a constant peer routed through a synthetic flow-control input port). Such a
255
+ # peer is never also a workflow-output source, so its required_by_handle entry
256
+ # stays inert and it is inlined in the topo loop below -- which is why the
257
+ # conflict branch immediately below remains unreachable in parser-produced
258
+ # recipes.
259
+ for handle, name in flow_control_input_requirements(recipe).items():
219
260
  if ( # pragma: no cover - twin of the output-edge guard above; only a
220
261
  # hand-built recipe (one a parser never emits) can name a source for
221
262
  # both a flow-control input and an output differently. See
@@ -229,8 +270,101 @@ def emit_workflow_body(
229
270
  )
230
271
  required_by_handle[handle] = name
231
272
 
273
+ def _port_source(
274
+ label: str, port: str
275
+ ) -> edge_models.SourceHandle | edge_models.InputSource | None:
276
+ target = edge_models.TargetHandle(node=label, port=port)
277
+ return recipe.input_edges.get(target) or recipe.edges.get(target)
278
+
279
+ def _obj_is_symbolic(label: str, obj_port: str) -> bool:
280
+ """True if the access node's object resolves to a symbol we can subscript or
281
+ write '.' after.
282
+
283
+ An object fed by an *inlined* constant would sugar to nonsense (``5 .a``), or --
284
+ for an item -- to legal syntax that re-parses to a different recipe (``[1, 2][0]``
285
+ is not rooted at a symbol). Either way such a node keeps the plain-call emission.
286
+ The parser cannot produce one.
287
+ """
288
+ source = _port_source(label, obj_port)
289
+ if source is None:
290
+ return False
291
+ if isinstance(source, edge_models.InputSource):
292
+ return True
293
+ peer = recipe.nodes[source.node]
294
+ if isinstance(peer, constant_recipe.ConstantRecipe):
295
+ return source.node in materialized_constants
296
+ return True
297
+
298
+ # A recognised getattr or getitem node is emitted as native syntax -- `sym = obj.attr`
299
+ # or `sym = obj[key]` -- always as its own statement, never inlined into a consumer's
300
+ # argument list.
301
+ #
302
+ # Inlining would be prettier (`f(dc.a)`, `dc.a.val`, `d['a']['b']`) but it is not
303
+ # safe. The parser injects an access node together with any `ConstantRecipe` peer,
304
+ # and constant labels come from one shared `constant_N` counter over the whole
305
+ # workflow. Inlining moves an access node's creation to its consumer's statement,
306
+ # which reorders that constant relative to every *other* constant in the workflow
307
+ # (e.g. a literal call argument) and permutes the counter. The recipe would still
308
+ # execute identically, but it would not re-parse to an equal one. Emitting one
309
+ # statement per access, in topological order, reproduces the parser's injection order
310
+ # exactly, so the round trip is exact.
311
+ #
312
+ # Materialising also gives the efficiency guarantee for free: one node, one
313
+ # statement, one symbol -- an access consumed by several nodes stays a single
314
+ # node on re-parse rather than being duplicated into each consumer.
315
+ sugared_attrs: dict[str, str] = {
316
+ label: attr_name
317
+ for label, node in recipe.nodes.items()
318
+ if sugar.is_std_getattr(node)
319
+ and (attr_name := sugar.attribute_name(label, recipe)) is not None
320
+ and _obj_is_symbolic(label, sugar.ATTR_OBJ_PORT)
321
+ }
322
+
323
+ # The key-source check is the getitem analogue of `attribute_name` returning None: it
324
+ # refuses a hand-built recipe whose key edge is missing, which would otherwise reach
325
+ # `resolve(None)`. No syntax check is needed -- `resolve` renders a constant key as a
326
+ # literal and any other key as its symbol, and both are legal between brackets.
327
+ sugared_items: set[str] = {
328
+ label
329
+ for label, node in recipe.nodes.items()
330
+ if sugar.is_std_getitem(node)
331
+ and _port_source(label, sugar.KEY_PORT) is not None
332
+ and _obj_is_symbolic(label, sugar.ITEM_OBJ_PORT)
333
+ }
334
+
232
335
  for label in _topological_nodes(recipe):
233
336
  node = recipe.nodes[label]
337
+ if isinstance(node, constant_recipe.ConstantRecipe):
338
+ if label not in materialized_constants:
339
+ continue # inline at the consumer call site via `resolve`
340
+ constant_label = constant_recipe.ConstantRecipe.std_label
341
+ name = required_by_handle.get((label, constant_label)) or alloc.fresh(
342
+ _output_name_suggestion(label, constant_label, 1)
343
+ )
344
+ produced[(label, constant_label)] = name
345
+ lines.append(
346
+ f"{name} = {_identity_materialization(node.constant, emitter)}"
347
+ )
348
+ continue
349
+
350
+ if label in sugared_attrs:
351
+ name = required_by_handle.get((label, sugar.ATTR_PORT)) or alloc.fresh(
352
+ _output_name_suggestion(label, sugar.ATTR_PORT, 1)
353
+ )
354
+ produced[(label, sugar.ATTR_PORT)] = name
355
+ obj = resolve(_port_source(label, sugar.ATTR_OBJ_PORT))
356
+ lines.append(f"{name} = {obj}.{sugared_attrs[label]}")
357
+ continue
358
+
359
+ if label in sugared_items:
360
+ name = required_by_handle.get((label, sugar.ITEM_PORT)) or alloc.fresh(
361
+ _output_name_suggestion(label, sugar.ITEM_PORT, 1)
362
+ )
363
+ produced[(label, sugar.ITEM_PORT)] = name
364
+ obj = resolve(_port_source(label, sugar.ITEM_OBJ_PORT))
365
+ key = resolve(_port_source(label, sugar.KEY_PORT))
366
+ lines.append(f"{name} = {obj}[{key}]")
367
+ continue
234
368
 
235
369
  call_path = node_call_path(node, label, emitter, alloc)
236
370
  if call_path is not None:
@@ -282,6 +416,15 @@ def emit_body(
282
416
  return flow_control.emit_flow_control_body(
283
417
  recipe, label, in_syms, required, emitter, alloc
284
418
  )
419
+ if isinstance(recipe, constant_recipe.ConstantRecipe):
420
+ constant_label = constant_recipe.ConstantRecipe.std_label
421
+ name = required.get(constant_label) or alloc.fresh(
422
+ _output_name_suggestion(label, constant_label, 1)
423
+ )
424
+ return (
425
+ [f"{name} = {_identity_materialization(recipe.constant, emitter)}"],
426
+ {constant_label: name},
427
+ )
285
428
  return _emit_single_node_body(recipe, label, in_syms, required, alloc, emitter)
286
429
 
287
430
 
@@ -0,0 +1,86 @@
1
+ from __future__ import annotations
2
+
3
+ import keyword
4
+ from typing import Any, cast
5
+
6
+ from flowrep import edge_models, std
7
+ from flowrep.prospective import (
8
+ atomic_recipe,
9
+ constant_recipe,
10
+ union_types,
11
+ workflow_recipe,
12
+ )
13
+
14
+ # `LabeledRecipe.recipe` is a discriminated union; the cast narrows it for mypy.
15
+ _GETATTR = cast(atomic_recipe.AtomicRecipe, std.get_attr.flowrep_recipe) # type: ignore[attr-defined]
16
+ _GETATTR_FQN = _GETATTR.reference.info.fully_qualified_name
17
+
18
+ # Port names are derived from the recipe, never spelled out: editing `std.get_attr` or
19
+ # `std.getitem` must not require editing strings anywhere else in the source. The two
20
+ # object ports differ -- `obj` for get_attr, `a` for getitem, which inherits
21
+ # `operator.getitem(a, b)`'s signature -- so each is named for its own link kind.
22
+ ATTR_OBJ_PORT, NAME_PORT = _GETATTR.inputs
23
+ (ATTR_PORT,) = _GETATTR.outputs
24
+
25
+ # `LabeledRecipe.recipe` is a discriminated union; the cast narrows it for mypy.
26
+ _GETITEM = cast(atomic_recipe.AtomicRecipe, std.getitem.flowrep_recipe) # type: ignore[attr-defined]
27
+ _GETITEM_FQN = _GETITEM.reference.info.fully_qualified_name
28
+
29
+ ITEM_OBJ_PORT, KEY_PORT = _GETITEM.inputs
30
+ (ITEM_PORT,) = _GETITEM.outputs
31
+
32
+
33
+ def is_std_getattr(node: union_types.RecipeDiscrimination) -> bool:
34
+ """True if *node* is the standard-library attribute-access recipe.
35
+
36
+ Matched on the referenced function's fully qualified name rather than on
37
+ ``VersionInfo`` equality, so a recipe serialised under a different flowrep
38
+ version is still recognised.
39
+ """
40
+ return (
41
+ isinstance(node, atomic_recipe.AtomicRecipe)
42
+ and node.reference.info.fully_qualified_name == _GETATTR_FQN
43
+ and node.inputs == _GETATTR.inputs
44
+ and node.outputs == _GETATTR.outputs
45
+ )
46
+
47
+
48
+ def is_std_getitem(node: union_types.RecipeDiscrimination) -> bool:
49
+ """True if *node* is the standard-library item-access recipe.
50
+
51
+ Matched on the referenced function's fully qualified name rather than on
52
+ ``VersionInfo`` equality, so a recipe serialised under a different flowrep
53
+ version is still recognised.
54
+ """
55
+ return (
56
+ isinstance(node, atomic_recipe.AtomicRecipe)
57
+ and node.reference.info.fully_qualified_name == _GETITEM_FQN
58
+ and node.inputs == _GETITEM.inputs
59
+ and node.outputs == _GETITEM.outputs
60
+ )
61
+
62
+
63
+ def is_attribute_syntax(value: Any) -> bool:
64
+ """True if *value* can be written after a ``.`` in Python source.
65
+
66
+ Deliberately laxer than :func:`base_models.is_valid_label`, which also
67
+ excludes the reserved port names ``inputs``/``outputs``. Those are perfectly
68
+ legal attributes, and ``dc.inputs`` must compile back to ``dc.inputs``.
69
+ """
70
+ return (
71
+ isinstance(value, str) and value.isidentifier() and not keyword.iskeyword(value)
72
+ )
73
+
74
+
75
+ def attribute_name(label: str, recipe: workflow_recipe.WorkflowRecipe) -> str | None:
76
+ """The identifier fed to *label*'s ``name`` port by a constant peer, else None."""
77
+ source = recipe.edges.get(edge_models.TargetHandle(node=label, port=NAME_PORT))
78
+ if source is None:
79
+ return None
80
+ peer = recipe.nodes.get(source.node)
81
+ if not isinstance(peer, constant_recipe.ConstantRecipe):
82
+ return None
83
+ constant = peer.constant
84
+ if isinstance(constant, str) and is_attribute_syntax(constant):
85
+ return constant
86
+ return None