From 3a71a5abc579dbfbe1d934fe886c3416999d2d3f Mon Sep 17 00:00:00 2001 From: samwaseda Date: Tue, 20 Jun 2023 10:18:33 -0700 Subject: [PATCH 01/81] Only give the final warning --- pyiron_contrib/workflow/has_nodes.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pyiron_contrib/workflow/has_nodes.py b/pyiron_contrib/workflow/has_nodes.py index 7c927417a..9594233f2 100644 --- a/pyiron_contrib/workflow/has_nodes.py +++ b/pyiron_contrib/workflow/has_nodes.py @@ -69,12 +69,13 @@ def _add_suffix_to_label(self, label): i = 0 new_label = label while new_label in self.nodes.keys(): + new_label = f"{label}{i}" + i += 1 + if new_label != label: warn( f"{label} is already a node; appending an index to the " - f"node label instead: {label}{i}" + f"node label instead: {new_label}" ) - new_label = f"{label}{i}" - i += 1 return new_label def _ensure_node_has_no_other_parent(self, node: Node, label: str): From fc59f88f85455bf6c8072f336b02d352fe6d441f Mon Sep 17 00:00:00 2001 From: samwaseda Date: Tue, 20 Jun 2023 10:19:50 -0700 Subject: [PATCH 02/81] Streamline strict naming flag --- pyiron_contrib/workflow/has_nodes.py | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/pyiron_contrib/workflow/has_nodes.py b/pyiron_contrib/workflow/has_nodes.py index 9594233f2..8de161e51 100644 --- a/pyiron_contrib/workflow/has_nodes.py +++ b/pyiron_contrib/workflow/has_nodes.py @@ -22,7 +22,7 @@ class HasNodes(ABC): def __init__(self, *args, strict_naming=True, **kwargs): self.nodes: DotDict = DotDict() self.add: NodeAdder = NodeAdder(self) - self._strict_naming: bool = strict_naming + self.strict_naming: bool = strict_naming def add_node(self, node: Node, label: Optional[str] = None) -> None: """ @@ -96,16 +96,6 @@ def _ensure_node_has_no_other_parent(self, node: Node, label: str): f"add it to this parent ({self.label})." ) - @property - def strict_naming(self) -> bool: - return self._strict_naming - - def activate_strict_naming(self): - self._strict_naming = True - - def deactivate_strict_naming(self): - self._strict_naming = False - def remove(self, node: Node | str): if isinstance(node, Node): node.parent = None From 160b77c0b915623bd559928cbdec2c1cfc91434f Mon Sep 17 00:00:00 2001 From: liamhuber Date: Tue, 20 Jun 2023 10:56:45 -0700 Subject: [PATCH 03/81] Rolled back too far --- pyiron_contrib/workflow/workflow.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pyiron_contrib/workflow/workflow.py b/pyiron_contrib/workflow/workflow.py index 34a77aab8..55ceea55b 100644 --- a/pyiron_contrib/workflow/workflow.py +++ b/pyiron_contrib/workflow/workflow.py @@ -121,8 +121,7 @@ class Workflow(HasToDict, HasNodes): def __init__(self, label: str, *nodes: Node, strict_naming=True): super().__init__(strict_naming=strict_naming) - self.__dict__["label"] = label - # We directly assign using __dict__ because we override the setattr later + self.label = label for node in nodes: self.add_node(node) From 76df65072caf9931abb55e82bacbc1c69ad96e3b Mon Sep 17 00:00:00 2001 From: liamhuber Date: Wed, 21 Jun 2023 12:26:12 -0700 Subject: [PATCH 04/81] Extract a new parent class for nodal objects and rebase node onto it --- pyiron_contrib/workflow/has_nodes.py | 30 ++++++----- pyiron_contrib/workflow/is_nodal.py | 80 ++++++++++++++++++++++++++++ pyiron_contrib/workflow/node.py | 56 +++++++++---------- tests/unit/workflow/test_workflow.py | 10 +++- 4 files changed, 128 insertions(+), 48 deletions(-) create mode 100644 pyiron_contrib/workflow/is_nodal.py diff --git a/pyiron_contrib/workflow/has_nodes.py b/pyiron_contrib/workflow/has_nodes.py index 79cc34357..cf4bc6be8 100644 --- a/pyiron_contrib/workflow/has_nodes.py +++ b/pyiron_contrib/workflow/has_nodes.py @@ -39,16 +39,16 @@ def add_node(self, node: Node, label: Optional[str] = None) -> None: raise TypeError( f"Only new node instances may be added, but got {type(node)}." ) - - label = self._ensure_label_is_unique(node.label if label is None else label) - self._ensure_node_has_no_other_parent(node, label) + self._ensure_node_has_no_other_parent(node) + label = self._get_unique_label(node.label if label is None else label) + self._ensure_node_is_not_duplicated(node, label) self.nodes[label] = node node.label = label node.parent = self return node - def _ensure_label_is_unique(self, label): + def _get_unique_label(self, label): if label in self.__dir__(): if isinstance(getattr(self, label), Node): if self.strict_naming: @@ -78,23 +78,25 @@ def _add_suffix_to_label(self, label): ) return new_label - def _ensure_node_has_no_other_parent(self, node: Node, label: str): + def _ensure_node_has_no_other_parent(self, node: Node): + if node.parent is not None and node.parent is not self: + raise ValueError( + f"The node ({node.label}) already belongs to the parent " + f"{node.parent.label}. Please remove it there before trying to " + f"add it to this parent ({self.label})." + ) + + def _ensure_node_is_not_duplicated(self, node: Node, label: str): if ( - node.parent is self # This should guarantee the node is in self.nodes - and label != node.label + node.parent is self + and label != node.label + and self.nodes[node.label] is node ): - assert self.nodes[node.label] is node # Should be unreachable by users warn( f"Reassigning the node {node.label} to the label {label} when " f"adding it to the parent {self.label}." ) del self.nodes[node.label] - elif node.parent is not None: - raise ValueError( - f"The node ({node.label}) already belongs to the parent " - f"{node.parent.label}. Please remove it there before trying to " - f"add it to this parent ({self.label})." - ) def remove(self, node: Node | str): if isinstance(node, Node): diff --git a/pyiron_contrib/workflow/is_nodal.py b/pyiron_contrib/workflow/is_nodal.py new file mode 100644 index 000000000..680d1711e --- /dev/null +++ b/pyiron_contrib/workflow/is_nodal.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pyiron_base.jobs.job.extension.server.generic import Server + + from pyiron_contrib.workflow.io import Inputs, Outputs, Signals + + +class IsNodal(ABC): + """ + A mixin class for classes that can represent nodes on a computation graph. + """ + + def __init__( + self, + label: str, + *args, + **kwargs + ): + super().__init__(*args, **kwargs) + self.label: str = label + self.running = False + self.failed = False + # TODO: Replace running and failed with a state object + self._server: Server | None = None # Or "task_manager" or "executor" -- we'll see what's best + + @property + @abstractmethod + def inputs(self) -> Inputs: + pass + + @property + @abstractmethod + def outputs(self) -> Outputs: + pass + + @property + @abstractmethod + def signals(self) -> Signals: + pass + + @abstractmethod + def update(self): + pass + + @abstractmethod + def run(self): + pass + + @property + def server(self) -> Server | None: + return self._server + + @server.setter + def server(self, server: Server | None): + self._server = server + + def disconnect(self): + self.inputs.disconnect() + self.outputs.disconnect() + self.signals.disconnect() + + @property + def ready(self) -> bool: + return not (self.running or self.failed) and self.inputs.ready + + @property + def connected(self) -> bool: + return self.inputs.connected or self.outputs.connected or self.signals.connected + + @property + def fully_connected(self): + return ( + self.inputs.fully_connected + and self.outputs.fully_connected + and self.signals.fully_connected + ) \ No newline at end of file diff --git a/pyiron_contrib/workflow/node.py b/pyiron_contrib/workflow/node.py index 761d9a601..d170bbd6c 100644 --- a/pyiron_contrib/workflow/node.py +++ b/pyiron_contrib/workflow/node.py @@ -14,12 +14,13 @@ from pyiron_contrib.workflow.has_channel import HasChannel from pyiron_contrib.workflow.has_to_dict import HasToDict from pyiron_contrib.workflow.io import Inputs, Outputs, Signals +from pyiron_contrib.workflow.is_nodal import IsNodal if TYPE_CHECKING: from pyiron_contrib.workflow.workflow import Workflow -class Node(HasToDict): +class Node(IsNodal, HasToDict): """ Nodes have input and output data channels that interface with the outside world, and a callable that determines what they actually compute. After running, their output @@ -323,28 +324,27 @@ def __init__( parent: Optional[Workflow] = None, **kwargs, ): + super().__init__( + label=label if label is not None else node_function.__name__, + # **kwargs, + ) + self.parent = parent + if parent is not None: + parent.add(self) if len(output_labels) == 0: raise ValueError("Nodes must have at least one output label.") - self.running = False - self.failed = False - self.server = None # Or "task_manager" or "executor" -- we'll see what's best self.node_function = node_function - self.label = label if label is not None else node_function.__name__ - - self.parent = None - if parent is not None: - parent.add(self) input_channels = self._build_input_channels(input_storage_priority) - self.inputs = Inputs(*input_channels) + self._inputs = Inputs(*input_channels) output_channels = self._build_output_channels( *output_labels, storage_priority=output_storage_priority ) - self.outputs = Outputs(*output_channels) + self._outputs = Outputs(*output_channels) - self.signals = self._build_signal_channels() + self._signals = self._build_signal_channels() self.channels_requiring_update_after_run = ( [] @@ -364,6 +364,18 @@ def __init__( if update_on_instantiation: self.update() + @property + def inputs(self) -> Inputs: + return self._inputs + + @property + def outputs(self) -> Outputs: + return self._outputs + + @property + def signals(self) -> Signals: + return self._signals + def _build_input_channels(self, storage_priority: dict[str:int]): channels = [] type_hints = get_type_hints(self.node_function) @@ -520,26 +532,6 @@ def process_output(self, function_output): def __call__(self) -> None: self.run() - def disconnect(self): - self.inputs.disconnect() - self.outputs.disconnect() - self.signals.disconnect() - - @property - def ready(self) -> bool: - return not (self.running or self.failed) and self.inputs.ready - - @property - def connected(self) -> bool: - return self.inputs.connected or self.outputs.connected or self.signals.connected - - @property - def fully_connected(self): - return ( - self.inputs.fully_connected - and self.outputs.fully_connected - and self.signals.fully_connected - ) def set_storage_priority(self, priority: int): self.inputs.set_storage_priority(priority) diff --git a/tests/unit/workflow/test_workflow.py b/tests/unit/workflow/test_workflow.py index 33699b5c4..e954b3056 100644 --- a/tests/unit/workflow/test_workflow.py +++ b/tests/unit/workflow/test_workflow.py @@ -19,8 +19,14 @@ def test_node_addition(self): wf.add(Node(fnc, "x", label="foo")) wf.add.Node(fnc, "y", label="bar") wf.baz = Node(fnc, "y", label="whatever_baz_gets_used") - Node(fnc, "x", label="boa", parent=wf) - self.assertListEqual(list(wf.nodes.keys()), ["foo", "bar", "baz", "boa"]) + Node(fnc, "x", label="qux", parent=wf) + self.assertListEqual(list(wf.nodes.keys()), ["foo", "bar", "baz", "qux"]) + wf.boa = wf.qux + self.assertListEqual( + list(wf.nodes.keys()), + ["foo", "bar", "baz", "boa"], + msg="Reassignment should remove the original instance" + ) wf.strict_naming = False # Validate name incrementation From b326d66a05a9d08875a2bcbc1ad42b57abda1abc Mon Sep 17 00:00:00 2001 From: liamhuber Date: Wed, 21 Jun 2023 12:27:24 -0700 Subject: [PATCH 05/81] :bug: call super in has_nodes mixin --- pyiron_contrib/workflow/has_nodes.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pyiron_contrib/workflow/has_nodes.py b/pyiron_contrib/workflow/has_nodes.py index cf4bc6be8..e560b0c2a 100644 --- a/pyiron_contrib/workflow/has_nodes.py +++ b/pyiron_contrib/workflow/has_nodes.py @@ -20,6 +20,7 @@ class HasNodes(ABC): """ def __init__(self, *args, strict_naming=True, **kwargs): + super().__init__(*args, **kwargs) self.nodes: DotDict = DotDict() self.add: NodeAdder = NodeAdder(self) self.strict_naming: bool = strict_naming From 96a6f74899fff627a70d75d8968bd8f225f7f502 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Wed, 21 Jun 2023 12:35:25 -0700 Subject: [PATCH 06/81] Move initialization of signals up into the parent class --- pyiron_contrib/workflow/is_nodal.py | 16 ++++++++++------ pyiron_contrib/workflow/node.py | 12 ------------ 2 files changed, 10 insertions(+), 18 deletions(-) diff --git a/pyiron_contrib/workflow/is_nodal.py b/pyiron_contrib/workflow/is_nodal.py index 680d1711e..e0e0078ce 100644 --- a/pyiron_contrib/workflow/is_nodal.py +++ b/pyiron_contrib/workflow/is_nodal.py @@ -3,10 +3,12 @@ from abc import ABC, abstractmethod from typing import TYPE_CHECKING +from pyiron_contrib.workflow.io import Signals, InputSignal, OutputSignal + if TYPE_CHECKING: from pyiron_base.jobs.job.extension.server.generic import Server - from pyiron_contrib.workflow.io import Inputs, Outputs, Signals + from pyiron_contrib.workflow.io import Inputs, Outputs class IsNodal(ABC): @@ -26,6 +28,7 @@ def __init__( self.failed = False # TODO: Replace running and failed with a state object self._server: Server | None = None # Or "task_manager" or "executor" -- we'll see what's best + self.signals = self._build_signal_channels() @property @abstractmethod @@ -37,11 +40,6 @@ def inputs(self) -> Inputs: def outputs(self) -> Outputs: pass - @property - @abstractmethod - def signals(self) -> Signals: - pass - @abstractmethod def update(self): pass @@ -50,6 +48,12 @@ def update(self): def run(self): pass + def _build_signal_channels(self) -> Signals: + signals = Signals() + signals.input.run = InputSignal("run", self, self.run) + signals.output.ran = OutputSignal("ran", self) + return signals + @property def server(self) -> Server | None: return self._server diff --git a/pyiron_contrib/workflow/node.py b/pyiron_contrib/workflow/node.py index d170bbd6c..66b99bf9f 100644 --- a/pyiron_contrib/workflow/node.py +++ b/pyiron_contrib/workflow/node.py @@ -344,8 +344,6 @@ def __init__( ) self._outputs = Outputs(*output_channels) - self._signals = self._build_signal_channels() - self.channels_requiring_update_after_run = ( [] if channels_requiring_update_after_run is None @@ -372,10 +370,6 @@ def inputs(self) -> Inputs: def outputs(self) -> Outputs: return self._outputs - @property - def signals(self) -> Signals: - return self._signals - def _build_input_channels(self, storage_priority: dict[str:int]): channels = [] type_hints = get_type_hints(self.node_function) @@ -463,12 +457,6 @@ def _build_output_channels( return channels - def _build_signal_channels(self) -> Signals: - signals = Signals() - signals.input.run = InputSignal("run", self, self.run) - signals.output.ran = OutputSignal("ran", self) - return signals - def _verify_that_channels_requiring_update_all_exist(self): if not all( channel_name in self.inputs.labels From 98486ce7af125852f6e989cc3d04ee45e9d97fe6 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Wed, 21 Jun 2023 12:35:36 -0700 Subject: [PATCH 07/81] Rebase workflow onto IsNodal mixin --- pyiron_contrib/workflow/workflow.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pyiron_contrib/workflow/workflow.py b/pyiron_contrib/workflow/workflow.py index eb15b012d..672f8157d 100644 --- a/pyiron_contrib/workflow/workflow.py +++ b/pyiron_contrib/workflow/workflow.py @@ -2,6 +2,7 @@ from pyiron_contrib.workflow.has_nodes import HasNodes from pyiron_contrib.workflow.has_to_dict import HasToDict +from pyiron_contrib.workflow.is_nodal import IsNodal from pyiron_contrib.workflow.node import Node, node, fast_node, single_value_node from pyiron_contrib.workflow.util import DotDict @@ -14,7 +15,7 @@ class _NodeDecoratorAccess: single_value_node = single_value_node -class Workflow(HasToDict, HasNodes): +class Workflow(IsNodal, HasToDict, HasNodes): """ Workflows are an abstraction for holding a collection of related nodes. @@ -119,8 +120,7 @@ class Workflow(HasToDict, HasNodes): wrap_as = _NodeDecoratorAccess def __init__(self, label: str, *nodes: Node, strict_naming=True): - super().__init__(strict_naming=strict_naming) - self.label = label + super().__init__(label=label, strict_naming=strict_naming) for node in nodes: self.add_node(node) From bbe69889d3c71c334655be55b5c973bb5a352461 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Wed, 21 Jun 2023 12:39:03 -0700 Subject: [PATCH 08/81] Remove unused imports --- pyiron_contrib/workflow/node.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/pyiron_contrib/workflow/node.py b/pyiron_contrib/workflow/node.py index 66b99bf9f..93fc2ff10 100644 --- a/pyiron_contrib/workflow/node.py +++ b/pyiron_contrib/workflow/node.py @@ -5,12 +5,7 @@ from functools import partialmethod from typing import get_args, get_type_hints, Optional, TYPE_CHECKING -from pyiron_contrib.workflow.channels import ( - InputData, - OutputData, - InputSignal, - OutputSignal, -) +from pyiron_contrib.workflow.channels import InputData, OutputData from pyiron_contrib.workflow.has_channel import HasChannel from pyiron_contrib.workflow.has_to_dict import HasToDict from pyiron_contrib.workflow.io import Inputs, Outputs, Signals From 2a4effb0f6d027f0173384641c80c7167b314d17 Mon Sep 17 00:00:00 2001 From: pyiron-runner Date: Wed, 21 Jun 2023 19:42:50 +0000 Subject: [PATCH 09/81] Format black --- pyiron_contrib/workflow/has_nodes.py | 6 +++--- pyiron_contrib/workflow/is_nodal.py | 13 +++++-------- pyiron_contrib/workflow/node.py | 1 - 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/pyiron_contrib/workflow/has_nodes.py b/pyiron_contrib/workflow/has_nodes.py index e560b0c2a..964f0fd30 100644 --- a/pyiron_contrib/workflow/has_nodes.py +++ b/pyiron_contrib/workflow/has_nodes.py @@ -89,9 +89,9 @@ def _ensure_node_has_no_other_parent(self, node: Node): def _ensure_node_is_not_duplicated(self, node: Node, label: str): if ( - node.parent is self - and label != node.label - and self.nodes[node.label] is node + node.parent is self + and label != node.label + and self.nodes[node.label] is node ): warn( f"Reassigning the node {node.label} to the label {label} when " diff --git a/pyiron_contrib/workflow/is_nodal.py b/pyiron_contrib/workflow/is_nodal.py index e0e0078ce..915938cd8 100644 --- a/pyiron_contrib/workflow/is_nodal.py +++ b/pyiron_contrib/workflow/is_nodal.py @@ -16,18 +16,15 @@ class IsNodal(ABC): A mixin class for classes that can represent nodes on a computation graph. """ - def __init__( - self, - label: str, - *args, - **kwargs - ): + def __init__(self, label: str, *args, **kwargs): super().__init__(*args, **kwargs) self.label: str = label self.running = False self.failed = False # TODO: Replace running and failed with a state object - self._server: Server | None = None # Or "task_manager" or "executor" -- we'll see what's best + self._server: Server | None = ( + None # Or "task_manager" or "executor" -- we'll see what's best + ) self.signals = self._build_signal_channels() @property @@ -81,4 +78,4 @@ def fully_connected(self): self.inputs.fully_connected and self.outputs.fully_connected and self.signals.fully_connected - ) \ No newline at end of file + ) diff --git a/pyiron_contrib/workflow/node.py b/pyiron_contrib/workflow/node.py index 93fc2ff10..f6351975c 100644 --- a/pyiron_contrib/workflow/node.py +++ b/pyiron_contrib/workflow/node.py @@ -515,7 +515,6 @@ def process_output(self, function_output): def __call__(self) -> None: self.run() - def set_storage_priority(self, priority: int): self.inputs.set_storage_priority(priority) self.outputs.set_storage_priority(priority) From b6d2cd3deee08b59281f198f7be287b1b03b0247 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Thu, 22 Jun 2023 10:49:41 -0700 Subject: [PATCH 10/81] Add module summary --- pyiron_contrib/workflow/is_nodal.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pyiron_contrib/workflow/is_nodal.py b/pyiron_contrib/workflow/is_nodal.py index 915938cd8..4d5bec011 100644 --- a/pyiron_contrib/workflow/is_nodal.py +++ b/pyiron_contrib/workflow/is_nodal.py @@ -1,3 +1,8 @@ +""" +A base class for objects that can form nodes in the graph representation of a +computational workflow. +""" + from __future__ import annotations from abc import ABC, abstractmethod From 04d748ee55e559a934d08289c987cd880c38cd6c Mon Sep 17 00:00:00 2001 From: liamhuber Date: Thu, 22 Jun 2023 11:26:08 -0700 Subject: [PATCH 11/81] Add docstrings --- pyiron_contrib/workflow/is_nodal.py | 63 ++++++++++++++++++++++++++++- 1 file changed, 62 insertions(+), 1 deletion(-) diff --git a/pyiron_contrib/workflow/is_nodal.py b/pyiron_contrib/workflow/is_nodal.py index 4d5bec011..266d54c31 100644 --- a/pyiron_contrib/workflow/is_nodal.py +++ b/pyiron_contrib/workflow/is_nodal.py @@ -18,10 +18,65 @@ class IsNodal(ABC): """ - A mixin class for classes that can represent nodes on a computation graph. + A mixin class for objects that can form nodes in the graph representation of a + computational workflow. + + Nodal objects have `inputs` and `outputs` channels for passing data, and `signals` + channels for making callbacks on the class (input) and controlling execution flow + (output) when connected to other nodal objects. + + Nodal objects can `run` to complete some computational task, or call a softer + `update` which will run the task only if it is `ready` -- i.e. it is not currently + running, has not previously tried to run and failed, and all of its inputs are ready + (i.e. populated with data that passes type requirements, if any). + + Attributes: + connected (bool): Whether _any_ of the IO (including signals) are connected. + failed (bool): Whether the nodal object raised an error calling `run`. (Default + is False.) + fully_connected (bool): whether _all_ of the IO (including signals) are + connected. + inputs (pyiron_contrib.workflow.io.Inputs): **Abstract.** Children must define + a property returning an `Inputs` object. + label (str): A name for the nodal object. + output (pyiron_contrib.workflow.io.Outputs): **Abstract.** Children must define + a property returning an `Outputs` object. + ready (bool): Whether the inputs are all ready and the nodal object is neither + already running nor already failed. + running (bool): Whether the nodal object has called `run` and has not yet + received output from from this call. (Default is False.) + server (Optional[pyiron_base.jobs.job.extension.server.generic.Server]): A + server object for computing things somewhere else. Default (and currently + _only_) behaviour is to compute things on the main python process owning + the nodal object. + signals (pyiron_contrib.workflow.io.Signals): A container for input and output + signals, which are channels for controlling execution flow. By default, has + a `signals.inputs.run` channel which has a callback to the `run` method, + and `signals.outputs.ran` which should be called at when the `run` method + is finished (TODO: Don't leave this step up to child class developers!). + Additional signal channels in derived classes can be added to + `signals.inputs` and `signals.outputs` after this mixin class is + initialized. + + Methods: + disconnect: Remove all connections, including signals. + run: **Abstract.** Do the thing. + update: **Abstract.** Do the thing if you're ready and you run on updates. + TODO: Once `run_on_updates` is in this class, we can un-abstract this. """ def __init__(self, label: str, *args, **kwargs): + """ + A mixin class for objects that can form nodes in the graph representation of a + computational workflow. + + Args: + label (str): A name for this nodal object. + *args: Arguments passed on with `super`. + **kwargs: Keyword arguments passed on with `super`. + + TODO: Shouldn't `update_on_instantiation` and `run_on_updates` both live here?? + """ super().__init__(*args, **kwargs) self.label: str = label self.running = False @@ -30,6 +85,8 @@ def __init__(self, label: str, *args, **kwargs): self._server: Server | None = ( None # Or "task_manager" or "executor" -- we'll see what's best ) + # TODO: Move from a traditional "sever" to a tinybase "executor" + # TODO: Provide support for actually computing stuff with the server/executor self.signals = self._build_signal_channels() @property @@ -54,6 +111,10 @@ def _build_signal_channels(self) -> Signals: signals = Signals() signals.input.run = InputSignal("run", self, self.run) signals.output.ran = OutputSignal("ran", self) + # TODO: Build `run` such that developers inheriting from this class don't need + # to remember to invoke `self.signals.output.ran()`! Probably this will + # involve pulling `run` up into this class and exposing `_on_run` as an + # abstract method to developers (or a similar attack). return signals @property From 05ac87570a1ca6f9b5be1311e2651450efda51a6 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Thu, 22 Jun 2023 14:17:41 -0700 Subject: [PATCH 12/81] Make Workflow conform to IsNodal type hints for data IO --- pyiron_contrib/workflow/workflow.py | 34 +++++++++++++---------------- 1 file changed, 15 insertions(+), 19 deletions(-) diff --git a/pyiron_contrib/workflow/workflow.py b/pyiron_contrib/workflow/workflow.py index 672f8157d..bf5fd1ee4 100644 --- a/pyiron_contrib/workflow/workflow.py +++ b/pyiron_contrib/workflow/workflow.py @@ -2,9 +2,9 @@ from pyiron_contrib.workflow.has_nodes import HasNodes from pyiron_contrib.workflow.has_to_dict import HasToDict +from pyiron_contrib.workflow.io import Inputs, Outputs from pyiron_contrib.workflow.is_nodal import IsNodal from pyiron_contrib.workflow.node import Node, node, fast_node, single_value_node -from pyiron_contrib.workflow.util import DotDict class _NodeDecoratorAccess: @@ -126,26 +126,22 @@ def __init__(self, label: str, *nodes: Node, strict_naming=True): self.add_node(node) @property - def inputs(self): - return DotDict( - { - f"{node.label}_{channel.label}": channel - for node in self.nodes.values() - for channel in node.inputs - if not channel.connected - } - ) + def inputs(self) -> Inputs: + inputs = Inputs() + for node_label, node in self.nodes.items(): + for channel in node.inputs: + if not channel.connected: + inputs[f"{node_label}_{channel.label}"] = channel + return inputs @property - def outputs(self): - return DotDict( - { - f"{node.label}_{channel.label}": channel - for node in self.nodes.values() - for channel in node.outputs - if not channel.connected - } - ) + def outputs(self) -> Outputs: + outputs = Outputs() + for node_label, node in self.nodes.items(): + for channel in node.outputs: + if not channel.connected: + outputs[f"{node_label}_{channel.label}"] = channel + return outputs def to_dict(self): return { From b2e5b224f266423a6cfd9db0846db879671fc684 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Thu, 22 Jun 2023 14:56:03 -0700 Subject: [PATCH 13/81] Pull run method up to the parent class --- pyiron_contrib/workflow/is_nodal.py | 71 ++++++++++++++++++++++++++++- pyiron_contrib/workflow/node.py | 46 +++++-------------- pyiron_contrib/workflow/workflow.py | 2 +- 3 files changed, 82 insertions(+), 37 deletions(-) diff --git a/pyiron_contrib/workflow/is_nodal.py b/pyiron_contrib/workflow/is_nodal.py index 266d54c31..e7939f76f 100644 --- a/pyiron_contrib/workflow/is_nodal.py +++ b/pyiron_contrib/workflow/is_nodal.py @@ -103,10 +103,79 @@ def outputs(self) -> Outputs: def update(self): pass + @property @abstractmethod - def run(self): + def on_run(self) -> callable[..., tuple]: + """ + What the nodal object actually does! + """ pass + @property + def run_args(self) -> dict: + """ + Any data needed for `on_run`, will be passed as **kwargs. + """ + return {} + + def process_run_result(self, run_output: tuple) -> None: + """ + What to _do_ with the results of `on_run` once you have them. + + Args: + run_output (tuple): The results of a `self.on_run(self.run_args)` call. + """ + pass + + def run(self) -> None: + """ + Executes the functionality of the nodal object defined in `on_run`. + Handles the status of the nodal object, and communicating with any remote + computing resources. + """ + if self.running: + raise RuntimeError(f"{self.label} is already running") + + self.running = True + self.failed = False + + if self.server is None: + try: + run_output = self.on_run(**self.run_args) + except Exception as e: + self.running = False + self.failed = True + raise e + self.finish_run(run_output) + else: + raise NotImplementedError( + "We currently only support executing the node functionality right on " + "the main python process that the node instance lives on. Come back " + "later for cool new features." + ) + # TODO: Send the `on_run` callable and the `run_args` data off to remote + # resources and register `finish_run` as a callback. + + def finish_run(self, run_output: tuple): + """ + Process the run result, then wrap up statuses etc. + + By extracting this as a separate method, we allow the node to pass the actual + execution off to another entity and release the python process to do other + things. In such a case, this function should be registered as a callback + so that the node can finish "running" and, e.g. push its data forward when that + execution is finished. + """ + try: + self.process_run_result(run_output) + except Exception as e: + self.running = False + self.failed = True + raise e + + self.signals.output.ran() + self.running = False + def _build_signal_channels(self) -> Signals: signals = Signals() signals.input.run = InputSignal("run", self, self.run) diff --git a/pyiron_contrib/workflow/node.py b/pyiron_contrib/workflow/node.py index f6351975c..d186f0672 100644 --- a/pyiron_contrib/workflow/node.py +++ b/pyiron_contrib/workflow/node.py @@ -467,51 +467,27 @@ def update(self) -> None: if self.run_on_updates and self.ready: self.run() - def run(self) -> None: - if self.running: - raise RuntimeError(f"{self.label} is already running") - - self.running = True - self.failed = False + @property + def on_run(self): + return self.node_function - if self.server is None: - try: - function_output = self.node_function(**self.inputs.to_value_dict()) - except Exception as e: - self.running = False - self.failed = True - raise e - self.process_output(function_output) - else: - raise NotImplementedError( - "We currently only support executing the node functionality right on " - "the main python process that the node instance lives on. Come back " - "later for cool new features." - ) + @property + def run_args(self) -> dict: + return self.inputs.to_value_dict() - def process_output(self, function_output): + def process_run_result(self, function_output): """ - Take the results of the node function, and use them to update the node. - - By extracting this as a separate method, we allow the node to pass the actual - execution off to another entity and release the python process to do other - things. In such a case, this function should be registered as a callback - so that the node can finishing "running" and push its data forward when that - execution is finished. + Take the results of the node function, and use them to update the node output. """ + for channel_name in self.channels_requiring_update_after_run: + self.inputs[channel_name].wait_for_update() + if len(self.outputs) == 1: function_output = (function_output,) for out, value in zip(self.outputs, function_output): out.update(value) - self.signals.output.ran() - - for channel_name in self.channels_requiring_update_after_run: - self.inputs[channel_name].wait_for_update() - - self.running = False - def __call__(self) -> None: self.run() diff --git a/pyiron_contrib/workflow/workflow.py b/pyiron_contrib/workflow/workflow.py index bf5fd1ee4..778a0c66a 100644 --- a/pyiron_contrib/workflow/workflow.py +++ b/pyiron_contrib/workflow/workflow.py @@ -168,6 +168,6 @@ def update(self): if node.outputs.connected and not node.inputs.connected: node.update() - def run(self): + def on_run(self): # Maybe we need this if workflows can be used as nodes? raise NotImplementedError From 1deee14b153e5ce2537c8c679f583832e02ac828 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Mon, 26 Jun 2023 12:43:51 -0700 Subject: [PATCH 14/81] Finish merging Sam's with-self functionality --- pyiron_contrib/workflow/node.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pyiron_contrib/workflow/node.py b/pyiron_contrib/workflow/node.py index a450f2a22..b32cb135a 100644 --- a/pyiron_contrib/workflow/node.py +++ b/pyiron_contrib/workflow/node.py @@ -483,7 +483,10 @@ def on_run(self): @property def run_args(self) -> dict: - return self.inputs.to_value_dict() + kwargs = self.inputs.to_value_dict() + if "self" in self._input_args: + kwargs["self"] = self + return kwargs def process_run_result(self, function_output): """ From 1c62b714dd01a4b6eb0e0ecc1feed5fb1153da1e Mon Sep 17 00:00:00 2001 From: liamhuber Date: Mon, 26 Jun 2023 13:01:49 -0700 Subject: [PATCH 15/81] Remove todo -- it is done in finish_run --- pyiron_contrib/workflow/is_nodal.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/pyiron_contrib/workflow/is_nodal.py b/pyiron_contrib/workflow/is_nodal.py index e7939f76f..c782a30f4 100644 --- a/pyiron_contrib/workflow/is_nodal.py +++ b/pyiron_contrib/workflow/is_nodal.py @@ -180,10 +180,6 @@ def _build_signal_channels(self) -> Signals: signals = Signals() signals.input.run = InputSignal("run", self, self.run) signals.output.ran = OutputSignal("ran", self) - # TODO: Build `run` such that developers inheriting from this class don't need - # to remember to invoke `self.signals.output.ran()`! Probably this will - # involve pulling `run` up into this class and exposing `_on_run` as an - # abstract method to developers (or a similar attack). return signals @property From fe9b55e38c47a849f91d8e26ef746e9d5e3ecb82 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Mon, 26 Jun 2023 13:07:13 -0700 Subject: [PATCH 16/81] Remove todo -- it is done in finish_run --- pyiron_contrib/workflow/is_nodal.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyiron_contrib/workflow/is_nodal.py b/pyiron_contrib/workflow/is_nodal.py index c782a30f4..437ac635f 100644 --- a/pyiron_contrib/workflow/is_nodal.py +++ b/pyiron_contrib/workflow/is_nodal.py @@ -53,7 +53,7 @@ class IsNodal(ABC): signals, which are channels for controlling execution flow. By default, has a `signals.inputs.run` channel which has a callback to the `run` method, and `signals.outputs.ran` which should be called at when the `run` method - is finished (TODO: Don't leave this step up to child class developers!). + is finished. Additional signal channels in derived classes can be added to `signals.inputs` and `signals.outputs` after this mixin class is initialized. From e52d0a2714617043545490a810a7f81b1f1cd02b Mon Sep 17 00:00:00 2001 From: liamhuber Date: Tue, 27 Jun 2023 12:25:12 -0700 Subject: [PATCH 17/81] Remove ununsed import --- pyiron_contrib/workflow/workflow.py | 1 - 1 file changed, 1 deletion(-) diff --git a/pyiron_contrib/workflow/workflow.py b/pyiron_contrib/workflow/workflow.py index a427a2b08..120412911 100644 --- a/pyiron_contrib/workflow/workflow.py +++ b/pyiron_contrib/workflow/workflow.py @@ -5,7 +5,6 @@ from pyiron_contrib.workflow.io import Inputs, Outputs from pyiron_contrib.workflow.is_nodal import IsNodal from pyiron_contrib.workflow.node import Node, node, fast_node, single_value_node -from pyiron_contrib.workflow.util import DotDict from pyiron_contrib.workflow.files import DirectoryObject From 7a1bc23c676df37fd64507170a4ccf37fa02be18 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Tue, 27 Jun 2023 12:30:34 -0700 Subject: [PATCH 18/81] Move _working_directory up into the parent class --- pyiron_contrib/workflow/is_nodal.py | 7 +++++++ pyiron_contrib/workflow/node.py | 2 -- pyiron_contrib/workflow/workflow.py | 3 +-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/pyiron_contrib/workflow/is_nodal.py b/pyiron_contrib/workflow/is_nodal.py index 437ac635f..02c8efed0 100644 --- a/pyiron_contrib/workflow/is_nodal.py +++ b/pyiron_contrib/workflow/is_nodal.py @@ -8,6 +8,7 @@ from abc import ABC, abstractmethod from typing import TYPE_CHECKING +from pyiron_contrib.workflow.files import DirectoryObject from pyiron_contrib.workflow.io import Signals, InputSignal, OutputSignal if TYPE_CHECKING: @@ -88,6 +89,7 @@ def __init__(self, label: str, *args, **kwargs): # TODO: Move from a traditional "sever" to a tinybase "executor" # TODO: Provide support for actually computing stuff with the server/executor self.signals = self._build_signal_channels() + self._working_directory = None @property @abstractmethod @@ -103,6 +105,11 @@ def outputs(self) -> Outputs: def update(self): pass + @property + @abstractmethod + def working_directory(self) -> DirectoryObject: + pass + @property @abstractmethod def on_run(self) -> callable[..., tuple]: diff --git a/pyiron_contrib/workflow/node.py b/pyiron_contrib/workflow/node.py index 37dab6778..0809581ac 100644 --- a/pyiron_contrib/workflow/node.py +++ b/pyiron_contrib/workflow/node.py @@ -363,8 +363,6 @@ def __init__( if update_on_instantiation: self.update() - self._working_directory = None - @property def _input_args(self): return inspect.signature(self.node_function).parameters diff --git a/pyiron_contrib/workflow/workflow.py b/pyiron_contrib/workflow/workflow.py index 120412911..134e7f9ef 100644 --- a/pyiron_contrib/workflow/workflow.py +++ b/pyiron_contrib/workflow/workflow.py @@ -1,11 +1,11 @@ from __future__ import annotations +from pyiron_contrib.workflow.files import DirectoryObject from pyiron_contrib.workflow.has_nodes import HasNodes from pyiron_contrib.workflow.has_to_dict import HasToDict from pyiron_contrib.workflow.io import Inputs, Outputs from pyiron_contrib.workflow.is_nodal import IsNodal from pyiron_contrib.workflow.node import Node, node, fast_node, single_value_node -from pyiron_contrib.workflow.files import DirectoryObject class _NodeDecoratorAccess: @@ -122,7 +122,6 @@ class Workflow(IsNodal, HasToDict, HasNodes): def __init__(self, label: str, *nodes: Node, strict_naming=True): super().__init__(label=label, strict_naming=strict_naming) - self._working_directory = None for node in nodes: self.add_node(node) From 91dc898486f1c7cafb97d3f0a779ea2b7524148d Mon Sep 17 00:00:00 2001 From: liamhuber Date: Tue, 27 Jun 2023 12:40:17 -0700 Subject: [PATCH 19/81] Refactor: pull parent attribute into base class --- pyiron_contrib/workflow/is_nodal.py | 16 ++++++++++++++-- pyiron_contrib/workflow/node.py | 4 ---- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/pyiron_contrib/workflow/is_nodal.py b/pyiron_contrib/workflow/is_nodal.py index 02c8efed0..ca8170de4 100644 --- a/pyiron_contrib/workflow/is_nodal.py +++ b/pyiron_contrib/workflow/is_nodal.py @@ -6,7 +6,7 @@ from __future__ import annotations from abc import ABC, abstractmethod -from typing import TYPE_CHECKING +from typing import Optional, TYPE_CHECKING from pyiron_contrib.workflow.files import DirectoryObject from pyiron_contrib.workflow.io import Signals, InputSignal, OutputSignal @@ -14,6 +14,7 @@ if TYPE_CHECKING: from pyiron_base.jobs.job.extension.server.generic import Server + from pyiron_contrib.workflow.has_nodes import HasNodes from pyiron_contrib.workflow.io import Inputs, Outputs @@ -42,6 +43,8 @@ class IsNodal(ABC): label (str): A name for the nodal object. output (pyiron_contrib.workflow.io.Outputs): **Abstract.** Children must define a property returning an `Outputs` object. + parent (pyiron_contrib.workflow.has_nodes.HasNodes | None): The parent object + owning this, if any. ready (bool): Whether the inputs are all ready and the nodal object is neither already running nor already failed. running (bool): Whether the nodal object has called `run` and has not yet @@ -66,7 +69,13 @@ class IsNodal(ABC): TODO: Once `run_on_updates` is in this class, we can un-abstract this. """ - def __init__(self, label: str, *args, **kwargs): + def __init__( + self, + label: str, + *args, + parent: Optional[HasNodes] = None, + **kwargs + ): """ A mixin class for objects that can form nodes in the graph representation of a computational workflow. @@ -80,6 +89,9 @@ def __init__(self, label: str, *args, **kwargs): """ super().__init__(*args, **kwargs) self.label: str = label + self.parent = parent + if parent is not None: + parent.add(self) self.running = False self.failed = False # TODO: Replace running and failed with a state object diff --git a/pyiron_contrib/workflow/node.py b/pyiron_contrib/workflow/node.py index 0809581ac..f698e551a 100644 --- a/pyiron_contrib/workflow/node.py +++ b/pyiron_contrib/workflow/node.py @@ -323,16 +323,12 @@ def __init__( run_on_updates: bool = False, update_on_instantiation: bool = False, channels_requiring_update_after_run: Optional[list[str]] = None, - parent: Optional[Workflow] = None, **kwargs, ): super().__init__( label=label if label is not None else node_function.__name__, # **kwargs, ) - self.parent = parent - if parent is not None: - parent.add(self) if len(output_labels) == 0: raise ValueError("Nodes must have at least one output label.") From 2959fe79e28edd8b720162a9dc92bde0571ebcba Mon Sep 17 00:00:00 2001 From: liamhuber Date: Tue, 27 Jun 2023 12:44:02 -0700 Subject: [PATCH 20/81] Pull working_directory up into base class and allow nodes to have their own working directory without a parent --- pyiron_contrib/workflow/is_nodal.py | 15 ++++++++++----- pyiron_contrib/workflow/node.py | 13 ------------- pyiron_contrib/workflow/workflow.py | 7 ------- 3 files changed, 10 insertions(+), 25 deletions(-) diff --git a/pyiron_contrib/workflow/is_nodal.py b/pyiron_contrib/workflow/is_nodal.py index ca8170de4..7b2d74309 100644 --- a/pyiron_contrib/workflow/is_nodal.py +++ b/pyiron_contrib/workflow/is_nodal.py @@ -117,11 +117,6 @@ def outputs(self) -> Outputs: def update(self): pass - @property - @abstractmethod - def working_directory(self) -> DirectoryObject: - pass - @property @abstractmethod def on_run(self) -> callable[..., tuple]: @@ -201,6 +196,16 @@ def _build_signal_channels(self) -> Signals: signals.output.ran = OutputSignal("ran", self) return signals + @property + def working_directory(self): + if self._working_directory is None: + if self.parent is not None and hasattr(self.parent, "working_directory"): + parent_dir = self.parent.working_directory + self._working_directory = parent_dir.create_subdirectory(self.label) + else: + self._working_directory = DirectoryObject(self.label) + return self._working_directory + @property def server(self) -> Server | None: return self._server diff --git a/pyiron_contrib/workflow/node.py b/pyiron_contrib/workflow/node.py index f698e551a..67aabdd18 100644 --- a/pyiron_contrib/workflow/node.py +++ b/pyiron_contrib/workflow/node.py @@ -522,19 +522,6 @@ def to_dict(self): "signals": self.signals.to_dict(), } - @property - def working_directory(self): - if self._working_directory is None: - if self.parent is None: - raise ValueError( - "working directory is available only if the node is" - " attached to a workflow" - ) - self._working_directory = self.parent.working_directory.create_subdirectory( - self.label - ) - return self._working_directory - class FastNode(Node): """ diff --git a/pyiron_contrib/workflow/workflow.py b/pyiron_contrib/workflow/workflow.py index 134e7f9ef..778a0c66a 100644 --- a/pyiron_contrib/workflow/workflow.py +++ b/pyiron_contrib/workflow/workflow.py @@ -1,6 +1,5 @@ from __future__ import annotations -from pyiron_contrib.workflow.files import DirectoryObject from pyiron_contrib.workflow.has_nodes import HasNodes from pyiron_contrib.workflow.has_to_dict import HasToDict from pyiron_contrib.workflow.io import Inputs, Outputs @@ -126,12 +125,6 @@ def __init__(self, label: str, *nodes: Node, strict_naming=True): for node in nodes: self.add_node(node) - @property - def working_directory(self): - if self._working_directory is None: - self._working_directory = DirectoryObject(self.label) - return self._working_directory - @property def inputs(self) -> Inputs: inputs = Inputs() From 62d36bc9f89f4b881c7472274c3dded6c69028f1 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Tue, 27 Jun 2023 13:21:52 -0700 Subject: [PATCH 21/81] Oops, add the parent kwarg back to nodes --- pyiron_contrib/workflow/node.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyiron_contrib/workflow/node.py b/pyiron_contrib/workflow/node.py index 67aabdd18..044bd3e69 100644 --- a/pyiron_contrib/workflow/node.py +++ b/pyiron_contrib/workflow/node.py @@ -12,6 +12,7 @@ from pyiron_contrib.workflow.is_nodal import IsNodal if TYPE_CHECKING: + from pyiron_contrib.workflow.has_nodes import HasNodes from pyiron_contrib.workflow.workflow import Workflow @@ -323,10 +324,12 @@ def __init__( run_on_updates: bool = False, update_on_instantiation: bool = False, channels_requiring_update_after_run: Optional[list[str]] = None, + parent: Optional[HasNodes] = None, **kwargs, ): super().__init__( label=label if label is not None else node_function.__name__, + parent=parent, # **kwargs, ) if len(output_labels) == 0: From 5bbf4ddc43fafc748c9ca7e9f85b84e6336dd2a2 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Tue, 27 Jun 2023 21:15:36 -0700 Subject: [PATCH 22/81] Conform to and test the spec That workflow's can't have parents. --- pyiron_contrib/workflow/workflow.py | 15 +++++++++++++++ tests/unit/workflow/test_workflow.py | 9 +++++++++ 2 files changed, 24 insertions(+) diff --git a/pyiron_contrib/workflow/workflow.py b/pyiron_contrib/workflow/workflow.py index 778a0c66a..24052a180 100644 --- a/pyiron_contrib/workflow/workflow.py +++ b/pyiron_contrib/workflow/workflow.py @@ -120,6 +120,7 @@ class Workflow(IsNodal, HasToDict, HasNodes): wrap_as = _NodeDecoratorAccess def __init__(self, label: str, *nodes: Node, strict_naming=True): + self._parent = None super().__init__(label=label, strict_naming=strict_naming) for node in nodes: @@ -171,3 +172,17 @@ def update(self): def on_run(self): # Maybe we need this if workflows can be used as nodes? raise NotImplementedError + + @property + def parent(self) -> None: + return self._parent + + @parent.setter + def parent(self, new_parent: None): + # Currently workflows are not allowed to have a parent -- maybe we want to + # change our minds on this in the future? + if new_parent is not None: + raise TypeError( + f"{self.__class__} may only take None as a parent but got " + f"{type(new_parent)}" + ) \ No newline at end of file diff --git a/tests/unit/workflow/test_workflow.py b/tests/unit/workflow/test_workflow.py index 576953fca..12e095d51 100644 --- a/tests/unit/workflow/test_workflow.py +++ b/tests/unit/workflow/test_workflow.py @@ -124,6 +124,15 @@ def test_working_directory(self): self.assertTrue(str(wf.fnc.working_directory.path).endswith(wf.fnc.label)) wf.working_directory.delete() + def test_no_parents(self): + wf = Workflow("wf") + wf2 = Workflow("wf2") + wf2.parent = None # Is already the value and should ignore this + with self.assertRaises(TypeError): + # We currently specify workflows shouldn't get parents, this just verifies + # the spec. If that spec changes, test instead that you _can_ set parents! + wf2.parent = wf + if __name__ == '__main__': unittest.main() From 5a0d3e5548af465a787497fa867b9a3ef6048a4d Mon Sep 17 00:00:00 2001 From: liamhuber Date: Tue, 27 Jun 2023 21:18:30 -0700 Subject: [PATCH 23/81] Modify node tests to conform to new spec That nodes can have working directories --- tests/unit/workflow/test_node.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/unit/workflow/test_node.py b/tests/unit/workflow/test_node.py index df179d276..d3e9c960a 100644 --- a/tests/unit/workflow/test_node.py +++ b/tests/unit/workflow/test_node.py @@ -3,6 +3,7 @@ from typing import Optional, Union import warnings +from pyiron_contrib.workflow.files import DirectoryObject from pyiron_contrib.workflow.node import ( FastNode, Node, SingleValueNode, node, single_value_node ) @@ -361,9 +362,10 @@ def my_node(x: int = 0, y: int = 0, z: int = 0): def test_working_directory(self): n_f = Node(plus_one, "output") - with self.assertRaises(ValueError): - _ = n_f.working_directory - # cf. test_workflow.py for the case that it does notraise an error + self.assertTrue(n_f._working_directory is None) + self.assertIsInstance(n_f.working_directory, DirectoryObject) + self.assertTrue(str(n_f.working_directory.path).endswith(n_f.label)) + n_f.working_directory.delete() if __name__ == '__main__': From 738ac1b7a1ef2ffb848d6f0f411c578668feaacd Mon Sep 17 00:00:00 2001 From: liamhuber Date: Tue, 27 Jun 2023 21:18:42 -0700 Subject: [PATCH 24/81] Order imports alphabetically --- tests/unit/workflow/test_workflow.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/unit/workflow/test_workflow.py b/tests/unit/workflow/test_workflow.py index 12e095d51..5a5f9796d 100644 --- a/tests/unit/workflow/test_workflow.py +++ b/tests/unit/workflow/test_workflow.py @@ -1,9 +1,10 @@ import unittest from sys import version_info +from pyiron_contrib.workflow.files import DirectoryObject from pyiron_contrib.workflow.node import Node from pyiron_contrib.workflow.workflow import Workflow -from pyiron_contrib.workflow.files import DirectoryObject + def fnc(x=0): From 265406135d836bbeb6dd8aede33bc926c22d7510 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Tue, 27 Jun 2023 21:18:48 -0700 Subject: [PATCH 25/81] PEP8 --- tests/unit/workflow/test_workflow.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/unit/workflow/test_workflow.py b/tests/unit/workflow/test_workflow.py index 5a5f9796d..12a8b670c 100644 --- a/tests/unit/workflow/test_workflow.py +++ b/tests/unit/workflow/test_workflow.py @@ -6,7 +6,6 @@ from pyiron_contrib.workflow.workflow import Workflow - def fnc(x=0): return x + 1 From 7e8827d7132c385eeebfd4caedbd03f1bed60ae3 Mon Sep 17 00:00:00 2001 From: Liam Huber Date: Wed, 28 Jun 2023 06:56:53 -0700 Subject: [PATCH 26/81] Update pyiron_contrib/workflow/workflow.py Co-authored-by: Sam Dareska <37879103+samwaseda@users.noreply.github.com> --- pyiron_contrib/workflow/workflow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyiron_contrib/workflow/workflow.py b/pyiron_contrib/workflow/workflow.py index 24052a180..ff06d35cb 100644 --- a/pyiron_contrib/workflow/workflow.py +++ b/pyiron_contrib/workflow/workflow.py @@ -175,7 +175,7 @@ def on_run(self): @property def parent(self) -> None: - return self._parent + return None @parent.setter def parent(self, new_parent: None): From f774a135f9770144887a2e7ea47247eb57752384 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Wed, 28 Jun 2023 08:25:46 -0700 Subject: [PATCH 27/81] Better clarify intent to future devs --- pyiron_contrib/workflow/workflow.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pyiron_contrib/workflow/workflow.py b/pyiron_contrib/workflow/workflow.py index ff06d35cb..818dbd6c3 100644 --- a/pyiron_contrib/workflow/workflow.py +++ b/pyiron_contrib/workflow/workflow.py @@ -120,7 +120,7 @@ class Workflow(IsNodal, HasToDict, HasNodes): wrap_as = _NodeDecoratorAccess def __init__(self, label: str, *nodes: Node, strict_naming=True): - self._parent = None + self._parent = None # Necessary to pre-populate public property/setter var super().__init__(label=label, strict_naming=strict_naming) for node in nodes: @@ -180,7 +180,9 @@ def parent(self) -> None: @parent.setter def parent(self, new_parent: None): # Currently workflows are not allowed to have a parent -- maybe we want to - # change our minds on this in the future? + # change our minds on this in the future? If we do, we can just expose `parent` + # as a kwarg and roll back this private var/property/setter protection and let + # the super call in init handle everything if new_parent is not None: raise TypeError( f"{self.__class__} may only take None as a parent but got " From be0bdfa1fdff591f0f2200c311a0cd2db668fa01 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Wed, 28 Jun 2023 10:06:18 -0700 Subject: [PATCH 28/81] PEP8 newline --- pyiron_contrib/workflow/workflow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyiron_contrib/workflow/workflow.py b/pyiron_contrib/workflow/workflow.py index 818dbd6c3..2ecc80580 100644 --- a/pyiron_contrib/workflow/workflow.py +++ b/pyiron_contrib/workflow/workflow.py @@ -187,4 +187,4 @@ def parent(self, new_parent: None): raise TypeError( f"{self.__class__} may only take None as a parent but got " f"{type(new_parent)}" - ) \ No newline at end of file + ) From 9c5d2e98b6f6e06640780a7476f4e703a1dfa700 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Wed, 28 Jun 2023 10:18:23 -0700 Subject: [PATCH 29/81] Make all nodes have to_dict --- pyiron_contrib/workflow/is_nodal.py | 3 ++- pyiron_contrib/workflow/node.py | 3 +-- pyiron_contrib/workflow/workflow.py | 3 +-- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/pyiron_contrib/workflow/is_nodal.py b/pyiron_contrib/workflow/is_nodal.py index 7b2d74309..8e68c0507 100644 --- a/pyiron_contrib/workflow/is_nodal.py +++ b/pyiron_contrib/workflow/is_nodal.py @@ -9,6 +9,7 @@ from typing import Optional, TYPE_CHECKING from pyiron_contrib.workflow.files import DirectoryObject +from pyiron_contrib.workflow.has_to_dict import HasToDict from pyiron_contrib.workflow.io import Signals, InputSignal, OutputSignal if TYPE_CHECKING: @@ -18,7 +19,7 @@ from pyiron_contrib.workflow.io import Inputs, Outputs -class IsNodal(ABC): +class IsNodal(HasToDict, ABC): """ A mixin class for objects that can form nodes in the graph representation of a computational workflow. diff --git a/pyiron_contrib/workflow/node.py b/pyiron_contrib/workflow/node.py index 044bd3e69..3a96b5f70 100644 --- a/pyiron_contrib/workflow/node.py +++ b/pyiron_contrib/workflow/node.py @@ -7,7 +7,6 @@ from pyiron_contrib.workflow.channels import InputData, OutputData from pyiron_contrib.workflow.has_channel import HasChannel -from pyiron_contrib.workflow.has_to_dict import HasToDict from pyiron_contrib.workflow.io import Inputs, Outputs, Signals from pyiron_contrib.workflow.is_nodal import IsNodal @@ -16,7 +15,7 @@ from pyiron_contrib.workflow.workflow import Workflow -class Node(IsNodal, HasToDict): +class Node(IsNodal): """ Nodes have input and output data channels that interface with the outside world, and a callable that determines what they actually compute. After running, their output diff --git a/pyiron_contrib/workflow/workflow.py b/pyiron_contrib/workflow/workflow.py index 2ecc80580..64bf04285 100644 --- a/pyiron_contrib/workflow/workflow.py +++ b/pyiron_contrib/workflow/workflow.py @@ -1,7 +1,6 @@ from __future__ import annotations from pyiron_contrib.workflow.has_nodes import HasNodes -from pyiron_contrib.workflow.has_to_dict import HasToDict from pyiron_contrib.workflow.io import Inputs, Outputs from pyiron_contrib.workflow.is_nodal import IsNodal from pyiron_contrib.workflow.node import Node, node, fast_node, single_value_node @@ -15,7 +14,7 @@ class _NodeDecoratorAccess: single_value_node = single_value_node -class Workflow(IsNodal, HasToDict, HasNodes): +class Workflow(IsNodal, HasNodes): """ Workflows are an abstraction for holding a collection of related nodes. From 07381125418a1bafee82181e948dbfb99776f3b1 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Wed, 28 Jun 2023 10:45:20 -0700 Subject: [PATCH 30/81] Pull update up to the parent class --- pyiron_contrib/workflow/is_nodal.py | 10 ++++++---- pyiron_contrib/workflow/node.py | 7 ++----- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/pyiron_contrib/workflow/is_nodal.py b/pyiron_contrib/workflow/is_nodal.py index 8e68c0507..e022ee603 100644 --- a/pyiron_contrib/workflow/is_nodal.py +++ b/pyiron_contrib/workflow/is_nodal.py @@ -75,6 +75,7 @@ def __init__( label: str, *args, parent: Optional[HasNodes] = None, + run_on_updates: bool = False, **kwargs ): """ @@ -103,6 +104,7 @@ def __init__( # TODO: Provide support for actually computing stuff with the server/executor self.signals = self._build_signal_channels() self._working_directory = None + self.run_on_updates: bool = run_on_updates @property @abstractmethod @@ -114,10 +116,6 @@ def inputs(self) -> Inputs: def outputs(self) -> Outputs: pass - @abstractmethod - def update(self): - pass - @property @abstractmethod def on_run(self) -> callable[..., tuple]: @@ -197,6 +195,10 @@ def _build_signal_channels(self) -> Signals: signals.output.ran = OutputSignal("ran", self) return signals + def update(self) -> None: + if self.run_on_updates and self.ready: + self.run() + @property def working_directory(self): if self._working_directory is None: diff --git a/pyiron_contrib/workflow/node.py b/pyiron_contrib/workflow/node.py index 3a96b5f70..5fa06a68a 100644 --- a/pyiron_contrib/workflow/node.py +++ b/pyiron_contrib/workflow/node.py @@ -351,12 +351,13 @@ def __init__( self._verify_that_channels_requiring_update_all_exist() self.run_on_updates = False + # Temporarily disable running on updates to set all initial values at once for k, v in kwargs.items(): if k in self.inputs.labels: self.inputs[k] = v elif k not in self._init_keywords: warnings.warn(f"The keyword '{k}' was received but not used.") - self.run_on_updates = run_on_updates + self.run_on_updates = run_on_updates # Restore provided value if update_on_instantiation: self.update() @@ -476,10 +477,6 @@ def _verify_that_channels_requiring_update_all_exist(self): f"not found among the input channels ({self.inputs.labels})" ) - def update(self) -> None: - if self.run_on_updates and self.ready: - self.run() - @property def on_run(self): return self.node_function From 0068072241963a370bae22f3b1bf11b3ffa71c04 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Wed, 28 Jun 2023 10:56:05 -0700 Subject: [PATCH 31/81] Replace HasNodes mix-in behaviour with Composite inheritance behaviour --- .../workflow/{has_nodes.py => composite.py} | 83 ++++++++++++++++--- pyiron_contrib/workflow/is_nodal.py | 6 +- pyiron_contrib/workflow/node.py | 4 +- .../workflow/node_library/package.py | 4 +- pyiron_contrib/workflow/workflow.py | 26 ++---- 5 files changed, 85 insertions(+), 38 deletions(-) rename pyiron_contrib/workflow/{has_nodes.py => composite.py} (66%) diff --git a/pyiron_contrib/workflow/has_nodes.py b/pyiron_contrib/workflow/composite.py similarity index 66% rename from pyiron_contrib/workflow/has_nodes.py rename to pyiron_contrib/workflow/composite.py index 964f0fd30..d1856a4e9 100644 --- a/pyiron_contrib/workflow/has_nodes.py +++ b/pyiron_contrib/workflow/composite.py @@ -1,3 +1,8 @@ +""" +A base class for nodal objects that have internal structure -- i.e. they hold a +sub-graph +""" + from __future__ import annotations from abc import ABC @@ -5,25 +10,79 @@ from typing import Optional from warnings import warn -from pyiron_contrib.workflow.node import Node +from pyiron_contrib.workflow.is_nodal import IsNodal +from pyiron_contrib.workflow.node import Node, node, fast_node, single_value_node from pyiron_contrib.workflow.node_library import atomistics, standard from pyiron_contrib.workflow.node_library.package import NodePackage from pyiron_contrib.workflow.util import DotDict -class HasNodes(ABC): +class _NodeDecoratorAccess: + """An intermediate container to store node-creating decorators as class methods.""" + + node = node + fast_node = fast_node + single_value_node = single_value_node + + +class Composite(IsNodal, ABC): """ - A mixin class for classes which hold a graph of nodes. + A base class for nodes that have internal structure -- i.e. they hold a sub-graph. + + Item and attribute access is modified to give access to owned nodes. + Adding a node with the `add` functionality or by direct attribute assignment sets + this object as the parent of that node. + + Offers a class method (`wrap_as`) to give easy access to the node-creating + decorators. - Attribute assignment is overriden such that assignment of a `Node` instance adds - it directly to the collection of nodes. + Specifies the required `on_run()` to call `run()` on a subset of owned nodes, i.e. + to kick-start computation on the owned sub-graph. + By default, `run()` will be called on all owned nodes who's input has no + connections, but this can be overridden to specify particular nodes to use instead. + + Does not specify `input` and `output` as demanded by the parent class; this + requirement is still passed on to children. + + Attributes: + TBA + + Methods: + TBA """ - def __init__(self, *args, strict_naming=True, **kwargs): - super().__init__(*args, **kwargs) - self.nodes: DotDict = DotDict() + wrap_as = _NodeDecoratorAccess # Class method access to decorators + # Allows users/devs to easily create new nodes when using children of this class + + def __init__( + self, + label: str, + *args, + parent: Optional[Composite] = None, + strict_naming: bool = True, + **kwargs + ): + super().__init__(*args, label=label, parent=parent, **kwargs) + self.stict_naming: bool = strict_naming + self.nodes: DotDict[str: IsNodal] = DotDict() self.add: NodeAdder = NodeAdder(self) - self.strict_naming: bool = strict_naming + + def to_dict(self): + return { + "label": self.label, + "nodes": {n.label: n.to_dict() for n in self.nodes.values()}, + } + + @property + def starting_nodes(self) -> list[IsNodal]: + return [ + node for node in self.nodes.values() + if node.outputs.connected and not node.inputs.connected + ] + + def on_run(self): + for node in self.starting_nodes: + node.run() def add_node(self, node: Node, label: Optional[str] = None) -> None: """ @@ -134,15 +193,15 @@ def __dir__(self): class NodeAdder: """ - This class provides a layer of misdirection so that `HasNodes` objects can set + This class provides a layer of misdirection so that `Composite` objects can set themselves as the parent of owned nodes. It also provides access to packages of nodes and the ability to register new packages. """ - def __init__(self, parent: HasNodes): - self._parent: HasNodes = parent + def __init__(self, parent: Composite): + self._parent: Composite = parent self.register_nodes("atomistics", *atomistics.nodes) self.register_nodes("standard", *standard.nodes) diff --git a/pyiron_contrib/workflow/is_nodal.py b/pyiron_contrib/workflow/is_nodal.py index e022ee603..3451c0d52 100644 --- a/pyiron_contrib/workflow/is_nodal.py +++ b/pyiron_contrib/workflow/is_nodal.py @@ -15,7 +15,7 @@ if TYPE_CHECKING: from pyiron_base.jobs.job.extension.server.generic import Server - from pyiron_contrib.workflow.has_nodes import HasNodes + from pyiron_contrib.workflow.composite import Composite from pyiron_contrib.workflow.io import Inputs, Outputs @@ -44,7 +44,7 @@ class IsNodal(HasToDict, ABC): label (str): A name for the nodal object. output (pyiron_contrib.workflow.io.Outputs): **Abstract.** Children must define a property returning an `Outputs` object. - parent (pyiron_contrib.workflow.has_nodes.HasNodes | None): The parent object + parent (pyiron_contrib.workflow.composite.Composite | None): The parent object owning this, if any. ready (bool): Whether the inputs are all ready and the nodal object is neither already running nor already failed. @@ -74,7 +74,7 @@ def __init__( self, label: str, *args, - parent: Optional[HasNodes] = None, + parent: Optional[Composite] = None, run_on_updates: bool = False, **kwargs ): diff --git a/pyiron_contrib/workflow/node.py b/pyiron_contrib/workflow/node.py index 5fa06a68a..3951def51 100644 --- a/pyiron_contrib/workflow/node.py +++ b/pyiron_contrib/workflow/node.py @@ -11,7 +11,7 @@ from pyiron_contrib.workflow.is_nodal import IsNodal if TYPE_CHECKING: - from pyiron_contrib.workflow.has_nodes import HasNodes + from pyiron_contrib.workflow.composite import Composite from pyiron_contrib.workflow.workflow import Workflow @@ -323,7 +323,7 @@ def __init__( run_on_updates: bool = False, update_on_instantiation: bool = False, channels_requiring_update_after_run: Optional[list[str]] = None, - parent: Optional[HasNodes] = None, + parent: Optional[Composite] = None, **kwargs, ): super().__init__( diff --git a/pyiron_contrib/workflow/node_library/package.py b/pyiron_contrib/workflow/node_library/package.py index 62f94bcfc..d0a27009e 100644 --- a/pyiron_contrib/workflow/node_library/package.py +++ b/pyiron_contrib/workflow/node_library/package.py @@ -7,7 +7,7 @@ from pyiron_contrib.workflow.util import DotDict if TYPE_CHECKING: - from pyiron_contrib.workflow.workflow import Workflow + from pyiron_contrib.workflow.composite import Composite class NodePackage(DotDict): @@ -21,7 +21,7 @@ class NodePackage(DotDict): but to update an existing node the `update` method must be used. """ - def __init__(self, parent: Workflow, *node_classes: Node): + def __init__(self, parent: Composite, *node_classes: Node): super().__init__() self.__dict__["_parent"] = parent # Avoid the __setattr__ override for node in node_classes: diff --git a/pyiron_contrib/workflow/workflow.py b/pyiron_contrib/workflow/workflow.py index 64bf04285..de299c944 100644 --- a/pyiron_contrib/workflow/workflow.py +++ b/pyiron_contrib/workflow/workflow.py @@ -1,20 +1,16 @@ from __future__ import annotations -from pyiron_contrib.workflow.has_nodes import HasNodes -from pyiron_contrib.workflow.io import Inputs, Outputs -from pyiron_contrib.workflow.is_nodal import IsNodal -from pyiron_contrib.workflow.node import Node, node, fast_node, single_value_node +from typing import TYPE_CHECKING +from pyiron_contrib.workflow.composite import Composite +from pyiron_contrib.workflow.io import Inputs, Outputs -class _NodeDecoratorAccess: - """An intermediate container to store node-creating decorators as class methods.""" - node = node - fast_node = fast_node - single_value_node = single_value_node +if TYPE_CHECKING: + from pyiron_contrib.workflow.node import Node -class Workflow(IsNodal, HasNodes): +class Workflow(Composite): """ Workflows are an abstraction for holding a collection of related nodes. @@ -116,11 +112,9 @@ class Workflow(IsNodal, HasNodes): integrity of workflows when they're used somewhere else? """ - wrap_as = _NodeDecoratorAccess - def __init__(self, label: str, *nodes: Node, strict_naming=True): self._parent = None # Necessary to pre-populate public property/setter var - super().__init__(label=label, strict_naming=strict_naming) + super().__init__(label=label, parent=None, strict_naming=strict_naming) for node in nodes: self.add_node(node) @@ -143,12 +137,6 @@ def outputs(self) -> Outputs: outputs[f"{node_label}_{channel.label}"] = channel return outputs - def to_dict(self): - return { - "label": self.label, - "nodes": {n.label: n.to_dict() for n in self.nodes.values()}, - } - def to_node(self): """ Export the workflow to a macro node, with the currently exposed IO mapped to From f9b04485a814aa0f6f1b2d6c38a322ce22256b0c Mon Sep 17 00:00:00 2001 From: liamhuber Date: Wed, 28 Jun 2023 10:58:02 -0700 Subject: [PATCH 32/81] Allow workflow to inherit on_run and update behaviour from Composite --- pyiron_contrib/workflow/workflow.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/pyiron_contrib/workflow/workflow.py b/pyiron_contrib/workflow/workflow.py index de299c944..d3b634239 100644 --- a/pyiron_contrib/workflow/workflow.py +++ b/pyiron_contrib/workflow/workflow.py @@ -151,15 +151,6 @@ def serialize(self): def deserialize(self, source): raise NotImplementedError - def update(self): - for node in self.nodes.values(): - if node.outputs.connected and not node.inputs.connected: - node.update() - - def on_run(self): - # Maybe we need this if workflows can be used as nodes? - raise NotImplementedError - @property def parent(self) -> None: return None From f727e74b72b51e54e9f2fee200c67ca1cbc573a0 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Wed, 28 Jun 2023 11:08:33 -0700 Subject: [PATCH 33/81] Add run_on_updates docs to parent class --- pyiron_contrib/workflow/is_nodal.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyiron_contrib/workflow/is_nodal.py b/pyiron_contrib/workflow/is_nodal.py index 3451c0d52..564d19ee4 100644 --- a/pyiron_contrib/workflow/is_nodal.py +++ b/pyiron_contrib/workflow/is_nodal.py @@ -48,6 +48,8 @@ class IsNodal(HasToDict, ABC): owning this, if any. ready (bool): Whether the inputs are all ready and the nodal object is neither already running nor already failed. + run_on_updates (bool): Whether to run when you are updated and all your input + is ready and your status does not prohibit running. (Default is False). running (bool): Whether the nodal object has called `run` and has not yet received output from from this call. (Default is False.) server (Optional[pyiron_base.jobs.job.extension.server.generic.Server]): A From 856a15255b9ba4186651a314771ef7be7df05ef5 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Wed, 28 Jun 2023 11:14:23 -0700 Subject: [PATCH 34/81] Fix typo --- pyiron_contrib/workflow/composite.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyiron_contrib/workflow/composite.py b/pyiron_contrib/workflow/composite.py index d1856a4e9..015c932b5 100644 --- a/pyiron_contrib/workflow/composite.py +++ b/pyiron_contrib/workflow/composite.py @@ -63,7 +63,7 @@ def __init__( **kwargs ): super().__init__(*args, label=label, parent=parent, **kwargs) - self.stict_naming: bool = strict_naming + self.strict_naming: bool = strict_naming self.nodes: DotDict[str: IsNodal] = DotDict() self.add: NodeAdder = NodeAdder(self) From 4cfaa8a4a33ea926ff7830f298be0bb4fe47527c Mon Sep 17 00:00:00 2001 From: liamhuber Date: Wed, 28 Jun 2023 11:20:39 -0700 Subject: [PATCH 35/81] Allow composite nodes to specify what nodes should start runs --- pyiron_contrib/workflow/composite.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/pyiron_contrib/workflow/composite.py b/pyiron_contrib/workflow/composite.py index 015c932b5..e13dc20a5 100644 --- a/pyiron_contrib/workflow/composite.py +++ b/pyiron_contrib/workflow/composite.py @@ -66,6 +66,7 @@ def __init__( self.strict_naming: bool = strict_naming self.nodes: DotDict[str: IsNodal] = DotDict() self.add: NodeAdder = NodeAdder(self) + self.starting_nodes: None | list[Node] = None def to_dict(self): return { @@ -74,14 +75,16 @@ def to_dict(self): } @property - def starting_nodes(self) -> list[IsNodal]: + def upstream_nodes(self) -> list[IsNodal]: return [ node for node in self.nodes.values() if node.outputs.connected and not node.inputs.connected ] def on_run(self): - for node in self.starting_nodes: + starting_nodes = self.upstream_nodes if self.starting_nodes is None \ + else self.starting_nodes + for node in starting_nodes: node.run() def add_node(self, node: Node, label: Optional[str] = None) -> None: From 8bf8ef37357d66316d710c78c4d51e6b8bb15580 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Wed, 28 Jun 2023 11:24:02 -0700 Subject: [PATCH 36/81] Update docs --- pyiron_contrib/workflow/composite.py | 22 ++++++++++++++++++---- pyiron_contrib/workflow/workflow.py | 19 ++++++++++++------- 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/pyiron_contrib/workflow/composite.py b/pyiron_contrib/workflow/composite.py index e13dc20a5..3616d7736 100644 --- a/pyiron_contrib/workflow/composite.py +++ b/pyiron_contrib/workflow/composite.py @@ -33,22 +33,36 @@ class Composite(IsNodal, ABC): Adding a node with the `add` functionality or by direct attribute assignment sets this object as the parent of that node. + Guarantees that each owned node is unique, and does not belong to any other parents. + Offers a class method (`wrap_as`) to give easy access to the node-creating decorators. Specifies the required `on_run()` to call `run()` on a subset of owned nodes, i.e. to kick-start computation on the owned sub-graph. - By default, `run()` will be called on all owned nodes who's input has no - connections, but this can be overridden to specify particular nodes to use instead. + By default, `run()` will be called on all owned nodes have output connections but no + input connections (i.e. the upstream-most nodes), but this can be overridden to + specify particular nodes to use instead. Does not specify `input` and `output` as demanded by the parent class; this requirement is still passed on to children. Attributes: - TBA + nodes (DotDict[Node]): The owned nodes that form the composite subgraph. + strict_naming (bool): When true, repeated assignment of a new node to an + existing node label will raise an error, otherwise the label gets appended + with an index and the assignment proceeds. (Default is true: disallow assigning + to existing labels.) + add (NodeAdder): A tool for adding new nodes to this subgraph. + upstream_nodes (list[Node]): All the owned nodes that have output connections + but no input connections, i.e. the upstream-most nodes. + starting_nodes (None | list[Node]): A subset of the owned nodes to be used on + running. (Default is None, running falls back on using the `upstream_nodes`.) Methods: - TBA + add(node: Node): Add the node instance to this subgraph. + remove(node: Node): Break all connections the node has, remove it from this + subgraph, and set its parent to `None`. """ wrap_as = _NodeDecoratorAccess # Class method access to decorators diff --git a/pyiron_contrib/workflow/workflow.py b/pyiron_contrib/workflow/workflow.py index d3b634239..804dbe76b 100644 --- a/pyiron_contrib/workflow/workflow.py +++ b/pyiron_contrib/workflow/workflow.py @@ -1,3 +1,9 @@ +""" +Provides the main workhorse class for creating and running workflows. + +This class is intended as the single point of entry for users making an import. +""" + from __future__ import annotations from typing import TYPE_CHECKING @@ -12,15 +18,14 @@ class Workflow(Composite): """ - Workflows are an abstraction for holding a collection of related nodes. + Workflows are a dynamic composite node -- i.e. they hold and run a collection of + nodes (a subgraph) which can be dynamically modified (adding and removing nodes, + and modifying their connections). Nodes can be added to the workflow at instantiation or with dot-assignment later on. They are then accessible either under the `nodes` dot-dictionary, or just directly by dot-access on the workflow object itself. - The workflow guarantees that each node it owns has a unique within the scope of this - workflow, and that each node instance appears only once. - Using the `input` and `output` attributes, the workflow gives access to all the IO channels among its nodes which are currently unconnected. @@ -43,9 +48,9 @@ class Workflow(Composite): 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 with the - `(de)activate_strict_naming()` method(s). When deactivated, repeated assignments - to the same label just get appended with an index: + 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.deactivate_strict_naming() >>> wf.my_node = Node(fnc, "y", x=0) >>> wf.my_node = Node(fnc, "y", x=1) From 285d13d9cc1e8d8721800820c0f96dc3d8e6115c Mon Sep 17 00:00:00 2001 From: samwaseda Date: Wed, 28 Jun 2023 11:29:54 -0700 Subject: [PATCH 37/81] Remove variable that's not used any more --- pyiron_contrib/workflow/workflow.py | 1 - 1 file changed, 1 deletion(-) diff --git a/pyiron_contrib/workflow/workflow.py b/pyiron_contrib/workflow/workflow.py index 64bf04285..445c8f341 100644 --- a/pyiron_contrib/workflow/workflow.py +++ b/pyiron_contrib/workflow/workflow.py @@ -119,7 +119,6 @@ class Workflow(IsNodal, HasNodes): wrap_as = _NodeDecoratorAccess def __init__(self, label: str, *nodes: Node, strict_naming=True): - self._parent = None # Necessary to pre-populate public property/setter var super().__init__(label=label, strict_naming=strict_naming) for node in nodes: From 506735af4d843f29b4109e35358ceaa100d74959 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Wed, 28 Jun 2023 13:50:57 -0700 Subject: [PATCH 38/81] Update type hints to use base class --- pyiron_contrib/workflow/channels.py | 23 ++++++++++--------- pyiron_contrib/workflow/composite.py | 20 ++++++++-------- .../workflow/node_library/package.py | 12 +++++----- pyiron_contrib/workflow/workflow.py | 4 ++-- 4 files changed, 30 insertions(+), 29 deletions(-) diff --git a/pyiron_contrib/workflow/channels.py b/pyiron_contrib/workflow/channels.py index e08f4d4a0..f106ad29e 100644 --- a/pyiron_contrib/workflow/channels.py +++ b/pyiron_contrib/workflow/channels.py @@ -32,7 +32,7 @@ ) if typing.TYPE_CHECKING: - from pyiron_contrib.workflow.node import Node + from pyiron_contrib.workflow.is_nodal import IsNodal class Channel(HasChannel, HasToDict, ABC): @@ -51,25 +51,26 @@ class Channel(HasChannel, HasToDict, ABC): Attributes: label (str): The name of the channel. - node (pyiron_contrib.workflow.node.Node): The node to which the channel belongs. + node (pyiron_contrib.workflow.is_nodal.IsNodal): The node to which the channel + belongs. connections (list[Channel]): Other channels to which this channel is connected. """ def __init__( self, label: str, - node: Node, + node: IsNodal, ): """ Make a new channel. Args: label (str): A name for the channel. - node (pyiron_contrib.workflow.node.Node): The node to which the channel - belongs. + node (pyiron_contrib.workflow.is_nodal.IsNodal): The node to which the + channel belongs. """ self.label: str = label - self.node: Node = node + self.node: IsNodal = node self.connections: list[Channel] = [] @abstractmethod @@ -180,7 +181,7 @@ class DataChannel(Channel, ABC): def __init__( self, label: str, - node: Node, + node: IsNodal, default: typing.Optional[typing.Any] = None, type_hint: typing.Optional[typing.Any] = None, ): @@ -312,7 +313,7 @@ class InputData(DataChannel): def __init__( self, label: str, - node: Node, + node: IsNodal, default: typing.Optional[typing.Any] = None, type_hint: typing.Optional[typing.Any] = None, strict_connections: bool = True, @@ -447,7 +448,7 @@ class InputSignal(SignalChannel): def __init__( self, label: str, - node: Node, + node: IsNodal, callback: callable, ): """ @@ -455,8 +456,8 @@ def __init__( Args: label (str): A name for the channel. - node (pyiron_contrib.workflow.node.Node): The node to which the channel - belongs. + node (pyiron_contrib.workflow.is_nodal.IsNodal): The node to which the + channel belongs. callback (callable): An argument-free callback to invoke when calling this object. """ diff --git a/pyiron_contrib/workflow/composite.py b/pyiron_contrib/workflow/composite.py index 3616d7736..a20193fcb 100644 --- a/pyiron_contrib/workflow/composite.py +++ b/pyiron_contrib/workflow/composite.py @@ -80,7 +80,7 @@ def __init__( self.strict_naming: bool = strict_naming self.nodes: DotDict[str: IsNodal] = DotDict() self.add: NodeAdder = NodeAdder(self) - self.starting_nodes: None | list[Node] = None + self.starting_nodes: None | list[IsNodal] = None def to_dict(self): return { @@ -101,7 +101,7 @@ def on_run(self): for node in starting_nodes: node.run() - def add_node(self, node: Node, label: Optional[str] = None) -> None: + def add_node(self, node: IsNodal, label: Optional[str] = None) -> None: """ Assign a node to the parent. Optionally provide a new label for that node. @@ -112,7 +112,7 @@ def add_node(self, node: Node, label: Optional[str] = None) -> None: Raises: TypeError: If the """ - if not isinstance(node, Node): + if not isinstance(node, IsNodal): raise TypeError( f"Only new node instances may be added, but got {type(node)}." ) @@ -127,7 +127,7 @@ def add_node(self, node: Node, label: Optional[str] = None) -> None: def _get_unique_label(self, label): if label in self.__dir__(): - if isinstance(getattr(self, label), Node): + if isinstance(getattr(self, label), IsNodal): if self.strict_naming: raise AttributeError( f"{label} is already the label for a node. Please remove it " @@ -155,7 +155,7 @@ def _add_suffix_to_label(self, label): ) return new_label - def _ensure_node_has_no_other_parent(self, node: Node): + def _ensure_node_has_no_other_parent(self, node: IsNodal): if node.parent is not None and node.parent is not self: raise ValueError( f"The node ({node.label}) already belongs to the parent " @@ -163,7 +163,7 @@ def _ensure_node_has_no_other_parent(self, node: Node): f"add it to this parent ({self.label})." ) - def _ensure_node_is_not_duplicated(self, node: Node, label: str): + def _ensure_node_is_not_duplicated(self, node: IsNodal, label: str): if ( node.parent is self and label != node.label @@ -175,16 +175,16 @@ def _ensure_node_is_not_duplicated(self, node: Node, label: str): ) del self.nodes[node.label] - def remove(self, node: Node | str): - if isinstance(node, Node): + def remove(self, node: IsNodal | str): + if isinstance(node, IsNodal): node.parent = None node.disconnect() del self.nodes[node.label] else: del self.nodes[node] - def __setattr__(self, label: str, node: Node): - if isinstance(node, Node): + def __setattr__(self, label: str, node: IsNodal): + if isinstance(node, IsNodal): self.add_node(node, label=label) else: super().__setattr__(label, node) diff --git a/pyiron_contrib/workflow/node_library/package.py b/pyiron_contrib/workflow/node_library/package.py index d0a27009e..74484242d 100644 --- a/pyiron_contrib/workflow/node_library/package.py +++ b/pyiron_contrib/workflow/node_library/package.py @@ -3,7 +3,7 @@ from functools import partial from typing import TYPE_CHECKING -from pyiron_contrib.workflow.node import Node +from pyiron_contrib.workflow.is_nodal import IsNodal from pyiron_contrib.workflow.util import DotDict if TYPE_CHECKING: @@ -21,7 +21,7 @@ class NodePackage(DotDict): but to update an existing node the `update` method must be used. """ - def __init__(self, parent: Composite, *node_classes: Node): + def __init__(self, parent: Composite, *node_classes: IsNodal): super().__init__() self.__dict__["_parent"] = parent # Avoid the __setattr__ override for node in node_classes: @@ -35,16 +35,16 @@ def __setitem__(self, key, value): f"The name {key} is already an attribute of this " f"{self.__class__.__name__} instance." ) - if not isinstance(value, type) or not issubclass(value, Node): + if not isinstance(value, type) or not issubclass(value, IsNodal): raise TypeError( - f"Can only set members that are (sub)classes of {Node.__name__}, but " - f"got {type(value)}" + f"Can only set members that are (sub)classes of {IsNodal.__name__}, " + f"but got {type(value)}" ) super().__setitem__(key, value) def __getitem__(self, item): value = super().__getitem__(item) - if issubclass(value, Node): + if issubclass(value, IsNodal): return partial(value, parent=self._parent) else: return value diff --git a/pyiron_contrib/workflow/workflow.py b/pyiron_contrib/workflow/workflow.py index 16b3e27da..ee6c49b28 100644 --- a/pyiron_contrib/workflow/workflow.py +++ b/pyiron_contrib/workflow/workflow.py @@ -13,7 +13,7 @@ if TYPE_CHECKING: - from pyiron_contrib.workflow.node import Node + from pyiron_contrib.workflow.is_nodal import IsNodal class Workflow(Composite): @@ -117,7 +117,7 @@ class Workflow(Composite): integrity of workflows when they're used somewhere else? """ - def __init__(self, label: str, *nodes: Node, strict_naming=True): + def __init__(self, label: str, *nodes: IsNodal, strict_naming=True): super().__init__(label=label, parent=None, strict_naming=strict_naming) for node in nodes: From 03f6cef8d40141801f6625f9395ca3d58a911c13 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Wed, 28 Jun 2023 13:53:44 -0700 Subject: [PATCH 39/81] Fix workflow tests --- tests/unit/workflow/test_workflow.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/unit/workflow/test_workflow.py b/tests/unit/workflow/test_workflow.py index 12a8b670c..2e060022b 100644 --- a/tests/unit/workflow/test_workflow.py +++ b/tests/unit/workflow/test_workflow.py @@ -131,6 +131,14 @@ def test_no_parents(self): with self.assertRaises(TypeError): # We currently specify workflows shouldn't get parents, this just verifies # the spec. If that spec changes, test instead that you _can_ set parents! + wf2.parent = "not None" + + with self.assertRaises(AttributeError): + # Setting a non-None value to parent raises the type error above + # If that value is further a nodal object, the __setattr__ definition + # takes over, and we try to add it to the nodes, but there we will run into + # the fact you can't add a node to a taken attribute label + # In both cases, we satisfy the spec that workflow's can't have parents wf2.parent = wf From 6d05ff4883796256f9f69537544f86126fb7c409 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Wed, 28 Jun 2023 14:01:58 -0700 Subject: [PATCH 40/81] Fix some more hints --- pyiron_contrib/workflow/composite.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/pyiron_contrib/workflow/composite.py b/pyiron_contrib/workflow/composite.py index a20193fcb..8f61bb976 100644 --- a/pyiron_contrib/workflow/composite.py +++ b/pyiron_contrib/workflow/composite.py @@ -48,16 +48,19 @@ class Composite(IsNodal, ABC): requirement is still passed on to children. Attributes: - nodes (DotDict[Node]): The owned nodes that form the composite subgraph. + nodes (DotDict[pyiron_contrib.workflow.is_nodal,IsNodal]): The owned nodes that + form the composite subgraph. strict_naming (bool): When true, repeated assignment of a new node to an existing node label will raise an error, otherwise the label gets appended with an index and the assignment proceeds. (Default is true: disallow assigning to existing labels.) add (NodeAdder): A tool for adding new nodes to this subgraph. - upstream_nodes (list[Node]): All the owned nodes that have output connections - but no input connections, i.e. the upstream-most nodes. - starting_nodes (None | list[Node]): A subset of the owned nodes to be used on - running. (Default is None, running falls back on using the `upstream_nodes`.) + upstream_nodes (list[pyiron_contrib.workflow.is_nodal,IsNodal]): All the owned + nodes that have output connections but no input connections, i.e. the + upstream-most nodes. + starting_nodes (None | list[pyiron_contrib.workflow.is_nodal,IsNodal]): A subset + of the owned nodes to be used on running. (Default is None, running falls back + on using the `upstream_nodes`.) Methods: add(node: Node): Add the node instance to this subgraph. @@ -106,7 +109,7 @@ def add_node(self, node: IsNodal, label: Optional[str] = None) -> None: Assign a node to the parent. Optionally provide a new label for that node. Args: - node (pyiron_contrib.workflow.node.Node): The node to add. + node (pyiron_contrib.workflow.is_nodal.IsNodal): The node to add. label (Optional[str]): The label for this node. Raises: From aad1c1adedaca614696ccd001b2c40728c8018d6 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Wed, 28 Jun 2023 14:09:48 -0700 Subject: [PATCH 41/81] Fix some more hints --- pyiron_contrib/workflow/composite.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyiron_contrib/workflow/composite.py b/pyiron_contrib/workflow/composite.py index 8f61bb976..52f4e8efc 100644 --- a/pyiron_contrib/workflow/composite.py +++ b/pyiron_contrib/workflow/composite.py @@ -233,10 +233,10 @@ def __getattribute__(self, key): return partial(Node, parent=self._parent) return value - def __call__(self, node: Node): + def __call__(self, node: IsNodal): return self._parent.add_node(node) - def register_nodes(self, domain: str, *nodes: list[type[Node]]): + def register_nodes(self, domain: str, *nodes: list[type[IsNodal]]): """ Add a list of node classes to be accessible for creation under the provided domain name. From b77952d2cfed6f1914b0952dca95df51b960ed27 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Wed, 28 Jun 2023 14:14:11 -0700 Subject: [PATCH 42/81] Rename Node to Function --- pyiron_contrib/workflow/composite.py | 8 ++-- .../workflow/{node.py => function.py} | 28 ++++++------- .../workflow/node_library/atomistics.py | 2 +- .../workflow/node_library/standard.py | 2 +- pyiron_contrib/workflow/workflow.py | 18 ++++---- .../{test_node.py => test_function.py} | 42 +++++++++---------- tests/unit/workflow/test_workflow.py | 38 ++++++++--------- 7 files changed, 69 insertions(+), 69 deletions(-) rename pyiron_contrib/workflow/{node.py => function.py} (97%) rename tests/unit/workflow/{test_node.py => test_function.py} (91%) diff --git a/pyiron_contrib/workflow/composite.py b/pyiron_contrib/workflow/composite.py index 52f4e8efc..77d72f396 100644 --- a/pyiron_contrib/workflow/composite.py +++ b/pyiron_contrib/workflow/composite.py @@ -11,7 +11,7 @@ from warnings import warn from pyiron_contrib.workflow.is_nodal import IsNodal -from pyiron_contrib.workflow.node import Node, node, fast_node, single_value_node +from pyiron_contrib.workflow.function import Function, node, fast_node, single_value_node from pyiron_contrib.workflow.node_library import atomistics, standard from pyiron_contrib.workflow.node_library.package import NodePackage from pyiron_contrib.workflow.util import DotDict @@ -225,12 +225,12 @@ def __init__(self, parent: Composite): self.register_nodes("atomistics", *atomistics.nodes) self.register_nodes("standard", *standard.nodes) - Node = Node + Function = Function def __getattribute__(self, key): value = super().__getattribute__(key) - if value == Node: - return partial(Node, parent=self._parent) + if value == Function: + return partial(Function, parent=self._parent) return value def __call__(self, node: IsNodal): diff --git a/pyiron_contrib/workflow/node.py b/pyiron_contrib/workflow/function.py similarity index 97% rename from pyiron_contrib/workflow/node.py rename to pyiron_contrib/workflow/function.py index 3951def51..a38c9c06c 100644 --- a/pyiron_contrib/workflow/node.py +++ b/pyiron_contrib/workflow/function.py @@ -15,7 +15,7 @@ from pyiron_contrib.workflow.workflow import Workflow -class Node(IsNodal): +class Function(IsNodal): """ Nodes have input and output data channels that interface with the outside world, and a callable that determines what they actually compute. After running, their output @@ -96,12 +96,12 @@ class Node(IsNodal): Examples: At the most basic level, to use nodes all we need to do is provide the `Node` class with a function and labels for its output, like so: - >>> from pyiron_contrib.workflow.node import Node + >>> from pyiron_contrib.workflow.node import Function >>> >>> def mwe(x, y): ... return x+1, y-1 >>> - >>> plus_minus_1 = Node(mwe, "p1", "m1") + >>> plus_minus_1 = Function(mwe, "p1", "m1") >>> >>> print(plus_minus_1.outputs.p1) None @@ -131,7 +131,7 @@ class with a function and labels for its output, like so: {'p1': 3, 'm1': 2} We can also, optionally, provide initial values for some or all of the input - >>> plus_minus_1 = Node( + >>> plus_minus_1 = Function( ... mwe, "p1", "m1", ... x=1, ... run_on_updates=True @@ -142,7 +142,7 @@ class with a function and labels for its output, like so: Finally, we might want the node to be ready-to-go right after instantiation. To do this, we need to provide initial values for everything and set two flags: - >>> plus_minus_1 = Node( + >>> plus_minus_1 = Function( ... mwe, "p1", "m1", ... x=0, y=0, ... run_on_updates=True, update_on_instantiation=True @@ -160,7 +160,7 @@ class with a function and labels for its output, like so: Thus, the second solution is to ensure that _all_ the arguments of our function are receiving good enough initial values to facilitate an execution of the node function at the end of instantiation: - >>> plus_minus_1 = Node(mwe, "p1", "m1", x=1, y=2) + >>> plus_minus_1 = Function(mwe, "p1", "m1", x=1, y=2) >>> >>> print(plus_minus_1.outputs.to_value_dict()) {'p1': 2, 'm1': 1} @@ -182,7 +182,7 @@ class with a function and labels for its output, like so: ... ) -> tuple[int, int | float]: ... return x+1, y-1 >>> - >>> plus_minus_1 = Node( + >>> plus_minus_1 = Function( ... hinted_example, "p1", "m1", ... run_on_updates=True, update_on_instantiation=True ... ) @@ -239,7 +239,7 @@ class with a function and labels for its output, like so: The first is to override the `__init__` method directly: >>> from typing import Literal, Optional >>> - >>> class AlphabetModThree(Node): + >>> class AlphabetModThree(Function): ... def __init__( ... self, ... label: Optional[str] = None, @@ -267,13 +267,13 @@ class with a function and labels for its output, like so: afterwards because we were accessing it through self). >>> from functools import partialmethod >>> - >>> class Adder(Node): + >>> class Adder(Function): ... @staticmethod ... def adder(x: int = 0, y: int = 0) -> int: ... return x + y ... ... __init__ = partialmethod( - ... Node.__init__, + ... Function.__init__, ... adder, ... "sum", ... run_on_updates=True, @@ -522,7 +522,7 @@ def to_dict(self): } -class FastNode(Node): +class FastNode(Function): """ Like a regular node, but _all_ input channels _must_ have default values provided, and the initialization signature forces `run_on_updates` and @@ -630,7 +630,7 @@ def node(*output_labels: str, **node_class_kwargs): Decorates a function. Takes an output label for each returned value of the function. - Returns a `Node` subclass whose name is the camel-case version of the function node, + Returns a `Function` subclass whose name is the camel-case version of the function node, and whose signature is modified to exclude the node function and output labels (which are explicitly defined in the process of using the decorator). """ @@ -638,10 +638,10 @@ def node(*output_labels: str, **node_class_kwargs): def as_node(node_function: callable): return type( node_function.__name__.title().replace("_", ""), # fnc_name to CamelCase - (Node,), # Define parentage + (Function,), # Define parentage { "__init__": partialmethod( - Node.__init__, + Function.__init__, node_function, *output_labels, **node_class_kwargs, diff --git a/pyiron_contrib/workflow/node_library/atomistics.py b/pyiron_contrib/workflow/node_library/atomistics.py index 781d039b4..874ffae91 100644 --- a/pyiron_contrib/workflow/node_library/atomistics.py +++ b/pyiron_contrib/workflow/node_library/atomistics.py @@ -7,7 +7,7 @@ from pyiron_atomistics.atomistics.structure.atoms import Atoms from pyiron_atomistics.lammps.lammps import Lammps as LammpsJob -from pyiron_contrib.workflow.node import node, single_value_node +from pyiron_contrib.workflow.function import node, single_value_node @single_value_node("structure") diff --git a/pyiron_contrib/workflow/node_library/standard.py b/pyiron_contrib/workflow/node_library/standard.py index 9020bba0d..a920e2538 100644 --- a/pyiron_contrib/workflow/node_library/standard.py +++ b/pyiron_contrib/workflow/node_library/standard.py @@ -5,7 +5,7 @@ import numpy as np from matplotlib import pyplot as plt -from pyiron_contrib.workflow.node import single_value_node +from pyiron_contrib.workflow.function import single_value_node @single_value_node("fig") diff --git a/pyiron_contrib/workflow/workflow.py b/pyiron_contrib/workflow/workflow.py index ee6c49b28..0b14b01a6 100644 --- a/pyiron_contrib/workflow/workflow.py +++ b/pyiron_contrib/workflow/workflow.py @@ -32,18 +32,18 @@ class Workflow(Composite): Examples: We allow adding nodes to workflows in five equivalent ways: >>> from pyiron_contrib.workflow.workflow import Workflow - >>> from pyiron_contrib.workflow.node import Node + >>> from pyiron_contrib.workflow.node import Function >>> >>> def fnc(x=0): return x + 1 >>> - >>> n1 = Node(fnc, "x", label="n1") + >>> n1 = Function(fnc, "x", label="n1") >>> >>> wf = Workflow("my_workflow", n1) # As *args at instantiation - >>> wf.add(Node(fnc, "x", label="n2")) # Passing a node to the add caller - >>> wf.add.Node(fnc, "y", label="n3") # Instantiating from add - >>> wf.n4 = Node(fnc, "y", label="whatever_n4_gets_used") + >>> wf.add(Function(fnc, "x", label="n2")) # Passing a node to the add caller + >>> wf.add.Function(fnc, "y", label="n3") # Instantiating from add + >>> wf.n4 = Function(fnc, "y", label="whatever_n4_gets_used") >>> # By attribute assignment - >>> Node(fnc, "x", label="n5", parent=wf) + >>> Function(fnc, "x", label="n5", parent=wf) >>> # By instantiating the node with a workflow By default, the node naming scheme is strict, so if you try to add a node to a @@ -52,9 +52,9 @@ class Workflow(Composite): bool to this property. When deactivated, repeated assignments to the same label just get appended with an index: >>> wf.deactivate_strict_naming() - >>> wf.my_node = Node(fnc, "y", x=0) - >>> wf.my_node = Node(fnc, "y", x=1) - >>> wf.my_node = Node(fnc, "y", x=2) + >>> wf.my_node = Function(fnc, "y", x=0) + >>> wf.my_node = Function(fnc, "y", x=1) + >>> wf.my_node = Function(fnc, "y", x=2) >>> print(wf.my_node.inputs.x, wf.my_node0.inputs.x, wf.my_node1.inputs.x) 0, 1, 2 diff --git a/tests/unit/workflow/test_node.py b/tests/unit/workflow/test_function.py similarity index 91% rename from tests/unit/workflow/test_node.py rename to tests/unit/workflow/test_function.py index d3e9c960a..6963af100 100644 --- a/tests/unit/workflow/test_node.py +++ b/tests/unit/workflow/test_function.py @@ -4,8 +4,8 @@ import warnings from pyiron_contrib.workflow.files import DirectoryObject -from pyiron_contrib.workflow.node import ( - FastNode, Node, SingleValueNode, node, single_value_node +from pyiron_contrib.workflow.function import ( + FastNode, Function, SingleValueNode, node, single_value_node ) @@ -22,19 +22,19 @@ def no_default(x, y): @unittest.skipUnless(version_info[0] == 3 and version_info[1] >= 10, "Only supported for 3.10+") -class TestNode(unittest.TestCase): +class TestFunction(unittest.TestCase): def test_defaults(self): - Node(plus_one, "y") + Function(plus_one, "y") def test_failure_without_output_labels(self): with self.assertRaises( ValueError, msg="Instantiated nodes should demand at least one output label" ): - Node(plus_one) + Function(plus_one) def test_instantiation_update(self): - no_update = Node( + no_update = Function( plus_one, "y", run_on_updates=True, @@ -42,7 +42,7 @@ def test_instantiation_update(self): ) self.assertIsNone(no_update.outputs.y.value) - update = Node( + update = Function( plus_one, "y", run_on_updates=True, @@ -51,16 +51,16 @@ def test_instantiation_update(self): self.assertEqual(2, update.outputs.y.value) with self.assertRaises(TypeError): - run_without_value = Node(no_default, "z") + run_without_value = Function(no_default, "z") run_without_value.run() # None + None + 1 -> error with self.assertRaises(TypeError): - run_without_value = Node(no_default, "z", x=1) + run_without_value = Function(no_default, "z", x=1) run_without_value.run() # 1 + None + 1 -> error - deferred_update = Node(no_default, "z", x=1, y=1) + deferred_update = Function(no_default, "z", x=1, y=1) deferred_update.run() self.assertEqual( deferred_update.outputs.z.value, @@ -70,7 +70,7 @@ def test_instantiation_update(self): ) def test_input_kwargs(self): - node = Node( + node = Function( plus_one, "y", x=2, @@ -79,12 +79,12 @@ def test_input_kwargs(self): ) self.assertEqual(3, node.outputs.y.value, msg="Initialize from value") - node2 = Node(plus_one, "y", x=node.outputs.y, run_on_updates=True) + node2 = Function(plus_one, "y", x=node.outputs.y, run_on_updates=True) node.update() self.assertEqual(4, node2.outputs.y.value, msg="Initialize from connection") def test_automatic_updates(self): - node = Node(throw_error, "no_return", run_on_updates=True) + node = Function(throw_error, "no_return", run_on_updates=True) with self.subTest("Shouldn't run for invalid input on update"): node.inputs.x.update("not an int") @@ -120,7 +120,7 @@ def times_two(y): ) def test_statuses(self): - n = Node(plus_one, "p1") + n = Function(plus_one, "p1") self.assertTrue(n.ready) self.assertFalse(n.running) self.assertFalse(n.failed) @@ -172,7 +172,7 @@ def with_self(self, x: float) -> float: self.some_counter = 1 return x + 0.1 - node = Node(with_self, "output") + node = Function(with_self, "output") self.assertTrue( "x" in node.inputs.labels, msg=f"Expected to find function input 'x' in the node input but got " @@ -193,14 +193,14 @@ def with_self(self, x: float) -> float: self.assertEqual( node.some_counter, 1, - msg="Node functions should be able to modify attributes on the node object." + msg="Function functions should be able to modify attributes on the node object." ) def with_messed_self(x: float, self) -> float: return x + 0.1 with warnings.catch_warnings(record=True) as warning_list: - node = Node(with_messed_self, "output") + node = Function(with_messed_self, "output") self.assertTrue("self" in node.inputs.labels) self.assertEqual(len(warning_list), 1) @@ -279,12 +279,12 @@ def test_str(self): str(svn).endswith(str(svn.single_value)), msg="SingleValueNodes should have their output as a string in their string " "representation (e.g., perhaps with a reminder note that this is " - "actually still a Node and not just the value you're seeing.)" + "actually still a Function and not just the value you're seeing.)" ) def test_easy_output_connection(self): svn = SingleValueNode(plus_one, "y") - regular = Node(plus_one, "y") + regular = Function(plus_one, "y") regular.inputs.x = svn @@ -301,7 +301,7 @@ def test_easy_output_connection(self): "case default->plus_one->plus_one = 1 + 1 +1 = 3" ) - at_instantiation = Node(plus_one, "y", x=svn) + at_instantiation = Function(plus_one, "y", x=svn) self.assertIn( svn.outputs.y, at_instantiation.inputs.x.connections, msg="The parsing of SingleValueNode output as a connection should also work" @@ -361,7 +361,7 @@ def my_node(x: int = 0, y: int = 0, z: int = 0): ) def test_working_directory(self): - n_f = Node(plus_one, "output") + n_f = Function(plus_one, "output") self.assertTrue(n_f._working_directory is None) self.assertIsInstance(n_f.working_directory, DirectoryObject) self.assertTrue(str(n_f.working_directory.path).endswith(n_f.label)) diff --git a/tests/unit/workflow/test_workflow.py b/tests/unit/workflow/test_workflow.py index 2e060022b..b1fcb18b1 100644 --- a/tests/unit/workflow/test_workflow.py +++ b/tests/unit/workflow/test_workflow.py @@ -2,7 +2,7 @@ from sys import version_info from pyiron_contrib.workflow.files import DirectoryObject -from pyiron_contrib.workflow.node import Node +from pyiron_contrib.workflow.function import Function from pyiron_contrib.workflow.workflow import Workflow @@ -17,10 +17,10 @@ def test_node_addition(self): wf = Workflow("my_workflow") # Validate the four ways to add a node - wf.add(Node(fnc, "x", label="foo")) - wf.add.Node(fnc, "y", label="bar") - wf.baz = Node(fnc, "y", label="whatever_baz_gets_used") - Node(fnc, "x", label="qux", parent=wf) + wf.add(Function(fnc, "x", label="foo")) + wf.add.Function(fnc, "y", label="bar") + wf.baz = Function(fnc, "y", label="whatever_baz_gets_used") + Function(fnc, "x", label="qux", parent=wf) self.assertListEqual(list(wf.nodes.keys()), ["foo", "bar", "baz", "qux"]) wf.boa = wf.qux self.assertListEqual( @@ -31,14 +31,14 @@ def test_node_addition(self): wf.strict_naming = False # Validate name incrementation - wf.add(Node(fnc, "x", label="foo")) - wf.add.Node(fnc, "y", label="bar") - wf.baz = Node( + wf.add(Function(fnc, "x", label="foo")) + wf.add.Function(fnc, "y", label="bar") + wf.baz = Function( fnc, "y", label="without_strict_you_can_override_by_assignment" ) - Node(fnc, "x", label="boa", parent=wf) + Function(fnc, "x", label="boa", parent=wf) self.assertListEqual( list(wf.nodes.keys()), [ @@ -50,16 +50,16 @@ def test_node_addition(self): wf.strict_naming = True # Validate name preservation with self.assertRaises(AttributeError): - wf.add(Node(fnc, "x", label="foo")) + wf.add(Function(fnc, "x", label="foo")) with self.assertRaises(AttributeError): - wf.add.Node(fnc, "y", label="bar") + wf.add.Function(fnc, "y", label="bar") with self.assertRaises(AttributeError): - wf.baz = Node(fnc, "y", label="whatever_baz_gets_used") + wf.baz = Function(fnc, "y", label="whatever_baz_gets_used") with self.assertRaises(AttributeError): - Node(fnc, "x", label="boa", parent=wf) + Function(fnc, "x", label="boa", parent=wf) def test_node_packages(self): wf = Workflow("my_workflow") @@ -78,8 +78,8 @@ def test_node_packages(self): def test_double_workfloage_and_node_removal(self): wf1 = Workflow("one") - wf1.add.Node(fnc, "y", label="node1") - node2 = Node(fnc, "y", label="node2", parent=wf1, x=wf1.node1.outputs.y) + wf1.add.Function(fnc, "y", label="node1") + node2 = Function(fnc, "y", label="node2", parent=wf1, x=wf1.node1.outputs.y) self.assertTrue(node2.connected) wf2 = Workflow("two") @@ -93,9 +93,9 @@ def test_double_workfloage_and_node_removal(self): def test_workflow_io(self): wf = Workflow("wf") - wf.add.Node(fnc, "y", label="n1") - wf.add.Node(fnc, "y", label="n2") - wf.add.Node(fnc, "y", label="n3") + wf.add.Function(fnc, "y", label="n1") + wf.add.Function(fnc, "y", label="n2") + wf.add.Function(fnc, "y", label="n3") with self.subTest("Workflow IO should be drawn from its nodes"): self.assertEqual(len(wf.inputs), 3) @@ -120,7 +120,7 @@ def test_working_directory(self): self.assertTrue(wf._working_directory is None) self.assertIsInstance(wf.working_directory, DirectoryObject) self.assertTrue(str(wf.working_directory.path).endswith(wf.label)) - wf.add.Node(fnc, "output") + wf.add.Function(fnc, "output") self.assertTrue(str(wf.fnc.working_directory.path).endswith(wf.fnc.label)) wf.working_directory.delete() From cf03a5201016e57b0adb8f0b0da0acd904666256 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Wed, 28 Jun 2023 14:16:59 -0700 Subject: [PATCH 43/81] Rename FastNode to Fast --- notebooks/workflow_example.ipynb | 4 ++-- pyiron_contrib/workflow/function.py | 12 ++++++------ tests/unit/workflow/test_function.py | 6 +++--- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/notebooks/workflow_example.ipynb b/notebooks/workflow_example.ipynb index 9d5febfbe..31c8928ac 100644 --- a/notebooks/workflow_example.ipynb +++ b/notebooks/workflow_example.ipynb @@ -399,7 +399,7 @@ "source": [ "## Special nodes\n", "\n", - "In addition to the basic `Node` class, for the sake of convenience we also offer `FastNode(Node)` -- which enforces that all the node function inputs are type-hinted and have defaults, then sets `run_on_updates=True` and `update_on_instantiation=True` --, and `SingleValueNode(FastNode)` -- which further enforces that there is only a _single_ return value to the node function (i.e. a single output label), and then lets attribute and item access fall back to looking for attributes and items of this single output value. Of course there are decorators available for both of these.\n", + "In addition to the basic `Node` class, for the sake of convenience we also offer `Fast(Node)` -- which enforces that all the node function inputs are type-hinted and have defaults, then sets `run_on_updates=True` and `update_on_instantiation=True` --, and `SingleValueNode(Fast)` -- which further enforces that there is only a _single_ return value to the node function (i.e. a single output label), and then lets attribute and item access fall back to looking for attributes and items of this single output value. Of course there are decorators available for both of these.\n", "\n", "Let's look at a use case:" ] @@ -736,7 +736,7 @@ "\n", "Currently we have a handfull of pre-build nodes available for import from the `nodes` package. Let's use these to quickly put together a workflow for looking at some MD data.\n", "\n", - "The `calc_md`, node is _not_ at `FastNode`, but we happen to know that the calculation we're doing here is very easy, so we'll set `run_on_updates` and `update_at_instantiation` to `True`.\n", + "The `calc_md`, node is _not_ at `Fast`, but we happen to know that the calculation we're doing here is very easy, so we'll set `run_on_updates` and `update_at_instantiation` to `True`.\n", "\n", "Finally, `SingleValueNode` has one more piece of syntactic sugar: when you're making a connection to the (single!) output channel, you can just pass the node itself!" ] diff --git a/pyiron_contrib/workflow/function.py b/pyiron_contrib/workflow/function.py index a38c9c06c..528f86595 100644 --- a/pyiron_contrib/workflow/function.py +++ b/pyiron_contrib/workflow/function.py @@ -197,7 +197,7 @@ class with a function and labels for its output, like so: >>> plus_minus_1.outputs.to_value_dict() {'p1': 2, 'm1': 0} - Note: the `FastNode(Node)` child class will enforce all function arguments to + Note: the `Fast(Node)` child class will enforce all function arguments to be type-hinted and have defaults, and will automatically set the updating and instantiation flags to `True` for nodes that execute quickly and are meant to _always_ have good output data. @@ -522,7 +522,7 @@ def to_dict(self): } -class FastNode(Function): +class Fast(Function): """ Like a regular node, but _all_ input channels _must_ have default values provided, and the initialization signature forces `run_on_updates` and @@ -564,7 +564,7 @@ def ensure_params_have_defaults(cls, fnc: callable) -> None: ) -class SingleValueNode(FastNode, HasChannel): +class SingleValueNode(Fast, HasChannel): """ A fast node that _must_ return only a single value. @@ -660,13 +660,13 @@ def fast_node(*output_labels: str, **node_class_kwargs): """ def as_fast_node(node_function: callable): - FastNode.ensure_params_have_defaults(node_function) + Fast.ensure_params_have_defaults(node_function) return type( node_function.__name__.title().replace("_", ""), # fnc_name to CamelCase - (FastNode,), # Define parentage + (Fast,), # Define parentage { "__init__": partialmethod( - FastNode.__init__, + Fast.__init__, node_function, *output_labels, **node_class_kwargs, diff --git a/tests/unit/workflow/test_function.py b/tests/unit/workflow/test_function.py index 6963af100..98d3580a3 100644 --- a/tests/unit/workflow/test_function.py +++ b/tests/unit/workflow/test_function.py @@ -5,7 +5,7 @@ from pyiron_contrib.workflow.files import DirectoryObject from pyiron_contrib.workflow.function import ( - FastNode, Function, SingleValueNode, node, single_value_node + Fast, Function, SingleValueNode, node, single_value_node ) @@ -209,10 +209,10 @@ def with_messed_self(x: float, self) -> float: @unittest.skipUnless(version_info[0] == 3 and version_info[1] >= 10, "Only supported for 3.10+") class TestFastNode(unittest.TestCase): def test_instantiation(self): - has_defaults_is_ok = FastNode(plus_one, "y") + has_defaults_is_ok = Fast(plus_one, "y") with self.assertRaises(ValueError): - missing_defaults_should_fail = FastNode(no_default, "z") + missing_defaults_should_fail = Fast(no_default, "z") @unittest.skipUnless(version_info[0] == 3 and version_info[1] >= 10, "Only supported for 3.10+") From 8be12636e55dfde56bab507f06a3a14885bc5ddb Mon Sep 17 00:00:00 2001 From: liamhuber Date: Wed, 28 Jun 2023 14:18:26 -0700 Subject: [PATCH 44/81] Rename SingleValueNode to SingleValue --- notebooks/workflow_example.ipynb | 4 ++-- pyiron_contrib/workflow/function.py | 10 +++++----- tests/unit/workflow/test_function.py | 18 +++++++++--------- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/notebooks/workflow_example.ipynb b/notebooks/workflow_example.ipynb index 31c8928ac..4c7e7093d 100644 --- a/notebooks/workflow_example.ipynb +++ b/notebooks/workflow_example.ipynb @@ -399,7 +399,7 @@ "source": [ "## Special nodes\n", "\n", - "In addition to the basic `Node` class, for the sake of convenience we also offer `Fast(Node)` -- which enforces that all the node function inputs are type-hinted and have defaults, then sets `run_on_updates=True` and `update_on_instantiation=True` --, and `SingleValueNode(Fast)` -- which further enforces that there is only a _single_ return value to the node function (i.e. a single output label), and then lets attribute and item access fall back to looking for attributes and items of this single output value. Of course there are decorators available for both of these.\n", + "In addition to the basic `Node` class, for the sake of convenience we also offer `Fast(Node)` -- which enforces that all the node function inputs are type-hinted and have defaults, then sets `run_on_updates=True` and `update_on_instantiation=True` --, and `SingleValue(Fast)` -- which further enforces that there is only a _single_ return value to the node function (i.e. a single output label), and then lets attribute and item access fall back to looking for attributes and items of this single output value. Of course there are decorators available for both of these.\n", "\n", "Let's look at a use case:" ] @@ -738,7 +738,7 @@ "\n", "The `calc_md`, node is _not_ at `Fast`, but we happen to know that the calculation we're doing here is very easy, so we'll set `run_on_updates` and `update_at_instantiation` to `True`.\n", "\n", - "Finally, `SingleValueNode` has one more piece of syntactic sugar: when you're making a connection to the (single!) output channel, you can just pass the node itself!" + "Finally, `SingleValue` has one more piece of syntactic sugar: when you're making a connection to the (single!) output channel, you can just pass the node itself!" ] }, { diff --git a/pyiron_contrib/workflow/function.py b/pyiron_contrib/workflow/function.py index 528f86595..b34806e99 100644 --- a/pyiron_contrib/workflow/function.py +++ b/pyiron_contrib/workflow/function.py @@ -564,7 +564,7 @@ def ensure_params_have_defaults(cls, fnc: callable) -> None: ) -class SingleValueNode(Fast, HasChannel): +class SingleValue(Fast, HasChannel): """ A fast node that _must_ return only a single value. @@ -685,14 +685,14 @@ def single_value_node(*output_labels: str, **node_class_kwargs): """ def as_single_value_node(node_function: callable): - SingleValueNode.ensure_there_is_only_one_return_value(output_labels) - SingleValueNode.ensure_params_have_defaults(node_function) + SingleValue.ensure_there_is_only_one_return_value(output_labels) + SingleValue.ensure_params_have_defaults(node_function) return type( node_function.__name__.title().replace("_", ""), # fnc_name to CamelCase - (SingleValueNode,), # Define parentage + (SingleValue,), # Define parentage { "__init__": partialmethod( - SingleValueNode.__init__, + SingleValue.__init__, node_function, *output_labels, **node_class_kwargs, diff --git a/tests/unit/workflow/test_function.py b/tests/unit/workflow/test_function.py index 98d3580a3..0e438b7eb 100644 --- a/tests/unit/workflow/test_function.py +++ b/tests/unit/workflow/test_function.py @@ -5,7 +5,7 @@ from pyiron_contrib.workflow.files import DirectoryObject from pyiron_contrib.workflow.function import ( - Fast, Function, SingleValueNode, node, single_value_node + Fast, Function, SingleValue, node, single_value_node ) @@ -218,10 +218,10 @@ def test_instantiation(self): @unittest.skipUnless(version_info[0] == 3 and version_info[1] >= 10, "Only supported for 3.10+") class TestSingleValueNode(unittest.TestCase): def test_instantiation(self): - has_defaults_and_one_return = SingleValueNode(plus_one, "y") + has_defaults_and_one_return = SingleValue(plus_one, "y") with self.assertRaises(ValueError): - too_many_labels = SingleValueNode(plus_one, "z", "excess_label") + too_many_labels = SingleValue(plus_one, "z", "excess_label") def test_item_and_attribute_access(self): class Foo: @@ -237,7 +237,7 @@ def __getitem__(self, item): def returns_foo() -> Foo: return Foo() - svn = SingleValueNode(returns_foo, "foo") + svn = SingleValue(returns_foo, "foo") self.assertEqual( svn.some_attribute, @@ -267,14 +267,14 @@ def returns_foo() -> Foo: ) def test_repr(self): - svn = SingleValueNode(plus_one, "y") + svn = SingleValue(plus_one, "y") self.assertEqual( svn.__repr__(), svn.outputs.y.value.__repr__(), msg="SingleValueNodes should have their output as their representation" ) def test_str(self): - svn = SingleValueNode(plus_one, "y") + svn = SingleValue(plus_one, "y") self.assertTrue( str(svn).endswith(str(svn.single_value)), msg="SingleValueNodes should have their output as a string in their string " @@ -283,7 +283,7 @@ def test_str(self): ) def test_easy_output_connection(self): - svn = SingleValueNode(plus_one, "y") + svn = SingleValue(plus_one, "y") regular = Function(plus_one, "y") regular.inputs.x = svn @@ -297,14 +297,14 @@ def test_easy_output_connection(self): regular.run() self.assertEqual( regular.outputs.y.value, 3, - msg="SingleValueNode connections should pass data just like usual; in this " + msg="SingleValue connections should pass data just like usual; in this " "case default->plus_one->plus_one = 1 + 1 +1 = 3" ) at_instantiation = Function(plus_one, "y", x=svn) self.assertIn( svn.outputs.y, at_instantiation.inputs.x.connections, - msg="The parsing of SingleValueNode output as a connection should also work" + msg="The parsing of SingleValue output as a connection should also work" "from assignment at instantiation" ) From 3751e2d1e8d15c602d4174209be20da29513746e Mon Sep 17 00:00:00 2001 From: liamhuber Date: Wed, 28 Jun 2023 14:19:23 -0700 Subject: [PATCH 45/81] Update test class names --- tests/unit/workflow/test_function.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/workflow/test_function.py b/tests/unit/workflow/test_function.py index 0e438b7eb..571dc3034 100644 --- a/tests/unit/workflow/test_function.py +++ b/tests/unit/workflow/test_function.py @@ -207,7 +207,7 @@ def with_messed_self(x: float, self) -> float: @unittest.skipUnless(version_info[0] == 3 and version_info[1] >= 10, "Only supported for 3.10+") -class TestFastNode(unittest.TestCase): +class TestFast(unittest.TestCase): def test_instantiation(self): has_defaults_is_ok = Fast(plus_one, "y") @@ -216,7 +216,7 @@ def test_instantiation(self): @unittest.skipUnless(version_info[0] == 3 and version_info[1] >= 10, "Only supported for 3.10+") -class TestSingleValueNode(unittest.TestCase): +class TestSingleValue(unittest.TestCase): def test_instantiation(self): has_defaults_and_one_return = SingleValue(plus_one, "y") From 1b049f7a6cf34eaed8ede4fb656e696bc00a7f9b Mon Sep 17 00:00:00 2001 From: liamhuber Date: Wed, 28 Jun 2023 14:24:57 -0700 Subject: [PATCH 46/81] Rename the decorator --- pyiron_contrib/workflow/composite.py | 4 ++-- pyiron_contrib/workflow/function.py | 2 +- pyiron_contrib/workflow/node_library/atomistics.py | 6 +++--- tests/unit/workflow/test_function.py | 6 +++--- tests/unit/workflow/test_node_package.py | 2 +- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/pyiron_contrib/workflow/composite.py b/pyiron_contrib/workflow/composite.py index 77d72f396..9dae3de4e 100644 --- a/pyiron_contrib/workflow/composite.py +++ b/pyiron_contrib/workflow/composite.py @@ -11,7 +11,7 @@ from warnings import warn from pyiron_contrib.workflow.is_nodal import IsNodal -from pyiron_contrib.workflow.function import Function, node, fast_node, single_value_node +from pyiron_contrib.workflow.function import Function, function_node, fast_node, single_value_node from pyiron_contrib.workflow.node_library import atomistics, standard from pyiron_contrib.workflow.node_library.package import NodePackage from pyiron_contrib.workflow.util import DotDict @@ -20,7 +20,7 @@ class _NodeDecoratorAccess: """An intermediate container to store node-creating decorators as class methods.""" - node = node + function_node = function_node fast_node = fast_node single_value_node = single_value_node diff --git a/pyiron_contrib/workflow/function.py b/pyiron_contrib/workflow/function.py index b34806e99..3aaa952d5 100644 --- a/pyiron_contrib/workflow/function.py +++ b/pyiron_contrib/workflow/function.py @@ -624,7 +624,7 @@ def __str__(self): ) -def node(*output_labels: str, **node_class_kwargs): +def function_node(*output_labels: str, **node_class_kwargs): """ A decorator for dynamically creating node classes from functions. diff --git a/pyiron_contrib/workflow/node_library/atomistics.py b/pyiron_contrib/workflow/node_library/atomistics.py index 874ffae91..e4fdd5a6c 100644 --- a/pyiron_contrib/workflow/node_library/atomistics.py +++ b/pyiron_contrib/workflow/node_library/atomistics.py @@ -7,7 +7,7 @@ from pyiron_atomistics.atomistics.structure.atoms import Atoms from pyiron_atomistics.lammps.lammps import Lammps as LammpsJob -from pyiron_contrib.workflow.function import node, single_value_node +from pyiron_contrib.workflow.function import function_node, single_value_node @single_value_node("structure") @@ -81,7 +81,7 @@ def _run_and_remove_job(job, modifier: Optional[callable] = None, **modifier_kwa ) -@node( +@function_node( "cells", "displacements", "energy_pot", @@ -103,7 +103,7 @@ def calc_static( return _run_and_remove_job(job=job) -@node( +@function_node( "cells", "displacements", "energy_pot", diff --git a/tests/unit/workflow/test_function.py b/tests/unit/workflow/test_function.py index 571dc3034..ffe03a964 100644 --- a/tests/unit/workflow/test_function.py +++ b/tests/unit/workflow/test_function.py @@ -5,7 +5,7 @@ from pyiron_contrib.workflow.files import DirectoryObject from pyiron_contrib.workflow.function import ( - Fast, Function, SingleValue, node, single_value_node + Fast, Function, SingleValue, function_node, single_value_node ) @@ -94,11 +94,11 @@ def test_automatic_updates(self): node.inputs.x.update(1) def test_signals(self): - @node("y") + @function_node("y") def linear(x): return x - @node("z") + @function_node("z") def times_two(y): return 2 * y diff --git a/tests/unit/workflow/test_node_package.py b/tests/unit/workflow/test_node_package.py index f90394eeb..5ee86fb75 100644 --- a/tests/unit/workflow/test_node_package.py +++ b/tests/unit/workflow/test_node_package.py @@ -41,7 +41,7 @@ def test_update(self): with self.assertRaises(TypeError): self.package.available_name = "But we can still only assign node classes" - @Workflow.wrap_as.node("y") + @Workflow.wrap_as.function_node("y") def add(x: int = 0): return x + 1 From 0a08a9fd6c8857b3b8dcffee656fcee4a1b25666 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Wed, 28 Jun 2023 14:28:17 -0700 Subject: [PATCH 47/81] Update docstrings --- pyiron_contrib/workflow/function.py | 14 +++++++------- pyiron_contrib/workflow/workflow.py | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/pyiron_contrib/workflow/function.py b/pyiron_contrib/workflow/function.py index 3aaa952d5..fbb32b745 100644 --- a/pyiron_contrib/workflow/function.py +++ b/pyiron_contrib/workflow/function.py @@ -17,9 +17,9 @@ class Function(IsNodal): """ - Nodes have input and output data channels that interface with the outside world, and - a callable that determines what they actually compute. After running, their output - channels are updated with the results of the node's computation, which + Function nodes have input and output data channels that interface with the outside + world, and a callable that determines what they actually compute. After running, + their output channels are updated with the results of the node's computation, which triggers downstream node updates if those output channels are connected to other input channels. @@ -39,7 +39,7 @@ class Function(IsNodal): Nodes won't update themselves while setting inputs to initial values, but can optionally update themselves at the end instantiation. - Nodes must be instantiated with a callable to deterimine their function, and an + Nodes must be instantiated with a callable to deterimine their function, and a strings to name each returned value of that callable. (If you really want to return a tuple, just have multiple return values but only one output label -- there is currently no way to mix-and-match, i.e. to have multiple return values at least one @@ -96,7 +96,7 @@ class Function(IsNodal): Examples: At the most basic level, to use nodes all we need to do is provide the `Node` class with a function and labels for its output, like so: - >>> from pyiron_contrib.workflow.node import Function + >>> from pyiron_contrib.workflow.function import Function >>> >>> def mwe(x, y): ... return x+1, y-1 @@ -213,9 +213,9 @@ class with a function and labels for its output, like so: This can be done most easily with the `node` decorator, which takes a function and returns a node class: - >>> from pyiron_contrib.workflow.node import node + >>> from pyiron_contrib.workflow.function import function_node >>> - >>> @node( + >>> @function_node( ... "p1", "m1", ... run_on_updates=True, update_on_instantiation=True ... ) diff --git a/pyiron_contrib/workflow/workflow.py b/pyiron_contrib/workflow/workflow.py index 0b14b01a6..e3e19f718 100644 --- a/pyiron_contrib/workflow/workflow.py +++ b/pyiron_contrib/workflow/workflow.py @@ -32,7 +32,7 @@ class Workflow(Composite): Examples: We allow adding nodes to workflows in five equivalent ways: >>> from pyiron_contrib.workflow.workflow import Workflow - >>> from pyiron_contrib.workflow.node import Function + >>> from pyiron_contrib.workflow.function import Function >>> >>> def fnc(x=0): return x + 1 >>> From 2c44a10e6704ddffd9dac0d2f79c7227d5e2271a Mon Sep 17 00:00:00 2001 From: liamhuber Date: Wed, 28 Jun 2023 14:32:49 -0700 Subject: [PATCH 48/81] Rename IsNodal to Node --- pyiron_contrib/workflow/channels.py | 18 ++++----- pyiron_contrib/workflow/composite.py | 40 +++++++++---------- pyiron_contrib/workflow/function.py | 4 +- .../workflow/{is_nodal.py => node.py} | 2 +- .../workflow/node_library/package.py | 10 ++--- pyiron_contrib/workflow/workflow.py | 4 +- 6 files changed, 39 insertions(+), 39 deletions(-) rename pyiron_contrib/workflow/{is_nodal.py => node.py} (99%) diff --git a/pyiron_contrib/workflow/channels.py b/pyiron_contrib/workflow/channels.py index f106ad29e..754ca4337 100644 --- a/pyiron_contrib/workflow/channels.py +++ b/pyiron_contrib/workflow/channels.py @@ -32,7 +32,7 @@ ) if typing.TYPE_CHECKING: - from pyiron_contrib.workflow.is_nodal import IsNodal + from pyiron_contrib.workflow.node import Node class Channel(HasChannel, HasToDict, ABC): @@ -51,7 +51,7 @@ class Channel(HasChannel, HasToDict, ABC): Attributes: label (str): The name of the channel. - node (pyiron_contrib.workflow.is_nodal.IsNodal): The node to which the channel + node (pyiron_contrib.workflow.node.Node): The node to which the channel belongs. connections (list[Channel]): Other channels to which this channel is connected. """ @@ -59,18 +59,18 @@ class Channel(HasChannel, HasToDict, ABC): def __init__( self, label: str, - node: IsNodal, + node: Node, ): """ Make a new channel. Args: label (str): A name for the channel. - node (pyiron_contrib.workflow.is_nodal.IsNodal): The node to which the + node (pyiron_contrib.workflow.node.Node): The node to which the channel belongs. """ self.label: str = label - self.node: IsNodal = node + self.node: Node = node self.connections: list[Channel] = [] @abstractmethod @@ -181,7 +181,7 @@ class DataChannel(Channel, ABC): def __init__( self, label: str, - node: IsNodal, + node: Node, default: typing.Optional[typing.Any] = None, type_hint: typing.Optional[typing.Any] = None, ): @@ -313,7 +313,7 @@ class InputData(DataChannel): def __init__( self, label: str, - node: IsNodal, + node: Node, default: typing.Optional[typing.Any] = None, type_hint: typing.Optional[typing.Any] = None, strict_connections: bool = True, @@ -448,7 +448,7 @@ class InputSignal(SignalChannel): def __init__( self, label: str, - node: IsNodal, + node: Node, callback: callable, ): """ @@ -456,7 +456,7 @@ def __init__( Args: label (str): A name for the channel. - node (pyiron_contrib.workflow.is_nodal.IsNodal): The node to which the + node (pyiron_contrib.workflow.node.Node): The node to which the channel belongs. callback (callable): An argument-free callback to invoke when calling this object. diff --git a/pyiron_contrib/workflow/composite.py b/pyiron_contrib/workflow/composite.py index 9dae3de4e..d5b13a897 100644 --- a/pyiron_contrib/workflow/composite.py +++ b/pyiron_contrib/workflow/composite.py @@ -10,7 +10,7 @@ from typing import Optional from warnings import warn -from pyiron_contrib.workflow.is_nodal import IsNodal +from pyiron_contrib.workflow.node import Node from pyiron_contrib.workflow.function import Function, function_node, fast_node, single_value_node from pyiron_contrib.workflow.node_library import atomistics, standard from pyiron_contrib.workflow.node_library.package import NodePackage @@ -25,7 +25,7 @@ class _NodeDecoratorAccess: single_value_node = single_value_node -class Composite(IsNodal, ABC): +class Composite(Node, ABC): """ A base class for nodes that have internal structure -- i.e. they hold a sub-graph. @@ -48,17 +48,17 @@ class Composite(IsNodal, ABC): requirement is still passed on to children. Attributes: - nodes (DotDict[pyiron_contrib.workflow.is_nodal,IsNodal]): The owned nodes that + nodes (DotDict[pyiron_contrib.workflow.node,Node]): The owned nodes that form the composite subgraph. strict_naming (bool): When true, repeated assignment of a new node to an existing node label will raise an error, otherwise the label gets appended with an index and the assignment proceeds. (Default is true: disallow assigning to existing labels.) add (NodeAdder): A tool for adding new nodes to this subgraph. - upstream_nodes (list[pyiron_contrib.workflow.is_nodal,IsNodal]): All the owned + upstream_nodes (list[pyiron_contrib.workflow.node,Node]): All the owned nodes that have output connections but no input connections, i.e. the upstream-most nodes. - starting_nodes (None | list[pyiron_contrib.workflow.is_nodal,IsNodal]): A subset + starting_nodes (None | list[pyiron_contrib.workflow.node,Node]): A subset of the owned nodes to be used on running. (Default is None, running falls back on using the `upstream_nodes`.) @@ -81,9 +81,9 @@ def __init__( ): super().__init__(*args, label=label, parent=parent, **kwargs) self.strict_naming: bool = strict_naming - self.nodes: DotDict[str: IsNodal] = DotDict() + self.nodes: DotDict[str: Node] = DotDict() self.add: NodeAdder = NodeAdder(self) - self.starting_nodes: None | list[IsNodal] = None + self.starting_nodes: None | list[Node] = None def to_dict(self): return { @@ -92,7 +92,7 @@ def to_dict(self): } @property - def upstream_nodes(self) -> list[IsNodal]: + def upstream_nodes(self) -> list[Node]: return [ node for node in self.nodes.values() if node.outputs.connected and not node.inputs.connected @@ -104,18 +104,18 @@ def on_run(self): for node in starting_nodes: node.run() - def add_node(self, node: IsNodal, 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. Args: - node (pyiron_contrib.workflow.is_nodal.IsNodal): The node to add. + node (pyiron_contrib.workflow.node.Node): The node to add. label (Optional[str]): The label for this node. Raises: TypeError: If the """ - if not isinstance(node, IsNodal): + if not isinstance(node, Node): raise TypeError( f"Only new node instances may be added, but got {type(node)}." ) @@ -130,7 +130,7 @@ def add_node(self, node: IsNodal, label: Optional[str] = None) -> None: def _get_unique_label(self, label): if label in self.__dir__(): - if isinstance(getattr(self, label), IsNodal): + if isinstance(getattr(self, label), Node): if self.strict_naming: raise AttributeError( f"{label} is already the label for a node. Please remove it " @@ -158,7 +158,7 @@ def _add_suffix_to_label(self, label): ) return new_label - def _ensure_node_has_no_other_parent(self, node: IsNodal): + def _ensure_node_has_no_other_parent(self, node: Node): if node.parent is not None and node.parent is not self: raise ValueError( f"The node ({node.label}) already belongs to the parent " @@ -166,7 +166,7 @@ def _ensure_node_has_no_other_parent(self, node: IsNodal): f"add it to this parent ({self.label})." ) - def _ensure_node_is_not_duplicated(self, node: IsNodal, label: str): + def _ensure_node_is_not_duplicated(self, node: Node, label: str): if ( node.parent is self and label != node.label @@ -178,16 +178,16 @@ def _ensure_node_is_not_duplicated(self, node: IsNodal, label: str): ) del self.nodes[node.label] - def remove(self, node: IsNodal | str): - if isinstance(node, IsNodal): + def remove(self, node: Node | str): + if isinstance(node, Node): node.parent = None node.disconnect() del self.nodes[node.label] else: del self.nodes[node] - def __setattr__(self, label: str, node: IsNodal): - if isinstance(node, IsNodal): + def __setattr__(self, label: str, node: Node): + if isinstance(node, Node): self.add_node(node, label=label) else: super().__setattr__(label, node) @@ -233,10 +233,10 @@ def __getattribute__(self, key): return partial(Function, parent=self._parent) return value - def __call__(self, node: IsNodal): + def __call__(self, node: Node): return self._parent.add_node(node) - def register_nodes(self, domain: str, *nodes: list[type[IsNodal]]): + def register_nodes(self, domain: str, *nodes: list[type[Node]]): """ Add a list of node classes to be accessible for creation under the provided domain name. diff --git a/pyiron_contrib/workflow/function.py b/pyiron_contrib/workflow/function.py index fbb32b745..62f475c7d 100644 --- a/pyiron_contrib/workflow/function.py +++ b/pyiron_contrib/workflow/function.py @@ -8,14 +8,14 @@ from pyiron_contrib.workflow.channels import InputData, OutputData from pyiron_contrib.workflow.has_channel import HasChannel from pyiron_contrib.workflow.io import Inputs, Outputs, Signals -from pyiron_contrib.workflow.is_nodal import IsNodal +from pyiron_contrib.workflow.node import Node if TYPE_CHECKING: from pyiron_contrib.workflow.composite import Composite from pyiron_contrib.workflow.workflow import Workflow -class Function(IsNodal): +class Function(Node): """ Function nodes have input and output data channels that interface with the outside world, and a callable that determines what they actually compute. After running, diff --git a/pyiron_contrib/workflow/is_nodal.py b/pyiron_contrib/workflow/node.py similarity index 99% rename from pyiron_contrib/workflow/is_nodal.py rename to pyiron_contrib/workflow/node.py index 564d19ee4..1b90f315f 100644 --- a/pyiron_contrib/workflow/is_nodal.py +++ b/pyiron_contrib/workflow/node.py @@ -19,7 +19,7 @@ from pyiron_contrib.workflow.io import Inputs, Outputs -class IsNodal(HasToDict, ABC): +class Node(HasToDict, ABC): """ A mixin class for objects that can form nodes in the graph representation of a computational workflow. diff --git a/pyiron_contrib/workflow/node_library/package.py b/pyiron_contrib/workflow/node_library/package.py index 74484242d..748e4ed75 100644 --- a/pyiron_contrib/workflow/node_library/package.py +++ b/pyiron_contrib/workflow/node_library/package.py @@ -3,7 +3,7 @@ from functools import partial from typing import TYPE_CHECKING -from pyiron_contrib.workflow.is_nodal import IsNodal +from pyiron_contrib.workflow.node import Node from pyiron_contrib.workflow.util import DotDict if TYPE_CHECKING: @@ -21,7 +21,7 @@ class NodePackage(DotDict): but to update an existing node the `update` method must be used. """ - def __init__(self, parent: Composite, *node_classes: IsNodal): + def __init__(self, parent: Composite, *node_classes: Node): super().__init__() self.__dict__["_parent"] = parent # Avoid the __setattr__ override for node in node_classes: @@ -35,16 +35,16 @@ def __setitem__(self, key, value): f"The name {key} is already an attribute of this " f"{self.__class__.__name__} instance." ) - if not isinstance(value, type) or not issubclass(value, IsNodal): + if not isinstance(value, type) or not issubclass(value, Node): raise TypeError( - f"Can only set members that are (sub)classes of {IsNodal.__name__}, " + f"Can only set members that are (sub)classes of {Node.__name__}, " f"but got {type(value)}" ) super().__setitem__(key, value) def __getitem__(self, item): value = super().__getitem__(item) - if issubclass(value, IsNodal): + if issubclass(value, Node): return partial(value, parent=self._parent) else: return value diff --git a/pyiron_contrib/workflow/workflow.py b/pyiron_contrib/workflow/workflow.py index e3e19f718..92c9249f1 100644 --- a/pyiron_contrib/workflow/workflow.py +++ b/pyiron_contrib/workflow/workflow.py @@ -13,7 +13,7 @@ if TYPE_CHECKING: - from pyiron_contrib.workflow.is_nodal import IsNodal + from pyiron_contrib.workflow.node import Node class Workflow(Composite): @@ -117,7 +117,7 @@ class Workflow(Composite): integrity of workflows when they're used somewhere else? """ - def __init__(self, label: str, *nodes: IsNodal, strict_naming=True): + def __init__(self, label: str, *nodes: Node, strict_naming=True): super().__init__(label=label, parent=None, strict_naming=strict_naming) for node in nodes: From 3fb23b365e10f989158b8c500780cf7b3376b148 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Wed, 28 Jun 2023 14:53:43 -0700 Subject: [PATCH 49/81] Update the demo notebook Including a couple of small fixes that are unrelated and needed to be done, but didn't result in errors so hadn't been caught by the CI: Changing the `workflow` kwarg to `parent` in a node instantiation, and updating the description of how things are (or aren't) automatically updated in cell 17. --- notebooks/workflow_example.ipynb | 108 +++++++++---------------------- 1 file changed, 32 insertions(+), 76 deletions(-) diff --git a/notebooks/workflow_example.ipynb b/notebooks/workflow_example.ipynb index 4c7e7093d..2d7107e30 100644 --- a/notebooks/workflow_example.ipynb +++ b/notebooks/workflow_example.ipynb @@ -6,30 +6,10 @@ "id": "8dee8129-6b23-4abf-90d2-217d71b8ba7a", "metadata": {}, "outputs": [ - { - "data": { - "text/html": [ - "
\n"
-      ],
-      "text/plain": []
-     },
-     "metadata": {},
-     "output_type": "display_data"
-    },
-    {
-     "data": {
-      "text/html": [
-       "
\n"
-      ],
-      "text/plain": []
-     },
-     "metadata": {},
-     "output_type": "display_data"
-    },
     {
      "data": {
       "application/vnd.jupyter.widget-view+json": {
-       "model_id": "0116bf172477443ea1eebe5c8c1a8704",
+       "model_id": "ee8645d92ea44a7aa8583a924cd3a804",
        "version_major": 2,
        "version_minor": 0
       },
@@ -37,17 +17,10 @@
      },
      "metadata": {},
      "output_type": "display_data"
-    },
-    {
-     "name": "stderr",
-     "output_type": "stream",
-     "text": [
-      "* Owlready2 * Warning: optimized Cython parser module 'owlready2_optimized' is not available, defaulting to slower Python implementation\n"
-     ]
     }
    ],
    "source": [
-    "from pyiron_contrib.workflow.node import Node"
+    "from pyiron_contrib.workflow.function import Function"
    ]
   },
   {
@@ -74,7 +47,7 @@
    "source": [
     "## Instantiating a node\n",
     "\n",
-    "Nodes can be defined on-the-fly by passing any callable to the `Node` class, along with a string (tuple of strings) giving names for the output value(s)."
+    "Simple nodes can be defined on-the-fly by passing any callable to the `Function(Node)` class, along with a string (tuple of strings) giving names for the output value(s)."
    ]
   },
   {
@@ -87,7 +60,7 @@
     "def plus_minus_one(x):\n",
     "    return x+1, x-1\n",
     "\n",
-    "pm_node = Node(plus_minus_one, \"p1\", \"m1\")"
+    "pm_node = Function(plus_minus_one, \"p1\", \"m1\")"
    ]
   },
   {
@@ -177,7 +150,7 @@
     "def adder(x: int, y: int = 1) -> int:\n",
     "    return x + y\n",
     "\n",
-    "adder_node = Node(adder, \"sum\", run_on_updates=True, update_on_instantiation=True)\n",
+    "adder_node = Function(adder, \"sum\", run_on_updates=True, update_on_instantiation=True)\n",
     "adder_node.inputs.x = 1\n",
     "adder_node.outputs.sum.value  # We use `value` to see the data the channel holds"
    ]
@@ -251,9 +224,9 @@
    "source": [
     "## Reusable node classes\n",
     "\n",
-    "If we're going to use a node many times, we may want to define a new sub-class of `Node` to handle this.\n",
+    "If we're going to use a node many times, we may want to define a new sub-class of `Function` to handle this.\n",
     "\n",
-    "The can be done directly by inheriting from `Node` and overriding it's `__init__` function so that the core functionality of the node (i.e. the node function and output labels) are set in stone, but even easier is to use the `node` decorator to do this for you!\n",
+    "The can be done directly by inheriting from `Function` and overriding it's `__init__` function so that the core functionality of the node (i.e. the node function and output labels) are set in stone, but even easier is to use the `function_node` decorator to do this for you!\n",
     "\n",
     "The decorator takes the output labels and whatever other class kwargs you want to override, and the function is defined like any other node function:"
    ]
@@ -265,7 +238,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "from pyiron_contrib.workflow.node import node"
+    "from pyiron_contrib.workflow.function import function_node"
    ]
   },
   {
@@ -285,7 +258,7 @@
     }
    ],
    "source": [
-    "@node(\"diff\", run_on_updates=True, update_on_instantiation=True)\n",
+    "@function_node(\"diff\", run_on_updates=True, update_on_instantiation=True)\n",
     "def subtract_node(x: int | float = 2, y: int | float = 1) -> int | float:\n",
     "    return x - y\n",
     "\n",
@@ -351,7 +324,7 @@
     }
    ],
    "source": [
-    "@node(\"sum\", run_on_updates=True, update_on_instantiation=True)\n",
+    "@function_node(\"sum\", run_on_updates=True, update_on_instantiation=True)\n",
     "def add_node(x: int | float = 1, y: int | float = 1) -> int | float:\n",
     "    return x + y\n",
     "\n",
@@ -399,7 +372,7 @@
    "source": [
     "## Special nodes\n",
     "\n",
-    "In addition to the basic `Node` class, for the sake of convenience we also offer `Fast(Node)` -- which enforces that all the node function inputs are type-hinted and have defaults, then sets `run_on_updates=True` and `update_on_instantiation=True` --, and `SingleValue(Fast)` -- which further enforces that there is only a _single_ return value to the node function (i.e. a single output label), and then lets attribute and item access fall back to looking for attributes and items of this single output value. Of course there are decorators available for both of these.\n",
+    "In addition to the basic `Function` class, for the sake of convenience we also offer `Fast(Function)` -- which enforces that all the node function inputs are type-hinted and have defaults, then sets `run_on_updates=True` and `update_on_instantiation=True` --, and `SingleValue(Fast)` -- which further enforces that there is only a _single_ return value to the node function (i.e. a single output label), and then lets attribute and item access fall back to looking for attributes and items of this single output value. Of course there are decorators available for both of these.\n",
     "\n",
     "Let's look at a use case:"
    ]
@@ -412,7 +385,7 @@
    "outputs": [],
    "source": [
     "import numpy as np\n",
-    "from pyiron_contrib.workflow.node import single_value_node"
+    "from pyiron_contrib.workflow.function import single_value_node"
    ]
   },
   {
@@ -453,7 +426,7 @@
     "# Workflows\n",
     "\n",
     "Typically, you will have a group of nodes working together with their connections.\n",
-    "We call these groups workflows, and offer a `Workflow` object as a single point of entry -- i.e. most of the time you shouldn't need the node imports used above, because the decorators are available right on the workflow class."
+    "We call these groups workflows, and offer a `Workflow(Node)` object as a single point of entry -- i.e. most of the time you shouldn't need the node imports used above, because the decorators are available right on the workflow class."
    ]
   },
   {
@@ -492,29 +465,21 @@
      "output_type": "stream",
      "text": [
       "n1 n1 n1 (GreaterThanHalf) output single-value: False\n",
-      "n2 n2 \n",
+      "n2 n2 \n",
       "n3 n3 n3 (GreaterThanHalf) output single-value: False\n",
       "n4 n4 n4 (GreaterThanHalf) output single-value: False\n",
       "n5 n5 n5 (GreaterThanHalf) output single-value: False\n"
      ]
-    },
-    {
-     "name": "stderr",
-     "output_type": "stream",
-     "text": [
-      "/Users/huber/work/pyiron/pyiron_contrib/pyiron_contrib/workflow/workflow.py:187: UserWarning: Reassigning the node will_get_overwritten_with_n4 to the label n4 when adding it to the workflow my_wf.\n",
-      "  warn(\n"
-     ]
     }
    ],
    "source": [
     "n1 = greater_than_half(label=\"n1\")\n",
     "\n",
     "wf = Workflow(\"my_wf\", n1)  # As args at init\n",
-    "wf.add.Node(lambda: x + 1, \"p1\", label=\"n2\")  # Instantiating from the node adder\n",
+    "wf.add.Function(lambda: x + 1, \"p1\", label=\"n2\")  # Instantiating from the node adder\n",
     "wf.add(greater_than_half(label=\"n3\"))  # Instantiating then passing to node adder\n",
     "wf.n4 = greater_than_half(label=\"will_get_overwritten_with_n4\")  # Set attribute to instance\n",
-    "greater_than_half(label=\"n5\", workflow=wf)  # By passing the workflow to the node\n",
+    "greater_than_half(label=\"n5\", parent=wf)  # By passing the workflow to the node\n",
     "\n",
     "for k, v in wf.nodes.items():\n",
     "    print(k, v.label, v)"
@@ -546,31 +511,22 @@
      "name": "stdout",
      "output_type": "stream",
      "text": [
-      "None None\n"
-     ]
-    },
-    {
-     "name": "stderr",
-     "output_type": "stream",
-     "text": [
-      "/Users/huber/work/pyiron/pyiron_contrib/pyiron_contrib/workflow/node.py:341: UserWarning: The keyword 'run_automatically' was received but not used.\n",
-      "  warnings.warn(f\"The keyword '{k}' was received but not used.\")\n"
+      "1 None\n"
      ]
     }
    ],
    "source": [
-    "@node(\"y\")\n",
+    "@function_node(\"y\")\n",
     "def linear(x):\n",
     "    return x\n",
     "\n",
-    "@node(\"z\")\n",
+    "@function_node(\"z\")\n",
     "def times_two(y):\n",
     "    return 2 * y\n",
     "\n",
     "l = linear(x=1)\n",
-    "t2 = times_two(\n",
-    "    y=l.outputs.y, update_on_instantiation=False, run_automatically=False\n",
-    ")\n",
+    "l.run()\n",
+    "t2 = times_two(y=l.outputs.y)\n",
     "print(t2.inputs.y, t2.outputs.z)"
    ]
   },
@@ -579,7 +535,7 @@
    "id": "37aa4455-9b98-4be5-a365-363e3c490bb6",
    "metadata": {},
    "source": [
-    "Now the input of `t2` got updated when the connection is made, but we told this node not to do any automatic updates, so the output has its uninitialized value of `None`.\n",
+    "Now the input of `t2` got updated when the connection is made, but by default we told this node not to do any automatic updates, so the output has its uninitialized value of `None`.\n",
     "\n",
     "Often, you will probably want to have nodes with data connections to have signal connections, but this is not strictly required. Here, we'll introduce a (not strictly necessary) third node to control starting the workflow, and chain together to signals from our two functional nodes.\n",
     "\n",
@@ -601,7 +557,7 @@
     }
    ],
    "source": [
-    "@node(\"void\")\n",
+    "@function_node(\"void\")\n",
     "def control():\n",
     "    return\n",
     "\n",
@@ -638,7 +594,7 @@
    "outputs": [
     {
      "data": {
-      "image/png": "iVBORw0KGgoAAAANSUhEUgAAAiMAAAGgCAYAAAB45mdaAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjcuMSwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/bCgiHAAAACXBIWXMAAA9hAAAPYQGoP6dpAAAoD0lEQVR4nO3df1DV153/8dcFApdYuSka8SYgJTZRkeaHMBhgrdOqRJvadXe6oc1qYlZ3S9Y0MWwyI+NuCE5naH40MWmF1Y0kYzSG3ajbOCV2mdk0wdCtI+pOXdKYKl2IuYQBmwtpKjRwvn+48M0NYPhc7r2He3k+Zj5/3OP53Ps+vY335Tmfz/m4jDFGAAAAlsTZLgAAAExthBEAAGAVYQQAAFhFGAEAAFYRRgAAgFWEEQAAYBVhBAAAWEUYAQAAVhFGAACAVYQRAABgVVBhpLq6WllZWXK73crNzVVjY+Nl++/bt0833XSTrrzySnm9Xt1zzz3q7u4OqmAAABBbXE6fTVNXV6d169apurpaRUVF2rlzp5577jm1tLRozpw5I/ofPXpUS5cu1dNPP63Vq1fr/PnzKi0t1fXXX69Dhw6N6zMHBwf1/vvva/r06XK5XE7KBQAAlhhj1Nvbq2uuuUZxcZeZ/zAO5efnm9LS0oC2+fPnmy1btoza/4knnjDXXXddQNuzzz5r0tPTx/2Z7e3tRhIHBwcHBwdHFB7t7e2X/Z1PkAP9/f1qbm7Wli1bAtqLi4vV1NQ06jmFhYXaunWr6uvrtWrVKnV2duqVV17R7bffPubn9PX1qa+vb/i1+b/Jm/b2dqWkpDgpGQAAWNLT06OMjAxNnz79sv0chZGuri4NDAwoLS0toD0tLU0dHR2jnlNYWKh9+/appKREFy9e1CeffKJvfetb+vGPfzzm51RVVamysnJEe0pKCmEEAIAo83mXWAR1Aetn39QYM+YHtbS06P7779cjjzyi5uZmHTlyRK2trSotLR3z/cvLy+X3+4eP9vb2YMoEAABRwNHMyMyZMxUfHz9iFqSzs3PEbMmQqqoqFRUV6eGHH5Yk3XjjjZo2bZqWLFmiH/zgB/J6vSPOSUpKUlJSkpPSAABAlHI0M5KYmKjc3Fw1NDQEtDc0NKiwsHDUcz7++OMRV9DGx8dL+v/XggAAgKnL8TJNWVmZnnvuOdXW1urtt9/Wgw8+qLa2tuFll/Lyct11113D/VevXq2DBw+qpqZG586d01tvvaX7779f+fn5uuaaa0I3EgAAEJUcLdNIUklJibq7u7Vt2zb5fD7l5OSovr5emZmZkiSfz6e2trbh/uvXr1dvb69+8pOf6B/+4R901VVX6etf/7oee+yx0I0CAABELcebntnQ09Mjj8cjv9/P3TQAAESJ8f5+82waAABgFWEEAABY5fiakVgxMGh0rPWCOnsvatZ0t/KzUhUfx3NvAACItCkZRo6c9qnycIt8/ovDbV6PWxWrs7UyZ+S+JwAAIHym3DLNkdM+3bv3REAQkaQO/0Xdu/eEjpz2WaoMAICpaUqFkYFBo8rDLRrt9qGhtsrDLRoYnPQ3GAEAxmFg0OiXZ7v101Pn9cuz3fz9PklNqWWaY60XRsyIfJqR5PNf1LHWCyqYOyNyhQEAQo4l+egxpWZGOnvHDiLB9AMATE4syUeXKRVGZk13h7QfAGDyYUk++kypMJKflSqvx62xbuB16dIUXn5WaiTLAgCEkJMleUwOUyqMxMe5VLE6W5JGBJKh1xWrs9lvBACiGEvy0WdKhRFJWpnjVc3aRZrtCVyKme1xq2btIi5qAoAox5J89JlSd9MMWZnj1Yrs2ezACgAxaGhJvsN/cdTrRly69A9QluQnjykZRqRLSzbcvgsAsWdoSf7evSfkkgICCUvyk9OUW6YBAMQ+luSjy5SdGQEAxDaW5KMHYQQAELNYko8OLNMAAACrCCMAAMAqwggAALCKMAIAAKwijAAAAKsIIwAAwCrCCAAAsIowAgAArCKMAAAAqwgjAADAKsIIAACwijACAACsIowAAACreGovgClhYNDwKHlgkiKMAIh5R077VHm4RT7/xeE2r8etitXZWpnjtVgZAIllGgAx7shpn+7deyIgiEhSh/+i7t17QkdO+yxVBmAIYQRAzBoYNKo83CIzyp8NtVUebtHA4Gg9AEQKYQRAzDrWemHEjMinGUk+/0Uda70QuaIAjEAYARCzOnvHDiLB9AMQHoQRADFr1nR3SPsBCA/CCICYlZ+VKq/HrbFu4HXp0l01+VmpkSwLwGcQRgDErPg4lypWZ0vSiEAy9LpidTb7jQCWEUaAzzEwaPTLs9366anz+uXZbu68iDIrc7yqWbtIsz2BSzGzPW7VrF3EPiPAJMCmZ8BlsFlWbFiZ49WK7NnswApMUi5jzKT/Z15PT488Ho/8fr9SUlJsl4MpYmizrM/+BzL088W/qgHg8sb7+80yDTAKNssCgMghjACjYLMsAIicoMJIdXW1srKy5Ha7lZubq8bGxjH7rl+/Xi6Xa8SxcOHCoIsGwo3NsgAgchyHkbq6Om3evFlbt27VyZMntWTJEq1atUptbW2j9n/mmWfk8/mGj/b2dqWmpuqv/uqvJlw8EC5slgUAkeM4jDz11FPasGGDNm7cqAULFmj79u3KyMhQTU3NqP09Ho9mz549fBw/fly///3vdc8990y4eCBc2CwLACLHURjp7+9Xc3OziouLA9qLi4vV1NQ0rvfYvXu3li9frszMzDH79PX1qaenJ+AAIonNsgAgchyFka6uLg0MDCgtLS2gPS0tTR0dHZ97vs/n02uvvaaNGzdetl9VVZU8Hs/wkZGR4aRMICTYLAsAIiOoTc9crsB/DRpjRrSN5oUXXtBVV12lNWvWXLZfeXm5ysrKhl/39PQQSGAFm2UBQPg5CiMzZ85UfHz8iFmQzs7OEbMln2WMUW1trdatW6fExMTL9k1KSlJSUpKT0oCwiY9zqWDuDNtlAEDMcrRMk5iYqNzcXDU0NAS0NzQ0qLCw8LLnvvHGG/rtb3+rDRs2OK8SAADELMfLNGVlZVq3bp3y8vJUUFCgXbt2qa2tTaWlpZIuLbGcP39ee/bsCThv9+7dWrx4sXJyckJTOQAAiAmOw0hJSYm6u7u1bds2+Xw+5eTkqL6+fvjuGJ/PN2LPEb/frwMHDuiZZ54JTdUAACBm8KA8AAAQFjwoDwAARAXCCAAAsIowAgAArCKMAAAAqwgjAADAKsIIAACwijACAACsIowAAACrCCMAAMAqwggAALCKMAIAAKwijAAAAKsIIwAAwCrCCAAAsIowAgAArCKMAAAAqwgjAADAKsIIAACwijACAACsIowAAACrCCMAAMAqwggAALCKMAIAAKwijAAAAKsIIwAAwCrCCAAAsIowAgAArCKMAAAAqwgjAADAKsIIAACwijACAACsIowAAACrCCMAAMAqwggAALCKMAIAAKwijAAAAKsIIwAAwCrCCAAAsIowAgAArCKMAAAAqwgjAADAKsIIAACwijACAACsIowAAACrggoj1dXVysrKktvtVm5urhobGy/bv6+vT1u3blVmZqaSkpI0d+5c1dbWBlUwAACILQlOT6irq9PmzZtVXV2toqIi7dy5U6tWrVJLS4vmzJkz6jl33HGHPvjgA+3evVtf/vKX1dnZqU8++WTCxQMAgOjnMsYYJycsXrxYixYtUk1NzXDbggULtGbNGlVVVY3of+TIEX3nO9/RuXPnlJqaGlSRPT098ng88vv9SklJCeo9AABAZI3399vRMk1/f7+am5tVXFwc0F5cXKympqZRz3n11VeVl5enxx9/XNdee61uuOEGPfTQQ/rjH/845uf09fWpp6cn4AAAALHJ0TJNV1eXBgYGlJaWFtCelpamjo6OUc85d+6cjh49KrfbrUOHDqmrq0t///d/rwsXLox53UhVVZUqKyudlAYAAKJUUBewulyugNfGmBFtQwYHB+VyubRv3z7l5+frG9/4hp566im98MILY86OlJeXy+/3Dx/t7e3BlAkAAKKAo5mRmTNnKj4+fsQsSGdn54jZkiFer1fXXnutPB7PcNuCBQtkjNF7772n66+/fsQ5SUlJSkpKclIaAACIUo5mRhITE5Wbm6uGhoaA9oaGBhUWFo56TlFRkd5//3199NFHw21nzpxRXFyc0tPTgygZAADEEsfLNGVlZXruuedUW1urt99+Ww8++KDa2tpUWloq6dISy1133TXc/84779SMGTN0zz33qKWlRW+++aYefvhh/c3f/I2Sk5NDNxIAABCVHO8zUlJSou7ubm3btk0+n085OTmqr69XZmamJMnn86mtrW24/xe+8AU1NDTo+9//vvLy8jRjxgzdcccd+sEPfhC6UQAAgKjleJ8RG9hnBACA6BOWfUYAAABCjTACAACsIowAAACrCCMAAMAqwggAALCKMAIAAKwijAAAAKsIIwAAwCrCCAAAsIowAgAArCKMAAAAqwgjAADAKsIIAACwijACAACsIowAAACrCCMAAMAqwggAALCKMAIAAKwijAAAAKsIIwAAwCrCCAAAsIowAgAArCKMAAAAqwgjAADAKsIIAACwijACAACsIowAAACrCCMAAMAqwggAALCKMAIAAKwijAAAAKsIIwAAwCrCCAAAsIowAgAArEqwXQAuGRg0OtZ6QZ29FzVrulv5WamKj3PZLgsAgLAjjEwCR077VHm4RT7/xeE2r8etitXZWpnjtVgZAADhxzKNZUdO+3Tv3hMBQUSSOvwXde/eEzpy2mepMgAAIoMwYtHAoFHl4RaZUf5sqK3ycIsGBkfrAQBAbCCMWHSs9cKIGZFPM5J8/os61nohckUBABBhhBGLOnvHDiLB9AMAIBoRRiyaNd0d0n4AAEQjwohF+Vmp8nrcGusGXpcu3VWTn5UaybIAAIgowohF8XEuVazOlqQRgWTodcXqbPYbAQDENMKIZStzvKpZu0izPYFLMbM9btWsXcQ+IwCAmBfUpmfV1dV64okn5PP5tHDhQm3fvl1LliwZte8vfvELfe1rXxvR/vbbb2v+/PnBfHzMWZnj1Yrs2ezACgCYkhyHkbq6Om3evFnV1dUqKirSzp07tWrVKrW0tGjOnDljnvfOO+8oJSVl+PXVV18dXMUxKj7OpYK5M2yXAQBAxDlepnnqqae0YcMGbdy4UQsWLND27duVkZGhmpqay543a9YszZ49e/iIj48PumgAABA7HIWR/v5+NTc3q7i4OKC9uLhYTU1Nlz33lltukdfr1bJly/T6669ftm9fX596enoCDgAAEJschZGuri4NDAwoLS0toD0tLU0dHR2jnuP1erVr1y4dOHBABw8e1Lx587Rs2TK9+eabY35OVVWVPB7P8JGRkeGkTAAAEEWCuoDV5Qq8sNIYM6JtyLx58zRv3rzh1wUFBWpvb9eTTz6pr371q6OeU15errKysuHXPT09BBIAAGKUo5mRmTNnKj4+fsQsSGdn54jZksu59dZb9e67747550lJSUpJSQk4AABAbHIURhITE5Wbm6uGhoaA9oaGBhUWFo77fU6ePCmvl/0zAABAEMs0ZWVlWrdunfLy8lRQUKBdu3apra1NpaWlki4tsZw/f1579uyRJG3fvl1f+tKXtHDhQvX392vv3r06cOCADhw4ENqRAACAqOQ4jJSUlKi7u1vbtm2Tz+dTTk6O6uvrlZmZKUny+Xxqa2sb7t/f36+HHnpI58+fV3JyshYuXKif/exn+sY3vhG6UQAAgKjlMsYY20V8np6eHnk8Hvn9fq4fAQAgSoz395tn0wAAAKsIIwAAwCrCCAAAsIowAgAArCKMAAAAqwgjAADAKsIIAACwijACAACsIowAAACrCCMAAMAqwggAALCKMAIAAKwijAAAAKsIIwAAwCrCCAAAsIowAgAArCKMAAAAqwgjAADAKsIIAACwijACAACsIowAAACrCCMAAMAqwggAALCKMAIA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rTUpKSlm69attoYQFKfj/qxovJvG6Ziffvppc+jQIXPmzBlz+vRps2XLFiPJHDhwwNYQHHM65t7eXpOenm6+/e1vm//5n/8xb7zxhrn++uvNxo0bbQ0hKMH+/3vt2rVm8eLFkS43JJyO+fnnnzcJCQmmurranD171hw9etTk5eWZ/Px8W0NwzOmY33nnHfPiiy+aM2fOmF/96lempKTEpKammtbWVksjcGbKhBFjjNmxY4fJzMw0iYmJZtGiReaNN94Y/rO7777bLF26dPj10qVLjaQRx9133x35wifAyZifffZZs3DhQnPllVealJQUc8stt5jq6mozMDBgofKJcTLuz4rGMGKMszE/9thjZu7cucbtdpsvfvGL5s/+7M/Mz372MwtVT4zT7/ntt982y5cvN8nJySY9Pd2UlZWZjz/+OMJVT5zTcX/44YcmOTnZ7Nq1K8KVho7TMT/77LMmOzvbJCcnG6/Xa/76r//avPfeexGuemKcjLmlpcXcfPPNJjk52aSkpJg///M/N7/5zW8sVB0clzFRMGcFAABiVhQsJAEAgFhGGAEAAFYRRgAAgFWEEQAAYBVhBAAAWEUYAQAAVhFGAACAVYQRAABgFWEEAABYRRgBAABWEUYAAIBV/w+VpjKACTa2QwAAAABJRU5ErkJggg==",
+      "image/png": "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Fenw4cMTT8cEg8FJe46EQiEdPHhQ3/3ud+emagAAsGA43mfEBvYZAQAg8czLPiMAAABzjTACAACsIowAAACrCCMAAMAqwggAALCKMAIAAKwijAAAAKsIIwAAwCrCCAAAsIowAgAArCKMAAAAqwgjAADAKsIIAACwijACAACsIowAAACrCCMAAMAqwggAALCKMAIAAKwijAAAAKsIIwAAwCrCCAAAsIowAgAArCKMAAAAqwgjAADAKsIIAACwijACAACsIowAAACrCCMAAMAqwggAALCKMAIAAKwijAAAAKsIIwAAwCrCCAAAsIowAgAArCKMAAAAqwgjAADAKsIIAACwijACAACsIowAAACrCCMAAMAqwggAALBqVmGkqalJ+fn58ng88vv9amlpOWf/kZER7dixQ3l5eUpPT9eKFSu0d+/eWRUMAAAWllSnAw4cOKCamho1NTVp9erVeuihh7R+/Xp1dXXp0ksvnXLMzTffrHfeeUePPvqoPv7xj6u/v1/vv//+eRcPAAASn8sYY5wMKC0tVXFxsZqbmyfaCgoKtGHDBjU2Nk7qf+TIEX3xi1/UW2+9pSVLlsyqyHA4LK/Xq1AopMzMzFn9DgDA3IqMGbV1n1b/0LCWLfZoVf4SuVNctsv6SIladyKa6fnb0crI6Oio2tvbtX379qj2iooKtba2TjnmmWeeUUlJie677z499thjWrRokT772c/q7//+75WRkeHk7QFgxjjhzK8jnUE1HOpSMDQ80ebzelRfWah1RT6LlZ1bota90DkKIwMDA4pEIsrOzo5qz87OVl9f35Rj3nrrLR09elQej0dPP/20BgYG9LWvfU2nT5+e9r6RkZERjYyMTLwOh8NOygSQ5DjhzK8jnUFt239cH15W7wsNa9v+42reVByX85yodSeDWd3A6nJF/9+FMWZS27ixsTG5XC49/vjjWrVqlT796U9r165d+sEPfqDf/e53U45pbGyU1+udOHJzc2dTJoAkNH7COTuISP93wjnSGbRU2cIQGTNqONQ16YQuaaKt4VCXImOO7gCYd4lad7JwFEaysrLkdrsnrYL09/dPWi0Z5/P5dMkll8jr9U60FRQUyBijt99+e8oxdXV1CoVCE0dvb6+TMgEkKU4486+t+/SkoHc2IykYGlZb9+nYFTUDiVp3snAURtLS0uT3+xUIBKLaA4GA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SUlRTk6O7TLiUmZm5oL9I55LzNPMME8zwzzNDPM0Mwt9ns61IjKOG1gBAIBVhBEAAGAVYSRBpaenq76+Xunp6bZLiWvM08wwTzPDPM0M8zQzzNP/SYgbWAEAwMLFyggAALCKMAIAAKwijAAAAKsIIwAAwCrCSBxrampSfn6+PB6P/H6/Wlpapu0bDAa1ceNGrVy5UikpKaqpqYldoZY5maennnpKN910k/7wD/9QmZmZKisr07/8y7/EsFp7nMzT0aNHtXr1ai1dulQZGRm68sor9cADD8SwWnuczNPZXnrpJaWmpurqq6+e3wLjhJN5ev755+VyuSYdv/rVr2JYcew5/VsaGRnRjh07lJeXp/T0dK1YsUJ79+6NUbWWGcSlH/3oR+aCCy4wDz/8sOnq6jJ33nmnWbRokfnv//7vKft3d3ebO+64w/zwhz80V199tbnzzjtjW7AlTufpzjvvNPfee69pa2szJ06cMHV1deaCCy4wx48fj3HlseV0no4fP26eeOIJ09nZabq7u81jjz1mLrzwQvPQQw/FuPLYcjpP437729+aP/qjPzIVFRXmk5/8ZGyKtcjpPD333HNGknn99ddNMBicON5///0YVx47s/lb+uxnP2tKS0tNIBAw3d3d5t///d/NSy+9FMOq7SGMxKlVq1aZ6urqqLYrr7zSbN++/SPHXn/99UkTRs5nnsYVFhaahoaGuS4trszFPH3uc58zmzZtmuvS4sps56mqqsrcddddpr6+PinCiNN5Gg8jv/nNb2JQXXxwOke/+MUvjNfrNYODg7EoL+5wmSYOjY6Oqr29XRUVFVHtFRUVam1ttVRV/JmLeRobG9PQ0JCWLFkyHyXGhbmYp46ODrW2tur666+fjxLjwmzn6fvf/77efPNN1dfXz3eJceF8/p6uueYa+Xw+rV27Vs8999x8lmnVbObomWeeUUlJie677z5dcskluuKKK/TNb35Tv/vd72JRsnUJ8UV5yWZgYECRSETZ2dlR7dnZ2err67NUVfyZi3n6zne+o//93//VzTffPB8lxoXzmaecnBz9z//8j95//33dfffd2rp163yWatVs5umNN97Q9u3b1dLSotTU5PjP6Wzmyefzac+ePfL7/RoZGdFjjz2mtWvX6vnnn9enPvWpWJQdU7OZo7feektHjx6Vx+PR008/rYGBAX3ta1/T6dOnk+K+keT415OgXC5X1GtjzKQ2zH6ennzySd1999362c9+pmXLls1XeXFjNvPU0tKid999Vy+//LK2b9+uj3/847rlllvms0zrZjpPkUhEGzduVENDg6644opYlRc3nPw9rVy5UitXrpx4XVZWpt7eXt1///0LMoyMczJHY2Njcrlcevzxxye+5XbXrl36whe+oN27dysjI2Pe67WJMBKHsrKy5Ha7JyXo/v7+SUk7mZ3PPB04cEBbtmzRj3/8Y914443zWaZ15zNP+fn5kqRPfOITeuedd3T33Xcv2DDidJ6GhoZ07NgxdXR06Pbbb5d05oRijFFqaqr+9V//VTfccENMao+lufrv07XXXqv9+/fPdXlxYTZz5PP5dMkll0wEEUkqKCiQMUZvv/22Lr/88nmt2TbuGYlDaWlp8vv9CgQCUe2BQEDl5eWWqoo/s52nJ598UrfddpueeOIJfeYzn5nvMq2bq78nY4xGRkbmury44XSeMjMz9ctf/lKvvPLKxFFdXa2VK1fqlVdeUWlpaaxKj6m5+nvq6OiQz+eb6/LiwmzmaPXq1Tp16pTefffdibYTJ04oJSVFOTk581pvXLB26yzOafyxsEcffdR0dXWZmpoas2jRIvPrX//aGGPM9u3bzebNm6PGdHR0mI6ODuP3+83GjRtNR0eHefXVV22UHzNO5+mJJ54wqampZvfu3VGPGP72t7+19RFiwuk8fe973zPPPPOMOXHihDlx4oTZu3evyczMNDt27LD1EWJiNv/uzpYsT9M4nacHHnjAPP300+bEiROms7PTbN++3UgyBw8etPUR5p3TORoaGjI5OTnmC1/4gnn11VfNCy+8YC6//HKzdetWWx8hpggjcWz37t0mLy/PpKWlmeLiYvPCCy9M/OxLX/qSuf7666P6S5p05OXlxbZoC5zM0/XXXz/lPH3pS1+KfeEx5mSe/vEf/9FcddVV5sILLzSZmZnmmmuuMU1NTSYSiVioPLac/rs7W7KEEWOczdO9995rVqxYYTwej/nYxz5mrrvuOvPzn//cQtWx5fRv6bXXXjM33nijycjIMDk5Oaa2tta89957Ma7aDpcxxlhalAEAAOCeEQAAYBdhBAAAWEUYAQAAVhFGAACAVYQRAABgFWEEAABYRRgBAABWEUYAAIBVhBEAAGAVYQQAAFhFGAEAAFYRRgAAgFX/H3N6t3HGHIYSAAAAAElFTkSuQmCC",
       "text/plain": [
        "
" ] @@ -652,7 +608,7 @@ "def noise(length: int = 1):\n", " return np.random.rand(length)\n", "\n", - "@node(\"fig\")\n", + "@function_node(\"fig\")\n", "def plot(x, y):\n", " return plt.scatter(x, y)\n", "\n", @@ -705,7 +661,7 @@ "outputs": [ { "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAAAiMAAAGdCAYAAADAAnMpAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjcuMSwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/bCgiHAAAACXBIWXMAAA9hAAAPYQGoP6dpAAAkOklEQVR4nO3de2zb1f3/8ZfttDGXxCgtSdyLSqi41ETAkiolZRUao1kAhTFpovuylovan2iBQelgv1adCEFIEUxUXEbD6LgItXQRDBiRukC031ZaypbRphIhaKA2Wy84REmEYy5ph31+f+SbUNdOGzuJj/3x8yH5D58c1+8cxf68es7ncz4uY4wRAACAJW7bBQAAgNxGGAEAAFYRRgAAgFWEEQAAYBVhBAAAWEUYAQAAVhFGAACAVYQRAABgVZ7tAsYjGo3qs88+U0FBgVwul+1yAADAOBhjFA6HNWvWLLndY89/ZEUY+eyzzzR37lzbZQAAgBQcPnxYc+bMGfPnWRFGCgoKJA3/MoWFhZarAQAA4zE4OKi5c+eOHsfHkhVhZGRpprCwkDACAECWOd0pFpzACgAArCKMAAAAqwgjAADAKsIIAACwijACAACsIowAAACrCCMAAMAqwggAALAqKzY9A2BHJGrU3j2g3vCQigu8qiorksfN/aEATC7CCICEWjuDamjpUjA0NNrm93lVXxdQbbnfYmUAnIZlGgBxWjuDWrN1X0wQkaSe0JDWbN2n1s6gpcoAOBFhBECMSNSooaVLJsHPRtoaWroUiSbqAQDJI4wAiNHePRA3I3IiIykYGlJ790D6igLgaIQRADF6w2MHkVT6AcDpEEYAxCgu8E5qPwA4HcIIgBhVZUXy+7wa6wJel4avqqkqK0pnWQAcjDACIIbH7VJ9XUCS4gLJyPP6ugD7jQCYNIQRAHFqy/1qWl6hUl/sUkypz6um5RXsMwJgUrHpGYCEasv9WhooZQdWAFOOMAJgTB63S9XzZ9guA4DDsUwDAACsIowAAACrCCMAAMAqwggAALCKMAIAAKwijAAAAKsIIwAAwCrCCAAAsIowAgAArCKMAAAAqwgjAADAKsIIAACwijACAACsIowAAACrCCMAAMAqwggAALCKMAIAAKwijAAAAKsIIwAAwCrCCAAAsIowAgAArCKMAAAAqwgjAADAKsIIAACwijACAACsIowAAACrCCMAAMAqwggAALCKMAIAAKwijAAAAKsIIwAAwCrCCAAAsIowAgAArCKMAAAAq1IKI5s3b1ZZWZm8Xq8qKyu1a9euU/bftm2bLrvsMp155pny+/26/fbb1d/fn1LBAADAWZIOI83NzVq7dq02btyojo4OLVmyRNdee60OHTqUsP/u3bt1yy23aOXKlfroo4/06quv6p///KdWrVo14eIBAED2SzqMbNq0SStXrtSqVau0YMECPfHEE5o7d66ampoS9v/73/+u8847T/fcc4/Kysr0/e9/X3fccYc++OCDCRcPAACyX1Jh5Pjx49q7d69qampi2mtqarRnz56Er1m8eLGOHDmiHTt2yBijzz//XK+99pquv/76Md/n2LFjGhwcjHkAAABnSiqM9PX1KRKJqKSkJKa9pKREPT09CV+zePFibdu2TcuWLdP06dNVWlqqc845R08//fSY79PY2Cifzzf6mDt3bjJlAgCALJLSCawulyvmuTEmrm1EV1eX7rnnHj344IPau3evWltb1d3drdWrV4/572/YsEGhUGj0cfjw4VTKBAAAWSAvmc4zZ86Ux+OJmwXp7e2Nmy0Z0djYqCuvvFIPPPCAJOnSSy/VWWedpSVLluiRRx6R3++Pe01+fr7y8/OTKQ0AAGSppGZGpk+frsrKSrW1tcW0t7W1afHixQlf8/XXX8vtjn0bj8cjaXhGBQAA5Lakl2nWrVun3//+93rhhRf08ccf67777tOhQ4dGl102bNigW265ZbR/XV2dXn/9dTU1NengwYN67733dM8996iqqkqzZs2avN8EAABkpaSWaSRp2bJl6u/v18MPP6xgMKjy8nLt2LFD8+bNkyQFg8GYPUduu+02hcNh/fa3v9Uvf/lLnXPOObr66qv16KOPTt5vAQAAspbLZMFayeDgoHw+n0KhkAoLC22XAwAAxmG8x2/uTQMAAKwijAAAAKsIIwAAwCrCCAAAsIowAgAArCKMAAAAqwgjAADAKsIIAACwijACAACsIowAAACrCCMAAMAqwggAALAq6bv2AtkoEjVq7x5Qb3hIxQVeVZUVyeN22S4LACDCCHJAa2dQDS1dCoaGRtv8Pq/q6wKqLfdbrAwAILFMA4dr7QxqzdZ9MUFEknpCQ1qzdZ9aO4OWKgMAjCCMwLEiUaOGli6ZBD8baWto6VIkmqgHACBd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/1/96lfm4osvjmm74447zBVXXDFlNTpJsuOdSCAQMA0NDZNdmiOlOt7Lli0zv/71r019fT1hZJySHes///nPxufzmf7+/nSU5zjJjvdvfvMbc/7558e0PfXUU2bOnDlTVqMTjSeMpPM46YhlmuPHj2vv3r2qqamJaa+pqdGePXsSvub999+P6/+jH/1IH3zwgf773/9OWa1OkMp4nywajSocDquoqGgqSnSUVMf7xRdf1IEDB1RfXz/VJTpGKmP91ltvaeHChXrsscc0e/ZsXXjhhbr//vv1zTffpKPkrJbKeC9evFhHjhzRjh07ZIzR559/rtdee03XX399OkrOKek8TmbFjfJOp6+vT5FIRCUlJTHtJSUl6unpSfianp6ehP2//fZb9fX1ye/3T1m92S6V8T7Z448/rq+++ko33XTTVJToKKmM96effqr169dr165dystzxMc8LVIZ64MHD2r37t3yer1644031NfXpzvvvFMDAwOcN3IaqYz34sWLtW3bNi1btkxDQ0P69ttvdcMNN+jpp59OR8k5JZ3HSUfMjIxwuVwxz40xcW2n65+oHYklO94jtm/froceekjNzc0qLi6eqvIcZ7zjHYlEdPPNN6uhoUEXXnhhuspzlGT+tqPRqFwul7Zt26aqqipdd9112rRpk1566SVmR8YpmfHu6urSPffcowcffFB79+5Va2ururu7tXr16nSUmnPSdZx0xH+ZZs6cKY/HE5eke3t741LdiNLS0oT98/LyNGPGjCmr1QlSGe8Rzc3NWrlypV599VVdc801U1mmYyQ73uFwWB988IE6Ojp09913Sxo+YBpjlJeXp3feeUdXX311WmrPNqn8bfv9fs2ePTvmNukLFiyQMUZHjhzRBRdcMKU1Z7NUxruxsVFXXnmlHnjgAUnSpZdeqrPOOktLlizRI488wqz2JErncdIRMyPTp09XZWWl2traYtrb2tq0ePHihK+prq6O6//OO+9o4cKFmjZt2pTV6gSpjLc0PCNy22236ZVXXmF9NwnJjndhYaE+/PBD7d+/f/SxevVqXXTRRdq/f78WLVqUrtKzTip/21deeaU+++wzffnll6Ntn3zyidxut+bMmTOl9Wa7VMb766+/ltsde+jyeDySvvtfOyZHWo+Tk35KrCUjl4c9//zzpqury6xdu9acddZZ5t///rcxxpj169ebFStWjPYfuWTpvvvuM11dXeb555/n0t4kJDver7zyisnLyzPPPPOMCQaDo48vvvjC1q+QVZId75NxNc34JTvW4XDYzJkzx/z0pz81H330kdm5c6e54IILzKpVq2z9Clkl2fF+8cUXTV5entm8ebM5cOCA2b17t1m4cKGpqqqy9StkjXA4bDo6OkxHR4eRZDZt2mQ6OjpGL6O2eZx0TBgxxphnnnnGzJs3z0yfPt1UVFSYnTt3jv7s1ltvNVdddVVM/7/97W/me9/7npk+fbo577zzTFNTU5orzm7JjPdVV11lJMU9br311vQXnqWS/fs+EWEkOcmO9ccff2yuueYac8YZZ5g5c+aYdevWma+//jrNVWevZMf7qaeeMoFAwJxxxhnG7/ebn//85+bIkSNprjr7/PWvfz3l97DN46TLGOa1AACAPY44ZwQAAGQvwggAALCKMAIAAKwijAAAAKsIIwAAwCrCCAAAsIowAgAArCKMAAAAqwgjAADAKsIIAACwijACAACsIowAAACr/j8tzG4eQYg8SwAAAABJRU5ErkJggg==", + "image/png": "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zjlHfr9fd999t/r6+pJUrUsYB/rTn/5kJk2aZF5++WXT0dFhHnroIXPuueeazz77LGb7AwcOmHPOOcc89NBDpqOjw7z88stm0qRJ5q233kpy5c4Sbz889NBD5sknnzS7du0yn3zyiVm9erWZNGmS2b17d5Ird5Z4+yHiiy++MBdffLGpqqoyV111VXKKdbBE+uGWW24xc+fONa2traazs9P861//Mu+//34Sq3aeePuhra3NZGRkmGeffdYcOHDAtLW1mSuuuMLceuutSa7c2RwZRsrLy01tbW3Utcsvv9ysWrUqZvtf/vKX5vLLL4+6du+995prrrnmrNXoBvH2QyxFRUWmvr5+oktzlUT7oaamxvzmN78xdXV1hJEJEG8//PWvfzU+n8/09fUlozzXiLcffve735mLL7446tpzzz1nZs6cedZqdCPHTdMcO3ZM7e3tqqqqirpeVVWlHTt2xHzOzp07R7W/8cYb9cEHH+jbb789a7U6WSL9cLJwOKzBwUFNnTr1bJToCon2wyuvvKL9+/errq7ubJfoCon0w7vvvquysjI99dRTuvDCC3XppZfqkUce0TfffJOMkh0pkX6orKzUoUOH1NzcLGOMPv/8c7311lu6+eabk1Gya6TFjfLi0dvbq1AopLy8vKjreXl56u7ujvmc7u7umO2PHz+u3t5e+f3+s1avUyXSDyf7/e9/r6+++kq333772SjRFRLph08//VSrVq1SW1ubsrIc91+EFYn0w4EDB7R9+3Z5vV6988476u3t1X333af+/n7WjSQokX6orKzU5s2bVVNTo6GhIR0/fly33HKLnn/++WSU7BqOGxmJ8Hg8UV8bY0ZdO137WNcRn3j7IeKNN97Q448/rqamJs2YMeNsleca4+2HUCikxYsXq76+XpdeemmyynONeP49hMNheTwebd68WeXl5brpppv09NNP69VXX2V05AzF0w8dHR168MEH9dhjj6m9vV0tLS3q7OxUbW1tMkp1Dcd97Jk+fboyMzNHpdyenp5RaTgiPz8/ZvusrCxNmzbtrNXqZIn0Q0RTU5OWLVumN998UzfccMPZLNPx4u2HwcFBffDBB9qzZ48eeOABScNvisYYZWVlaevWrbr++uuTUruTJPLvwe/368ILL4y6/fqcOXNkjNGhQ4d0ySWXnNWanSiRfmhoaNC8efP06KOPSpKuvPJKnXvuuZo/f76eeOIJRs4niONGRrKzs1VaWqrW1tao662traqsrIz5nIqKilHtt27dqrKyMk2aNOms1epkifSDNDwictddd+n1119nTnYCxNsPubm5+vDDD7V3796RR21trS677DLt3btXc+fOTVbpjpLIv4d58+bpyJEj+vLLL0euffLJJ8rIyNDMmTPPar1OlUg/fP3118rIiH6rzMzMlPS/EXRMAFsrZ8+myNatDRs2mI6ODrNixQpz7rnnmv/+97/GGGNWrVplli5dOtI+srX34YcfNh0dHWbDhg1s7Z0A8fbD66+/brKyssy6detMIBAYeXzxxRe2/gRHiLcfTsZumokRbz8MDg6amTNnmttuu8189NFHZtu2beaSSy4x99xzj60/wRHi7YdXXnnFZGVlmfXr15v9+/eb7du3m7KyMlNeXm7rT3AkR4YRY4xZt26dmTVrlsnOzjYlJSVm27ZtI9+78847zXXXXRfV/u9//7v5/ve/b7Kzs81FF11kGhsbk1yxM8XTD9ddd52RNOpx5513Jr9wh4n338OJCCMTJ95++Pjjj80NN9xgJk+ebGbOnGlWrlxpvv766yRX7Tzx9sNzzz1nioqKzOTJk43f7zc/+clPzKFDh5JctbN5jGGcCQAA2OO4NSMAACC9EEYAAIBVhBEAAGAVYQQAAFhFGAEAAFYRRgAAgFWEEQAAYBVhBAAAWEUYAQAAVhFGAACAVYQRAABgFWEEAABY9f8Alw4iNhhoKkMAAAAASUVORK5CYII=", "text/plain": [ "
" ] @@ -751,9 +707,9 @@ "name": "stderr", "output_type": "stream", "text": [ - "/Users/huber/work/pyiron/pyiron_contrib/pyiron_contrib/workflow/workflow.py:187: UserWarning: Reassigning the node bulk_structure to the label structure when adding it to the workflow with_prebuilt.\n", + "/Users/huber/work/pyiron/pyiron_contrib/pyiron_contrib/workflow/composite.py:175: UserWarning: Reassigning the node bulk_structure to the label structure when adding it to the parent with_prebuilt.\n", " warn(\n", - "/Users/huber/work/pyiron/pyiron_contrib/pyiron_contrib/workflow/workflow.py:187: UserWarning: Reassigning the node lammps to the label engine when adding it to the workflow with_prebuilt.\n", + "/Users/huber/work/pyiron/pyiron_contrib/pyiron_contrib/workflow/composite.py:175: UserWarning: Reassigning the node lammps to the label engine when adding it to the parent with_prebuilt.\n", " warn(\n" ] }, @@ -761,16 +717,16 @@ "name": "stdout", "output_type": "stream", "text": [ - "The job JUSTAJOBNAME was saved and received the ID: 7418\n" + "The job JUSTAJOBNAME was saved and received the ID: 9553\n" ] }, { "name": "stderr", "output_type": "stream", "text": [ - "/Users/huber/work/pyiron/pyiron_contrib/pyiron_contrib/workflow/workflow.py:187: UserWarning: Reassigning the node calc_md to the label calc when adding it to the workflow with_prebuilt.\n", + "/Users/huber/work/pyiron/pyiron_contrib/pyiron_contrib/workflow/composite.py:175: UserWarning: Reassigning the node calc_md to the label calc when adding it to the parent with_prebuilt.\n", " warn(\n", - "/Users/huber/work/pyiron/pyiron_contrib/pyiron_contrib/workflow/workflow.py:187: UserWarning: Reassigning the node scatter to the label plot when adding it to the workflow with_prebuilt.\n", + "/Users/huber/work/pyiron/pyiron_contrib/pyiron_contrib/workflow/composite.py:175: UserWarning: Reassigning the node scatter to the label plot when adding it to the parent with_prebuilt.\n", " warn(\n" ] }, @@ -826,7 +782,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.0" + "version": "3.11.4" } }, "nbformat": 4, From eceb8178ed21024985c3a4cc6166c4c2e196418e Mon Sep 17 00:00:00 2001 From: pyiron-runner Date: Wed, 28 Jun 2023 21:59:56 +0000 Subject: [PATCH 50/81] Format black --- pyiron_contrib/workflow/is_nodal.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/pyiron_contrib/workflow/is_nodal.py b/pyiron_contrib/workflow/is_nodal.py index e022ee603..94e94e835 100644 --- a/pyiron_contrib/workflow/is_nodal.py +++ b/pyiron_contrib/workflow/is_nodal.py @@ -71,12 +71,12 @@ class IsNodal(HasToDict, ABC): """ def __init__( - self, - label: str, - *args, - parent: Optional[HasNodes] = None, - run_on_updates: bool = False, - **kwargs + self, + label: str, + *args, + parent: Optional[HasNodes] = None, + run_on_updates: bool = False, + **kwargs, ): """ A mixin class for objects that can form nodes in the graph representation of a From ab8de4bc4815e55f319ffe6d137899d7267c7af9 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Wed, 28 Jun 2023 15:19:44 -0700 Subject: [PATCH 51/81] Black --- pyiron_contrib/workflow/node.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyiron_contrib/workflow/node.py b/pyiron_contrib/workflow/node.py index 1b90f315f..b227ddded 100644 --- a/pyiron_contrib/workflow/node.py +++ b/pyiron_contrib/workflow/node.py @@ -78,7 +78,7 @@ def __init__( *args, parent: Optional[Composite] = None, run_on_updates: bool = False, - **kwargs + **kwargs, ): """ A mixin class for objects that can form nodes in the graph representation of a From a98f4ddc05e652e6c529b57f9d1bcff7bca9c62e Mon Sep 17 00:00:00 2001 From: liamhuber Date: Wed, 28 Jun 2023 15:40:19 -0700 Subject: [PATCH 52/81] Update docs It's not exhaustive or finalized, but in general the universal stuff now sits on `Node` and the stuff specific to wrapping functions sits on `Function(Node)` --- pyiron_contrib/workflow/function.py | 61 ++++++++++-------------- pyiron_contrib/workflow/node.py | 72 +++++++++++++++++++---------- 2 files changed, 72 insertions(+), 61 deletions(-) diff --git a/pyiron_contrib/workflow/function.py b/pyiron_contrib/workflow/function.py index 62f475c7d..0103b38de 100644 --- a/pyiron_contrib/workflow/function.py +++ b/pyiron_contrib/workflow/function.py @@ -17,33 +17,17 @@ class Function(Node): """ - Function nodes have input and output data channels that interface with the outside - world, and a callable that determines what they actually compute. After running, - their output channels are updated with the results of the node's computation, which - triggers downstream node updates if those output channels are connected to other - input channels. - - An "update" is gentle and will only trigger the node to run if its run-on-update - flag is set to true and if its input is all ready -- i.e. having values matching - the type hints, and if it is not either already running or already failed. - - They also have input and output signal channels -- a run input and a ran output, - although these are extensible in child classes. Calling the run input signal - triggers the run method, and after running a signal is sent out on the ran output - signal channel. In this way, execution flow can be managed manually by connecting - signal channels. Be careful as the run input signal bypasses the checks for an - update and really forces a node to run with whatever data it currently has. - - Signal channels cannot be connected to data channels. - - Nodes won't update themselves while setting inputs to initial values, but can - optionally update themselves at the end instantiation. - - Nodes must be instantiated with a callable to deterimine their function, and a - strings to name each returned value of that callable. (If you really want to return - a tuple, just have multiple return values but only one output label -- there is - currently no way to mix-and-match, i.e. to have multiple return values at least one - of which is a tuple.) + Function nodes wrap an arbitrary python function. + Node IO, including type hints, is generated automatically from the provided function + and (in the case of labeling output channels) the provided output labels. + On running, the function node executes this wrapped function with its current input + and uses the results to populate the node output. + + Function nodes must be instantiated with a callable to deterimine their function, + and a string to name each returned value of that callable. (If you really want to + return a tuple, just have multiple return values but only one output label -- there + is currently no way to mix-and-match, i.e. to have multiple return values at least + one of which is a tuple.) The node label (unless otherwise provided), IO types, and input defaults for the node are produced _automatically_ from introspection of the node function. @@ -52,13 +36,18 @@ class Function(Node): keys corresponding to the channel labels (i.e. the node arguments of the node function, or the output labels provided). - Actual node instances can either be instances of the base node class, in which case - the callable node function and output labels *must* be provided, in addition to - other data, OR they can be instances of children of this class. + Actual function node instances can either be instances of the base node class, in + which case the callable node function and output labels *must* be provided, in + addition to other data, OR they can be instances of children of this class. Those children may define some or all of the node behaviour at the class level, and modify their signature accordingly so this is not available for alteration by the user, e.g. the node function and output labels may be hard-wired. + Although not strictly enforced, it is a best-practice that where possible, function + nodes should be both functional (always returning the same output given the same + input) and idempotent (not modifying input data in-place, but creating copies where + necessary and returning new objects as output). + Args: node_function (callable): The function determining the behaviour of the node. *output_labels (str): A name for each return value of the node function. @@ -94,8 +83,8 @@ class Function(Node): disconnect: Disconnect all data and signal IO connections. Examples: - At the most basic level, to use nodes all we need to do is provide the `Node` - class with a function and labels for its output, like so: + At the most basic level, to use nodes all we need to do is provide the + `Function` class with a function and labels for its output, like so: >>> from pyiron_contrib.workflow.function import Function >>> >>> def mwe(x, y): @@ -202,14 +191,14 @@ class with a function and labels for its output, like so: instantiation flags to `True` for nodes that execute quickly and are meant to _always_ have good output data. - In these examples, we've instantiated nodes directly from the base `Node` class, - and populated their input directly with data. + In these examples, we've instantiated nodes directly from the base `Function` + class, and populated their input directly with data. In practice, these nodes are meant to be part of complex workflows; that means both that you are likely to have particular nodes that get heavily re-used, and that you need the nodes to pass data to each other. - For reusable nodes, we want to create a sub-class of `Node` that fixes some of - the node behaviour -- usually the `node_function` and `output_labels`. + For reusable nodes, we want to create a sub-class of `Function` that fixes some + of the node behaviour -- usually the `node_function` and `output_labels`. This can be done most easily with the `node` decorator, which takes a function and returns a node class: diff --git a/pyiron_contrib/workflow/node.py b/pyiron_contrib/workflow/node.py index b227ddded..1cdc37f92 100644 --- a/pyiron_contrib/workflow/node.py +++ b/pyiron_contrib/workflow/node.py @@ -21,41 +21,64 @@ class Node(HasToDict, ABC): """ - A mixin class for objects that can form nodes in the graph representation of a - computational workflow. - - Nodal objects have `inputs` and `outputs` channels for passing data, and `signals` - channels for making callbacks on the class (input) and controlling execution flow - (output) when connected to other nodal objects. - - Nodal objects can `run` to complete some computational task, or call a softer - `update` which will run the task only if it is `ready` -- i.e. it is not currently - running, has not previously tried to run and failed, and all of its inputs are ready - (i.e. populated with data that passes type requirements, if any). + Nodes are elements of a computational graph. + They have input and output data channels that interface with the outside + world, and a callable that determines what they actually compute, and input and + output signal channels that can be used to customize the execution flow of the + graph; + Together these channels represent edges on the computational graph. + + Nodes can be run to force their computation, or more gently updated, which will + trigger a run only if the `run_on_update` flag is set to true and all of the input + is ready (i.e. channel values conform to any type hints provided). + + Nodes may have a `parent` node that owns them as part of a sub-graph. + + Every node must be named with a `label`, and may use this label to attempt to create + a working directory in memory for itself if requested. + These labels also help to identify nodes in the wider context of (potentially + nested) computational graphs. + + By default, nodes' signals input comes with `run` and `ran` IO ports which force + the `run()` method and which emit after `finish_run()` is completed, respectfully. + + Nodes have a status, which is currently represented by the `running` and `failed` + boolean flags. + Their value is controlled automatically in the defined `run` and `finish_run` + methods. + + This is an abstract class. + Children *must* define how `inputs` and `outputs` are constructed, and what will + happen `on_run`. + They may also override the `run_args` property to specify input passed to the + defined `on_run` method, and may add additional signal channels to the signals IO. + + # TODO: Everything with (de)serialization and executors for running on something + # other than the main python process. Attributes: connected (bool): Whether _any_ of the IO (including signals) are connected. - failed (bool): Whether the nodal object raised an error calling `run`. (Default + failed (bool): Whether the node raised an error calling `run`. (Default is False.) fully_connected (bool): whether _all_ of the IO (including signals) are connected. inputs (pyiron_contrib.workflow.io.Inputs): **Abstract.** Children must define a property returning an `Inputs` object. - label (str): A name for the nodal object. - output (pyiron_contrib.workflow.io.Outputs): **Abstract.** Children must define + label (str): A name for the node. + outputs (pyiron_contrib.workflow.io.Outputs): **Abstract.** Children must define a property returning an `Outputs` object. parent (pyiron_contrib.workflow.composite.Composite | None): The parent object owning this, if any. - ready (bool): Whether the inputs are all ready and the nodal object is neither + ready (bool): Whether the inputs are all ready and the node is neither already running nor already failed. run_on_updates (bool): Whether to run when you are updated and all your input is ready and your status does not prohibit running. (Default is False). - running (bool): Whether the nodal object has called `run` and has not yet - received output from from this call. (Default is False.) + running (bool): Whether the node has called `run` and has not yet + received output from this call. (Default is False.) server (Optional[pyiron_base.jobs.job.extension.server.generic.Server]): A server object for computing things somewhere else. Default (and currently _only_) behaviour is to compute things on the main python process owning - the nodal object. + the node. signals (pyiron_contrib.workflow.io.Signals): A container for input and output signals, which are channels for controlling execution flow. By default, has a `signals.inputs.run` channel which has a callback to the `run` method, @@ -67,9 +90,8 @@ class Node(HasToDict, ABC): Methods: disconnect: Remove all connections, including signals. - run: **Abstract.** Do the thing. - update: **Abstract.** Do the thing if you're ready and you run on updates. - TODO: Once `run_on_updates` is in this class, we can un-abstract this. + on_run: **Abstract.** Do the thing. + run: A wrapper to handle all the infrastructure around executing `on_run`. """ def __init__( @@ -85,7 +107,7 @@ def __init__( computational workflow. Args: - label (str): A name for this nodal object. + label (str): A name for this node. *args: Arguments passed on with `super`. **kwargs: Keyword arguments passed on with `super`. @@ -122,7 +144,7 @@ def outputs(self) -> Outputs: @abstractmethod def on_run(self) -> callable[..., tuple]: """ - What the nodal object actually does! + What the node actually does! """ pass @@ -144,8 +166,8 @@ def process_run_result(self, run_output: tuple) -> None: def run(self) -> None: """ - Executes the functionality of the nodal object defined in `on_run`. - Handles the status of the nodal object, and communicating with any remote + Executes the functionality of the node defined in `on_run`. + Handles the status of the node, and communicating with any remote computing resources. """ if self.running: From f11424c6a002756c41fc785a4df387063e38c7f6 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Thu, 29 Jun 2023 09:47:11 -0700 Subject: [PATCH 53/81] Introduce NotData as a default for data channels And have them fail readiness when this is their value --- pyiron_contrib/workflow/channels.py | 25 +++++++++++++++++++++---- tests/unit/workflow/test_channels.py | 16 +++++++++++++++- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/pyiron_contrib/workflow/channels.py b/pyiron_contrib/workflow/channels.py index 754ca4337..a7510a4f7 100644 --- a/pyiron_contrib/workflow/channels.py +++ b/pyiron_contrib/workflow/channels.py @@ -134,6 +134,15 @@ def to_dict(self) -> dict: } +class NotData: + """ + This class exists purely to initialize data channel values where no default value + is provided; it lets the channel know that it has _no data in it_ and thus should + not identify as ready. + """ + pass + + class DataChannel(Channel, ABC): """ Data channels control the flow of data on the graph. @@ -170,6 +179,10 @@ class DataChannel(Channel, ABC): E.g. `Literal[1, 2]` is as or more specific that both `Literal[1, 2]` and `Literal[1, 2, "three"]`. + The data `value` will initialize to an instance of `NotData` by default. + The channel will identify as `ready` when the value is _not_ an instance of + `NotData`, and when the value conforms to type hints (if any). + Warning: Type hinting in python is quite complex, and determining when a hint is "more specific" can be tricky. For instance, in python 3.11 you can now type @@ -182,7 +195,7 @@ def __init__( self, label: str, node: Node, - default: typing.Optional[typing.Any] = None, + default: typing.Optional[typing.Any] = NotData, type_hint: typing.Optional[typing.Any] = None, ): super().__init__(label=label, node=node) @@ -199,9 +212,13 @@ def ready(self) -> bool: (bool): Whether the value matches the type hint. """ if self.type_hint is not None: - return valid_value(self.value, self.type_hint) + return self.value_is_data and valid_value(self.value, self.type_hint) else: - return True + return self.value_is_data + + @property + def value_is_data(self): + return self.value is not NotData def update(self, value) -> None: """ @@ -314,7 +331,7 @@ def __init__( self, label: str, node: Node, - default: typing.Optional[typing.Any] = None, + default: typing.Optional[typing.Any] = NotData, type_hint: typing.Optional[typing.Any] = None, strict_connections: bool = True, ): diff --git a/tests/unit/workflow/test_channels.py b/tests/unit/workflow/test_channels.py index 1b5053aca..48bbd0b3d 100644 --- a/tests/unit/workflow/test_channels.py +++ b/tests/unit/workflow/test_channels.py @@ -2,7 +2,7 @@ from sys import version_info from pyiron_contrib.workflow.channels import ( - InputData, OutputData, InputSignal, OutputSignal + InputData, OutputData, InputSignal, OutputSignal, NotData ) @@ -100,6 +100,20 @@ def test_connection_validity_tests(self): ) def test_ready(self): + with self.subTest("Test defaults and not-data"): + without_default = InputData(label="without_default", node=DummyNode()) + self.assertIs( + without_default.value, + NotData, + msg=f"Without a default, spec is to have a NotData value but got " + f"{type(without_default.value)}" + ) + self.assertFalse( + without_default.ready, + msg="Even without type hints, readiness should be false when the value" + "is NotData" + ) + self.ni1.value = 1 self.assertTrue(self.ni1.ready) From 622a6a5943dfa4651ccb3866a8776924454304b5 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Thu, 29 Jun 2023 10:22:37 -0700 Subject: [PATCH 54/81] Update node tests that were expecting a None default --- tests/unit/workflow/test_function.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/tests/unit/workflow/test_function.py b/tests/unit/workflow/test_function.py index ffe03a964..99b61d263 100644 --- a/tests/unit/workflow/test_function.py +++ b/tests/unit/workflow/test_function.py @@ -3,6 +3,7 @@ from typing import Optional, Union import warnings +from pyiron_contrib.workflow.channels import NotData from pyiron_contrib.workflow.files import DirectoryObject from pyiron_contrib.workflow.function import ( Fast, Function, SingleValue, function_node, single_value_node @@ -40,7 +41,12 @@ def test_instantiation_update(self): run_on_updates=True, update_on_instantiation=False ) - self.assertIsNone(no_update.outputs.y.value) + self.assertIs( + no_update.outputs.y.value, + NotData, + msg=f"Expected the output to be in its initialized and not-updated NotData " + f"state, but got {no_update.outputs.y.value}" + ) update = Function( plus_one, @@ -106,9 +112,11 @@ def times_two(y): t2 = times_two( update_on_instantiation=False, run_automatically=False, y=l.outputs.y ) - self.assertIsNone( + self.assertIs( t2.outputs.z.value, - msg="Without updates, the output should initially be None" + NotData, + msg=f"Without updates, expected the output to be {NotData} but got " + f"{t2.outputs.z.value}" ) # Nodes should _all_ have the run and ran signals From bb4ff2fc8106fc1d0a41df9c8ac5fd4824a60d3d Mon Sep 17 00:00:00 2001 From: liamhuber Date: Thu, 29 Jun 2023 10:37:48 -0700 Subject: [PATCH 55/81] Don't override the data channel default with None When building input channels in function nodes. And test to make sure it stays this way! --- pyiron_contrib/workflow/function.py | 4 ++-- tests/unit/workflow/test_function.py | 15 ++++++++++++++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/pyiron_contrib/workflow/function.py b/pyiron_contrib/workflow/function.py index 0103b38de..b55a2f071 100644 --- a/pyiron_contrib/workflow/function.py +++ b/pyiron_contrib/workflow/function.py @@ -5,7 +5,7 @@ from functools import partialmethod from typing import get_args, get_type_hints, Optional, TYPE_CHECKING -from pyiron_contrib.workflow.channels import InputData, OutputData +from pyiron_contrib.workflow.channels import InputData, OutputData, NotData from pyiron_contrib.workflow.has_channel import HasChannel from pyiron_contrib.workflow.io import Inputs, Outputs, Signals from pyiron_contrib.workflow.node import Node @@ -398,7 +398,7 @@ def _build_input_channels(self): except KeyError: type_hint = None - default = None + default = NotData # The standard default in DataChannel if value.default is not inspect.Parameter.empty: if is_self: warnings.warn("default value for self ignored") diff --git a/tests/unit/workflow/test_function.py b/tests/unit/workflow/test_function.py index 99b61d263..1a37d60dd 100644 --- a/tests/unit/workflow/test_function.py +++ b/tests/unit/workflow/test_function.py @@ -25,7 +25,20 @@ def no_default(x, y): @unittest.skipUnless(version_info[0] == 3 and version_info[1] >= 10, "Only supported for 3.10+") class TestFunction(unittest.TestCase): def test_defaults(self): - Function(plus_one, "y") + with_defaults = Function(plus_one, "y") + self.assertEqual( + with_defaults.inputs.x.value, + 1, + msg=f"Expected to get the default provided in the underlying function but " + f"got {with_defaults.inputs.x.value}", + ) + without_defaults = Function(no_default, "sum_plus_one") + self.assertIs( + without_defaults.inputs.x.value, + NotData, + msg=f"Expected values with no default specified to start as {NotData} but " + f"got {without_defaults.inputs.x.value}", + ) def test_failure_without_output_labels(self): with self.assertRaises( From 86173f6fec14218cf8853b6f34535efe3cbfb290 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Thu, 29 Jun 2023 10:39:14 -0700 Subject: [PATCH 56/81] Test more than just implementation Maybe the implementation test should even be removed... --- tests/unit/workflow/test_function.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/unit/workflow/test_function.py b/tests/unit/workflow/test_function.py index 1a37d60dd..be76d878e 100644 --- a/tests/unit/workflow/test_function.py +++ b/tests/unit/workflow/test_function.py @@ -39,6 +39,11 @@ def test_defaults(self): msg=f"Expected values with no default specified to start as {NotData} but " f"got {without_defaults.inputs.x.value}", ) + self.assertFalse( + without_defaults.ready, + msg="I guess we should test for behaviour and not implementation... Without" + "defaults, the node should not be ready!" + ) def test_failure_without_output_labels(self): with self.assertRaises( From 17f90206d612e1cc03647c8295a3546737159184 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Thu, 29 Jun 2023 10:57:38 -0700 Subject: [PATCH 57/81] Update the example notebook --- notebooks/workflow_example.ipynb | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/notebooks/workflow_example.ipynb b/notebooks/workflow_example.ipynb index 2d7107e30..aca04c84d 100644 --- a/notebooks/workflow_example.ipynb +++ b/notebooks/workflow_example.ipynb @@ -9,7 +9,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "ee8645d92ea44a7aa8583a924cd3a804", + "model_id": "6a819a64e97a4eb5b91e66cae4689723", "version_major": 2, "version_minor": 0 }, @@ -94,8 +94,9 @@ "id": "22ee2a49-47d1-4cec-bb25-8441ea01faf7", "metadata": {}, "source": [ - "The output is still empty because we haven't `run` the node.\n", - "If we try that now though, we'll just get a type error because the input is not set!\n", + "The output is still empty (`NotData`) because we haven't `run` the node.\n", + "If we try that now though, we'll just get a type error because the input is not set! A softer `update()` will avoid the error because it will see that the node is not `ready` and choose not to `run()`.\n", + "\n", "Let's set the input and run the node:" ] }, @@ -109,7 +110,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "{'p1': None, 'm1': None}\n", + "{'p1': , 'm1': }\n", "{'p1': 6, 'm1': 4}\n" ] } @@ -184,7 +185,7 @@ "source": [ "adder_node.inputs.x = \"not an integer\"\n", "adder_node.inputs.x.type_hint\n", - "# No error because the update doesn't trigger a run" + "# No error because the update doesn't trigger a run since the type hint is not satisfied" ] }, { @@ -465,7 +466,7 @@ "output_type": "stream", "text": [ "n1 n1 n1 (GreaterThanHalf) output single-value: False\n", - "n2 n2 \n", + "n2 n2 \n", "n3 n3 n3 (GreaterThanHalf) output single-value: False\n", "n4 n4 n4 (GreaterThanHalf) output single-value: False\n", "n5 n5 n5 (GreaterThanHalf) output single-value: False\n" @@ -511,7 +512,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "1 None\n" + "1 \n" ] } ], @@ -594,7 +595,7 @@ "outputs": [ { "data": { - "image/png": "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Fenw4cMTT8cEg8FJe46EQiEdPHhQ3/3ud+emagAAsGA43mfEBvYZAQAg8czLPiMAAABzjTACAACsIowAAACrCCMAAMAqwggAALCKMAIAAKwijAAAAKsIIwAAwCrCCAAAsIowAgAArCKMAAAAqwgjAADAKsIIAACwijACAACsIowAAACrCCMAAMAqwggAALCKMAIAAKwijAAAAKsIIwAAwCrCCAAAsIowAgAArCKMAAAAqwgjAADAKsIIAACwijACAACsIowAAACrCCMAAMAqwggAALCKMAIAAKwijAAAAKsIIwAAwCrCCAAAsIowAgAArCKMAAAAqwgjAADAKsIIAACwijACAACsIowAAACrCCMAAMAqwggAALBqVmGkqalJ+fn58ng88vv9amlpOWf/kZER7dixQ3l5eUpPT9eKFSu0d+/eWRUMAAAWllSnAw4cOKCamho1NTVp9erVeuihh7R+/Xp1dXXp0ksvnXLMzTffrHfeeUePPvqoPv7xj6u/v1/vv//+eRcPAAASn8sYY5wMKC0tVXFxsZqbmyfaCgoKtGHDBjU2Nk7qf+TIEX3xi1/UW2+9pSVLlsyqyHA4LK/Xq1AopMzMzFn9DgDA3IqMGbV1n1b/0LCWLfZoVf4SuVNctsv6SIladyKa6fnb0crI6Oio2tvbtX379qj2iooKtba2TjnmmWeeUUlJie677z499thjWrRokT772c/q7//+75WRkeHk7QFgxjjhzK8jnUE1HOpSMDQ80ebzelRfWah1RT6LlZ1bota90DkKIwMDA4pEIsrOzo5qz87OVl9f35Rj3nrrLR09elQej0dPP/20BgYG9LWvfU2nT5+e9r6RkZERjYyMTLwOh8NOygSQ5DjhzK8jnUFt239cH15W7wsNa9v+42reVByX85yodSeDWd3A6nJF/9+FMWZS27ixsTG5XC49/vjjWrVqlT796U9r165d+sEPfqDf/e53U45pbGyU1+udOHJzc2dTJoAkNH7COTuISP93wjnSGbRU2cIQGTNqONQ16YQuaaKt4VCXImOO7gCYd4lad7JwFEaysrLkdrsnrYL09/dPWi0Z5/P5dMkll8jr9U60FRQUyBijt99+e8oxdXV1CoVCE0dvb6+TMgEkKU4486+t+/SkoHc2IykYGlZb9+nYFTUDiVp3snAURtLS0uT3+xUIBKLaA4GA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SUlRTk6O7TLiUmZm5oL9I55LzNPMME8zwzzNDPM0Mwt9ns61IjKOG1gBAIBVhBEAAGAVYSRBpaenq76+Xunp6bZLiWvM08wwTzPDPM0M8zQzzNP/SYgbWAEAwMLFyggAALCKMAIAAKwijAAAAKsIIwAAwCrCSBxrampSfn6+PB6P/H6/Wlpapu0bDAa1ceNGrVy5UikpKaqpqYldoZY5maennnpKN910k/7wD/9QmZmZKisr07/8y7/EsFp7nMzT0aNHtXr1ai1dulQZGRm68sor9cADD8SwWnuczNPZXnrpJaWmpurqq6+e3wLjhJN5ev755+VyuSYdv/rVr2JYcew5/VsaGRnRjh07lJeXp/T0dK1YsUJ79+6NUbWWGcSlH/3oR+aCCy4wDz/8sOnq6jJ33nmnWbRokfnv//7vKft3d3ebO+64w/zwhz80V199tbnzzjtjW7AlTufpzjvvNPfee69pa2szJ06cMHV1deaCCy4wx48fj3HlseV0no4fP26eeOIJ09nZabq7u81jjz1mLrzwQvPQQw/FuPLYcjpP437729+aP/qjPzIVFRXmk5/8ZGyKtcjpPD333HNGknn99ddNMBicON5///0YVx47s/lb+uxnP2tKS0tNIBAw3d3d5t///d/NSy+9FMOq7SGMxKlVq1aZ6urqqLYrr7zSbN++/SPHXn/99UkTRs5nnsYVFhaahoaGuS4trszFPH3uc58zmzZtmuvS4sps56mqqsrcddddpr6+PinCiNN5Gg8jv/nNb2JQXXxwOke/+MUvjNfrNYODg7EoL+5wmSYOjY6Oqr29XRUVFVHtFRUVam1ttVRV/JmLeRobG9PQ0JCWLFkyHyXGhbmYp46ODrW2tur666+fjxLjwmzn6fvf/77efPNN1dfXz3eJceF8/p6uueYa+Xw+rV27Vs8999x8lmnVbObomWeeUUlJie677z5dcskluuKKK/TNb35Tv/vd72JRsnUJ8UV5yWZgYECRSETZ2dlR7dnZ2err67NUVfyZi3n6zne+o//93//VzTffPB8lxoXzmaecnBz9z//8j95//33dfffd2rp163yWatVs5umNN97Q9u3b1dLSotTU5PjP6Wzmyefzac+ePfL7/RoZGdFjjz2mtWvX6vnnn9enPvWpWJQdU7OZo7feektHjx6Vx+PR008/rYGBAX3ta1/T6dOnk+K+keT415OgXC5X1GtjzKQ2zH6ennzySd1999362c9+pmXLls1XeXFjNvPU0tKid999Vy+//LK2b9+uj3/847rlllvms0zrZjpPkUhEGzduVENDg6644opYlRc3nPw9rVy5UitXrpx4XVZWpt7eXt1///0LMoyMczJHY2Njcrlcevzxxye+5XbXrl36whe+oN27dysjI2Pe67WJMBKHsrKy5Ha7JyXo/v7+SUk7mZ3PPB04cEBbtmzRj3/8Y914443zWaZ15zNP+fn5kqRPfOITeuedd3T33Xcv2DDidJ6GhoZ07NgxdXR06Pbbb5d05oRijFFqaqr+9V//VTfccENMao+lufrv07XXXqv9+/fPdXlxYTZz5PP5dMkll0wEEUkqKCiQMUZvv/22Lr/88nmt2TbuGYlDaWlp8vv9CgQCUe2BQEDl5eWWqoo/s52nJ598UrfddpueeOIJfeYzn5nvMq2bq78nY4xGRkbmury44XSeMjMz9ctf/lKvvPLKxFFdXa2VK1fqlVdeUWlpaaxKj6m5+nvq6OiQz+eb6/LiwmzmaPXq1Tp16pTefffdibYTJ04oJSVFOTk581pvXLB26yzOafyxsEcffdR0dXWZmpoas2jRIvPrX//aGGPM9u3bzebNm6PGdHR0mI6ODuP3+83GjRtNR0eHefXVV22UHzNO5+mJJ54wqampZvfu3VGPGP72t7+19RFiwuk8fe973zPPPPOMOXHihDlx4oTZu3evyczMNDt27LD1EWJiNv/uzpYsT9M4nacHHnjAPP300+bEiROms7PTbN++3UgyBw8etPUR5p3TORoaGjI5OTnmC1/4gnn11VfNCy+8YC6//HKzdetWWx8hpggjcWz37t0mLy/PpKWlmeLiYvPCCy9M/OxLX/qSuf7666P6S5p05OXlxbZoC5zM0/XXXz/lPH3pS1+KfeEx5mSe/vEf/9FcddVV5sILLzSZmZnmmmuuMU1NTSYSiVioPLac/rs7W7KEEWOczdO9995rVqxYYTwej/nYxz5mrrvuOvPzn//cQtWx5fRv6bXXXjM33nijycjIMDk5Oaa2tta89957Ma7aDpcxxlhalAEAAOCeEQAAYBdhBAAAWEUYAQAAVhFGAACAVYQRAABgFWEEAABYRRgBAABWEUYAAIBVhBEAAGAVYQQAAFhFGAEAAFYRRgAAgFX/H3N6t3HGHIYSAAAAAElFTkSuQmCC", + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAiMAAAGiCAYAAAA1LsZRAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjcuMSwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/bCgiHAAAACXBIWXMAAA9hAAAPYQGoP6dpAAAnc0lEQVR4nO3dcVBc13328WdZBKuoYl1EtFpbGG9UWQbROAUKBkXNxLaoZJdUzbQmdS1ZrtQJShyHUHteMXKNYTxD7MSqndRQE1t2VckKje1koikh3Zm0NjLTUiHciYIbuxYtSF5EgWaXxAHi5b5/qGy1XpC5a7SHZb+fmfvHPXvu7m/njrQP59x7rsOyLEsAAACGpJkuAAAApDbCCAAAMIowAgAAjCKMAAAAowgjAADAKMIIAAAwijACAACMIowAAACjCCMAAMAowggAADAqrjDS0tIin88nl8ul4uJidXV1Xbb/U089pfz8fK1cuVKbNm3SkSNH4ioWAAAsP+l2D2hvb1dtba1aWlq0ZcsWPf3009qxY4f6+/t17bXXxvRvbW1VfX29vvWtb+m3f/u31dPToz/7sz/Tr//6r6uqqmpRvgQAAEheDrsPyisrK1NRUZFaW1sjbfn5+dq5c6eam5tj+ldUVGjLli362te+Fmmrra3VqVOndPLkyQ9ROgAAWA5sjYxMT0+rt7dXBw4ciGqvrKxUd3f3nMdMTU3J5XJFta1cuVI9PT361a9+pRUrVsx5zNTUVGR/ZmZG4+PjWrNmjRwOh52SAQCAIZZlaWJiQldffbXS0ua/MsRWGBkdHVU4HJbH44lq93g8Gh4envOY3/3d39UzzzyjnTt3qqioSL29vTp8+LB+9atfaXR0VF6vN+aY5uZmNTY22ikNAAAsUUNDQ1q/fv28r9u+ZkRSzOiEZVnzjlj8xV/8hYaHh3XTTTfJsix5PB7t2bNHjz32mJxO55zH1NfXq66uLrIfDAZ17bXXamhoSFlZWfGUDAAAEiwUCik3N1erV6++bD9bYSQnJ0dOpzNmFGRkZCRmtGTWypUrdfjwYT399NO6cOGCvF6v2tratHr1auXk5Mx5TGZmpjIzM2Pas7KyCCMAACSZD7rEwtatvRkZGSouLpbf749q9/v9qqiouOyxK1as0Pr16+V0OvXtb39bv/d7v3fZ+SMAAJAabE/T1NXVadeuXSopKVF5ebna2to0ODiompoaSRenWM6fPx9ZS+TNN99UT0+PysrK9D//8z86dOiQzpw5o7/5m79Z3G8CAACSku0wUl1drbGxMTU1NSkQCKiwsFAdHR3Ky8uTJAUCAQ0ODkb6h8NhPf744/rpT3+qFStW6NOf/rS6u7t13XXXLdqXAAAAycv2OiMmhEIhud1uBYNBrhkBACBJLPT3m4s2AACAUYQRAABgFGEEAAAYRRgBAABGxbUCKwAAuLLCM5Z6BsY1MjGptatdKvVly5m2PJ/PRhgBAGCJ6TwTUOOJfgWCk5E2r9ulhqoCbS+MfaZbsmOaBgCAJaTzTED7j56OCiKSNByc1P6jp9V5JmCosiuHMAIAwBIRnrHUeKJfcy0ANtvWeKJf4Zklv0SYLYQRAACWiJ6B8ZgRkUtZkgLBSfUMjCeuqAQgjAAAsESMTMwfROLplywIIwAALBFrV7sWtV+yIIwAALBElPqy5XW7NN8NvA5dvKum1JedyLKuOMIIAABLhDPNoYaqAkmKCSSz+w1VBctuvRHCCAAAS8j2Qq9a7yrSOnf0VMw6t0utdxUty3VGWPQMAIAlZnuhV9sK1rECKwAAMMeZ5lD5hjWmy0gIpmkAAIBRhBEAAGAUYQQAABhFGAEAAEYRRgAAgFGEEQAAYBRhBAAAGEUYAQAARhFGAACAUYQRAABgFGEEAAAYRRgBAABGEUYAAIBRhBEAAGAUYQQAABhFGAEAAEYRRgAAgFGEEQAAYBRhBAAAGEUYAQAARhFGAACAUYQRAABgFGEEAAAYRRgBAABGEUYAAIBRcYWRlpYW+Xw+uVwuFRcXq6ur67L9jx07phtvvFEf+chH5PV6dc8992hsbCyuggEAwPJiO4y0t7ertrZWBw8eVF9fn7Zu3aodO3ZocHBwzv4nT57U7t27tXfvXv3kJz/Rd77zHf3rv/6r9u3b96GLBwAAyc92GDl06JD27t2rffv2KT8/X0888YRyc3PV2to6Z/9//ud/1nXXXaf77rtPPp9Pn/zkJ/X5z39ep06d+tDFAwCA5GcrjExPT6u3t1eVlZVR7ZWVleru7p7zmIqKCp07d04dHR2yLEsXLlzQiy++qNtvv33ez5mamlIoFIraAADA8mQrjIyOjiocDsvj8US1ezweDQ8Pz3lMRUWFjh07purqamVkZGjdunW66qqr9M1vfnPez2lubpbb7Y5subm5dsoEAABJJK4LWB0OR9S+ZVkxbbP6+/t133336aGHHlJvb686Ozs1MDCgmpqaed+/vr5ewWAwsg0NDcVTJgAASALpdjrn5OTI6XTGjIKMjIzEjJbMam5u1pYtW/TAAw9Ikj7+8Y9r1apV2rp1qx555BF5vd6YYzIzM5WZmWmnNAAAkKRsjYxkZGSouLhYfr8/qt3v96uiomLOY959912lpUV/jNPplHRxRAUAAKQ229M0dXV1euaZZ3T48GG98cYb+spXvqLBwcHItEt9fb12794d6V9VVaWXX35Zra2tOnv2rF577TXdd999Ki0t1dVXX7143wQAACQlW9M0klRdXa2xsTE1NTUpEAiosLBQHR0dysvLkyQFAoGoNUf27NmjiYkJ/dVf/ZX+/M//XFdddZVuvvlmPfroo4v3LQAAQNJyWEkwVxIKheR2uxUMBpWVlWW6HAAAsAAL/f3m2TQAAMAowggAADCKMAIAAIwijAAAAKMIIwAAwCjCCAAAMIowAgAAjCKMAAAAowgjAADAKMIIAAAwijACAACMIowAAACjCCMAAMAowggAADCKMAIAAIwijAAAAKMIIwAAwCjCCAAAMIowAgAAjCKMAAAAowgjAADAKMIIAAAwijACAACMIowAAACjCCMAAMAowggAADCKMAIAAIwijAAAAKMIIwAAwCjCCAAAMIowAgAAjCKMAAAAowgjAADAKMIIAAAwKt10AUhN4RlLPQPjGpmY1NrVLpX6suVMc5guCwBgAGEECdd5JqDGE/0KBCcjbV63Sw1VBdpe6DVYGQDABKZpkFCdZwLaf/R0VBCRpOHgpPYfPa3OMwFDlQEATCGMIGHCM5YaT/TLmuO12bbGE/0Kz8zVAwCwXBFGkDA9A+MxIyKXsiQFgpPqGRhPXFEAAOMII0iYkYn5g0g8/QAAywNhBAmzdrVrUfsBAJaHuMJIS0uLfD6fXC6XiouL1dXVNW/fPXv2yOFwxGybN2+Ou2gkp1Jftrxul+a7gdehi3fVlPqyE1kWAMAw22Gkvb1dtbW1OnjwoPr6+rR161bt2LFDg4ODc/Z/8sknFQgEItvQ0JCys7P1R3/0Rx+6eCQXZ5pDDVUFkhQTSGb3G6oKWG8EAFKMw7IsW7culJWVqaioSK2trZG2/Px87dy5U83NzR94/Pe+9z199rOf1cDAgPLy8hb0maFQSG63W8FgUFlZWXbKxRLEOiMAkBoW+vtta9Gz6elp9fb26sCBA1HtlZWV6u7uXtB7PPvss7r11lsvG0SmpqY0NTUV2Q+FQnbKxBK3vdCrbQXrWIEVACDJZhgZHR1VOByWx+OJavd4PBoeHv7A4wOBgH7wgx/ohRdeuGy/5uZmNTY22ikNScaZ5lD5hjWmywAALAFxXcDqcET/BWtZVkzbXJ5//nldddVV2rlz52X71dfXKxgMRrahoaF4ygQAAEnA1shITk6OnE5nzCjIyMhIzGjJ+1mWpcOHD2vXrl3KyMi4bN/MzExlZmbaKQ0AACQpWyMjGRkZKi4ult/vj2r3+/2qqKi47LGvvPKK/uM//kN79+61XyUAAFi2bD+1t66uTrt27VJJSYnKy8vV1tamwcFB1dTUSLo4xXL+/HkdOXIk6rhnn31WZWVlKiwsXJzKAQDAsmA7jFRXV2tsbExNTU0KBAIqLCxUR0dH5O6YQCAQs+ZIMBjUSy+9pCeffHJxqgYAAMuG7XVGTGCdEQAAks9Cf795Ng0AADCKMAIAAIwijAAAAKMIIwAAwCjCCAAAMIowAgAAjCKMAAAAowgjAADAKMIIAAAwijACAACMIowAAACjCCMAAMAowggAADCKMAIAAIwijAAAAKPSTReAxArPWOoZGNfIxKTWrnap1JctZ5rDdFkAgBRGGEkhnWcCajzRr0BwMtLmdbvUUFWg7YVeg5XhUgRGAKmGMJIiOs8EtP/oaVnvax8OTmr/0dNqvauIQLIEEBgBpCKuGUkB4RlLjSf6Y4KIpEhb44l+hWfm6oFEmQ2MlwYR6f8CY+eZgKHKAODKIoykgJ6B8ZgfuEtZkgLBSfUMjCeuKEQhMAJIZYSRFDAyMX8QiacfFh+BEUAqI4ykgLWrXYvaD4uPwAgglXEBawoo9WXL63ZpODg55zSAQ9I698W7NmAGgRFLAXdywRTCSApwpjnUUFWg/UdPyyFFBZLZ/2Yaqgr4T8cgAiNM404umMQ0TYrYXuhV611FWueO/st6ndvFbb1LwGxglP4vIM4iMOJK404umOawLGvJX54fCoXkdrsVDAaVlZVlupykxjDs0sZfp0i08IylTz76o3kvoJ4dlTv5/27m/wrYttDfb6ZpUowzzaHyDWtMl4F5bC/0alvBOgIjEsbOnVz834ErhTACLDEERiQSd3JhKeCaEQBIYdzJhaWAMAIAKWz2Tq75JgIdunjdEndy4UoijABACuNOLiwFhBEASHHc+g/TuIAVAMCdXDCKMAIAkMSdXDCHaRoAAGAUYQQAABhFGAEAAEYRRgAAgFGEEQAAYBRhBAAAGEUYAQAARsUVRlpaWuTz+eRyuVRcXKyurq7L9p+amtLBgweVl5enzMxMbdiwQYcPH46rYAAAsLzYXvSsvb1dtbW1amlp0ZYtW/T0009rx44d6u/v17XXXjvnMXfccYcuXLigZ599Vr/xG7+hkZERvffeex+6eAAAkPwclmVZdg4oKytTUVGRWltbI235+fnauXOnmpubY/p3dnbqc5/7nM6ePavs7Pie+hgKheR2uxUMBpWVlRXXewAAgMRa6O+3rWma6elp9fb2qrKyMqq9srJS3d3dcx7z/e9/XyUlJXrsscd0zTXX6Prrr9f999+vX/7yl/N+ztTUlEKhUNQGAACWJ1vTNKOjowqHw/J4PFHtHo9Hw8PDcx5z9uxZnTx5Ui6XS9/97nc1OjqqL3zhCxofH5/3upHm5mY1NjbaKQ0AACSpuC5gdTiin+JoWVZM26yZmRk5HA4dO3ZMpaWluu2223To0CE9//zz846O1NfXKxgMRrahoaF4ygQAAEnA1shITk6OnE5nzCjIyMhIzGjJLK/Xq2uuuUZutzvSlp+fL8uydO7cOW3cuDHmmMzMTGVmZtopDQAAJClbIyMZGRkqLi6W3++Pavf7/aqoqJjzmC1btuidd97Rz3/+80jbm2++qbS0NK1fvz6OkgEAwHJie5qmrq5OzzzzjA4fPqw33nhDX/nKVzQ4OKiamhpJF6dYdu/eHel/5513as2aNbrnnnvU39+vV199VQ888ID+9E//VCtXrly8bwIAAJKS7XVGqqurNTY2pqamJgUCARUWFqqjo0N5eXmSpEAgoMHBwUj/X/u1X5Pf79eXvvQllZSUaM2aNbrjjjv0yCOPLN63AAAAScv2OiMmsM4IAADJ54qsMwIAALDYCCMAAMAowggAADCKMAIAAIwijAAAAKMIIwAAwCjCCAAAMIowAgAAjCKMAAAAowgjAADAKMIIAAAwijACAACMIowAAACj0k0XYEp4xlLPwLhGJia1drVLpb5sOdMcpssCACDlpGQY6TwTUOOJfgWCk5E2r9ulhqoCbS/0GqwMAIDUk3LTNJ1nAtp/9HRUEJGk4eCk9h89rc4zAUOVAQCQmlIqjIRnLDWe6Jc1x2uzbY0n+hWemasHAAC4ElIqjPQMjMeMiFzKkhQITqpnYDxxRQEAkOJSKoyMTMwfROLpBwAAPryUCiNrV7sWtR8AAPjwUiqMlPqy5XW7NN8NvA5dvKum1JedyLIAAEhpKRVGnGkONVQVSFJMIJndb6gqYL0RAAASKKXCiCRtL/Sq9a4irXNHT8Wsc7vUelcR64wAAJBgKbno2fZCr7YVrGMFVgAAloCUDCPSxSmb8g1rTJcBAEDKS7lpGgAAsLQQRgAAgFGEEQAAYBRhBAAAGEUYAQAARhFGAACAUYQRAABgFGEEAAAYRRgBAABGEUYAAIBRhBEAAGAUYQQAABhFGAEAAEYRRgAAgFGEEQAAYBRhBAAAGBVXGGlpaZHP55PL5VJxcbG6urrm7ftP//RPcjgcMdu///u/x100AABYPmyHkfb2dtXW1urgwYPq6+vT1q1btWPHDg0ODl72uJ/+9KcKBAKRbePGjXEXDQAAlg/bYeTQoUPau3ev9u3bp/z8fD3xxBPKzc1Va2vrZY9bu3at1q1bF9mcTmfcRQMAgOXDVhiZnp5Wb2+vKisro9orKyvV3d192WN/67d+S16vV7fccov+8R//8bJ9p6amFAqFojYAALA82Qojo6OjCofD8ng8Ue0ej0fDw8NzHuP1etXW1qaXXnpJL7/8sjZt2qRbbrlFr7766ryf09zcLLfbHdlyc3PtlAkAAJJIejwHORyOqH3LsmLaZm3atEmbNm2K7JeXl2toaEhf//rX9Tu/8ztzHlNfX6+6urrIfigUIpAAALBM2RoZycnJkdPpjBkFGRkZiRktuZybbrpJb7311ryvZ2ZmKisrK2oDAADLk60wkpGRoeLiYvn9/qh2v9+vioqKBb9PX1+fvF6vnY8GAADLlO1pmrq6Ou3atUslJSUqLy9XW1ubBgcHVVNTI+niFMv58+d15MgRSdITTzyh6667Tps3b9b09LSOHj2ql156SS+99NLifhMAAJCUbIeR6upqjY2NqampSYFAQIWFhero6FBeXp4kKRAIRK05Mj09rfvvv1/nz5/XypUrtXnzZv393/+9brvttsX7FgAAIGk5LMuyTBfxQUKhkNxut4LBINePAACQJBb6+82zaQAAgFGEEQAAYBRhBAAAGEUYAQAARhFGAACAUYQRAABgFGEEAAAYRRgBAABGEUYAAIBRhBEAAGCU7WfTAACA5SE8Y6lnYFwjE5Nau9qlUl+2nGmOhNdBGAEAIAV1ngmo8US/AsHJSJvX7VJDVYG2F3oTWgvTNAAApJjOMwHtP3o6KohI0nBwUvuPnlbnmUBC6yGMAACQQsIzlhpP9Mua47XZtsYT/QrPzNXjyiCMAACQQnoGxmNGRC5lSQoEJ9UzMJ6wmggjAACkkJGJ+YNIPP0WA2EEAIAUsna1a1H7LQbCCAAAKaTUly2v26X5buB16OJdNaW+7ITVRBgBACCFONMcaqgqkKSYQDK731BVkND1RggjAACkmO2FXrXeVaR17uipmHVul1rvKkr4OiMsegYAQAraXujVtoJ1rMAKAADMcaY5VL5hjekymKYBAABmEUYAAIBRhBEAAGAUYQQAABhFGAEAAEYRRgAAgFGEEQAAYBRhBAAAGEUYAQAARhFGAACAUYQRAABgFGEEAAAYRRgBAABGEUYAAIBRhBEAAGAUYQQAABhFGAEAAEYRRgAAgFFxhZGWlhb5fD65XC4VFxerq6trQce99tprSk9P1yc+8Yl4PhYAACxDtsNIe3u7amtrdfDgQfX19Wnr1q3asWOHBgcHL3tcMBjU7t27dcstt8RdLAAAWH4clmVZdg4oKytTUVGRWltbI235+fnauXOnmpub5z3uc5/7nDZu3Cin06nvfe97ev311xf8maFQSG63W8FgUFlZWXbKBQAAhiz099vWyMj09LR6e3tVWVkZ1V5ZWanu7u55j3vuuef09ttvq6Ghwc7HAQCAFJBup/Po6KjC4bA8Hk9Uu8fj0fDw8JzHvPXWWzpw4IC6urqUnr6wj5uamtLU1FRkPxQK2SkTAAAkkbguYHU4HFH7lmXFtElSOBzWnXfeqcbGRl1//fULfv/m5ma53e7IlpubG0+ZAAAgCdgKIzk5OXI6nTGjICMjIzGjJZI0MTGhU6dO6d5771V6errS09PV1NSkf/u3f1N6erp+9KMfzfk59fX1CgaDkW1oaMhOmQAAIInYmqbJyMhQcXGx/H6//uAP/iDS7vf79fu///sx/bOysvTjH/84qq2lpUU/+tGP9OKLL8rn8835OZmZmcrMzLRTGgAASFK2wogk1dXVadeuXSopKVF5ebna2to0ODiompoaSRdHNc6fP68jR44oLS1NhYWFUcevXbtWLpcrph0AAKQm22GkurpaY2NjampqUiAQUGFhoTo6OpSXlydJCgQCH7jmCAAAwCzb64yYwDojAAAknyuyzggAAMBiI4wAAACjCCMAAMAowggAADCKMAIAAIwijAAAAKMIIwAAwCjCCAAAMIowAgAAjCKMAAAAowgjAADAKNsPygMAmBGesdQzMK6RiUmtXe1SqS9bzjSH6bKAD40wAgBJoPNMQI0n+hUITkbavG6XGqoKtL3Qa7Ay4MNjmgYAlrjOMwHtP3o6KohI0nBwUvuPnlbnmYChyoDFQRgBgCUsPGOp8US/rDlem21rPNGv8MxcPYDkQBgBgCWsZ2A8ZkTkUpakQHBSPQPjiSsKWGSEEQBYwkYm5g8i8fQDliLCCAAsYWtXuxa1H7AUEUYAYAkr9WXL63Zpvht4Hbp4V02pLzuRZQGLijACAEuYM82hhqoCSYoJJLP7DVUFrDeCpEYYAYAlbnuhV613FWmdO3oqZp3bpda7ilhnBEmPRc8AIAlsL/RqW8E6VmDFskQYAYAk4UxzqHzDGtNlAIuOaRoAAGAUYQQAABhFGAEAAEYRRgAAgFGEEQAAYBRhBAAAGEUYAQAARhFGAACAUYQRAABgFGEEAAAYRRgBAABG8WwaYBkJz1g8SA1A0iGMAMtE55mAGk/0KxCcjLR53S41VBXwiHkASxrTNMAy0HkmoP1HT0cFEUkaDk5q/9HT6jwTMFQZAHwwwgiQ5MIzlhpP9Mua47XZtsYT/QrPzNUDAMwjjABJrmdgPGZE5FKWpEBwUj0D44krCgBsIIwASW5kYv4gEk8/AEg0wgiQ5Naudi1qPwBItLjCSEtLi3w+n1wul4qLi9XV1TVv35MnT2rLli1as2aNVq5cqRtuuEF/+Zd/GXfBAKKV+rLldbs03w28Dl28q6bUl53IsgBgwWyHkfb2dtXW1urgwYPq6+vT1q1btWPHDg0ODs7Zf9WqVbr33nv16quv6o033tCDDz6oBx98UG1tbR+6eACSM82hhqoCSYoJJLP7DVUFrDcCYMlyWJZl6xL7srIyFRUVqbW1NdKWn5+vnTt3qrm5eUHv8dnPflarVq3S3/7t3y6ofygUktvtVjAYVFZWlp1ygZTBOiMAlpqF/n7bWvRsenpavb29OnDgQFR7ZWWluru7F/QefX196u7u1iOPPGLnowF8gO2FXm0rWMcKrACSjq0wMjo6qnA4LI/HE9Xu8Xg0PDx82WPXr1+v//7v/9Z7772nhx9+WPv27Zu379TUlKampiL7oVDITplAynKmOVS+YY3pMgDAlrguYHU4ov/Ssiwrpu39urq6dOrUKf31X/+1nnjiCR0/fnzevs3NzXK73ZEtNzc3njIBAEASsDUykpOTI6fTGTMKMjIyEjNa8n4+n0+S9Ju/+Zu6cOGCHn74Yf3xH//xnH3r6+tVV1cX2Q+FQgQSAACWKVsjIxkZGSouLpbf749q9/v9qqioWPD7WJYVNQ3zfpmZmcrKyoraAADA8mT7qb11dXXatWuXSkpKVF5erra2Ng0ODqqmpkbSxVGN8+fP68iRI5Kkp556Stdee61uuOEGSRfXHfn617+uL33pS4v4NQAAQLKyHUaqq6s1NjampqYmBQIBFRYWqqOjQ3l5eZKkQCAQtebIzMyM6uvrNTAwoPT0dG3YsEFf/epX9fnPf37xvgUAAEhattcZMYF1RgAASD4L/f3m2TQAAMAowggAADCKMAIAAIwijAAAAKMIIwAAwCjCCAAAMIowAgAAjCKMAAAAowgjAADAKMIIAAAwijACAACMIowAAACjCCMAAMAowggAADCKMAIAAIwijAAAAKMIIwAAwCjCCAAAMIowAgAAjCKMAAAAowgjAADAKMIIAAAwijACAACMIowAAACjCCMAAMAowggAADCKMAIAAIwijAAAAKMIIwAAwKh00wUAAOYWnrHUMzCukYlJrV3tUqkvW840h+mygEVHGAGAJajzTECNJ/oVCE5G2rxulxqqCrS90GuwMmDxMU0DAEtM55mA9h89HRVEJGk4OKn9R0+r80zAUGXAlUEYAYAlJDxjqfFEv6w5XpttazzRr/DMXD2A5EQYAYAlpGdgPGZE5FKWpEBwUj0D44krCrjCCCMAsISMTMwfROLpByQDwggALCFrV7sWtR+QDAgjALCElPqy5XW7NN8NvA5dvKum1JedyLKAK4owAgBLiDPNoYaqAkmKCSSz+w1VBaw3gmWFMAIAS8z2Qq9a7yrSOnf0VMw6t0utdxWxzgiWHRY9A4AlaHuhV9sK1rECK1ICYQQAlihnmkPlG9aYLgO44uKapmlpaZHP55PL5VJxcbG6urrm7fvyyy9r27Zt+uhHP6qsrCyVl5frhz/8YdwFAwCA5cV2GGlvb1dtba0OHjyovr4+bd26VTt27NDg4OCc/V999VVt27ZNHR0d6u3t1ac//WlVVVWpr6/vQxcPAACSn8OyLFtrCpeVlamoqEitra2Rtvz8fO3cuVPNzc0Leo/NmzerurpaDz300IL6h0Ihud1uBYNBZWVl2SkXAAAYstDfb1sjI9PT0+rt7VVlZWVUe2Vlpbq7uxf0HjMzM5qYmFB2NvfIAwAAmxewjo6OKhwOy+PxRLV7PB4NDw8v6D0ef/xx/eIXv9Add9wxb5+pqSlNTU1F9kOhkJ0yAQBAEonrAlaHI/rWMsuyYtrmcvz4cT388MNqb2/X2rVr5+3X3Nwst9sd2XJzc+MpEwAAJAFbYSQnJ0dOpzNmFGRkZCRmtOT92tvbtXfvXv3d3/2dbr311sv2ra+vVzAYjGxDQ0N2ygQAAEnEVhjJyMhQcXGx/H5/VLvf71dFRcW8xx0/flx79uzRCy+8oNtvv/0DPyczM1NZWVlRGwAAWJ5sL3pWV1enXbt2qaSkROXl5Wpra9Pg4KBqamokXRzVOH/+vI4cOSLpYhDZvXu3nnzySd10002RUZWVK1fK7XYv4lcBAADJyHYYqa6u1tjYmJqamhQIBFRYWKiOjg7l5eVJkgKBQNSaI08//bTee+89ffGLX9QXv/jFSPvdd9+t559/fkGfOXv3MReyAgCQPGZ/tz9oFRHb64yYcO7cOS5iBQAgSQ0NDWn9+vXzvp4UYWRmZkbvvPOOVq9evaC7dlJVKBRSbm6uhoaGuM5mieNcJQ/OVXLhfC0tlmVpYmJCV199tdLS5r9MNSkelJeWlnbZRIVoXPSbPDhXyYNzlVw4X0vHQq4PjWudEQAAgMVCGAEAAEYRRpaRzMxMNTQ0KDMz03Qp+ACcq+TBuUounK/klBQXsAIAgOWLkREAAGAUYQQAABhFGAEAAEYRRgAAgFGEkSTS0tIin88nl8ul4uJidXV1zdv35Zdf1rZt2/TRj35UWVlZKi8v1w9/+MMEVgs75+tSr732mtLT0/WJT3ziyhaICLvnampqSgcPHlReXp4yMzO1YcMGHT58OEHVwu75OnbsmG688UZ95CMfkdfr1T333KOxsbEEVYsFsZAUvv3tb1srVqywvvWtb1n9/f3Wl7/8ZWvVqlXWf/3Xf83Z/8tf/rL16KOPWj09Pdabb75p1dfXWytWrLBOnz6d4MpTk93zNetnP/uZ9bGPfcyqrKy0brzxxsQUm+LiOVef+cxnrLKyMsvv91sDAwPWv/zLv1ivvfZaAqtOXXbPV1dXl5WWlmY9+eST1tmzZ62uri5r8+bN1s6dOxNcOS6HMJIkSktLrZqamqi2G264wTpw4MCC36OgoMBqbGxc7NIwh3jPV3V1tfXggw9aDQ0NhJEEsXuufvCDH1hut9saGxtLRHl4H7vn62tf+5r1sY99LKrtG9/4hrV+/forViPsY5omCUxPT6u3t1eVlZVR7ZWVleru7l7Qe8zMzGhiYkLZ2dlXokRcIt7z9dxzz+ntt99WQ0PDlS4R/yuec/X9739fJSUleuyxx3TNNdfo+uuv1/33369f/vKXiSg5pcVzvioqKnTu3Dl1dHTIsixduHBBL774om6//fZElIwFSooH5aW60dFRhcNheTyeqHaPx6Ph4eEFvcfjjz+uX/ziF7rjjjuuRIm4RDzn66233tKBAwfU1dWl9HT+WSZKPOfq7NmzOnnypFwul7773e9qdHRUX/jCFzQ+Ps51I1dYPOeroqJCx44dU3V1tSYnJ/Xee+/pM5/5jL75zW8momQsECMjScThcETtW5YV0zaX48eP6+GHH1Z7e7vWrl17pcrD+yz0fIXDYd15551qbGzU9ddfn6jycAk7/7ZmZmbkcDh07NgxlZaW6rbbbtOhQ4f0/PPPMzqSIHbOV39/v+677z499NBD6u3tVWdnpwYGBlRTU5OIUrFA/AmWBHJycuR0OmOS/8jISMxfCO/X3t6uvXv36jvf+Y5uvfXWK1km/pfd8zUxMaFTp06pr69P9957r6SLP3iWZSk9PV3/8A//oJtvvjkhtaeaeP5teb1eXXPNNVGPRc/Pz5dlWTp37pw2btx4RWtOZfGcr+bmZm3ZskUPPPCAJOnjH/+4Vq1apa1bt+qRRx6R1+u94nXjgzEykgQyMjJUXFwsv98f1e73+1VRUTHvccePH9eePXv0wgsvMD+aQHbPV1ZWln784x/r9ddfj2w1NTXatGmTXn/9dZWVlSWq9JQTz7+tLVu26J133tHPf/7zSNubb76ptLQ0rV+//orWm+riOV/vvvuu0tKif+qcTqekiyMqWCLMXTsLO2ZvZ3v22Wet/v5+q7a21lq1apX1n//5n5ZlWdaBAwesXbt2Rfq/8MILVnp6uvXUU09ZgUAgsv3sZz8z9RVSit3z9X7cTZM4ds/VxMSEtX79eusP//APrZ/85CfWK6+8Ym3cuNHat2+fqa+QUuyer+eee85KT0+3WlparLfffts6efKkVVJSYpWWlpr6CpgDYSSJPPXUU1ZeXp6VkZFhFRUVWa+88krktbvvvtv61Kc+Fdn/1Kc+ZUmK2e6+++7EF56i7Jyv9yOMJJbdc/XGG29Yt956q7Vy5Upr/fr1Vl1dnfXuu+8muOrUZfd8feMb37AKCgqslStXWl6v1/qTP/kT69y5cwmuGpfjsCzGqQAAgDlcMwIAAIwijAAAAKMIIwAAwCjCCAAAMIowAgAAjCKMAAAAowgjAADAKMIIAAAwijACAACMIowAAACjCCMAAMAowggAADDq/wPEt2pAnEfmGQAAAABJRU5ErkJggg==", "text/plain": [ "
" ] @@ -661,7 +662,7 @@ "outputs": [ { "data": { - "image/png": "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zjlHfr9fd999t/r6+pJUrUsYB/rTn/5kJk2aZF5++WXT0dFhHnroIXPuueeazz77LGb7AwcOmHPOOcc89NBDpqOjw7z88stm0qRJ5q233kpy5c4Sbz889NBD5sknnzS7du0yn3zyiVm9erWZNGmS2b17d5Ird5Z4+yHiiy++MBdffLGpqqoyV111VXKKdbBE+uGWW24xc+fONa2traazs9P861//Mu+//34Sq3aeePuhra3NZGRkmGeffdYcOHDAtLW1mSuuuMLceuutSa7c2RwZRsrLy01tbW3Utcsvv9ysWrUqZvtf/vKX5vLLL4+6du+995prrrnmrNXoBvH2QyxFRUWmvr5+oktzlUT7oaamxvzmN78xdXV1hJEJEG8//PWvfzU+n8/09fUlozzXiLcffve735mLL7446tpzzz1nZs6cedZqdCPHTdMcO3ZM7e3tqqqqirpeVVWlHTt2xHzOzp07R7W/8cYb9cEHH+jbb789a7U6WSL9cLJwOKzBwUFNnTr1bJToCon2wyuvvKL9+/errq7ubJfoCon0w7vvvquysjI99dRTuvDCC3XppZfqkUce0TfffJOMkh0pkX6orKzUoUOH1NzcLGOMPv/8c7311lu6+eabk1Gya6TFjfLi0dvbq1AopLy8vKjreXl56u7ujvmc7u7umO2PHz+u3t5e+f3+s1avUyXSDyf7/e9/r6+++kq333772SjRFRLph08//VSrVq1SW1ubsrIc91+EFYn0w4EDB7R9+3Z5vV6988476u3t1X333af+/n7WjSQokX6orKzU5s2bVVNTo6GhIR0/fly33HKLnn/++WSU7BqOGxmJ8Hg8UV8bY0ZdO137WNcRn3j7IeKNN97Q448/rqamJs2YMeNsleca4+2HUCikxYsXq76+XpdeemmyynONeP49hMNheTwebd68WeXl5brpppv09NNP69VXX2V05AzF0w8dHR168MEH9dhjj6m9vV0tLS3q7OxUbW1tMkp1Dcd97Jk+fboyMzNHpdyenp5RaTgiPz8/ZvusrCxNmzbtrNXqZIn0Q0RTU5OWLVumN998UzfccMPZLNPx4u2HwcFBffDBB9qzZ48eeOABScNvisYYZWVlaevWrbr++uuTUruTJPLvwe/368ILL4y6/fqcOXNkjNGhQ4d0ySWXnNWanSiRfmhoaNC8efP06KOPSpKuvPJKnXvuuZo/f76eeOIJRs4niONGRrKzs1VaWqrW1tao662traqsrIz5nIqKilHtt27dqrKyMk2aNOms1epkifSDNDwictddd+n1119nTnYCxNsPubm5+vDDD7V3796RR21trS677DLt3btXc+fOTVbpjpLIv4d58+bpyJEj+vLLL0euffLJJ8rIyNDMmTPPar1OlUg/fP3118rIiH6rzMzMlPS/EXRMAFsrZ8+myNatDRs2mI6ODrNixQpz7rnnmv/+97/GGGNWrVplli5dOtI+srX34YcfNh0dHWbDhg1s7Z0A8fbD66+/brKyssy6detMIBAYeXzxxRe2/gRHiLcfTsZumokRbz8MDg6amTNnmttuu8189NFHZtu2beaSSy4x99xzj60/wRHi7YdXXnnFZGVlmfXr15v9+/eb7du3m7KyMlNeXm7rT3AkR4YRY4xZt26dmTVrlsnOzjYlJSVm27ZtI9+78847zXXXXRfV/u9//7v5/ve/b7Kzs81FF11kGhsbk1yxM8XTD9ddd52RNOpx5513Jr9wh4n338OJCCMTJ95++Pjjj80NN9xgJk+ebGbOnGlWrlxpvv766yRX7Tzx9sNzzz1nioqKzOTJk43f7zc/+clPzKFDh5JctbN5jGGcCQAA2OO4NSMAACC9EEYAAIBVhBEAAGAVYQQAAFhFGAEAAFYRRgAAgFWEEQAAYBVhBAAAWEUYAQAAVhFGAACAVYQRAABgFWEEAABY9f8Alw4iNhhoKkMAAAAASUVORK5CYII=", + "image/png": "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", "text/plain": [ "
" ] From b6d16b8ac49fac82a3f919fd21d558804b4983e1 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Thu, 29 Jun 2023 11:10:08 -0700 Subject: [PATCH 58/81] Update docstring --- pyiron_contrib/workflow/function.py | 43 +++++++++++++++++++---------- 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/pyiron_contrib/workflow/function.py b/pyiron_contrib/workflow/function.py index b55a2f071..3cb6cdc8a 100644 --- a/pyiron_contrib/workflow/function.py +++ b/pyiron_contrib/workflow/function.py @@ -93,32 +93,38 @@ class Function(Node): >>> plus_minus_1 = Function(mwe, "p1", "m1") >>> >>> print(plus_minus_1.outputs.p1) - None + There is no output because we haven't given our function any input, it has - no defaults, and we never ran it! + no defaults, and we never ran it! So it has the channel default value of + `NotData` -- a special non-data class (since `None` is sometimes a meaningful + value in python). We'll run into a hiccup if we try to set only one of the inputs and update >>> plus_minus_1.inputs.x = 1 >>> plus_minus_1.run() TypeError - This is because the second input (y) still has no input value so we can't do the - sum. + This is because the second input (y) still has no input value, so we can't do + the sum. Let's set the node to run automatically when its inputs are updated, then update x and y. >>> plus_minus_1.run_on_updates = True >>> plus_minus_1.inputs.x = 2 - TypeError + >>> print(plus_minus_1.outputs.p1.value) + + + The gentler `update()` call sees that the `y` input is still `NotData`, so it + does not proceed to the `run()` and the output is not yet updated. - What happened here? Well, since we didn't offer any type hints for the function, - when updating the `x` value triggered the node update, it didn't see any - trouble with the other inputs and tried to run! First, let's provide a y-value - as well, then go back and see how to avoid this. + Let's provide a y-value as well: >>> plus_minus_1.inputs.y = 3 >>> plus_minus_1.outputs.to_value_dict() {'p1': 3, 'm1': 2} + Now that both inputs have been provided, the node update triggers a run and we + get the expected output. + We can also, optionally, provide initial values for some or all of the input >>> plus_minus_1 = Function( ... mwe, "p1", "m1", @@ -146,16 +152,24 @@ class Function(Node): We can provide initial values for our node function at instantiation using our kwargs. The node update is deferred until _all_ of these initial values are processed. - Thus, the second solution is to ensure that _all_ the arguments of our function - are receiving good enough initial values to facilitate an execution of the node - function at the end of instantiation: - >>> plus_minus_1 = Function(mwe, "p1", "m1", x=1, y=2) + Thus, if _all_ the arguments of our function are receiving good enough initial + values to facilitate an execution of the node function at the end of + instantiation, the output gets updated right away: + >>> plus_minus_1 = Function( + ... mwe, "p1", "m1", + ... x=1, y=2, + ... run_on_updates=True, update_on_instantiation=True + ... ) >>> >>> print(plus_minus_1.outputs.to_value_dict()) {'p1': 2, 'm1': 1} Second, we could add type hints/defaults to our function so that it knows better than to try to evaluate itself with bad data. + You can always force the node to run with its current input using `run()`, but + `update()` will always check if the node is `ready` -- i.e. if none of its + inputs are `NotData` and all of them obey any type hints that have been + provided. Let's make a new node following the second path. In this example, note the mixture of old-school (`typing.Union`) and new (`|`) @@ -176,7 +190,8 @@ class Function(Node): ... run_on_updates=True, update_on_instantiation=True ... ) >>> plus_minus_1.outputs.to_value_dict() - {'p1': None, 'm1': None} + {'p1': , 'm1': } Here we got an update automatically at the end of instantiation, but because both values are type hinted this didn't result in any errors! From 3608cd6c334b6f9b3bbb873075039d4bf0a4bea4 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Thu, 29 Jun 2023 11:10:16 -0700 Subject: [PATCH 59/81] Fix typo --- pyiron_contrib/workflow/function.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyiron_contrib/workflow/function.py b/pyiron_contrib/workflow/function.py index 3cb6cdc8a..b9969e571 100644 --- a/pyiron_contrib/workflow/function.py +++ b/pyiron_contrib/workflow/function.py @@ -314,7 +314,7 @@ class Function(Node): >>> ... >>> return x - For this function, you don't have a freedom to choose `self`, because + For this function, you don't have the freedom to choose `self`, because pyiron automatically sets the node object there (which is also the reason why you do not see `self` in the list of inputs). """ From c85afdd8b92ab10308c717b7256ed70cedb4bbb3 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Thu, 29 Jun 2023 11:54:14 -0700 Subject: [PATCH 60/81] Make fast the default behaviour for function nodes --- pyiron_contrib/workflow/function.py | 4 ++-- tests/unit/workflow/test_function.py | 12 ++++++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/pyiron_contrib/workflow/function.py b/pyiron_contrib/workflow/function.py index b9969e571..d80dfe622 100644 --- a/pyiron_contrib/workflow/function.py +++ b/pyiron_contrib/workflow/function.py @@ -324,8 +324,8 @@ def __init__( node_function: callable, *output_labels: str, label: Optional[str] = None, - run_on_updates: bool = False, - update_on_instantiation: bool = False, + run_on_updates: bool = True, + update_on_instantiation: bool = True, channels_requiring_update_after_run: Optional[list[str]] = None, parent: Optional[Composite] = None, **kwargs, diff --git a/tests/unit/workflow/test_function.py b/tests/unit/workflow/test_function.py index be76d878e..f32347fde 100644 --- a/tests/unit/workflow/test_function.py +++ b/tests/unit/workflow/test_function.py @@ -74,6 +74,14 @@ def test_instantiation_update(self): ) self.assertEqual(2, update.outputs.y.value) + default = Function(plus_one, "y") + self.assertEqual( + 2, + default.outputs.y.value, + msg="Default behaviour should be to run on updates and update on " + "instantiation", + ) + with self.assertRaises(TypeError): run_without_value = Function(no_default, "z") run_without_value.run() @@ -108,7 +116,7 @@ def test_input_kwargs(self): self.assertEqual(4, node2.outputs.y.value, msg="Initialize from connection") def test_automatic_updates(self): - node = Function(throw_error, "no_return", run_on_updates=True) + node = Function(throw_error, "no_return", update_on_instantiation=False) with self.subTest("Shouldn't run for invalid input on update"): node.inputs.x.update("not an int") @@ -146,7 +154,7 @@ def times_two(y): ) def test_statuses(self): - n = Function(plus_one, "p1") + n = Function(plus_one, "p1", run_on_updates=False) self.assertTrue(n.ready) self.assertFalse(n.running) self.assertFalse(n.failed) From d23a83eb2f8aec8ae43645b77c5847256f8cce85 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Thu, 29 Jun 2023 12:21:24 -0700 Subject: [PATCH 61/81] Update the docstring --- pyiron_contrib/workflow/function.py | 136 ++++++++++++---------------- 1 file changed, 59 insertions(+), 77 deletions(-) diff --git a/pyiron_contrib/workflow/function.py b/pyiron_contrib/workflow/function.py index d80dfe622..a227c4be4 100644 --- a/pyiron_contrib/workflow/function.py +++ b/pyiron_contrib/workflow/function.py @@ -48,14 +48,18 @@ class Function(Node): input) and idempotent (not modifying input data in-place, but creating copies where necessary and returning new objects as output). + By default, function nodes will attempt to run whenever one or more inputs is + updated, and will attempt to update on initialization (after setting _all_ initial + input values). + Args: node_function (callable): The function determining the behaviour of the node. *output_labels (str): A name for each return value of the node function. label (str): The node's label. (Defaults to the node function's name.) run_on_updates (bool): Whether to run when you are updated and all your - input is ready. (Default is False). + input is ready. (Default is True). update_on_instantiation (bool): Whether to force an update at the end of - instantiation. (Default is False.) + instantiation. (Default is True.) channels_requiring_update_after_run (list[str]): All the input channels named here will be set to `wait_for_update()` at the end of each node run, such that they are not `ready` again until they have had their `.update` method @@ -96,81 +100,63 @@ class Function(Node): There is no output because we haven't given our function any input, it has - no defaults, and we never ran it! So it has the channel default value of + no defaults, and we never ran it! It tried to `update()` on instantiation, but + the update never got to `run()` because the node could see that some its input + had never been specified. So outputs have the channel default value of `NotData` -- a special non-data class (since `None` is sometimes a meaningful value in python). - We'll run into a hiccup if we try to set only one of the inputs and update - >>> plus_minus_1.inputs.x = 1 + We'll run into a hiccup if we try to set only one of the inputs and force the + run: + >>> plus_minus_1.inputs.x = 2 >>> plus_minus_1.run() TypeError - This is because the second input (y) still has no input value, so we can't do + This is because the second input (`y`) still has no input value, so we can't do the sum. - Let's set the node to run automatically when its inputs are updated, then update - x and y. - >>> plus_minus_1.run_on_updates = True - >>> plus_minus_1.inputs.x = 2 - >>> print(plus_minus_1.outputs.p1.value) - - The gentler `update()` call sees that the `y` input is still `NotData`, so it - does not proceed to the `run()` and the output is not yet updated. - - Let's provide a y-value as well: - >>> plus_minus_1.inputs.y = 3 + Once we update `y`, all the input is ready and the automatic `update()` call + will be allowed to proceed to a `run()` call, which succeeds and updates the + output: + >>> plus_minus_1.inputs.x = 3 >>> plus_minus_1.outputs.to_value_dict() {'p1': 3, 'm1': 2} - Now that both inputs have been provided, the node update triggers a run and we - get the expected output. - We can also, optionally, provide initial values for some or all of the input - >>> plus_minus_1 = Function( - ... mwe, "p1", "m1", - ... x=1, - ... run_on_updates=True - ) + >>> plus_minus_1 = Function(mwe, "p1", "m1", x=1) >>> plus_minus_1.inputs.y = 2 # Automatically triggers an update call now >>> plus_minus_1.outputs.to_value_dict() {'p1': 2, 'm1': 1} - Finally, we might want the node to be ready-to-go right after instantiation. - To do this, we need to provide initial values for everything and set two flags: + Finally, we might stop these updates from happening automatically, even when + all the input data is present and available: >>> plus_minus_1 = Function( ... mwe, "p1", "m1", ... x=0, y=0, - ... run_on_updates=True, update_on_instantiation=True + ... run_on_updates=False, update_on_instantiation=False ... ) + >>> plus_minus_1.outputs.p1.value + + + With these flags set, the node requires us to manually call a run: + >>> plus_minus_1.run() >>> plus_minus_1.outputs.to_value_dict() {'p1': 1, 'm1': -1} - Another way to stop the node from running with bad input is to provide type - hints (and, optionally, default values) when defining the function the node - wraps. All of these get determined by inspection. - - We can provide initial values for our node function at instantiation using our - kwargs. - The node update is deferred until _all_ of these initial values are processed. - Thus, if _all_ the arguments of our function are receiving good enough initial - values to facilitate an execution of the node function at the end of - instantiation, the output gets updated right away: - >>> plus_minus_1 = Function( - ... mwe, "p1", "m1", - ... x=1, y=2, - ... run_on_updates=True, update_on_instantiation=True - ... ) - >>> - >>> print(plus_minus_1.outputs.to_value_dict()) - {'p1': 2, 'm1': 1} + So function nodes have the most basic level of protection that they won't run + if they haven't seen any input data. + However, we could still get them to raise an error by providing the _wrong_ + data: + >>> plus_minus_1 = Function(mwe, "p1", "m1", x=1, y="can't add to an int") + TypeError - Second, we could add type hints/defaults to our function so that it knows better - than to try to evaluate itself with bad data. - You can always force the node to run with its current input using `run()`, but - `update()` will always check if the node is `ready` -- i.e. if none of its - inputs are `NotData` and all of them obey any type hints that have been - provided. - Let's make a new node following the second path. + Here everything tries to run automatically, but we get an error from adding the + integer and string! + We can make our node even more sensible by adding type + hints (and, optionally, default values) when defining the function that the node + wraps. + The node will automatically figure out defaults and type hints for the IO + channels from inspection of the wrapped function. In this example, note the mixture of old-school (`typing.Union`) and new (`|`) type hints as well as nested hinting with a union-type inside the tuple for the @@ -185,26 +171,23 @@ class Function(Node): ... ) -> tuple[int, int | float]: ... return x+1, y-1 >>> - >>> plus_minus_1 = Function( - ... hinted_example, "p1", "m1", - ... run_on_updates=True, update_on_instantiation=True - ... ) + >>> plus_minus_1 = Function(hinted_example, "p1", "m1", x="not an int") >>> plus_minus_1.outputs.to_value_dict() {'p1': , 'm1': } - Here we got an update automatically at the end of instantiation, but because - both values are type hinted this didn't result in any errors! - Still, we need to provide the rest of the input data in order to get results: - - >>> plus_minus_1.inputs.x = 1 - >>> plus_minus_1.outputs.to_value_dict() - {'p1': 2, 'm1': 0} + Here, even though all the input has data, the node sees that some of it is the + wrong type and so the automatic updates don't proceed all the way to a run. + Note that the type hinting doesn't actually prevent us from assigning bad values + directly to the channel (although it will, by default, prevent connections + _between_ type-hinted channels with incompatible hints), but it _does_ stop the + node from running and throwing an error because it sees that the channel (and + thus node) is not ready + >>> plus_minus_1.inputs.x.value + 'not an int' - Note: the `Fast(Node)` child class will enforce all function arguments to - be type-hinted and have defaults, and will automatically set the updating and - instantiation flags to `True` for nodes that execute quickly and are meant to - _always_ have good output data. + >>> plus_minus_1.ready, plus_minus_1.inputs.x.ready, plus_minus_1.inputs.y.ready + (False, False, True) In these examples, we've instantiated nodes directly from the base `Function` class, and populated their input directly with data. @@ -219,10 +202,7 @@ class Function(Node): and returns a node class: >>> from pyiron_contrib.workflow.function import function_node >>> - >>> @function_node( - ... "p1", "m1", - ... run_on_updates=True, update_on_instantiation=True - ... ) + >>> @function_node("p1", "m1") ... def my_mwe_node( ... x: int | float, y: int | float = 1 ... ) -> tuple[int | float, int | float]: @@ -235,8 +215,7 @@ class Function(Node): Where we've passed the output labels and class arguments to the decorator, and inital values to the newly-created node class (`my_mwe_node`) at instantiation. - Because we told it to run on updates and to update on instantation _and_ we - provided a good initial value for `x`, we get our result right away. + Because we provided a good initial value for `x`, we get our result right away. Using the decorator is the recommended way to create new node classes, but this magic is just equivalent to these two more verbose ways of defining a new class. @@ -254,7 +233,7 @@ class Function(Node): ... super().__init__( ... self.alphabet_mod_three, ... "letter", - ... labe=label, + ... label=label, ... run_on_updates=run_on_updates, ... update_on_instantiation=update_on_instantiation, ... **kwargs @@ -264,6 +243,11 @@ class Function(Node): ... def alphabet_mod_three(i: int) -> Literal["a", "b", "c"]: ... return ["a", "b", "c"][i % 3] + Note that we've overridden the default value for `update_on_instantiation` + above. + We can also provide different defaults for these flags as kwargs in the + decorator. + The second effectively does the same thing, but leverages python's `functools.partialmethod` to do so much more succinctly. In this example, note that the function is declared _before_ `__init__` is set, @@ -280,8 +264,6 @@ class Function(Node): ... Function.__init__, ... adder, ... "sum", - ... run_on_updates=True, - ... update_on_instantiation=True ... ) Finally, let's put it all together by using both of these nodes at once. From 76c5ab554631b46e36c8c8b5cfb18b2f8e355149 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Thu, 29 Jun 2023 12:23:54 -0700 Subject: [PATCH 62/81] Remove unnecessary specification of the defaults --- tests/unit/workflow/test_function.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/tests/unit/workflow/test_function.py b/tests/unit/workflow/test_function.py index f32347fde..aca78389f 100644 --- a/tests/unit/workflow/test_function.py +++ b/tests/unit/workflow/test_function.py @@ -102,16 +102,10 @@ def test_instantiation_update(self): ) def test_input_kwargs(self): - node = Function( - plus_one, - "y", - x=2, - run_on_updates=True, - update_on_instantiation=True - ) + node = Function(plus_one, "y", x=2) self.assertEqual(3, node.outputs.y.value, msg="Initialize from value") - node2 = Function(plus_one, "y", x=node.outputs.y, run_on_updates=True) + node2 = Function(plus_one, "y", x=node.outputs.y) node.update() self.assertEqual(4, node2.outputs.y.value, msg="Initialize from connection") From cdd8cd98916a2cec5d009e7bc591a17ea16b6b21 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Thu, 29 Jun 2023 12:33:28 -0700 Subject: [PATCH 63/81] Reparent SingleValue directly onto Function Now that Function is "fast" by default and we don't explode when no defaults are specified --- pyiron_contrib/workflow/function.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/pyiron_contrib/workflow/function.py b/pyiron_contrib/workflow/function.py index a227c4be4..dc55cd47b 100644 --- a/pyiron_contrib/workflow/function.py +++ b/pyiron_contrib/workflow/function.py @@ -550,9 +550,9 @@ def ensure_params_have_defaults(cls, fnc: callable) -> None: ) -class SingleValue(Fast, HasChannel): +class SingleValue(Function, HasChannel): """ - A fast node that _must_ return only a single value. + A node that _must_ return only a single value. Attribute and item access is modified to finally attempt access on the output value. """ @@ -672,7 +672,6 @@ def single_value_node(*output_labels: str, **node_class_kwargs): def as_single_value_node(node_function: callable): SingleValue.ensure_there_is_only_one_return_value(output_labels) - SingleValue.ensure_params_have_defaults(node_function) return type( node_function.__name__.title().replace("_", ""), # fnc_name to CamelCase (SingleValue,), # Define parentage From d36060c8b44106b4b3509ba4e1d3ac96c4b63ce6 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Thu, 29 Jun 2023 12:45:08 -0700 Subject: [PATCH 64/81] Replace the Fast node with a Slow node now that fast is default --- pyiron_contrib/workflow/composite.py | 4 +-- pyiron_contrib/workflow/function.py | 44 +++++++++--------------- pyiron_contrib/workflow/workflow.py | 2 +- tests/unit/workflow/test_function.py | 27 +++++++++++---- tests/unit/workflow/test_node_package.py | 4 +-- tests/unit/workflow/test_workflow.py | 2 +- 6 files changed, 43 insertions(+), 40 deletions(-) diff --git a/pyiron_contrib/workflow/composite.py b/pyiron_contrib/workflow/composite.py index d5b13a897..21e8a8fb5 100644 --- a/pyiron_contrib/workflow/composite.py +++ b/pyiron_contrib/workflow/composite.py @@ -11,7 +11,7 @@ from warnings import warn from pyiron_contrib.workflow.node import Node -from pyiron_contrib.workflow.function import Function, function_node, fast_node, single_value_node +from pyiron_contrib.workflow.function import Function, function_node, slow_node, single_value_node from pyiron_contrib.workflow.node_library import atomistics, standard from pyiron_contrib.workflow.node_library.package import NodePackage from pyiron_contrib.workflow.util import DotDict @@ -21,7 +21,7 @@ class _NodeDecoratorAccess: """An intermediate container to store node-creating decorators as class methods.""" function_node = function_node - fast_node = fast_node + slow_node = slow_node single_value_node = single_value_node diff --git a/pyiron_contrib/workflow/function.py b/pyiron_contrib/workflow/function.py index dc55cd47b..d9de5d8c6 100644 --- a/pyiron_contrib/workflow/function.py +++ b/pyiron_contrib/workflow/function.py @@ -508,11 +508,12 @@ def to_dict(self): } -class Fast(Function): +class Slow(Function): """ - Like a regular node, but _all_ input channels _must_ have default values provided, - and the initialization signature forces `run_on_updates` and - `update_on_instantiation` to be `True`. + Like a regular node, but `run_on_updates` and `update_on_instantiation` default to + `False`. + This is intended for wrapping function which are potentially expensive to call, + where you don't want the output recomputed unless `run()` is _explicitly_ called. """ def __init__( @@ -520,12 +521,11 @@ def __init__( node_function: callable, *output_labels: str, label: Optional[str] = None, - run_on_updates=True, - update_on_instantiation=True, + run_on_updates=False, + update_on_instantiation=False, parent: Optional[Workflow] = None, **kwargs, ): - self.ensure_params_have_defaults(node_function) super().__init__( node_function, *output_labels, @@ -536,19 +536,6 @@ def __init__( **kwargs, ) - @classmethod - def ensure_params_have_defaults(cls, fnc: callable) -> None: - """Raise a `ValueError` if any parameters of the callable lack defaults.""" - if any( - param.default == inspect._empty - for param in inspect.signature(fnc).parameters.values() - ): - raise ValueError( - f"{cls.__name__} requires all function parameters to have defaults, " - f"but {fnc.__name__} has the parameters " - f"{inspect.signature(fnc).parameters.values()}" - ) - class SingleValue(Function, HasChannel): """ @@ -638,21 +625,22 @@ def as_node(node_function: callable): return as_node -def fast_node(*output_labels: str, **node_class_kwargs): +def slow_node(*output_labels: str, **node_class_kwargs): """ - A decorator for dynamically creating fast node classes from functions. + A decorator for dynamically creating slow node classes from functions. - Unlike normal nodes, fast nodes _must_ have default values set for all their inputs. + Unlike normal nodes, slow nodes do update themselves on initialization and do not + run themselves when they get updated -- i.e. they will not run when their input + changes, `run()` must be explicitly called. """ - def as_fast_node(node_function: callable): - Fast.ensure_params_have_defaults(node_function) + def as_slow_node(node_function: callable): return type( node_function.__name__.title().replace("_", ""), # fnc_name to CamelCase - (Fast,), # Define parentage + (Slow,), # Define parentage { "__init__": partialmethod( - Fast.__init__, + Slow.__init__, node_function, *output_labels, **node_class_kwargs, @@ -660,7 +648,7 @@ def as_fast_node(node_function: callable): }, ) - return as_fast_node + return as_slow_node def single_value_node(*output_labels: str, **node_class_kwargs): diff --git a/pyiron_contrib/workflow/workflow.py b/pyiron_contrib/workflow/workflow.py index 92c9249f1..9ce81342e 100644 --- a/pyiron_contrib/workflow/workflow.py +++ b/pyiron_contrib/workflow/workflow.py @@ -63,7 +63,7 @@ class Workflow(Composite): workflow (cf. the `Node` docs for more detail on the node types). Let's use these to explore a workflow's input and output, which are dynamically generated from the unconnected IO of its nodes: - >>> @Workflow.wrap_as.fast_node("y") + >>> @Workflow.wrap_as.function_node("y") >>> def plus_one(x: int = 0): ... return x + 1 >>> diff --git a/tests/unit/workflow/test_function.py b/tests/unit/workflow/test_function.py index aca78389f..5d2d15869 100644 --- a/tests/unit/workflow/test_function.py +++ b/tests/unit/workflow/test_function.py @@ -6,7 +6,7 @@ from pyiron_contrib.workflow.channels import NotData from pyiron_contrib.workflow.files import DirectoryObject from pyiron_contrib.workflow.function import ( - Fast, Function, SingleValue, function_node, single_value_node + Slow, Function, SingleValue, function_node, single_value_node ) @@ -235,12 +235,27 @@ def with_messed_self(x: float, self) -> float: @unittest.skipUnless(version_info[0] == 3 and version_info[1] >= 10, "Only supported for 3.10+") -class TestFast(unittest.TestCase): +class TestSlow(unittest.TestCase): def test_instantiation(self): - has_defaults_is_ok = Fast(plus_one, "y") - - with self.assertRaises(ValueError): - missing_defaults_should_fail = Fast(no_default, "z") + slow = Slow(plus_one, "y") + self.assertIs( + slow.outputs.y.value, + NotData, + msg="Slow nodes should not run at instantiation", + ) + slow.inputs.x = 10 + self.assertIs( + slow.outputs.y.value, + NotData, + msg="Slow nodes should not run on updates", + ) + slow.run() + self.assertEqual( + slow.outputs.y.value, + 11, + msg=f"Slow nodes should still run when asked! Expected 11 but got " + f"{slow.outputs.y.value}" + ) @unittest.skipUnless(version_info[0] == 3 and version_info[1] >= 10, "Only supported for 3.10+") diff --git a/tests/unit/workflow/test_node_package.py b/tests/unit/workflow/test_node_package.py index 5ee86fb75..c8492437c 100644 --- a/tests/unit/workflow/test_node_package.py +++ b/tests/unit/workflow/test_node_package.py @@ -5,7 +5,7 @@ from pyiron_contrib.workflow.workflow import Workflow -@Workflow.wrap_as.fast_node("x") +@Workflow.wrap_as.function_node("x") def dummy(x: int = 0): return x @@ -53,7 +53,7 @@ def add(x: int = 0): old_dummy_instance = self.package.Dummy(label="old_dummy_instance") - @Workflow.wrap_as.fast_node("y") + @Workflow.wrap_as.function_node("y") def dummy(x: int = 0): return x + 1 diff --git a/tests/unit/workflow/test_workflow.py b/tests/unit/workflow/test_workflow.py index b1fcb18b1..b93708086 100644 --- a/tests/unit/workflow/test_workflow.py +++ b/tests/unit/workflow/test_workflow.py @@ -109,7 +109,7 @@ def test_workflow_io(self): self.assertEqual(len(wf.outputs), 1) def test_node_decorator_access(self): - @Workflow.wrap_as.fast_node("y") + @Workflow.wrap_as.function_node("y") def plus_one(x: int = 0) -> int: return x + 1 From 4158fd3d514dccde0a738852950933a087770954 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Thu, 29 Jun 2023 12:46:05 -0700 Subject: [PATCH 65/81] Make atomistic calculation nodes slow --- pyiron_contrib/workflow/node_library/atomistics.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pyiron_contrib/workflow/node_library/atomistics.py b/pyiron_contrib/workflow/node_library/atomistics.py index e4fdd5a6c..1da3bdac6 100644 --- a/pyiron_contrib/workflow/node_library/atomistics.py +++ b/pyiron_contrib/workflow/node_library/atomistics.py @@ -7,7 +7,7 @@ from pyiron_atomistics.atomistics.structure.atoms import Atoms from pyiron_atomistics.lammps.lammps import Lammps as LammpsJob -from pyiron_contrib.workflow.function import function_node, single_value_node +from pyiron_contrib.workflow.function import single_value_node, slow_node @single_value_node("structure") @@ -81,7 +81,7 @@ def _run_and_remove_job(job, modifier: Optional[callable] = None, **modifier_kwa ) -@function_node( +@slow_node( "cells", "displacements", "energy_pot", @@ -103,7 +103,7 @@ def calc_static( return _run_and_remove_job(job=job) -@function_node( +@slow_node( "cells", "displacements", "energy_pot", From 03a011fb6701aced42e97ec6c96fe6aec1d4da9f Mon Sep 17 00:00:00 2001 From: liamhuber Date: Thu, 29 Jun 2023 12:59:10 -0700 Subject: [PATCH 66/81] Expose the other functions on the node adder --- pyiron_contrib/workflow/composite.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pyiron_contrib/workflow/composite.py b/pyiron_contrib/workflow/composite.py index 21e8a8fb5..612359efa 100644 --- a/pyiron_contrib/workflow/composite.py +++ b/pyiron_contrib/workflow/composite.py @@ -11,7 +11,9 @@ from warnings import warn from pyiron_contrib.workflow.node import Node -from pyiron_contrib.workflow.function import Function, function_node, slow_node, single_value_node +from pyiron_contrib.workflow.function import ( + Function, SingleValue, Slow, function_node, slow_node, single_value_node +) from pyiron_contrib.workflow.node_library import atomistics, standard from pyiron_contrib.workflow.node_library.package import NodePackage from pyiron_contrib.workflow.util import DotDict @@ -226,6 +228,8 @@ def __init__(self, parent: Composite): self.register_nodes("standard", *standard.nodes) Function = Function + Slow = Slow + SingleValue = SingleValue def __getattribute__(self, key): value = super().__getattribute__(key) From 95eb3f5c213ca62248ef14a4f36e8bb4bf7f4989 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Thu, 29 Jun 2023 13:04:27 -0700 Subject: [PATCH 67/81] Update the example notebook --- notebooks/workflow_example.ipynb | 200 ++++++++++++++++++++++--------- 1 file changed, 142 insertions(+), 58 deletions(-) diff --git a/notebooks/workflow_example.ipynb b/notebooks/workflow_example.ipynb index aca04c84d..50843a9e8 100644 --- a/notebooks/workflow_example.ipynb +++ b/notebooks/workflow_example.ipynb @@ -9,7 +9,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "6a819a64e97a4eb5b91e66cae4689723", + "model_id": "d57449473dbc42f2997863543b5171c6", "version_major": 2, "version_minor": 0 }, @@ -94,10 +94,8 @@ "id": "22ee2a49-47d1-4cec-bb25-8441ea01faf7", "metadata": {}, "source": [ - "The output is still empty (`NotData`) because we haven't `run` the node.\n", - "If we try that now though, we'll just get a type error because the input is not set! A softer `update()` will avoid the error because it will see that the node is not `ready` and choose not to `run()`.\n", - "\n", - "Let's set the input and run the node:" + "The output is still empty (`NotData`) because we haven't `run()` the node.\n", + "If we try that now though, we'll just get a type error because the input is not set! " ] }, { @@ -110,15 +108,41 @@ "name": "stdout", "output_type": "stream", "text": [ - "{'p1': , 'm1': }\n", + "{'p1': , 'm1': }\n" + ] + } + ], + "source": [ + "print(pm_node.outputs.to_value_dict())\n" + ] + }, + { + "cell_type": "markdown", + "id": "48b0db5a-548e-4195-8361-76763ddf0474", + "metadata": {}, + "source": [ + "By default, a softer `update()` call is made at instantiation and whenever the node input is updated.\n", + "This call checks to make sure the input is `ready` before moving on to `run()`. \n", + "\n", + "If we update the input, we'll give the node enough data to work with and it will automatically update the output" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "b1500a40-f4f2-4c06-ad78-aaebcf3e9a50", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ "{'p1': 6, 'm1': 4}\n" ] } ], "source": [ - "print(pm_node.outputs.to_value_dict())\n", "pm_node.inputs.x = 5\n", - "pm_node.run()\n", "print(pm_node.outputs.to_value_dict())" ] }, @@ -127,22 +151,22 @@ "id": "df4520d7-856e-4bc8-817f-5b2e22c1ddce", "metadata": {}, "source": [ - "Nodes also have the option to `run_on_updates` -- i.e. to attempt a `run` command whenever _any_ of their input data gets updated -- and to `update_on_instantiation`." + "We can be stricter and force the node to wait for an explicit `run()` call by modifying the `run_on_updates` and `update_on_instantiation` flags." ] }, { "cell_type": "code", - "execution_count": 5, + "execution_count": 6, "id": "ab1ac28a-6e69-491f-882f-da4a43162dd7", "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "2" + "pyiron_contrib.workflow.channels.NotData" ] }, - "execution_count": 5, + "execution_count": 6, "metadata": {}, "output_type": "execute_result" } @@ -151,7 +175,7 @@ "def adder(x: int, y: int = 1) -> int:\n", " return x + y\n", "\n", - "adder_node = Function(adder, \"sum\", run_on_updates=True, update_on_instantiation=True)\n", + "adder_node = Function(adder, \"sum\", run_on_updates=False)\n", "adder_node.inputs.x = 1\n", "adder_node.outputs.sum.value # We use `value` to see the data the channel holds" ] @@ -161,33 +185,94 @@ "id": "0929f222-6073-4201-b5a1-723c31c8998a", "metadata": {}, "source": [ - "We see that now the output got populated automatically when we updated `x`. \n", - "We can safely update it back to something silly without causing an error because of our type hints." + "We see that now the output did not get populated automatically when we updated `x`. \n", + "We can still get the output by asking for it though:" ] }, { "cell_type": "code", - "execution_count": 6, + "execution_count": 7, + "id": "dc41a447-15fd-4df2-b60a-0935d81d469e", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "2" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "adder_node.run()\n", + "adder_node.outputs.sum.value" + ] + }, + { + "cell_type": "markdown", + "id": "58ed9b25-6dde-488d-9582-d49d405793c6", + "metadata": {}, + "source": [ + "This node also exploits type hinting!\n", + "After turning the automatic updates back on, we can see that we can safely pass incorrect data without running into an error:" + ] + }, + { + "cell_type": "code", + "execution_count": 8, "id": "ac0fe993-6c82-48c8-a780-cbd0c97fc386", "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "int" + "(int, str)" ] }, - "execution_count": 6, + "execution_count": 8, "metadata": {}, "output_type": "execute_result" } ], "source": [ + "adder_node.run_on_updates = True\n", "adder_node.inputs.x = \"not an integer\"\n", - "adder_node.inputs.x.type_hint\n", + "adder_node.inputs.x.type_hint, type(adder_node.inputs.x.value)\n", "# No error because the update doesn't trigger a run since the type hint is not satisfied" ] }, + { + "cell_type": "markdown", + "id": "2737de39-6e75-44e1-b751-6315afe5c676", + "metadata": {}, + "source": [ + "But `run()` never got called, so the output is unchanged" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "bcbd17f1-a3e4-44f0-bde1-cbddc51c5d73", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "2" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "adder_node.outputs.sum.value" + ] + }, { "cell_type": "markdown", "id": "263f5b24-113f-45d9-82cc-0475c59da587", @@ -198,7 +283,7 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": 10, "id": "15742a49-4c23-4d4a-84d9-9bf19677544c", "metadata": {}, "outputs": [ @@ -208,7 +293,7 @@ "3" ] }, - "execution_count": 7, + "execution_count": 10, "metadata": {}, "output_type": "execute_result" } @@ -234,7 +319,7 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": 11, "id": "61b43a9b-8dad-48b7-9194-2045e465793b", "metadata": {}, "outputs": [], @@ -244,7 +329,7 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": 12, "id": "647360a9-c971-4272-995c-aa01e5f5bb83", "metadata": {}, "outputs": [ @@ -259,7 +344,7 @@ } ], "source": [ - "@function_node(\"diff\", run_on_updates=True, update_on_instantiation=True)\n", + "@function_node(\"diff\")\n", "def subtract_node(x: int | float = 2, y: int | float = 1) -> int | float:\n", " return x - y\n", "\n", @@ -281,7 +366,7 @@ }, { "cell_type": "code", - "execution_count": 10, + "execution_count": 13, "id": "8fb0671b-045a-4d71-9d35-f0beadc9cf3a", "metadata": {}, "outputs": [ @@ -291,7 +376,7 @@ "-10" ] }, - "execution_count": 10, + "execution_count": 13, "metadata": {}, "output_type": "execute_result" } @@ -312,7 +397,7 @@ }, { "cell_type": "code", - "execution_count": 11, + "execution_count": 14, "id": "5ce91f42-7aec-492c-94fb-2320c971cd79", "metadata": {}, "outputs": [ @@ -325,7 +410,7 @@ } ], "source": [ - "@function_node(\"sum\", run_on_updates=True, update_on_instantiation=True)\n", + "@function_node(\"sum\")\n", "def add_node(x: int | float = 1, y: int | float = 1) -> int | float:\n", " return x + y\n", "\n", @@ -347,7 +432,7 @@ }, { "cell_type": "code", - "execution_count": 12, + "execution_count": 15, "id": "20360fe7-b422-4d78-9bd1-de233f28c8df", "metadata": {}, "outputs": [ @@ -373,14 +458,14 @@ "source": [ "## Special nodes\n", "\n", - "In addition to the basic `Function` class, for the sake of convenience we also offer `Fast(Function)` -- which enforces that all the node function inputs are type-hinted and have defaults, then sets `run_on_updates=True` and `update_on_instantiation=True` --, and `SingleValue(Fast)` -- which further enforces that there is only a _single_ return value to the node function (i.e. a single output label), and then lets attribute and item access fall back to looking for attributes and items of this single output value. Of course there are decorators available for both of these.\n", + "In addition to the basic `Function` class, for the sake of convenience we also offer `Slow(Function)` -- which changes the defaults of `run_on_updates` and `update_on_instantiation` to `False` so that `run()` calls are necessary -- this can be helpful for nodes that are computationally expensive; and `SingleValue(Function)` -- which enforces that there is only a _single_ return value to the node function (i.e. a single output label), and then lets attribute and item access fall back to looking for attributes and items of this single output value. Of course there are decorators available for both of these.\n", "\n", "Let's look at a use case:" ] }, { "cell_type": "code", - "execution_count": 13, + "execution_count": 16, "id": "1a4e9693-0980-4435-aecc-3331d8b608dd", "metadata": {}, "outputs": [], @@ -391,7 +476,7 @@ }, { "cell_type": "code", - "execution_count": 14, + "execution_count": 17, "id": "7c4d314b-33bb-4a67-bfb9-ed77fba3949c", "metadata": {}, "outputs": [ @@ -432,7 +517,7 @@ }, { "cell_type": "code", - "execution_count": 15, + "execution_count": 18, "id": "1cd000bd-9b24-4c39-9cac-70a3291d0660", "metadata": {}, "outputs": [], @@ -457,7 +542,7 @@ }, { "cell_type": "code", - "execution_count": 16, + "execution_count": 19, "id": "7964df3c-55af-4c25-afc5-9e07accb606a", "metadata": {}, "outputs": [ @@ -466,7 +551,6 @@ "output_type": "stream", "text": [ "n1 n1 n1 (GreaterThanHalf) output single-value: False\n", - "n2 n2 \n", "n3 n3 n3 (GreaterThanHalf) output single-value: False\n", "n4 n4 n4 (GreaterThanHalf) output single-value: False\n", "n5 n5 n5 (GreaterThanHalf) output single-value: False\n" @@ -474,10 +558,13 @@ } ], "source": [ + "from pyiron_contrib.workflow.function import Slow\n", + "\n", "n1 = greater_than_half(label=\"n1\")\n", "\n", "wf = Workflow(\"my_wf\", n1) # As args at init\n", - "wf.add.Function(lambda: x + 1, \"p1\", label=\"n2\") # Instantiating from the node adder\n", + "wf.add.Slow(lambda: x + 1, \"p1\", label=\"n2\") # Instantiating from the class with a lambda function\n", + "# (Slow since we don't have an x default)\n", "wf.add(greater_than_half(label=\"n3\")) # Instantiating then passing to node adder\n", "wf.n4 = greater_than_half(label=\"will_get_overwritten_with_n4\") # Set attribute to instance\n", "greater_than_half(label=\"n5\", parent=wf) # By passing the workflow to the node\n", @@ -504,7 +591,7 @@ }, { "cell_type": "code", - "execution_count": 17, + "execution_count": 20, "id": "2e418abf-7059-4e1e-9b9f-b3dc0a4b5e35", "metadata": {}, "outputs": [ @@ -521,12 +608,11 @@ "def linear(x):\n", " return x\n", "\n", - "@function_node(\"z\")\n", + "@function_node(\"z\", run_on_updates=False)\n", "def times_two(y):\n", " return 2 * y\n", "\n", "l = linear(x=1)\n", - "l.run()\n", "t2 = times_two(y=l.outputs.y)\n", "print(t2.inputs.y, t2.outputs.z)" ] @@ -536,16 +622,16 @@ "id": "37aa4455-9b98-4be5-a365-363e3c490bb6", "metadata": {}, "source": [ - "Now the input of `t2` got updated when the connection is made, but by default we told this node not to do any automatic updates, so the output has its uninitialized value of `None`.\n", + "Now the input of `t2` got updated when the connection is made, but by we told this node not to do any automatic updates, so the output has its uninitialized value of `NotData`.\n", "\n", - "Often, you will probably want to have nodes with data connections to have signal connections, but this is not strictly required. Here, we'll introduce a (not strictly necessary) third node to control starting the workflow, and chain together to signals from our two functional nodes.\n", + "Often, you will want to have nodes with data connections to have signal connections, but this is not strictly required. Here, we'll introduce a (not strictly necessary) third node to control starting the workflow, and chain together to signals from our two functional nodes.\n", "\n", "Note that we have all the same syntacic sugar from data channels when creating connections between signal channels." ] }, { "cell_type": "code", - "execution_count": 18, + "execution_count": 21, "id": "3310eac4-04f6-421b-9824-19bb2d680be6", "metadata": {}, "outputs": [ @@ -579,7 +665,7 @@ }, { "cell_type": "code", - "execution_count": 19, + "execution_count": 22, "id": "7a6f2bce-6b5e-4321-9457-0a6790d2202a", "metadata": {}, "outputs": [], @@ -589,13 +675,13 @@ }, { "cell_type": "code", - "execution_count": 20, + "execution_count": 23, "id": "6569014a-815b-46dd-8b47-4e1cd4584b3b", "metadata": {}, "outputs": [ { "data": { - "image/png": "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", + "image/png": "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", "text/plain": [ "
" ] @@ -617,9 +703,7 @@ "y = noise(length=10)\n", "f = plot(\n", " x=x, \n", - " y=y, \n", - " run_on_updates=True, \n", - " update_on_instantiation=True,\n", + " y=y,\n", " channels_requiring_update_after_run=[\"x\"],\n", ")\n", "f.inputs.y.require_update_after_node_runs(wait_now=True)" @@ -635,7 +719,7 @@ }, { "cell_type": "code", - "execution_count": 21, + "execution_count": 24, "id": "25f0495a-e85f-43b7-8a70-a2c9cbd51ebb", "metadata": {}, "outputs": [ @@ -645,7 +729,7 @@ "(False, False)" ] }, - "execution_count": 21, + "execution_count": 24, "metadata": {}, "output_type": "execute_result" } @@ -656,13 +740,13 @@ }, { "cell_type": "code", - "execution_count": 22, + "execution_count": 25, "id": "449ce797-be62-4211-b483-c717a3d70583", "metadata": {}, "outputs": [ { "data": { - "image/png": "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", + "image/png": "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iooKHT58OORz3nrrLS1cuFBPPvmkZs+erfnz5+vhhx/WN998M+H7DA8Pa2BgIOgBAADC40xxqLqyUNJI8Djf6M/VlYXG9huxFUbOnDkjv9+vnJycoPacnBz19PSEfM6JEyd08OBBdXR06M0339SuXbt04MAB3X///RO+T21trdxu99gjLy/PTpkAAOACy4s8ql9drFx38FRMrtul+tXFRvcZCWvTM4cjODlZljWubVQgEJDD4dC+ffvkdrslSTt37tTtt9+u3bt3a9q0aeOes23bNm3ZsmXs54GBAQIJAACXaXmRR8sKc+N7B9aZM2fK6XSOGwXp7e0dN1oyyuPxaPbs2WNBRBq5x8SyLJ06dUrz5s0b95z09HSlp6fbKQ0AAEyCM8WhsrkzTJcRxNY0TVpamkpKStTS0hLU3tLSovLy8pDPWbx4sU6fPq0vv/xyrO2TTz5RSkqK5syZE0bJAAAgkdjeZ2TLli168cUXtWfPHn388cd66KGH1N3drQ0bNkgamWJZu3btWP8777xTM2bM0D333KPOzk699957euSRR3TvvfeGnKIBAADJxfY9I1VVVerr69OOHTvk9XpVVFSkpqYm5efnS5K8Xq+6u7vH+n/rW99SS0uLfvnLX2rhwoWaMWOGVq1apccff3zq/goAABC3bO8zYgL7jAAAEH8iss8IAADAVCOMAAAAowgjAADAKMIIAAAwijACAACMIowAAACjCCMAAMAowggAADCKMAIAAIwijAAAAKMIIwAAwCjCCAAAMIowAgAAjCKMAAAAowgjAADAKMIIAAAwijACAACMIowAAACjCCMAAMAowggAADCKMAIAAIwijAAAAKMIIwAAwCjCCAAAMIowAgAAjCKMAAAAowgjAADAKMIIAAAwijACAACMIowAAACjCCMAAMAowggAADCKMAIAAIwijAAAAKMIIwAAwCjCCAAAMIowAgAAjCKMAAAAowgjAADAKMIIAAAwijACAACMIowAAACjCCMAAMAowggAADCKMAIAAIwijAAAAKNSTRcAAIhf/oCltq5+9Q4OKTvDpdKCLDlTHKbLQpwhjAAAwtLc4VVNY6e8vqGxNo/bperKQi0v8hisDPGGaRoAgG3NHV5t3Hs0KIhIUo9vSBv3HlVzh9dQZYhHhBEAgC3+gKWaxk5ZIX432lbT2Cl/IFQPYDzCCADA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", "text/plain": [ "
" ] @@ -693,14 +777,14 @@ "\n", "Currently we have a handfull of pre-build nodes available for import from the `nodes` package. Let's use these to quickly put together a workflow for looking at some MD data.\n", "\n", - "The `calc_md`, node is _not_ at `Fast`, but we happen to know that the calculation we're doing here is very easy, so we'll set `run_on_updates` and `update_at_instantiation` to `True`.\n", + "The `calc_md` node is `Slow`, but we happen to know that the calculation we're doing here is very easy, so we'll manually set `run_on_updates` and `update_at_instantiation` to `True` to get it to behave like a typical `Function` node.\n", "\n", "Finally, `SingleValue` has one more piece of syntactic sugar: when you're making a connection to the (single!) output channel, you can just pass the node itself!" ] }, { "cell_type": "code", - "execution_count": 23, + "execution_count": 26, "id": "ae500d5e-e55b-432c-8b5f-d5892193cdf5", "metadata": {}, "outputs": [ @@ -708,9 +792,9 @@ "name": "stderr", "output_type": "stream", "text": [ - "/Users/huber/work/pyiron/pyiron_contrib/pyiron_contrib/workflow/composite.py:175: UserWarning: Reassigning the node bulk_structure to the label structure when adding it to the parent with_prebuilt.\n", + "/Users/huber/work/pyiron/pyiron_contrib/pyiron_contrib/workflow/composite.py:177: UserWarning: Reassigning the node bulk_structure to the label structure when adding it to the parent with_prebuilt.\n", " warn(\n", - "/Users/huber/work/pyiron/pyiron_contrib/pyiron_contrib/workflow/composite.py:175: UserWarning: Reassigning the node lammps to the label engine when adding it to the parent with_prebuilt.\n", + "/Users/huber/work/pyiron/pyiron_contrib/pyiron_contrib/workflow/composite.py:177: UserWarning: Reassigning the node lammps to the label engine when adding it to the parent with_prebuilt.\n", " warn(\n" ] }, @@ -725,9 +809,9 @@ "name": "stderr", "output_type": "stream", "text": [ - "/Users/huber/work/pyiron/pyiron_contrib/pyiron_contrib/workflow/composite.py:175: UserWarning: Reassigning the node calc_md to the label calc when adding it to the parent with_prebuilt.\n", + "/Users/huber/work/pyiron/pyiron_contrib/pyiron_contrib/workflow/composite.py:177: UserWarning: Reassigning the node calc_md to the label calc when adding it to the parent with_prebuilt.\n", " warn(\n", - "/Users/huber/work/pyiron/pyiron_contrib/pyiron_contrib/workflow/composite.py:175: UserWarning: Reassigning the node scatter to the label plot when adding it to the parent with_prebuilt.\n", + "/Users/huber/work/pyiron/pyiron_contrib/pyiron_contrib/workflow/composite.py:177: UserWarning: Reassigning the node scatter to the label plot when adding it to the parent with_prebuilt.\n", " warn(\n" ] }, From e44feab3c3e1c7c418664b0e11c8ff0145fe4b43 Mon Sep 17 00:00:00 2001 From: "@liamhuber" <@samwaseda> Date: Fri, 30 Jun 2023 09:24:17 -0700 Subject: [PATCH 68/81] Make NotData checking property private To avoid cluttering the tab completion menu --- pyiron_contrib/workflow/channels.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pyiron_contrib/workflow/channels.py b/pyiron_contrib/workflow/channels.py index a7510a4f7..fd500e03f 100644 --- a/pyiron_contrib/workflow/channels.py +++ b/pyiron_contrib/workflow/channels.py @@ -212,12 +212,12 @@ def ready(self) -> bool: (bool): Whether the value matches the type hint. """ if self.type_hint is not None: - return self.value_is_data and valid_value(self.value, self.type_hint) + return self._value_is_data and valid_value(self.value, self.type_hint) else: - return self.value_is_data + return self._value_is_data @property - def value_is_data(self): + def _value_is_data(self): return self.value is not NotData def update(self, value) -> None: From 82fdcfd2a05a6a5fb5fd4c143a2a1ce8f7e87c20 Mon Sep 17 00:00:00 2001 From: pyiron-runner Date: Fri, 30 Jun 2023 16:47:14 +0000 Subject: [PATCH 69/81] Format black --- pyiron_contrib/workflow/channels.py | 1 + pyiron_contrib/workflow/composite.py | 29 +++++++++++-------- pyiron_contrib/workflow/node.py | 42 ++++++++++++++-------------- 3 files changed, 40 insertions(+), 32 deletions(-) diff --git a/pyiron_contrib/workflow/channels.py b/pyiron_contrib/workflow/channels.py index fd500e03f..d76f7406b 100644 --- a/pyiron_contrib/workflow/channels.py +++ b/pyiron_contrib/workflow/channels.py @@ -140,6 +140,7 @@ class NotData: is provided; it lets the channel know that it has _no data in it_ and thus should not identify as ready. """ + pass diff --git a/pyiron_contrib/workflow/composite.py b/pyiron_contrib/workflow/composite.py index 612359efa..49e7db78e 100644 --- a/pyiron_contrib/workflow/composite.py +++ b/pyiron_contrib/workflow/composite.py @@ -12,7 +12,12 @@ from pyiron_contrib.workflow.node import Node from pyiron_contrib.workflow.function import ( - Function, SingleValue, Slow, function_node, slow_node, single_value_node + Function, + SingleValue, + Slow, + function_node, + slow_node, + single_value_node, ) from pyiron_contrib.workflow.node_library import atomistics, standard from pyiron_contrib.workflow.node_library.package import NodePackage @@ -74,16 +79,16 @@ class Composite(Node, ABC): # Allows users/devs to easily create new nodes when using children of this class def __init__( - self, - label: str, - *args, - parent: Optional[Composite] = None, - strict_naming: bool = True, - **kwargs + self, + label: str, + *args, + parent: Optional[Composite] = None, + strict_naming: bool = True, + **kwargs, ): super().__init__(*args, label=label, parent=parent, **kwargs) self.strict_naming: bool = strict_naming - self.nodes: DotDict[str: Node] = DotDict() + self.nodes: DotDict[str:Node] = DotDict() self.add: NodeAdder = NodeAdder(self) self.starting_nodes: None | list[Node] = None @@ -96,13 +101,15 @@ def to_dict(self): @property def upstream_nodes(self) -> list[Node]: return [ - node for node in self.nodes.values() + node + for node in self.nodes.values() if node.outputs.connected and not node.inputs.connected ] def on_run(self): - starting_nodes = self.upstream_nodes if self.starting_nodes is None \ - else self.starting_nodes + starting_nodes = ( + self.upstream_nodes if self.starting_nodes is None else self.starting_nodes + ) for node in starting_nodes: node.run() diff --git a/pyiron_contrib/workflow/node.py b/pyiron_contrib/workflow/node.py index 1cdc37f92..ed84532d1 100644 --- a/pyiron_contrib/workflow/node.py +++ b/pyiron_contrib/workflow/node.py @@ -23,28 +23,28 @@ class Node(HasToDict, ABC): """ Nodes are elements of a computational graph. They have input and output data channels that interface with the outside - world, and a callable that determines what they actually compute, and input and - output signal channels that can be used to customize the execution flow of the - graph; + world, and a callable that determines what they actually compute, and input and + output signal channels that can be used to customize the execution flow of the + graph; Together these channels represent edges on the computational graph. - - Nodes can be run to force their computation, or more gently updated, which will + + Nodes can be run to force their computation, or more gently updated, which will trigger a run only if the `run_on_update` flag is set to true and all of the input is ready (i.e. channel values conform to any type hints provided). - - Nodes may have a `parent` node that owns them as part of a sub-graph. - + + Nodes may have a `parent` node that owns them as part of a sub-graph. + Every node must be named with a `label`, and may use this label to attempt to create a working directory in memory for itself if requested. - These labels also help to identify nodes in the wider context of (potentially + These labels also help to identify nodes in the wider context of (potentially nested) computational graphs. - - By default, nodes' signals input comes with `run` and `ran` IO ports which force - the `run()` method and which emit after `finish_run()` is completed, respectfully. - - Nodes have a status, which is currently represented by the `running` and `failed` + + By default, nodes' signals input comes with `run` and `ran` IO ports which force + the `run()` method and which emit after `finish_run()` is completed, respectfully. + + Nodes have a status, which is currently represented by the `running` and `failed` boolean flags. - Their value is controlled automatically in the defined `run` and `finish_run` + Their value is controlled automatically in the defined `run` and `finish_run` methods. This is an abstract class. @@ -95,12 +95,12 @@ class Node(HasToDict, ABC): """ def __init__( - self, - label: str, - *args, - parent: Optional[Composite] = None, - run_on_updates: bool = False, - **kwargs, + self, + label: str, + *args, + parent: Optional[Composite] = None, + run_on_updates: bool = False, + **kwargs, ): """ A mixin class for objects that can form nodes in the graph representation of a From ffd7a2b06de59b03a2c520cbd35274e8ec475bdd Mon Sep 17 00:00:00 2001 From: liamhuber Date: Wed, 5 Jul 2023 15:31:29 -0700 Subject: [PATCH 70/81] Introduce running on an executor --- pyiron_contrib/workflow/node.py | 25 ++++++++++++++++++------- pyiron_contrib/workflow/util.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 7 deletions(-) diff --git a/pyiron_contrib/workflow/node.py b/pyiron_contrib/workflow/node.py index ed84532d1..10e90fc34 100644 --- a/pyiron_contrib/workflow/node.py +++ b/pyiron_contrib/workflow/node.py @@ -6,11 +6,13 @@ from __future__ import annotations from abc import ABC, abstractmethod +from concurrent.futures import Future from typing import Optional, TYPE_CHECKING from pyiron_contrib.workflow.files import DirectoryObject from pyiron_contrib.workflow.has_to_dict import HasToDict from pyiron_contrib.workflow.io import Signals, InputSignal, OutputSignal +from pyiron_contrib.workflow.util import CloudpickleProcessPoolExecutor if TYPE_CHECKING: from pyiron_base.jobs.job.extension.server.generic import Server @@ -62,6 +64,8 @@ class Node(HasToDict, ABC): is False.) fully_connected (bool): whether _all_ of the IO (including signals) are connected. + future (concurrent.futures.Future | None): A futures object, if the node is + currently running or has already run using an executor. inputs (pyiron_contrib.workflow.io.Inputs): **Abstract.** Children must define a property returning an `Inputs` object. label (str): A name for the node. @@ -129,6 +133,8 @@ def __init__( self.signals = self._build_signal_channels() self._working_directory = None self.run_on_updates: bool = run_on_updates + self.executor: None | CloudpickleProcessPoolExecutor = None + self.future: None | Future = None @property @abstractmethod @@ -176,7 +182,7 @@ def run(self) -> None: self.running = True self.failed = False - if self.server is None: + if self.executor is None: try: run_output = self.on_run(**self.run_args) except Exception as e: @@ -184,16 +190,17 @@ def run(self) -> None: self.failed = True raise e self.finish_run(run_output) + elif isinstance(self.executor, CloudpickleProcessPoolExecutor): + self.future = self.executor.submit(self.on_run, **self.run_args) + self.future.add_done_callback(self.finish_run) else: raise NotImplementedError( "We currently only support executing the node functionality right on " - "the main python process that the node instance lives on. Come back " - "later for cool new features." + "the main python process or with a " + "pyiron_contrib.workflow.util.CloudpickleProcessPoolExecutor." ) - # TODO: Send the `on_run` callable and the `run_args` data off to remote - # resources and register `finish_run` as a callback. - def finish_run(self, run_output: tuple): + def finish_run(self, run_output: tuple | Future): """ Process the run result, then wrap up statuses etc. @@ -201,8 +208,12 @@ def finish_run(self, run_output: tuple): execution off to another entity and release the python process to do other things. In such a case, this function should be registered as a callback so that the node can finish "running" and, e.g. push its data forward when that - execution is finished. + execution is finished. In such a case, a `concurrent.futures.Future` object is + expected back and must be unpacked. """ + if isinstance(run_output, Future): + run_output = run_output.result() + try: self.process_run_result(run_output) except Exception as e: diff --git a/pyiron_contrib/workflow/util.py b/pyiron_contrib/workflow/util.py index d80bc7ad3..846f8e30c 100644 --- a/pyiron_contrib/workflow/util.py +++ b/pyiron_contrib/workflow/util.py @@ -1,6 +1,35 @@ +from concurrent.futures import ProcessPoolExecutor + +import cloudpickle + + class DotDict(dict): def __getattr__(self, item): return self.__getitem__(item) def __setattr__(self, key, value): self[key] = value + + +def _apply_cloudpickle(fn, /, *args, **kwargs): + fn = cloudpickle.loads(fn) + return fn(*args, **kwargs) + + +class CloudpickleProcessPoolExecutor(ProcessPoolExecutor): + """ + In our workflows, it is common to dynamically create classes from functions using a + decorator; + This makes the underlying function object mismatch with the pickle-findable + "function" (actually a class after wrapping). + The result is that a regular `ProcessPoolExecutor` cannot pickle our node functions. + + An alternative is to force the executor to use pickle under the hood, which _can_ + handle these sort of dynamic objects. + This solution comes from u/mostsquares @ stackoverflow: + https://stackoverflow.com/questions/62830970/submit-dynamically-loaded-functions-to-the-processpoolexecutor + """ + def submit(self, fn, /, *args, **kwargs): + return super().submit( + _apply_cloudpickle, cloudpickle.dumps(fn), *args, **kwargs + ) From 50efd4ac90fd83ead7a72e52ff1abd9ece8a0ed8 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Thu, 6 Jul 2023 13:45:18 -0700 Subject: [PATCH 71/81] Reorder when and where the result processing/ran signal happens This was necessary to get cyclic workflows to cycle without failing. --- pyiron_contrib/workflow/node.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/pyiron_contrib/workflow/node.py b/pyiron_contrib/workflow/node.py index ed84532d1..164152a0f 100644 --- a/pyiron_contrib/workflow/node.py +++ b/pyiron_contrib/workflow/node.py @@ -204,15 +204,14 @@ def finish_run(self, run_output: tuple): execution is finished. """ try: + self.running = False self.process_run_result(run_output) + self.signals.output.ran() except Exception as e: self.running = False self.failed = True raise e - self.signals.output.ran() - self.running = False - def _build_signal_channels(self) -> Signals: signals = Signals() signals.input.run = InputSignal("run", self, self.run) From 539eb89ada4cbc0dafbb5792c0ebb8a2e7d681c2 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Thu, 6 Jul 2023 14:13:53 -0700 Subject: [PATCH 72/81] Add an integration test for cyclic graphs --- tests/integration/test_workflow.py | 76 ++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 tests/integration/test_workflow.py diff --git a/tests/integration/test_workflow.py b/tests/integration/test_workflow.py new file mode 100644 index 000000000..01f3ae78a --- /dev/null +++ b/tests/integration/test_workflow.py @@ -0,0 +1,76 @@ +import time +import unittest + +import numpy as np + +from pyiron_contrib.workflow.channels import OutputSignal +from pyiron_contrib.workflow.function import Function +from pyiron_contrib.workflow.workflow import Workflow + + +class TestNothing(unittest.TestCase): + def test_cyclic_graphs(self): + """ + Check that cyclic graphs run. + + TODO: Update once logical switches are included in the node library + """ + + @Workflow.wrap_as.single_value_node("rand") + def numpy_randint(low=0, high=20): + rand = np.random.randint(low=low, high=high) + print(f"Generating random number between {low} and {high}...{rand}!") + return rand + + class GreaterThanLimitSwitch(Function): + """ + A switch class for sending signal output depending on a '>' check + applied to input + """ + + def __init__(self, **kwargs): + super().__init__(self.greater_than, "value_gt_limit", **kwargs) + self.signals.output.true = OutputSignal("true", self) + self.signals.output.false = OutputSignal("false", self) + + @staticmethod + def greater_than(value, limit=10): + return value > limit + + def process_run_result(self, function_output): + """ + Process the output as usual, then fire signals accordingly. + """ + super().process_run_result(function_output) + + if self.outputs.value_gt_limit.value: + print(f"{self.inputs.value.value} > {self.inputs.limit.value}") + self.signals.output.true() + else: + print(f"{self.inputs.value.value} <= {self.inputs.limit.value}") + self.signals.output.false() + + @Workflow.wrap_as.single_value_node("sqrt") + def numpy_sqrt(value=0): + sqrt = np.sqrt(value) + print(f"sqrt({value}) = {sqrt}") + return sqrt + + wf = Workflow("rand_until_big_then_sqrt") + + wf.rand = numpy_randint() + + wf.gt_switch = GreaterThanLimitSwitch(run_on_updates=False) + wf.gt_switch.inputs.value = wf.rand + + wf.sqrt = numpy_sqrt(run_on_updates=False) + wf.sqrt.inputs.value = wf.rand + + wf.gt_switch.signals.input.run = wf.rand.signals.output.ran + wf.sqrt.signals.input.run = wf.gt_switch.signals.output.true + wf.rand.signals.input.run = wf.gt_switch.signals.output.false + + wf.rand.update() + self.assertAlmostEqual( + np.sqrt(wf.rand.outputs.rand.value), wf.sqrt.outputs.sqrt.value, 6 + ) From a1efbd353296fdff3646fdf5e2fd09523346583d Mon Sep 17 00:00:00 2001 From: liamhuber Date: Fri, 7 Jul 2023 08:43:41 -0700 Subject: [PATCH 73/81] Clean the text output in integration test by avoiding 0th rand call --- tests/integration/test_workflow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/test_workflow.py b/tests/integration/test_workflow.py index 01f3ae78a..a8f2f4d58 100644 --- a/tests/integration/test_workflow.py +++ b/tests/integration/test_workflow.py @@ -58,7 +58,7 @@ def numpy_sqrt(value=0): wf = Workflow("rand_until_big_then_sqrt") - wf.rand = numpy_randint() + wf.rand = numpy_randint(update_on_instantiation=False) wf.gt_switch = GreaterThanLimitSwitch(run_on_updates=False) wf.gt_switch.inputs.value = wf.rand From 9f00bf85398cb1835c5116eab6da5c2362d9e0ce Mon Sep 17 00:00:00 2001 From: liamhuber Date: Mon, 10 Jul 2023 11:30:31 -0700 Subject: [PATCH 74/81] Update docs --- pyiron_contrib/workflow/function.py | 4 ++++ pyiron_contrib/workflow/node.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/pyiron_contrib/workflow/function.py b/pyiron_contrib/workflow/function.py index d9de5d8c6..72a426b5b 100644 --- a/pyiron_contrib/workflow/function.py +++ b/pyiron_contrib/workflow/function.py @@ -52,6 +52,10 @@ class Function(Node): updated, and will attempt to update on initialization (after setting _all_ initial input values). + Output is updated in the `process_run_result` inside the parent class `finish_run` + call, such that output data gets pushed after the node stops running but before + then `ran` signal fires. + Args: node_function (callable): The function determining the behaviour of the node. *output_labels (str): A name for each return value of the node function. diff --git a/pyiron_contrib/workflow/node.py b/pyiron_contrib/workflow/node.py index 164152a0f..c94ca19ed 100644 --- a/pyiron_contrib/workflow/node.py +++ b/pyiron_contrib/workflow/node.py @@ -195,7 +195,7 @@ def run(self) -> None: def finish_run(self, run_output: tuple): """ - Process the run result, then wrap up statuses etc. + Switch the node status, process the run result, then fire the ran signal. By extracting this as a separate method, we allow the node to pass the actual execution off to another entity and release the python process to do other From 86623f789b38749fbc114ca9483acec1a6a5e945 Mon Sep 17 00:00:00 2001 From: Liam Huber Date: Mon, 10 Jul 2023 12:25:46 -0700 Subject: [PATCH 75/81] Update pyiron_contrib/workflow/node.py Co-authored-by: Sam Dareska <37879103+samwaseda@users.noreply.github.com> --- pyiron_contrib/workflow/node.py | 1 - 1 file changed, 1 deletion(-) diff --git a/pyiron_contrib/workflow/node.py b/pyiron_contrib/workflow/node.py index c94ca19ed..ec17b81de 100644 --- a/pyiron_contrib/workflow/node.py +++ b/pyiron_contrib/workflow/node.py @@ -208,7 +208,6 @@ def finish_run(self, run_output: tuple): self.process_run_result(run_output) self.signals.output.ran() except Exception as e: - self.running = False self.failed = True raise e From 045cc531a9ca45aff32f0cb43bd86e863a3365a7 Mon Sep 17 00:00:00 2001 From: Liam Huber Date: Mon, 10 Jul 2023 12:25:54 -0700 Subject: [PATCH 76/81] Update pyiron_contrib/workflow/node.py Co-authored-by: Sam Dareska <37879103+samwaseda@users.noreply.github.com> --- pyiron_contrib/workflow/node.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pyiron_contrib/workflow/node.py b/pyiron_contrib/workflow/node.py index ec17b81de..14286001b 100644 --- a/pyiron_contrib/workflow/node.py +++ b/pyiron_contrib/workflow/node.py @@ -203,6 +203,7 @@ def finish_run(self, run_output: tuple): so that the node can finish "running" and, e.g. push its data forward when that execution is finished. """ + self.running = False try: self.running = False self.process_run_result(run_output) From d0dac284e6ee591cc544873dde2eaf73f00b8095 Mon Sep 17 00:00:00 2001 From: Liam Huber Date: Mon, 10 Jul 2023 12:26:02 -0700 Subject: [PATCH 77/81] Update pyiron_contrib/workflow/node.py Co-authored-by: Sam Dareska <37879103+samwaseda@users.noreply.github.com> --- pyiron_contrib/workflow/node.py | 1 - 1 file changed, 1 deletion(-) diff --git a/pyiron_contrib/workflow/node.py b/pyiron_contrib/workflow/node.py index 14286001b..9db0a95a5 100644 --- a/pyiron_contrib/workflow/node.py +++ b/pyiron_contrib/workflow/node.py @@ -205,7 +205,6 @@ def finish_run(self, run_output: tuple): """ self.running = False try: - self.running = False self.process_run_result(run_output) self.signals.output.ran() except Exception as e: From b488fa5909833f4a663e5bf8bf509b3092e41046 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Wed, 12 Jul 2023 12:50:22 -0700 Subject: [PATCH 78/81] Use the cloudpickle executor --- pyiron_contrib/workflow/node.py | 2 +- pyiron_contrib/workflow/util.py | 29 ----------------------------- 2 files changed, 1 insertion(+), 30 deletions(-) diff --git a/pyiron_contrib/workflow/node.py b/pyiron_contrib/workflow/node.py index bf9f4c3e3..3c4fd5774 100644 --- a/pyiron_contrib/workflow/node.py +++ b/pyiron_contrib/workflow/node.py @@ -9,10 +9,10 @@ from concurrent.futures import Future from typing import Optional, TYPE_CHECKING +from pyiron_contrib.executors import CloudpickleProcessPoolExecutor from pyiron_contrib.workflow.files import DirectoryObject from pyiron_contrib.workflow.has_to_dict import HasToDict from pyiron_contrib.workflow.io import Signals, InputSignal, OutputSignal -from pyiron_contrib.workflow.util import CloudpickleProcessPoolExecutor if TYPE_CHECKING: from pyiron_base.jobs.job.extension.server.generic import Server diff --git a/pyiron_contrib/workflow/util.py b/pyiron_contrib/workflow/util.py index 846f8e30c..d80bc7ad3 100644 --- a/pyiron_contrib/workflow/util.py +++ b/pyiron_contrib/workflow/util.py @@ -1,35 +1,6 @@ -from concurrent.futures import ProcessPoolExecutor - -import cloudpickle - - class DotDict(dict): def __getattr__(self, item): return self.__getitem__(item) def __setattr__(self, key, value): self[key] = value - - -def _apply_cloudpickle(fn, /, *args, **kwargs): - fn = cloudpickle.loads(fn) - return fn(*args, **kwargs) - - -class CloudpickleProcessPoolExecutor(ProcessPoolExecutor): - """ - In our workflows, it is common to dynamically create classes from functions using a - decorator; - This makes the underlying function object mismatch with the pickle-findable - "function" (actually a class after wrapping). - The result is that a regular `ProcessPoolExecutor` cannot pickle our node functions. - - An alternative is to force the executor to use pickle under the hood, which _can_ - handle these sort of dynamic objects. - This solution comes from u/mostsquares @ stackoverflow: - https://stackoverflow.com/questions/62830970/submit-dynamically-loaded-functions-to-the-processpoolexecutor - """ - def submit(self, fn, /, *args, **kwargs): - return super().submit( - _apply_cloudpickle, cloudpickle.dumps(fn), *args, **kwargs - ) From 44c9ffbd10a43f1d5e99fe6e58a0ea708b69919b Mon Sep 17 00:00:00 2001 From: liamhuber Date: Wed, 12 Jul 2023 13:06:29 -0700 Subject: [PATCH 79/81] Give access to the creator right from composite To maintain Workflow as a single point of import --- pyiron_contrib/workflow/composite.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/pyiron_contrib/workflow/composite.py b/pyiron_contrib/workflow/composite.py index 49e7db78e..bacc934e8 100644 --- a/pyiron_contrib/workflow/composite.py +++ b/pyiron_contrib/workflow/composite.py @@ -10,6 +10,7 @@ from typing import Optional from warnings import warn +from pyiron_contrib.executors import CloudpickleProcessPoolExecutor from pyiron_contrib.workflow.node import Node from pyiron_contrib.workflow.function import ( Function, @@ -32,6 +33,12 @@ class _NodeDecoratorAccess: single_value_node = single_value_node +class Creator: + """A shortcut interface for creating non-Node objects from the workflow class.""" + + CloudpickleProcessPoolExecutor = CloudpickleProcessPoolExecutor + + class Composite(Node, ABC): """ A base class for nodes that have internal structure -- i.e. they hold a sub-graph. @@ -78,6 +85,8 @@ class Composite(Node, ABC): wrap_as = _NodeDecoratorAccess # Class method access to decorators # Allows users/devs to easily create new nodes when using children of this class + create = Creator + def __init__( self, label: str, From d23f8d142e4af6ca10314d39a4349b557a1dc5db Mon Sep 17 00:00:00 2001 From: liamhuber Date: Wed, 12 Jul 2023 13:45:37 -0700 Subject: [PATCH 80/81] Test parallel process execution --- tests/unit/workflow/test_workflow.py | 47 ++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/tests/unit/workflow/test_workflow.py b/tests/unit/workflow/test_workflow.py index b93708086..db35843ed 100644 --- a/tests/unit/workflow/test_workflow.py +++ b/tests/unit/workflow/test_workflow.py @@ -1,6 +1,8 @@ import unittest from sys import version_info +from time import sleep +from pyiron_contrib.workflow.channels import NotData from pyiron_contrib.workflow.files import DirectoryObject from pyiron_contrib.workflow.function import Function from pyiron_contrib.workflow.workflow import Workflow @@ -141,6 +143,51 @@ def test_no_parents(self): # In both cases, we satisfy the spec that workflow's can't have parents wf2.parent = wf + def test_parallel_execution(self): + wf = Workflow("wf") + + @Workflow.wrap_as.single_value_node("five", run_on_updates=False) + def five(sleep_time=0.): + sleep(sleep_time) + return 5 + + @Workflow.wrap_as.single_value_node("sum") + def sum(a, b): + return a + b + + wf.slow = five(sleep_time=1) + wf.fast = five() + wf.sum = sum(a=wf.fast, b=wf.slow) + + wf.slow.executor = wf.create.CloudpickleProcessPoolExecutor() + + wf.slow.run() + wf.fast.run() + self.assertTrue( + wf.slow.running, + msg="The slow node should still be running" + ) + self.assertEqual( + wf.fast.outputs.five.value, + 5, + msg="The slow node should not prohibit the completion of the fast node" + ) + self.assertEqual( + wf.sum.outputs.sum.value, + NotData, + msg="The slow node _should_ hold up the downstream node to which it inputs" + ) + + while wf.slow.future.running(): + sleep(0.1) + + self.assertEqual( + wf.sum.outputs.sum.value, + 5 + 5, + msg="After the slow node completes, its output should be updated as a " + "callback, and downstream nodes should proceed" + ) + if __name__ == '__main__': unittest.main() From 60c34f510072fa681b3f8905d2a779322795de5b Mon Sep 17 00:00:00 2001 From: liamhuber Date: Wed, 12 Jul 2023 13:47:56 -0700 Subject: [PATCH 81/81] Update node docs --- pyiron_contrib/workflow/node.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pyiron_contrib/workflow/node.py b/pyiron_contrib/workflow/node.py index 3c4fd5774..76e67733e 100644 --- a/pyiron_contrib/workflow/node.py +++ b/pyiron_contrib/workflow/node.py @@ -49,6 +49,11 @@ class Node(HasToDict, ABC): Their value is controlled automatically in the defined `run` and `finish_run` methods. + Nodes can be run on the main python process that owns them, or by assigning an + appropriate executor to their `executor` attribute. + In case they are run with an executor, their `future` attribute will be populated + with the resulting future object. + This is an abstract class. Children *must* define how `inputs` and `outputs` are constructed, and what will happen `on_run`.