flowrep 0.5.2__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.
- {flowrep-0.5.2 → flowrep-0.6.0}/PKG-INFO +5 -3
- {flowrep-0.5.2 → flowrep-0.6.0}/docs/README.md +2 -2
- {flowrep-0.5.2 → flowrep-0.6.0}/pyproject.toml +2 -2
- {flowrep-0.5.2 → flowrep-0.6.0}/src/flowrep/__init__.py +2 -1
- {flowrep-0.5.2 → flowrep-0.6.0}/src/flowrep/_version.py +2 -2
- {flowrep-0.5.2 → flowrep-0.6.0}/src/flowrep/api/__init__.py +2 -0
- {flowrep-0.5.2 → flowrep-0.6.0}/src/flowrep/api/schemas.py +3 -1
- {flowrep-0.5.2 → flowrep-0.6.0}/src/flowrep/api/tools.py +2 -0
- {flowrep-0.5.2 → flowrep-0.6.0}/src/flowrep/base_models.py +25 -0
- {flowrep-0.5.2 → flowrep-0.6.0}/src/flowrep/compiler/flow_control.py +13 -9
- {flowrep-0.5.2 → flowrep-0.6.0}/src/flowrep/compiler/function.py +43 -25
- {flowrep-0.5.2 → flowrep-0.6.0}/src/flowrep/compiler/statements.py +149 -6
- flowrep-0.6.0/src/flowrep/compiler/sugar.py +86 -0
- {flowrep-0.5.2 → flowrep-0.6.0}/src/flowrep/parsers/atomic_parser.py +93 -80
- flowrep-0.6.0/src/flowrep/parsers/attribute_parser.py +48 -0
- {flowrep-0.5.2 → flowrep-0.6.0}/src/flowrep/parsers/case_helpers.py +38 -8
- flowrep-0.6.0/src/flowrep/parsers/chain_parser.py +266 -0
- flowrep-0.6.0/src/flowrep/parsers/constant_parser.py +67 -0
- flowrep-0.6.0/src/flowrep/parsers/dataclass_parser.py +160 -0
- {flowrep-0.5.2 → flowrep-0.6.0}/src/flowrep/parsers/for_parser.py +100 -50
- {flowrep-0.5.2 → flowrep-0.6.0}/src/flowrep/parsers/if_parser.py +22 -10
- flowrep-0.6.0/src/flowrep/parsers/item_parser.py +73 -0
- {flowrep-0.5.2 → flowrep-0.6.0}/src/flowrep/parsers/label_helpers.py +6 -4
- flowrep-0.6.0/src/flowrep/parsers/parser_helpers.py +332 -0
- {flowrep-0.5.2 → flowrep-0.6.0}/src/flowrep/parsers/symbol_scope.py +136 -15
- {flowrep-0.5.2 → flowrep-0.6.0}/src/flowrep/parsers/try_parser.py +12 -9
- flowrep-0.6.0/src/flowrep/parsers/while_parser.py +177 -0
- {flowrep-0.5.2 → flowrep-0.6.0}/src/flowrep/parsers/workflow_parser.py +152 -40
- {flowrep-0.5.2 → flowrep-0.6.0}/src/flowrep/prospective/atomic_recipe.py +1 -28
- flowrep-0.6.0/src/flowrep/prospective/constant_recipe.py +95 -0
- {flowrep-0.5.2 → flowrep-0.6.0}/src/flowrep/prospective/for_recipe.py +3 -3
- {flowrep-0.5.2 → flowrep-0.6.0}/src/flowrep/prospective/helper_models.py +5 -5
- {flowrep-0.5.2 → flowrep-0.6.0}/src/flowrep/prospective/if_recipe.py +3 -3
- {flowrep-0.5.2 → flowrep-0.6.0}/src/flowrep/prospective/try_recipe.py +2 -2
- {flowrep-0.5.2 → flowrep-0.6.0}/src/flowrep/prospective/union_types.py +2 -0
- {flowrep-0.5.2 → flowrep-0.6.0}/src/flowrep/prospective/while_recipe.py +2 -2
- {flowrep-0.5.2 → flowrep-0.6.0}/src/flowrep/retrospective/datastructures.py +53 -49
- flowrep-0.6.0/src/flowrep/std.py +292 -0
- {flowrep-0.5.2 → flowrep-0.6.0}/src/flowrep/wfms.py +21 -21
- flowrep-0.5.2/src/flowrep/parsers/parser_helpers.py +0 -166
- flowrep-0.5.2/src/flowrep/parsers/while_parser.py +0 -116
- {flowrep-0.5.2 → flowrep-0.6.0}/.gitignore +0 -0
- {flowrep-0.5.2 → flowrep-0.6.0}/LICENSE +0 -0
- {flowrep-0.5.2 → flowrep-0.6.0}/src/flowrep/compiler/__init__.py +0 -0
- {flowrep-0.5.2 → flowrep-0.6.0}/src/flowrep/compiler/annotate.py +0 -0
- {flowrep-0.5.2 → flowrep-0.6.0}/src/flowrep/compiler/source.py +0 -0
- {flowrep-0.5.2 → flowrep-0.6.0}/src/flowrep/converters/__init__.py +0 -0
- {flowrep-0.5.2 → flowrep-0.6.0}/src/flowrep/converters/python_workflow_definition.py +0 -0
- {flowrep-0.5.2 → flowrep-0.6.0}/src/flowrep/edge_models.py +0 -0
- {flowrep-0.5.2 → flowrep-0.6.0}/src/flowrep/parsers/__init__.py +0 -0
- {flowrep-0.5.2 → flowrep-0.6.0}/src/flowrep/parsers/object_scope.py +0 -0
- {flowrep-0.5.2 → flowrep-0.6.0}/src/flowrep/parsers/parser_protocol.py +0 -0
- {flowrep-0.5.2 → flowrep-0.6.0}/src/flowrep/prospective/__init__.py +0 -0
- {flowrep-0.5.2 → flowrep-0.6.0}/src/flowrep/prospective/workflow_recipe.py +0 -0
- {flowrep-0.5.2 → flowrep-0.6.0}/src/flowrep/retrospective/__init__.py +0 -0
- {flowrep-0.5.2 → flowrep-0.6.0}/src/flowrep/retrospective/storage.py +0 -0
- {flowrep-0.5.2 → flowrep-0.6.0}/src/flowrep/retrospective/storage_widget.py +0 -0
- {flowrep-0.5.2 → 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.
|
|
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"]["
|
|
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"]["
|
|
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"]["
|
|
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"]["
|
|
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.
|
|
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.
|
|
22
|
-
__version_tuple__ = version_tuple = (0,
|
|
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.
|
|
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
|
-
#
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
122
|
-
"""
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
140
|
+
_collect_pinned_symbols(node, names)
|
|
132
141
|
return names
|
|
133
142
|
|
|
134
143
|
|
|
135
|
-
def
|
|
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
|
-
|
|
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
|
-
|
|
161
|
+
_collect_pinned_symbols_from_body(body_case.body.recipe, names)
|
|
144
162
|
if node.else_case is not None:
|
|
145
|
-
|
|
163
|
+
_collect_pinned_symbols_from_body(node.else_case.recipe, names)
|
|
146
164
|
elif isinstance(node, try_recipe.TryRecipe):
|
|
147
|
-
|
|
165
|
+
_collect_pinned_symbols_from_body(node.try_node.recipe, names)
|
|
148
166
|
for exception_case in node.exception_cases:
|
|
149
|
-
|
|
167
|
+
_collect_pinned_symbols_from_body(exception_case.body.recipe, names)
|
|
150
168
|
elif isinstance(node, while_recipe.WhileRecipe):
|
|
151
|
-
|
|
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
|
|
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
|
-
|
|
180
|
+
_collect_pinned_symbols(node, names)
|
|
163
181
|
elif isinstance(body, flow_control.FLOW_CONTROL_TYPES):
|
|
164
|
-
|
|
165
|
-
# single atomic body:
|
|
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
|
-
#
|
|
323
|
-
#
|
|
324
|
-
for
|
|
325
|
-
alloc.reserve(
|
|
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
|
|
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
|
-
|
|
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
|