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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 58 additions & 40 deletions notebooks/deepdive.ipynb

Large diffs are not rendered by default.

101 changes: 14 additions & 87 deletions pyiron_workflow/composite.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from __future__ import annotations

from abc import ABC, abstractmethod
from functools import partial, wraps
from functools import wraps
from typing import Literal, Optional, TYPE_CHECKING

from bidict import bidict
Expand Down Expand Up @@ -37,7 +37,6 @@ class Composite(Node, ABC):
- From the instance level, created nodes get the instance as their parent
- Child nodes...
- Can be added by...
- Creating them from the creator on a composite _instance_
- Passing a node instance to the adding method
- Setting the composite instance as the node's parent at node instantiation
- Assigning a node instance as an attribute
Expand Down Expand Up @@ -88,11 +87,11 @@ class Composite(Node, ABC):
wrap_as (Wrappers): A tool for accessing node-creating decorators

Methods:
add(node: Node): Add the node instance to this subgraph.
remove(node: Node): Break all connections the node has, remove it from this
add_node(node: Node): Add the node instance to this subgraph.
remove_node(node: Node): Break all connections the node has, remove_node it from this
subgraph, and set its parent to `None`.
(de)activate_strict_hints(): Recursively (de)activate strict type hints.
replace(owned_node: Node | str, replacement: Node | type[Node]): Replaces an
replace_node(owned_node: Node | str, replacement: Node | type[Node]): Replaces an
owned node with a new node, as long as the new node's IO is commensurate
with the node being replaced.
register(): A short-cut to registering a new node package with the node creator.
Expand Down Expand Up @@ -120,8 +119,6 @@ def __init__(
self.outputs_map = outputs_map
self.nodes: DotDict[str, Node] = DotDict()
self.starting_nodes: list[Node] = []
self._creator = self.create
self.create = self._owned_creator # Override the create method from the class

@property
def inputs_map(self) -> bidict | None:
Expand Down Expand Up @@ -164,15 +161,6 @@ def deactivate_strict_hints(self):
for node in self:
node.deactivate_strict_hints()

@property
def _owned_creator(self):
"""
A misdirection so that the `create` method behaves differently on the class
and on instances (in the latter case, created nodes should get the instance as
their parent).
"""
return OwnedCreator(self, self._creator)

def to_dict(self):
return {
"label": self.label,
Expand Down Expand Up @@ -304,7 +292,7 @@ def _build_inputs(self) -> Inputs:
def _build_outputs(self) -> Outputs:
return self._build_io("outputs", self.outputs_map)

def add(self, node: Node, label: Optional[str] = None) -> None:
def add_node(self, node: Node, label: Optional[str] = None) -> None:
"""
Assign a node to the parent. Optionally provide a new label for that node.

Expand Down Expand Up @@ -382,7 +370,7 @@ def _ensure_node_is_not_duplicated(self, node: Node, label: str):
)
del self.nodes[node.label]

def remove(self, node: Node | str) -> list[tuple[Channel, Channel]]:
def remove_node(self, node: Node | str) -> list[tuple[Channel, Channel]]:
"""
Remove a node from the `nodes` collection, disconnecting it and setting its
`parent` to None.
Expand All @@ -401,7 +389,9 @@ def remove(self, node: Node | str) -> list[tuple[Channel, Channel]]:
del self.nodes[node.label]
return disconnected

def replace(self, owned_node: Node | str, replacement: Node | type[Node]) -> Node:
def replace_node(
self, owned_node: Node | str, replacement: Node | type[Node]
) -> Node:
"""
Replaces a node currently owned with a new node instance.
The replacement must not belong to any other parent or have any connections.
Expand Down Expand Up @@ -455,9 +445,9 @@ def replace(self, owned_node: Node | str, replacement: Node | type[Node]) -> Nod
# first guaranteed to be an unconnected orphan, there is not yet any permanent
# damage
is_starting_node = owned_node in self.starting_nodes
self.remove(owned_node)
self.remove_node(owned_node)
replacement.label, owned_node.label = owned_node.label, replacement.label
self.add(replacement)
self.add_node(replacement)
if is_starting_node:
self.starting_nodes.append(replacement)

Expand All @@ -470,7 +460,7 @@ def replace(self, owned_node: Node | str, replacement: Node | type[Node]) -> Nod
except Exception as e:
# If IO can't be successfully rebuilt using this node, revert changes and
# raise the exception
self.replace(replacement, owned_node) # Guaranteed to work since
self.replace_node(replacement, owned_node) # Guaranteed to work since
# replacement in the other direction was already a success
raise e

Expand Down Expand Up @@ -530,14 +520,14 @@ def executor_shutdown(self, wait=True, *, cancel_futures=False):

def __setattr__(self, key: str, node: Node):
if isinstance(node, Node) and key != "_parent":
self.add(node, label=key)
self.add_node(node, label=key)
elif (
isinstance(node, type)
and issubclass(node, Node)
and key in self.nodes.keys()
):
# When a class is assigned to an existing node, try a replacement
self.replace(key, node)
self.replace_node(key, node)
else:
super().__setattr__(key, node)

Expand Down Expand Up @@ -570,66 +560,3 @@ def __dir__(self):
def color(self) -> str:
"""For drawing the graph"""
return SeabornColors.brown


class OwnedCreator:
"""
A creator that overrides the `parent` arg of all accessed nodes to its own parent.

Necessary so that `Workflow.create.Function(...)` returns an unowned function node,
while `some_workflow_instance.create.Function(...)` returns a function node owned
by the workflow instance.
"""

def __init__(self, parent: Composite, creator: Creator):
self._parent = parent
self._creator = creator

def __getattr__(self, item):
value = getattr(self._creator, item)

try:
is_node_class = issubclass(value, Node)
except TypeError:
# issubclass complains if the value isn't even a class
is_node_class = False

if is_node_class:
value = partial(value, parent=self._parent)
elif isinstance(value, NodePackage):
value = OwnedNodePackage(self._parent, value)

return value

def __getstate__(self):
# Compatibility with python <3.11
return self.__dict__

def __setstate__(self, state):
# Because we override getattr, we need to use __dict__ assignment directly in
# __setstate__
self.__dict__["_parent"] = state["_parent"]
self.__dict__["_creator"] = state["_creator"]


class OwnedNodePackage:
"""
A wrapper for node packages so that accessed node classes can have their parent
value automatically filled.
"""

def __init__(self, parent: Composite, node_package: NodePackage):
self._parent = parent
self._node_package = node_package

def __getattr__(self, item):
value = getattr(self._node_package, item)
if issubclass(value, Node):
value = partial(value, parent=self._parent)
return value

def __getstate__(self):
return self.__dict__

def __setstate__(self, state):
self.__dict__ = state
2 changes: 1 addition & 1 deletion pyiron_workflow/macro.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ class Macro(Composite):
>>> # With the replace method
>>> # (replacement target can be specified by label or instance,
>>> # the replacing node can be specified by instance or class)
>>> replaced = adds_six_macro.replace(adds_six_macro.one, add_two())
>>> replaced = adds_six_macro.replace_node(adds_six_macro.one, add_two())
>>> # With the replace_with method
>>> adds_six_macro.two.replace_with(add_two())
>>> # And by assignment of a compatible class to an occupied node label
Expand Down
13 changes: 8 additions & 5 deletions pyiron_workflow/meta.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ def make_loop(macro):
# Parallelize over body nodes
for n in range(length):
body_nodes.append(
macro.add(loop_body_class(label=f"{loop_body_class.__name__}_{n}"))
macro.add_node(loop_body_class(label=f"{loop_body_class.__name__}_{n}"))
)

# Make input interface
Expand All @@ -138,7 +138,10 @@ def make_loop(macro):
# Or distribute the same input to each node equally
else:
interface = macro.create.standard.UserInput(
label=label, output_labels=label, user_input=inp.default
label=label,
output_labels=label,
user_input=inp.default,
parent=macro,
)
for body_node in body_nodes:
body_node.inputs[label] = interface
Expand Down Expand Up @@ -285,9 +288,9 @@ def while_loop(
"""

def make_loop(macro):
body_node = macro.add(loop_body_class(label=loop_body_class.__name__))
condition_node = macro.add(condition_class(label=condition_class.__name__))
switch = macro.create.standard.If(label="switch")
body_node = macro.add_node(loop_body_class(label=loop_body_class.__name__))
condition_node = macro.add_node(condition_class(label=condition_class.__name__))
switch = macro.create.standard.If(label="switch", parent=macro)

switch.inputs.condition = condition_node
for out_n, out_c, in_n, in_c in internal_connection_map:
Expand Down
8 changes: 4 additions & 4 deletions pyiron_workflow/node.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ def __init__(
self.label: str = label
self._parent = None
if parent is not None:
parent.add(self)
parent.add_node(self)
self.running = False
self.failed = False
self.signals = self._build_signal_channels()
Expand Down Expand Up @@ -900,7 +900,7 @@ def _copy_values(

def replace_with(self, other: Node | type[Node]):
"""
If this node has a parent, invokes `self.parent.replace(self, other)` to swap
If this node has a parent, invokes `self.parent.replace_node(self, other)` to swap
out this node for the other node in the parent graph.

The replacement must have fully compatible IO, i.e. its IO must be a superset of
Expand All @@ -912,9 +912,9 @@ def replace_with(self, other: Node | type[Node]):
other (Node|type[Node]): The replacement.
"""
if self.parent is not None:
self.parent.replace(self, other)
self.parent.replace_node(self, other)
else:
warnings.warn(f"Could not replace {self.label}, as it has no parent.")
warnings.warn(f"Could not replace_node {self.label}, as it has no parent.")

def __getstate__(self):
state = self.__dict__
Expand Down
27 changes: 12 additions & 15 deletions pyiron_workflow/workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,35 +54,32 @@ class Workflow(Composite):
We allow adding nodes to workflows in five equivalent ways:
>>> from pyiron_workflow.workflow import Workflow
>>>
>>> def fnc(x=0):
>>> @Workflow.wrap_as.single_value_node()
... def fnc(x=0):
... return x + 1
>>>
>>> # (1) As *args at instantiation
>>> n1 = Workflow.create.Function(fnc, label="n1")
>>> n1 = fnc(label="n1")
>>> wf = Workflow("my_workflow", n1)
>>>
>>> # (2) Being passed to the `add` method
>>> added = wf.add(Workflow.create.Function(fnc, label="n2"))
>>> n2 = wf.add_node(fnc(label="n2"))
>>>
>>> # (3) Calling `create` from the _workflow instance_ that will own the node
>>> added = wf.create.Function(fnc, label="n3") # Instantiating from add
>>> # (3) By attribute assignment
>>> wf.n3 = fnc(label="anyhow_n3_gets_used")
>>>
>>> # (4) By attribute assignment (here the node can be created from the
>>> # workflow class or instance and the end result is the same
>>> wf.n4 = wf.create.Function(fnc, label="anyhow_n4_gets_used")
>>>
>>> # (5) By creating from the workflow class but specifying the parent kwarg
>>> added = Workflow.create.Function(fnc, label="n5", parent=wf)
>>> # (4) By creating from the workflow class but specifying the parent kwarg
>>> n4 = fnc(label="n4", parent=wf)

By default, the node naming scheme is strict, so if you try to add a node to a
label that already exists, you will get an error. This behaviour can be changed
at instantiation with the `strict_naming` kwarg, or afterwards by assigning a
bool to this property. When deactivated, repeated assignments to the same label
just get appended with an index:
>>> wf.strict_naming = False
>>> wf.my_node = wf.create.Function(fnc, x=0)
>>> wf.my_node = wf.create.Function(fnc, x=1)
>>> wf.my_node = wf.create.Function(fnc, x=2)
>>> wf.my_node = fnc(x=0)
>>> wf.my_node = fnc(x=1)
>>> wf.my_node = fnc(x=2)
>>> print(wf.my_node.inputs.x, wf.my_node0.inputs.x, wf.my_node1.inputs.x)
0 1 2

Expand Down Expand Up @@ -193,7 +190,7 @@ def __init__(
self.automate_execution = automate_execution

for node in nodes:
self.add(node)
self.add_node(node)

def _get_linking_channel(
self,
Expand Down
2 changes: 1 addition & 1 deletion tests/integration/test_workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ def test_executor_and_creator_interaction(self):
C.f. `pyiron_workflow.function._wrapper_factory` for more detail.
"""
wf = Workflow("depickle")
wf.create.register("demo", "static.demo_nodes")
wf.register("demo", "static.demo_nodes")

wf.before_pickling = wf.create.demo.OptionallyAdd(1)
wf.before_pickling.executor = wf.create.Executor()
Expand Down
Loading