flowrep 0.2.0__tar.gz → 0.3.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 (91) hide show
  1. {flowrep-0.2.0 → flowrep-0.3.0}/.gitignore +1 -0
  2. {flowrep-0.2.0 → flowrep-0.3.0}/PKG-INFO +3 -1
  3. {flowrep-0.2.0 → flowrep-0.3.0}/cached-miniforge/my-env/lib/python3.1/site-packages/flowrep/_version.py +2 -2
  4. flowrep-0.3.0/cached-miniforge/my-env/lib/python3.1/site-packages/flowrep/models/api/live.py +9 -0
  5. {flowrep-0.2.0 → flowrep-0.3.0}/cached-miniforge/my-env/lib/python3.1/site-packages/flowrep/models/api/schemas.py +19 -3
  6. flowrep-0.3.0/cached-miniforge/my-env/lib/python3.1/site-packages/flowrep/models/api/wfms.py +8 -0
  7. flowrep-0.3.0/cached-miniforge/my-env/lib/python3.1/site-packages/flowrep/models/live.py +303 -0
  8. {flowrep-0.2.0 → flowrep-0.3.0}/cached-miniforge/my-env/lib/python3.1/site-packages/flowrep/models/nodes/for_model.py +6 -1
  9. {flowrep-0.2.0 → flowrep-0.3.0/cached-miniforge/my-env/lib/python3.1/site-packages}/flowrep/models/parsers/atomic_parser.py +4 -3
  10. {flowrep-0.2.0 → flowrep-0.3.0}/cached-miniforge/my-env/lib/python3.1/site-packages/flowrep/models/parsers/dependency_parser.py +15 -2
  11. flowrep-0.3.0/cached-miniforge/my-env/lib/python3.1/site-packages/flowrep/models/parsers/import_parser.py +51 -0
  12. {flowrep-0.2.0 → flowrep-0.3.0/cached-miniforge/my-env/lib/python3.1/site-packages}/flowrep/models/parsers/object_scope.py +63 -13
  13. flowrep-0.3.0/cached-miniforge/my-env/lib/python3.1/site-packages/flowrep/models/wfms.py +502 -0
  14. {flowrep-0.2.0 → flowrep-0.3.0}/flowrep/_version.py +2 -2
  15. flowrep-0.3.0/flowrep/models/api/live.py +9 -0
  16. {flowrep-0.2.0 → flowrep-0.3.0}/flowrep/models/api/schemas.py +19 -3
  17. flowrep-0.3.0/flowrep/models/api/wfms.py +8 -0
  18. flowrep-0.3.0/flowrep/models/live.py +303 -0
  19. {flowrep-0.2.0 → flowrep-0.3.0}/flowrep/models/nodes/for_model.py +6 -1
  20. {flowrep-0.2.0/cached-miniforge/my-env/lib/python3.1/site-packages → flowrep-0.3.0}/flowrep/models/parsers/atomic_parser.py +4 -3
  21. {flowrep-0.2.0 → flowrep-0.3.0}/flowrep/models/parsers/dependency_parser.py +15 -2
  22. flowrep-0.3.0/flowrep/models/parsers/import_parser.py +51 -0
  23. {flowrep-0.2.0/cached-miniforge/my-env/lib/python3.1/site-packages → flowrep-0.3.0}/flowrep/models/parsers/object_scope.py +63 -13
  24. flowrep-0.3.0/flowrep/models/wfms.py +502 -0
  25. {flowrep-0.2.0 → flowrep-0.3.0}/pyproject.toml +1 -0
  26. flowrep-0.2.0/cached-miniforge/my-env/lib/python3.1/site-packages/flowrep/models/api/nodes.py +0 -13
  27. flowrep-0.2.0/flowrep/models/api/nodes.py +0 -13
  28. {flowrep-0.2.0 → flowrep-0.3.0}/LICENSE +0 -0
  29. {flowrep-0.2.0 → flowrep-0.3.0}/cached-miniforge/my-env/lib/python3.1/site-packages/flowrep/__init__.py +0 -0
  30. {flowrep-0.2.0 → flowrep-0.3.0}/cached-miniforge/my-env/lib/python3.1/site-packages/flowrep/models/__init__.py +0 -0
  31. {flowrep-0.2.0 → flowrep-0.3.0}/cached-miniforge/my-env/lib/python3.1/site-packages/flowrep/models/api/__init__.py +0 -0
  32. {flowrep-0.2.0 → flowrep-0.3.0}/cached-miniforge/my-env/lib/python3.1/site-packages/flowrep/models/api/converters.py +0 -0
  33. {flowrep-0.2.0 → flowrep-0.3.0}/cached-miniforge/my-env/lib/python3.1/site-packages/flowrep/models/api/parsers.py +0 -0
  34. {flowrep-0.2.0 → flowrep-0.3.0}/cached-miniforge/my-env/lib/python3.1/site-packages/flowrep/models/base_models.py +0 -0
  35. {flowrep-0.2.0 → flowrep-0.3.0}/cached-miniforge/my-env/lib/python3.1/site-packages/flowrep/models/converters/__init__.py +0 -0
  36. {flowrep-0.2.0 → flowrep-0.3.0}/cached-miniforge/my-env/lib/python3.1/site-packages/flowrep/models/converters/python_workflow_definition.py +0 -0
  37. {flowrep-0.2.0 → flowrep-0.3.0}/cached-miniforge/my-env/lib/python3.1/site-packages/flowrep/models/edge_models.py +0 -0
  38. {flowrep-0.2.0 → flowrep-0.3.0}/cached-miniforge/my-env/lib/python3.1/site-packages/flowrep/models/nodes/__init__.py +0 -0
  39. {flowrep-0.2.0 → flowrep-0.3.0}/cached-miniforge/my-env/lib/python3.1/site-packages/flowrep/models/nodes/atomic_model.py +0 -0
  40. {flowrep-0.2.0 → flowrep-0.3.0}/cached-miniforge/my-env/lib/python3.1/site-packages/flowrep/models/nodes/helper_models.py +0 -0
  41. {flowrep-0.2.0 → flowrep-0.3.0}/cached-miniforge/my-env/lib/python3.1/site-packages/flowrep/models/nodes/if_model.py +0 -0
  42. {flowrep-0.2.0 → flowrep-0.3.0}/cached-miniforge/my-env/lib/python3.1/site-packages/flowrep/models/nodes/try_model.py +0 -0
  43. {flowrep-0.2.0 → flowrep-0.3.0}/cached-miniforge/my-env/lib/python3.1/site-packages/flowrep/models/nodes/union.py +0 -0
  44. {flowrep-0.2.0 → flowrep-0.3.0}/cached-miniforge/my-env/lib/python3.1/site-packages/flowrep/models/nodes/while_model.py +0 -0
  45. {flowrep-0.2.0 → flowrep-0.3.0}/cached-miniforge/my-env/lib/python3.1/site-packages/flowrep/models/nodes/workflow_model.py +0 -0
  46. {flowrep-0.2.0 → flowrep-0.3.0}/cached-miniforge/my-env/lib/python3.1/site-packages/flowrep/models/parsers/__init__.py +0 -0
  47. {flowrep-0.2.0 → flowrep-0.3.0}/cached-miniforge/my-env/lib/python3.1/site-packages/flowrep/models/parsers/case_helpers.py +0 -0
  48. {flowrep-0.2.0 → flowrep-0.3.0}/cached-miniforge/my-env/lib/python3.1/site-packages/flowrep/models/parsers/for_parser.py +0 -0
  49. {flowrep-0.2.0 → flowrep-0.3.0}/cached-miniforge/my-env/lib/python3.1/site-packages/flowrep/models/parsers/if_parser.py +0 -0
  50. {flowrep-0.2.0 → flowrep-0.3.0}/cached-miniforge/my-env/lib/python3.1/site-packages/flowrep/models/parsers/label_helpers.py +0 -0
  51. {flowrep-0.2.0 → flowrep-0.3.0}/cached-miniforge/my-env/lib/python3.1/site-packages/flowrep/models/parsers/parser_helpers.py +0 -0
  52. {flowrep-0.2.0 → flowrep-0.3.0}/cached-miniforge/my-env/lib/python3.1/site-packages/flowrep/models/parsers/parser_protocol.py +0 -0
  53. {flowrep-0.2.0 → flowrep-0.3.0}/cached-miniforge/my-env/lib/python3.1/site-packages/flowrep/models/parsers/symbol_scope.py +0 -0
  54. {flowrep-0.2.0 → flowrep-0.3.0}/cached-miniforge/my-env/lib/python3.1/site-packages/flowrep/models/parsers/try_parser.py +0 -0
  55. {flowrep-0.2.0 → flowrep-0.3.0}/cached-miniforge/my-env/lib/python3.1/site-packages/flowrep/models/parsers/while_parser.py +0 -0
  56. {flowrep-0.2.0 → flowrep-0.3.0}/cached-miniforge/my-env/lib/python3.1/site-packages/flowrep/models/parsers/workflow_parser.py +0 -0
  57. {flowrep-0.2.0 → flowrep-0.3.0}/cached-miniforge/my-env/lib/python3.1/site-packages/flowrep/models/subgraph_validation.py +0 -0
  58. {flowrep-0.2.0 → flowrep-0.3.0}/cached-miniforge/my-env/lib/python3.1/site-packages/flowrep/tools.py +0 -0
  59. {flowrep-0.2.0 → flowrep-0.3.0}/cached-miniforge/my-env/lib/python3.1/site-packages/flowrep/workflow.py +0 -0
  60. {flowrep-0.2.0 → flowrep-0.3.0}/docs/README.md +0 -0
  61. {flowrep-0.2.0 → flowrep-0.3.0}/flowrep/__init__.py +0 -0
  62. {flowrep-0.2.0 → flowrep-0.3.0}/flowrep/models/__init__.py +0 -0
  63. {flowrep-0.2.0 → flowrep-0.3.0}/flowrep/models/api/__init__.py +0 -0
  64. {flowrep-0.2.0 → flowrep-0.3.0}/flowrep/models/api/converters.py +0 -0
  65. {flowrep-0.2.0 → flowrep-0.3.0}/flowrep/models/api/parsers.py +0 -0
  66. {flowrep-0.2.0 → flowrep-0.3.0}/flowrep/models/base_models.py +0 -0
  67. {flowrep-0.2.0 → flowrep-0.3.0}/flowrep/models/converters/__init__.py +0 -0
  68. {flowrep-0.2.0 → flowrep-0.3.0}/flowrep/models/converters/python_workflow_definition.py +0 -0
  69. {flowrep-0.2.0 → flowrep-0.3.0}/flowrep/models/edge_models.py +0 -0
  70. {flowrep-0.2.0 → flowrep-0.3.0}/flowrep/models/nodes/__init__.py +0 -0
  71. {flowrep-0.2.0 → flowrep-0.3.0}/flowrep/models/nodes/atomic_model.py +0 -0
  72. {flowrep-0.2.0 → flowrep-0.3.0}/flowrep/models/nodes/helper_models.py +0 -0
  73. {flowrep-0.2.0 → flowrep-0.3.0}/flowrep/models/nodes/if_model.py +0 -0
  74. {flowrep-0.2.0 → flowrep-0.3.0}/flowrep/models/nodes/try_model.py +0 -0
  75. {flowrep-0.2.0 → flowrep-0.3.0}/flowrep/models/nodes/union.py +0 -0
  76. {flowrep-0.2.0 → flowrep-0.3.0}/flowrep/models/nodes/while_model.py +0 -0
  77. {flowrep-0.2.0 → flowrep-0.3.0}/flowrep/models/nodes/workflow_model.py +0 -0
  78. {flowrep-0.2.0 → flowrep-0.3.0}/flowrep/models/parsers/__init__.py +0 -0
  79. {flowrep-0.2.0 → flowrep-0.3.0}/flowrep/models/parsers/case_helpers.py +0 -0
  80. {flowrep-0.2.0 → flowrep-0.3.0}/flowrep/models/parsers/for_parser.py +0 -0
  81. {flowrep-0.2.0 → flowrep-0.3.0}/flowrep/models/parsers/if_parser.py +0 -0
  82. {flowrep-0.2.0 → flowrep-0.3.0}/flowrep/models/parsers/label_helpers.py +0 -0
  83. {flowrep-0.2.0 → flowrep-0.3.0}/flowrep/models/parsers/parser_helpers.py +0 -0
  84. {flowrep-0.2.0 → flowrep-0.3.0}/flowrep/models/parsers/parser_protocol.py +0 -0
  85. {flowrep-0.2.0 → flowrep-0.3.0}/flowrep/models/parsers/symbol_scope.py +0 -0
  86. {flowrep-0.2.0 → flowrep-0.3.0}/flowrep/models/parsers/try_parser.py +0 -0
  87. {flowrep-0.2.0 → flowrep-0.3.0}/flowrep/models/parsers/while_parser.py +0 -0
  88. {flowrep-0.2.0 → flowrep-0.3.0}/flowrep/models/parsers/workflow_parser.py +0 -0
  89. {flowrep-0.2.0 → flowrep-0.3.0}/flowrep/models/subgraph_validation.py +0 -0
  90. {flowrep-0.2.0 → flowrep-0.3.0}/flowrep/tools.py +0 -0
  91. {flowrep-0.2.0 → flowrep-0.3.0}/flowrep/workflow.py +0 -0
@@ -11,3 +11,4 @@ apidoc/
11
11
  .ipynb_checkpoints/
12
12
  test_times.dat
13
13
  core.*
14
+ flowrep/_version.py
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flowrep
3
- Version: 0.2.0
3
+ Version: 0.3.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
@@ -49,6 +49,8 @@ Requires-Python: <3.14,>=3.11
49
49
  Requires-Dist: networkx<3.7.0,>=3.4.2
50
50
  Requires-Dist: pydantic<2.13.0,>=2.12.0
51
51
  Requires-Dist: pyiron-snippets<2.0.0,>=1.2.1
52
+ Provides-Extra: notebooks
53
+ Requires-Dist: numpy==2.4.2; extra == 'notebooks'
52
54
  Provides-Extra: pwd
53
55
  Requires-Dist: python-workflow-definition==0.1.4; extra == 'pwd'
54
56
  Description-Content-Type: text/markdown
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '0.2.0'
32
- __version_tuple__ = version_tuple = (0, 2, 0)
31
+ __version__ = version = '0.3.0'
32
+ __version_tuple__ = version_tuple = (0, 3, 0)
33
33
 
34
34
  __commit_id__ = commit_id = None
@@ -0,0 +1,9 @@
1
+ """
2
+ Data classes for "live" stateful instance-views of the recipes.
3
+
4
+ Intended to be a common export- and communication-format for WfMS of instance-views.
5
+ Since they hold live, arbitrary python objects, they don't serialize trivially and are
6
+ not (by themselves) a valid storage format.
7
+ """
8
+
9
+ from flowrep.models.live import recipe2live as recipe2live
@@ -1,8 +1,9 @@
1
1
  """
2
- Pydantic models, enums, and types used in constructing or inspecting recipe models.
2
+ Pydantic models, types and classes, enums, and constants used in constructing or
3
+ inspecting recipe models.
3
4
 
4
- Intended for power-users like workflow management system (WfMS) designers to work deeply
5
- with recipe objects in a structured, well-typed way.
5
+ Primarily intended for power-users like workflow management system (WfMS) designers to
6
+ work deeply with recipe objects in a structured, well-typed way.
6
7
  """
7
8
 
8
9
  from flowrep.models.base_models import RESERVED_NAMES as RESERVED_NAMES
@@ -17,9 +18,24 @@ from flowrep.models.edge_models import InputSource as InputSource
17
18
  from flowrep.models.edge_models import OutputTarget as OutputTarget
18
19
  from flowrep.models.edge_models import SourceHandle as SourceHandle
19
20
  from flowrep.models.edge_models import TargetHandle as TargetHandle
21
+ from flowrep.models.live import NOT_DATA as NOT_DATA
22
+ from flowrep.models.live import Atomic as Atomic
23
+ from flowrep.models.live import Composite as Composite
24
+ from flowrep.models.live import FlowControl as FlowControl
25
+ from flowrep.models.live import InputPort as InputPort
26
+ from flowrep.models.live import LiveNode as LiveNode
27
+ from flowrep.models.live import NotData as NotData
28
+ from flowrep.models.live import OutputPort as OutputPort
29
+ from flowrep.models.live import Workflow as Workflow
30
+ from flowrep.models.nodes.atomic_model import AtomicNode as AtomicNode
20
31
  from flowrep.models.nodes.atomic_model import UnpackMode as UnpackMode
32
+ from flowrep.models.nodes.for_model import ForNode as ForNode
21
33
  from flowrep.models.nodes.helper_models import ConditionalCase as ConditionalCase
22
34
  from flowrep.models.nodes.helper_models import ExceptionCase as ExceptionCase
23
35
  from flowrep.models.nodes.helper_models import LabeledNode as LabeledNode
36
+ from flowrep.models.nodes.if_model import IfNode as IfNode
37
+ from flowrep.models.nodes.try_model import TryNode as TryNode
24
38
  from flowrep.models.nodes.union import Nodes as Nodes
25
39
  from flowrep.models.nodes.union import NodeType as NodeType
40
+ from flowrep.models.nodes.while_model import WhileNode as WhileNode
41
+ from flowrep.models.nodes.workflow_model import WorkflowNode as WorkflowNode
@@ -0,0 +1,8 @@
1
+ """
2
+ A toy Workflow Management System (WfMS) to convert recipes into live objects with
3
+ output data.
4
+
5
+ Intended for use in tests, and as an example for fully-fledged WfMS to refer to.
6
+ """
7
+
8
+ from flowrep.models.wfms import run_recipe as run_recipe
@@ -0,0 +1,303 @@
1
+ """
2
+ Flowrep recipes represent a class-view of how data can be processed via a workflow.
3
+
4
+ In this module, we provide a prototypical data structure for live, instance-view
5
+ workflows, which can be mutated as they are executed to be enriched with data.
6
+
7
+ Unlike the recipes, no goal is made to provide easy serialization, and these data
8
+ structures natively hold complex python objects.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import abc
14
+ import dataclasses
15
+ import inspect
16
+ import types
17
+ from collections.abc import Callable, MutableMapping
18
+ from typing import Any, get_args, get_origin, get_type_hints
19
+
20
+ from pyiron_snippets import dotdict, retrieve, singleton
21
+
22
+ from flowrep.models import base_models, edge_models
23
+ from flowrep.models.nodes import (
24
+ atomic_model,
25
+ for_model,
26
+ if_model,
27
+ try_model,
28
+ union,
29
+ while_model,
30
+ workflow_model,
31
+ )
32
+
33
+
34
+ class NotData(metaclass=singleton.Singleton):
35
+ """
36
+ This class exists purely to initialize data channel values where no default value
37
+ is provided; it lets the channel know that it has _no data in it_ and thus should
38
+ not identify as ready.
39
+ """
40
+
41
+ @classmethod
42
+ def __repr__(cls):
43
+ # We use the class directly (not instances of it) where there is not yet data
44
+ # So give it a decent repr, even as just a class
45
+ return "NOT_DATA"
46
+
47
+ def __reduce__(self):
48
+ return "NOT_DATA"
49
+
50
+ def __bool__(self):
51
+ return False
52
+
53
+
54
+ NOT_DATA = NotData()
55
+
56
+
57
+ @dataclasses.dataclass(frozen=False)
58
+ class _Port:
59
+ value: object | NotData = NOT_DATA
60
+ annotation: Any | None = None
61
+
62
+
63
+ @dataclasses.dataclass(frozen=False)
64
+ class InputPort(_Port):
65
+ default: object | NotData = NOT_DATA
66
+
67
+ def get_data(self) -> object | NotData:
68
+ """A shortcut for falling back on the default"""
69
+ return self.default if self.value is NOT_DATA else self.value
70
+
71
+
72
+ @dataclasses.dataclass(frozen=False)
73
+ class OutputPort(_Port): ...
74
+
75
+
76
+ @dataclasses.dataclass(frozen=False)
77
+ class LiveNode(abc.ABC):
78
+ recipe: union.NodeType
79
+ input_ports: MutableMapping[base_models.Label, InputPort]
80
+ output_ports: MutableMapping[base_models.Label, OutputPort]
81
+
82
+
83
+ def recipe2live(recipe: union.NodeType) -> LiveNode:
84
+ match recipe:
85
+ case atomic_model.AtomicNode():
86
+ return Atomic.from_recipe(recipe)
87
+ case for_model.ForNode():
88
+ return FlowControl.from_recipe(recipe)
89
+ case if_model.IfNode():
90
+ return FlowControl.from_recipe(recipe)
91
+ case try_model.TryNode():
92
+ return FlowControl.from_recipe(recipe)
93
+ case while_model.WhileNode():
94
+ return FlowControl.from_recipe(recipe)
95
+ case workflow_model.WorkflowNode():
96
+ return Workflow.from_recipe(recipe)
97
+
98
+
99
+ @dataclasses.dataclass(frozen=False)
100
+ class Atomic(LiveNode):
101
+ function: Callable
102
+
103
+ @classmethod
104
+ def from_recipe(cls, recipe: atomic_model.AtomicNode) -> Atomic:
105
+ function, input_ports, output_ports = _parse_function(
106
+ recipe.reference.info.fully_qualified_name,
107
+ recipe.inputs,
108
+ recipe.outputs,
109
+ recipe.unpack_mode,
110
+ )
111
+ return Atomic(
112
+ recipe=recipe,
113
+ input_ports=dotdict.DotDict(input_ports),
114
+ output_ports=dotdict.DotDict(output_ports),
115
+ function=function,
116
+ )
117
+
118
+
119
+ @dataclasses.dataclass(frozen=False)
120
+ class Composite(LiveNode, abc.ABC):
121
+ nodes: MutableMapping[base_models.Label, LiveNode]
122
+ input_edges: edge_models.InputEdges
123
+ edges: edge_models.Edges
124
+ output_edges: edge_models.OutputEdges
125
+
126
+
127
+ @dataclasses.dataclass(frozen=False)
128
+ class Workflow(Composite):
129
+ @classmethod
130
+ def from_recipe(cls, recipe: workflow_model.WorkflowNode) -> Workflow:
131
+ if recipe.reference:
132
+ function, input_ports, output_ports = _parse_function(
133
+ recipe.reference.info.fully_qualified_name,
134
+ recipe.inputs,
135
+ recipe.outputs,
136
+ )
137
+ else:
138
+ input_ports = {label: InputPort() for label in recipe.inputs}
139
+ output_ports = {label: OutputPort() for label in recipe.outputs}
140
+ nodes = {label: recipe2live(child) for label, child in recipe.nodes.items()}
141
+ return Workflow(
142
+ recipe=recipe,
143
+ input_ports=dotdict.DotDict(input_ports),
144
+ output_ports=dotdict.DotDict(output_ports),
145
+ nodes=dotdict.DotDict(nodes),
146
+ input_edges=dict(recipe.input_edges),
147
+ edges=dict(recipe.edges),
148
+ output_edges=dict(recipe.output_edges),
149
+ )
150
+
151
+ # TODO: add/remove_node/edge/input/output methods, each guarded that they are
152
+ # unavailable if the underlying recipe has a reference, and otherwise mutatiting
153
+ # the underlying recipe at the same time
154
+
155
+
156
+ @dataclasses.dataclass(frozen=False)
157
+ class FlowControl(Composite):
158
+ @classmethod
159
+ def from_recipe(
160
+ cls,
161
+ recipe: (
162
+ for_model.ForNode
163
+ | if_model.IfNode
164
+ | try_model.TryNode
165
+ | while_model.WhileNode
166
+ ),
167
+ ) -> FlowControl:
168
+ """
169
+ Flow control nodes are composite with dynamic bodies; WfMS must populate the
170
+ nodes and edges at runtime according to recipe execution.
171
+ """
172
+ return FlowControl(
173
+ recipe=recipe,
174
+ input_ports=dotdict.DotDict(
175
+ {label: InputPort() for label in recipe.inputs}
176
+ ),
177
+ output_ports=dotdict.DotDict(
178
+ {label: OutputPort() for label in recipe.outputs}
179
+ ),
180
+ nodes=dotdict.DotDict(),
181
+ input_edges={},
182
+ edges={},
183
+ output_edges={},
184
+ )
185
+
186
+
187
+ def _parse_function(
188
+ fully_qualified_name: str,
189
+ inputs: list[str],
190
+ outputs: list[str],
191
+ unpack_mode: atomic_model.UnpackMode = atomic_model.UnpackMode.TUPLE,
192
+ ) -> tuple[
193
+ types.FunctionType,
194
+ dict[base_models.Label, InputPort],
195
+ dict[base_models.Label, OutputPort],
196
+ ]:
197
+ function = retrieve.import_from_string(fully_qualified_name)
198
+ hints = get_type_hints(function, include_extras=True)
199
+ sig = inspect.signature(function)
200
+
201
+ # --- input ports ---
202
+ available = set(sig.parameters)
203
+ missing = set(inputs) - available
204
+ if missing:
205
+ raise ValueError(
206
+ f"Requested inputs {missing} not found in signature of {fully_qualified_name!r}"
207
+ )
208
+
209
+ input_ports: dict[str, InputPort] = {}
210
+ for name in inputs:
211
+ param = sig.parameters[name]
212
+ input_port = InputPort()
213
+ input_port.annotation = hints.get(name, None)
214
+ input_port.default = (
215
+ param.default if param.default is not inspect.Parameter.empty else NOT_DATA
216
+ )
217
+ input_ports[name] = input_port
218
+
219
+ # --- output ports ---
220
+ return_annotation = hints.get("return", None)
221
+ if unpack_mode == atomic_model.UnpackMode.NONE:
222
+ output_ports = _parse_return_without_unpacking(return_annotation, outputs)
223
+ elif unpack_mode == atomic_model.UnpackMode.TUPLE:
224
+ output_ports = _parse_return_tuple(return_annotation, outputs)
225
+ elif unpack_mode == atomic_model.UnpackMode.DATACLASS:
226
+ output_ports = _parse_return_dataclass(return_annotation, outputs)
227
+
228
+ return function, input_ports, output_ports
229
+
230
+
231
+ def _parse_return_without_unpacking(
232
+ return_annotation, outputs: list[str]
233
+ ) -> dict[str, OutputPort]:
234
+ if len(outputs) != 1: # pragma: no cover
235
+ raise ValueError(
236
+ f"Without return unpacking, only one output is allowed, but got {outputs}. "
237
+ f"This should have been caught by the underlying recipe validation. Please "
238
+ f"raise a GitHub issue reporting how you got here!"
239
+ )
240
+ return {outputs[0]: OutputPort(annotation=return_annotation)}
241
+
242
+
243
+ def _parse_return_tuple(return_annotation, outputs: list[str]) -> dict[str, OutputPort]:
244
+ output_ports: dict[str, OutputPort]
245
+ if len(outputs) > 1:
246
+ origin = get_origin(return_annotation)
247
+ args = get_args(return_annotation)
248
+
249
+ if return_annotation is not None:
250
+ unpacking_hint = (
251
+ f"To collect the entire tuple in a single port use "
252
+ f"{atomic_model.UnpackMode.NONE} unpacking mode."
253
+ )
254
+
255
+ if origin is not tuple:
256
+ raise ValueError(
257
+ f"Multiple outputs {outputs} requested but return annotation "
258
+ f"{return_annotation!r} is not splittable -- only tuple return "
259
+ f"hints are splittable. {unpacking_hint}"
260
+ )
261
+ if len(args) != len(outputs):
262
+ raise ValueError(
263
+ f"Output labels {outputs} (n={len(outputs)}) do not match "
264
+ f"length of return annotation {return_annotation} (n={len(args)}). "
265
+ f"Tuple return hint unpacking requires one hint element per output."
266
+ f" {unpacking_hint}"
267
+ )
268
+ output_ports = {
269
+ label: OutputPort(annotation=annotation)
270
+ for label, annotation in zip(outputs, args, strict=True)
271
+ }
272
+ else:
273
+ output_ports = {label: OutputPort() for label in outputs}
274
+ else:
275
+ output_ports = {outputs[0]: OutputPort(annotation=return_annotation)}
276
+ return output_ports
277
+
278
+
279
+ def _parse_return_dataclass(
280
+ return_annotation, outputs: list[str]
281
+ ) -> dict[str, OutputPort]:
282
+ if not dataclasses.is_dataclass(return_annotation): # pragma: no cover
283
+ raise TypeError(
284
+ f"Return annotation {return_annotation!r} is not a dataclass. This should "
285
+ f"have been caught by the underlying recipe validation. Please raise a "
286
+ f"GitHub issue reporting how you got here!"
287
+ )
288
+
289
+ fields = dataclasses.fields(return_annotation)
290
+ if len(outputs) != len(fields): # pragma: no cover
291
+ raise ValueError(
292
+ f"Return dataclass {return_annotation!r} has {len(fields)} fields, "
293
+ f"{[f.name for f in fields]}, but {len(outputs)} outputs, {outputs} were "
294
+ f"requested. This should have been caught by the underlying recipe "
295
+ f"validation. Please raise a GitHub issue reporting how you got here!"
296
+ )
297
+
298
+ return {
299
+ label: OutputPort(
300
+ annotation=(field.type if field.type is not dataclasses.MISSING else None),
301
+ )
302
+ for label, field in zip(outputs, fields, strict=True)
303
+ }
@@ -59,7 +59,12 @@ class ForNode(base_models.NodeModel):
59
59
  zipped_ports: The body node ports over which to do zipped iteration. Input
60
60
  edges will map parent input elements to each child node accordingly.
61
61
 
62
- Note:
62
+ Notes:
63
+ At runtime, iterated input values should themselves be iterable. It is
64
+ recommended to pass values conforming to `collections.abc.Collection`. This is
65
+ a runtime behaviour, and is thus not enforced here at the recipe level in any
66
+ way.
67
+
63
68
  All iterated output — whether collected from body executions or forwarded from
64
69
  scattered inputs — should have the same length. Thus, forwarded inputs empower
65
70
  the node output to precisely provide which input was used to produce each
@@ -119,9 +119,10 @@ def parse_atomic(
119
119
  scraped_output_labels = _get_output_labels(func, unpack_mode)
120
120
  if len(output_labels) > 0 and len(output_labels) != len(scraped_output_labels):
121
121
  raise ValueError(
122
- f"Explicitly provided output labels must match with function analysis and "
123
- f"unpacking mode. Expected {len(scraped_output_labels)} output labels with "
124
- f"unpacking mode '{unpack_mode}', got but got {output_labels}."
122
+ "Explicitly provided output labels must match the function analysis and "
123
+ f"unpack_mode: expected {len(scraped_output_labels)} labels for "
124
+ f"unpack_mode='{unpack_mode}', got {len(output_labels)} labels "
125
+ f"{output_labels}; inferred labels were {scraped_output_labels}."
125
126
  )
126
127
 
127
128
  return atomic_model.AtomicNode(
@@ -4,7 +4,7 @@ from collections.abc import Callable
4
4
 
5
5
  from pyiron_snippets import versions
6
6
 
7
- from flowrep.models.parsers import object_scope, parser_helpers
7
+ from flowrep.models.parsers import import_parser, object_scope, parser_helpers
8
8
 
9
9
  CallDependencies = dict[versions.VersionInfo, Callable]
10
10
 
@@ -43,10 +43,13 @@ def get_call_dependencies(
43
43
  return call_dependencies
44
44
  visited.add(func_fqn)
45
45
 
46
- scope = object_scope.get_scope(func)
47
46
  tree = parser_helpers.get_ast_function_node(func)
48
47
  collector = CallCollector()
49
48
  collector.visit(tree)
49
+ local_modules = import_parser.build_scope(collector.imports, collector.import_froms)
50
+ scope = object_scope.get_scope(func)
51
+ for name, obj in local_modules.items():
52
+ scope.register(name=name, obj=obj)
50
53
 
51
54
  for call in collector.calls:
52
55
  try:
@@ -105,7 +108,17 @@ def split_by_version_availability(
105
108
  class CallCollector(ast.NodeVisitor):
106
109
  def __init__(self):
107
110
  self.calls: list[ast.expr] = []
111
+ self.imports: list[ast.Import] = []
112
+ self.import_froms: list[ast.ImportFrom] = []
108
113
 
109
114
  def visit_Call(self, node: ast.Call) -> None:
110
115
  self.calls.append(node.func)
111
116
  self.generic_visit(node)
117
+
118
+ def visit_Import(self, node: ast.Import) -> None:
119
+ self.imports.append(node)
120
+ self.generic_visit(node)
121
+
122
+ def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
123
+ self.import_froms.append(node)
124
+ self.generic_visit(node)
@@ -0,0 +1,51 @@
1
+ import ast
2
+ import importlib
3
+
4
+ from flowrep.models.parsers import object_scope
5
+ from flowrep.models.parsers.object_scope import ScopeProxy
6
+
7
+
8
+ def build_scope(
9
+ imports: list[ast.Import] | None = None,
10
+ import_froms: list[ast.ImportFrom] | None = None,
11
+ ) -> object_scope.ScopeProxy:
12
+ """
13
+ Build a scope dictionary from a list of `import` and `from ... import ...` statements.
14
+
15
+ Args:
16
+ imports (list | None): A list of `ast.Import` nodes.
17
+ import_froms (list | None): A list of `ast.ImportFrom` nodes.
18
+
19
+ Returns:
20
+ object_scope.ScopeProxy: A mutable mapping representing the scope with imported
21
+ modules and objects.
22
+ """
23
+ scope = ScopeProxy()
24
+
25
+ imports = imports or []
26
+ import_froms = import_froms or []
27
+
28
+ # Handle `import` statements
29
+ for imp in imports:
30
+ for alias in imp.names:
31
+ asname = alias.asname or alias.name
32
+ module = importlib.import_module(alias.name)
33
+ scope.register(name=asname, obj=module)
34
+
35
+ # Handle `from ... import ...` statements
36
+ for imp_from in import_froms:
37
+ level = imp_from.level
38
+ # Dynamically import the module (absolute or relative)
39
+ if imp_from.module is None or level > 0:
40
+ raise ValueError(
41
+ f"Relative imports are not supported in dependency parsing. "
42
+ f"Encountered importing from {imp_from.module}."
43
+ )
44
+ module = importlib.import_module(imp_from.module)
45
+ for alias in imp_from.names:
46
+ name = alias.name
47
+ asname = alias.asname or name
48
+ obj = getattr(module, name)
49
+ scope.register(name=asname, obj=obj)
50
+
51
+ return scope
@@ -3,24 +3,63 @@ from __future__ import annotations
3
3
  import ast
4
4
  import builtins
5
5
  import inspect
6
- from types import FunctionType
6
+ import sys
7
+ from collections.abc import Callable, MutableMapping
8
+ from typing import Any
7
9
 
8
10
 
9
- class ScopeProxy:
11
+ class _EmptyValue: ...
12
+
13
+
14
+ class ScopeProxy(MutableMapping[str, object]):
10
15
  """
11
- Make the __dict__-like scope dot-accessible without duplicating the dictionary
12
- like types.SimpleNamespace would.
16
+ A mutable mapping to connect symbols to python objects.
17
+
18
+ By default, does not allow re-registration of existing symbols to new values.
13
19
  """
14
20
 
15
- def __init__(self, d: dict):
16
- self._d = d
21
+ def __init__(
22
+ self,
23
+ d: MutableMapping[str, object] | None = None,
24
+ allow_overwrite: bool = False,
25
+ ):
26
+ self._d = {} if d is None else {k: v for k, v in d.items()}
27
+ self.allow_overwrite = allow_overwrite
28
+
29
+ def __getitem__(self, name: str):
30
+ return self._d[name]
31
+
32
+ def __setitem__(self, name: str, value: object):
33
+ if not self.allow_overwrite:
34
+ old_value = self._d.get(name, _EmptyValue)
35
+ if old_value is not _EmptyValue and value is not old_value:
36
+ raise ValueError(
37
+ f"Variable {name} already exists as {old_value!r} in this "
38
+ f"scope. It cannot be reassigned to a new value of {value!r} "
39
+ f"while allow_overwrite is False."
40
+ )
41
+ self._d[name] = value
42
+ else:
43
+ self._d[name] = value
44
+
45
+ def __delitem__(self, name: str):
46
+ self._d.__delitem__(name)
47
+
48
+ def __iter__(self):
49
+ return iter(self._d)
50
+
51
+ def __len__(self):
52
+ return len(self._d)
17
53
 
18
54
  def __getattr__(self, name: str):
19
55
  try:
20
- return self._d[name]
56
+ return self.__getitem__(name)
21
57
  except KeyError:
22
58
  raise AttributeError(name) from None
23
59
 
60
+ def __str__(self):
61
+ return str(self._d)
62
+
24
63
  def register(self, name: str, obj: object) -> None:
25
64
  """
26
65
  Add a name → object binding to this scope.
@@ -30,7 +69,7 @@ class ScopeProxy:
30
69
  resolutions within this scope (or any scope that shares the same
31
70
  backing namespace).
32
71
  """
33
- self._d[name] = obj
72
+ self[name] = obj
34
73
 
35
74
  def fork(self) -> ScopeProxy:
36
75
  """
@@ -40,11 +79,22 @@ class ScopeProxy:
40
79
  affect the parent. Used when walking conditional branches so that
41
80
  branch-local imports don't leak into sibling branches.
42
81
  """
43
- return ScopeProxy(dict(self._d))
44
-
45
-
46
- def get_scope(func: FunctionType) -> ScopeProxy:
47
- return ScopeProxy(inspect.getmodule(func).__dict__ | vars(builtins))
82
+ return ScopeProxy(self, allow_overwrite=self.allow_overwrite)
83
+
84
+
85
+ def get_scope(func: Callable[..., Any] | type[Any]) -> ScopeProxy:
86
+ module = inspect.getmodule(func)
87
+ if module is None:
88
+ module_name = getattr(func, "__module__", None)
89
+ if module_name is not None:
90
+ module = sys.modules.get(module_name)
91
+ if module is None:
92
+ raise ValueError(
93
+ f"Cannot determine the module for {func!r}. "
94
+ "inspect.getmodule() returned None and no resolvable __module__ "
95
+ "attribute was found."
96
+ )
97
+ return ScopeProxy(module.__dict__ | vars(builtins))
48
98
 
49
99
 
50
100
  def resolve_attribute_to_object(attribute: str, scope: ScopeProxy | object) -> object: