From 81e6785661a2ef1004127c76ee9c42522d7d34a9 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Mon, 23 Oct 2023 09:49:23 -0700 Subject: [PATCH 01/24] Add options to run --- pyiron_workflow/node.py | 43 ++++++++++++++++++++++++++++++++--------- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/pyiron_workflow/node.py b/pyiron_workflow/node.py index 1199d380..3bd8fdbc 100644 --- a/pyiron_workflow/node.py +++ b/pyiron_workflow/node.py @@ -240,19 +240,39 @@ def execute(self): """ return self.process_run_result(self.on_run(**self.run_args)) - def run(self): + def run( + self, + first_fetch_input: bool = True, + then_emit_output_signals: bool = True, + force_local_execution: bool = False, + ): """ Update the input (with whatever is currently available -- does _not_ trigger - any other nodes to run) and use it to perform the node's operation. + any other nodes to run) and use it to perform the node's operation. After, + emit all output signals. If executor information is specified, execution happens on that process, a callback is registered, and futures object is returned. - Once complete, fire `ran` signal to propagate execution in the computation graph - that owns this node (if any). - """ - self.update_input() - return self._run(finished_callback=self.finish_run_and_emit_ran) + Args: + first_fetch_input (bool): Whether to first update inputs with the + highest-priority connections holding data. (Default is True.) + then_emit_output_signals (bool): Whether to fire off all output signals + (e.g. `ran`) afterwards. (Default is True.) + force_local_execution (bool): Whether to ignore any executor settings and + force the computation to run locally. (Default is False.) + + Returns: + (Any | Future): The result of running the node, or a futures object (if + running on an executor). + """ + if first_fetch_input: + self.inputs.fetch() + return self._run( + finished_callback=self.finish_run_and_emit_ran if then_emit_output_signals + else self.finish_run, + force_local_execution=force_local_execution, + ) def pull(self): raise NotImplementedError @@ -292,13 +312,18 @@ def update_input(self, **kwargs) -> None: ) @manage_status - def _run(self, finished_callback: callable) -> Any | tuple | Future: + def _run( + self, + finished_callback: callable, + force_local_execution: bool, + ) -> Any | tuple | Future: """ Executes the functionality of the node defined in `on_run`. Handles the status of the node, and communicating with any remote computing resources. """ - if not self.executor: + if force_local_execution or not self.executor: + # Run locally run_output = self.on_run(**self.run_args) return finished_callback(run_output) else: From a1a3b34cfd7c0c459c69be7259ce02631b85a90a Mon Sep 17 00:00:00 2001 From: liamhuber Date: Mon, 23 Oct 2023 09:52:10 -0700 Subject: [PATCH 02/24] Refactor: slide Group the running methods together, with necessary sub-methods between them --- pyiron_workflow/node.py | 102 ++++++++++++++++++++-------------------- 1 file changed, 51 insertions(+), 51 deletions(-) diff --git a/pyiron_workflow/node.py b/pyiron_workflow/node.py index 3bd8fdbc..d1fdfd46 100644 --- a/pyiron_workflow/node.py +++ b/pyiron_workflow/node.py @@ -231,15 +231,6 @@ def process_run_result(self, run_output): run_output: The results of a `self.on_run(self.run_args)` call. """ - @manage_status - def execute(self): - """ - Perform the node's operation with its current data. - - Execution happens directly on this python process. - """ - return self.process_run_result(self.on_run(**self.run_args)) - def run( self, first_fetch_input: bool = True, @@ -274,43 +265,6 @@ def run( force_local_execution=force_local_execution, ) - def pull(self): - raise NotImplementedError - # Need to implement everything for on-the-fly construction of the upstream - # graph and its execution - # Then, - self.update_input() - return self._run(finished_callback=self.finish_run) - - def update_input(self, **kwargs) -> None: - """ - Fetch the latest and highest-priority input values from connections, then - overwrite values with keywords arguments matching input channel labels. - - Any channel that has neither a connection nor a kwarg update at time of call is - left unchanged. - - Throws a warning if a keyword is provided that cannot be found among the input - keys. - - If you really want to update just a single value without any other side-effects, - this can always be accomplished by following the full semantic path to the - channel's value: `my_node.input.my_channel.value = "foo"`. - - Args: - **kwargs: input key - input value (including channels for connection) pairs. - """ - self.inputs.fetch() - for k, v in kwargs.items(): - if k in self.inputs.labels: - self.inputs[k] = v - else: - warnings.warn( - f"The keyword '{k}' was not found among input labels. If you are " - f"trying to update a node keyword, please use attribute assignment " - f"directly instead of calling" - ) - @manage_status def _run( self, @@ -359,11 +313,61 @@ def finish_run_and_emit_ran(self, run_output: tuple | Future) -> Any | tuple: finish_run_and_emit_ran.__doc__ = ( finish_run.__doc__ + """ - + Finally, fire the `ran` signal. """ ) + @manage_status + def execute(self): + """ + Perform the node's operation with its current data. + + Execution happens directly on this python process. + """ + return self.process_run_result(self.on_run(**self.run_args)) + + def pull(self): + raise NotImplementedError + # Need to implement everything for on-the-fly construction of the upstream + # graph and its execution + # Then, + self.update_input() + return self._run(finished_callback=self.finish_run) + + def __call__(self, **kwargs) -> None: + self.update_input(**kwargs) + return self.run() + + def update_input(self, **kwargs) -> None: + """ + Fetch the latest and highest-priority input values from connections, then + overwrite values with keywords arguments matching input channel labels. + + Any channel that has neither a connection nor a kwarg update at time of call is + left unchanged. + + Throws a warning if a keyword is provided that cannot be found among the input + keys. + + If you really want to update just a single value without any other side-effects, + this can always be accomplished by following the full semantic path to the + channel's value: `my_node.input.my_channel.value = "foo"`. + + Args: + **kwargs: input key - input value (including channels for connection) pairs. + """ + self.inputs.fetch() + for k, v in kwargs.items(): + if k in self.inputs.labels: + self.inputs[k] = v + else: + warnings.warn( + f"The keyword '{k}' was not found among input labels. If you are " + f"trying to update a node keyword, please use attribute assignment " + f"directly instead of calling" + ) + def _build_signal_channels(self) -> Signals: signals = Signals() signals.input.run = InputSignal("run", self, self.run) @@ -414,10 +418,6 @@ def fully_connected(self): and self.signals.fully_connected ) - def __call__(self, **kwargs) -> None: - self.update_input(**kwargs) - return self.run() - @property def color(self) -> str: """A hex code color for use in drawing.""" From e60a4b8a8b88355e10d8843f6efad1bc65f54390 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Mon, 23 Oct 2023 10:03:58 -0700 Subject: [PATCH 03/24] Make method private And update docstring to purge direct references. I'd like to only leave public (and thus in the tab-completion menu) methods that users are actually expected to invoke. --- pyiron_workflow/function.py | 6 +++--- pyiron_workflow/node.py | 21 ++++++++++++--------- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/pyiron_workflow/function.py b/pyiron_workflow/function.py index 34b85c91..7a739ce9 100644 --- a/pyiron_workflow/function.py +++ b/pyiron_workflow/function.py @@ -59,9 +59,9 @@ class Function(Node): Further, functions with multiple return branches that return different types or numbers of return values may or may not work smoothly, depending on the details. - 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: run, process and push result, ran. + Output is updated according to `process_run_result` -- which gets invoked by the + post-run callbacks defined in `Node` -- such that run results are used to populate + the output channels. After a node is instantiated, its input can be updated as `*args` and/or `**kwargs` on call. diff --git a/pyiron_workflow/node.py b/pyiron_workflow/node.py index d1fdfd46..532149d8 100644 --- a/pyiron_workflow/node.py +++ b/pyiron_workflow/node.py @@ -80,8 +80,11 @@ class Node(HasToDict, ABC): 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. + By default, nodes' signals input comes with `run` and `ran` IO ports, which invoke + the `run()` method and emit after running the node, respectfully. + (Whether we get all the way to emitting the `ran` signal depends on how the node + was invoked -- it is possible to computing things with the node without sending + any more signals downstream.) These signal connections can be made manually by reference to the node signals channel, or with the `>` symbol to indicate a flow of execution. This syntactic sugar can be mixed between actual signal channels (output signal > input signal), @@ -101,8 +104,8 @@ class Node(HasToDict, ABC): Nodes have a status, which is currently represented by the `running` and `failed` boolean flag attributes. - Their value is controlled automatically in the defined `run` and `finish_run` - methods. + These are updated automatically when the node's operation is invoked, e.g. with + `run`, `execute`, `pull`, or by calling the node instance. Nodes can be run on the main python process that owns them, or by setting their `executor` attribute to `True`, in which case a @@ -261,7 +264,7 @@ def run( self.inputs.fetch() return self._run( finished_callback=self.finish_run_and_emit_ran if then_emit_output_signals - else self.finish_run, + else self._finish_run, force_local_execution=force_local_execution, ) @@ -288,7 +291,7 @@ def _run( self.future.add_done_callback(finished_callback) return self.future - def finish_run(self, run_output: tuple | Future) -> Any | tuple: + def _finish_run(self, run_output: tuple | Future) -> Any | tuple: """ Switch the node status, then process and return the run result. @@ -306,12 +309,12 @@ def finish_run(self, run_output: tuple | Future) -> Any | tuple: raise e def finish_run_and_emit_ran(self, run_output: tuple | Future) -> Any | tuple: - processed_output = self.finish_run(run_output) + processed_output = self._finish_run(run_output) self.signals.output.ran() return processed_output finish_run_and_emit_ran.__doc__ = ( - finish_run.__doc__ + _finish_run.__doc__ + """ Finally, fire the `ran` signal. @@ -333,7 +336,7 @@ def pull(self): # graph and its execution # Then, self.update_input() - return self._run(finished_callback=self.finish_run) + return self._run(finished_callback=self._finish_run) def __call__(self, **kwargs) -> None: self.update_input(**kwargs) From d8b11b694ced25fedf5d9ef66b860954962dac33 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Mon, 23 Oct 2023 10:09:57 -0700 Subject: [PATCH 04/24] Refactor: Make method private --- pyiron_workflow/node.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pyiron_workflow/node.py b/pyiron_workflow/node.py index 532149d8..22b32385 100644 --- a/pyiron_workflow/node.py +++ b/pyiron_workflow/node.py @@ -263,7 +263,7 @@ def run( if first_fetch_input: self.inputs.fetch() return self._run( - finished_callback=self.finish_run_and_emit_ran if then_emit_output_signals + finished_callback=self._finish_run_and_emit_ran if then_emit_output_signals else self._finish_run, force_local_execution=force_local_execution, ) @@ -308,12 +308,12 @@ def _finish_run(self, run_output: tuple | Future) -> Any | tuple: self.failed = True raise e - def finish_run_and_emit_ran(self, run_output: tuple | Future) -> Any | tuple: + def _finish_run_and_emit_ran(self, run_output: tuple | Future) -> Any | tuple: processed_output = self._finish_run(run_output) self.signals.output.ran() return processed_output - finish_run_and_emit_ran.__doc__ = ( + _finish_run_and_emit_ran.__doc__ = ( _finish_run.__doc__ + """ From 07ab77644fe333538a0be0699cddd1c0edfa0c1f Mon Sep 17 00:00:00 2001 From: liamhuber Date: Mon, 23 Oct 2023 10:14:45 -0700 Subject: [PATCH 05/24] Rebase `execute` using `run`'s arguments and update docstring --- pyiron_workflow/node.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/pyiron_workflow/node.py b/pyiron_workflow/node.py index 22b32385..0ea73350 100644 --- a/pyiron_workflow/node.py +++ b/pyiron_workflow/node.py @@ -321,14 +321,19 @@ def _finish_run_and_emit_ran(self, run_output: tuple | Future) -> Any | tuple: """ ) - @manage_status def execute(self): """ - Perform the node's operation with its current data. + Run the node with whatever input it currently has, run it on this python + process, and don't emit the `ran` signal afterwards. - Execution happens directly on this python process. + Intended to be useful for debugging by just forcing the node to do its thing + right here, right now, and as-is. """ - return self.process_run_result(self.on_run(**self.run_args)) + return self.run( + first_fetch_input=False, + then_emit_output_signals=False, + force_local_execution=True + ) def pull(self): raise NotImplementedError From 076bf5808e86e676b701e922208d42b9c40ee263 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Mon, 23 Oct 2023 10:16:46 -0700 Subject: [PATCH 06/24] Rebase `pull` onto `run` with special arguments --- pyiron_workflow/node.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/pyiron_workflow/node.py b/pyiron_workflow/node.py index 0ea73350..2705e257 100644 --- a/pyiron_workflow/node.py +++ b/pyiron_workflow/node.py @@ -336,12 +336,16 @@ def execute(self): ) def pull(self): + """ + Use topological analysis to build a tree of all upstream dependencies; run them + first, then run this node to get an up-to-date result. Does _not_ fire the `ran` + signal afterwards. + """ raise NotImplementedError # Need to implement everything for on-the-fly construction of the upstream # graph and its execution # Then, - self.update_input() - return self._run(finished_callback=self._finish_run) + return self.run(then_emit_output_signals=False) def __call__(self, **kwargs) -> None: self.update_input(**kwargs) From b56bedb09db5638fad56f344d9543d5f980237bd Mon Sep 17 00:00:00 2001 From: liamhuber Date: Mon, 23 Oct 2023 10:19:07 -0700 Subject: [PATCH 07/24] Remove outdated TODOs --- pyiron_workflow/node.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/pyiron_workflow/node.py b/pyiron_workflow/node.py index 2705e257..d2831a69 100644 --- a/pyiron_workflow/node.py +++ b/pyiron_workflow/node.py @@ -185,8 +185,6 @@ def __init__( parent.add(self) self.running = False self.failed = False - # TODO: Move from a traditional "sever" to a tinybase "executor" - # TODO: Provide support for actually computing stuff with the executor self.signals = self._build_signal_channels() self._working_directory = None self.executor = False From 086404056b10c2fba1a49c31d2a22076be1c7e62 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Mon, 23 Oct 2023 10:19:19 -0700 Subject: [PATCH 08/24] Remove unused imports --- pyiron_workflow/node.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pyiron_workflow/node.py b/pyiron_workflow/node.py index d2831a69..d17d5329 100644 --- a/pyiron_workflow/node.py +++ b/pyiron_workflow/node.py @@ -16,7 +16,6 @@ from pyiron_workflow.files import DirectoryObject from pyiron_workflow.has_to_dict import HasToDict from pyiron_workflow.io import Signals, InputSignal, OutputSignal -from pyiron_workflow.type_hinting import valid_value from pyiron_workflow.util import SeabornColors if TYPE_CHECKING: @@ -24,7 +23,7 @@ from pyiron_workflow.channels import Channel from pyiron_workflow.composite import Composite - from pyiron_workflow.io import IO, Inputs, Outputs + from pyiron_workflow.io import Inputs, Outputs def manage_status(node_method): From db96579f684f1e6a19608efee8a8fbf101f462dc Mon Sep 17 00:00:00 2001 From: liamhuber Date: Mon, 23 Oct 2023 10:21:47 -0700 Subject: [PATCH 09/24] Don't fetch in the input update We fetch input from connections in `run` now (optionally, default true), so just let this method exclusively apply values to the input channels --- pyiron_workflow/node.py | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/pyiron_workflow/node.py b/pyiron_workflow/node.py index d17d5329..54ec2715 100644 --- a/pyiron_workflow/node.py +++ b/pyiron_workflow/node.py @@ -350,23 +350,14 @@ def __call__(self, **kwargs) -> None: def update_input(self, **kwargs) -> None: """ - Fetch the latest and highest-priority input values from connections, then - overwrite values with keywords arguments matching input channel labels. - - Any channel that has neither a connection nor a kwarg update at time of call is - left unchanged. + Match keywords to input channels and update their values. Throws a warning if a keyword is provided that cannot be found among the input keys. - If you really want to update just a single value without any other side-effects, - this can always be accomplished by following the full semantic path to the - channel's value: `my_node.input.my_channel.value = "foo"`. - Args: **kwargs: input key - input value (including channels for connection) pairs. """ - self.inputs.fetch() for k, v in kwargs.items(): if k in self.inputs.labels: self.inputs[k] = v From fc1ba3121f2facc5a1c369804e263dd91e61df70 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Mon, 23 Oct 2023 10:24:23 -0700 Subject: [PATCH 10/24] Refactor: rename to reflect previous commit behaviour change --- pyiron_workflow/function.py | 8 ++++---- pyiron_workflow/macro.py | 2 +- pyiron_workflow/node.py | 5 +++-- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/pyiron_workflow/function.py b/pyiron_workflow/function.py index 7a739ce9..377c23dc 100644 --- a/pyiron_workflow/function.py +++ b/pyiron_workflow/function.py @@ -103,7 +103,7 @@ class Function(Node): run: Parse and process the input, execute the engine, process the results and update the output. disconnect: Disconnect all data and signal IO connections. - update_input: Allows input channels' values to be updated without any running. + set_input_values: Allows input channels' values to be updated without any running. Examples: At the most basic level, to use nodes all we need to do is provide the @@ -333,7 +333,7 @@ def __init__( # TODO: Parse output labels from the node function in case output_labels is None self.signals = self._build_signal_channels() - self.update_input(*args, **kwargs) + self.set_input_values(*args, **kwargs) def _get_output_labels(self, output_labels: str | list[str] | tuple[str] | None): """ @@ -516,7 +516,7 @@ def _convert_input_args_and_kwargs_to_input_kwargs(self, *args, **kwargs): return kwargs - def update_input(self, *args, **kwargs) -> None: + def set_input_values(self, *args, **kwargs) -> None: """ Match positional and keyword arguments to input channels and update input values. @@ -527,7 +527,7 @@ def update_input(self, *args, **kwargs) -> None: pairs. """ kwargs = self._convert_input_args_and_kwargs_to_input_kwargs(*args, **kwargs) - return super().update_input(**kwargs) + return super().set_input_values(**kwargs) def __call__(self, *args, **kwargs) -> None: kwargs = self._convert_input_args_and_kwargs_to_input_kwargs(*args, **kwargs) diff --git a/pyiron_workflow/macro.py b/pyiron_workflow/macro.py index 1fb56756..e81d4975 100644 --- a/pyiron_workflow/macro.py +++ b/pyiron_workflow/macro.py @@ -183,7 +183,7 @@ def __init__( self._inputs: Inputs = self._build_inputs() self._outputs: Outputs = self._build_outputs() - self.update_input(**kwargs) + self.set_input_values(**kwargs) def _get_linking_channel( self, diff --git a/pyiron_workflow/node.py b/pyiron_workflow/node.py index 54ec2715..982ae1cd 100644 --- a/pyiron_workflow/node.py +++ b/pyiron_workflow/node.py @@ -159,6 +159,7 @@ class Node(HasToDict, ABC): its internal structure. on_run: **Abstract.** Do the thing. run: A wrapper to handle all the infrastructure around executing `on_run`. + set_input_values: Allows input channels' values to be updated without any running. """ def __init__( @@ -345,10 +346,10 @@ def pull(self): return self.run(then_emit_output_signals=False) def __call__(self, **kwargs) -> None: - self.update_input(**kwargs) + self.set_input_values(**kwargs) return self.run() - def update_input(self, **kwargs) -> None: + def set_input_values(self, **kwargs) -> None: """ Match keywords to input channels and update their values. From ce335696fd39e39bd88a319158a0a1e7445360ba Mon Sep 17 00:00:00 2001 From: liamhuber Date: Mon, 23 Oct 2023 10:32:49 -0700 Subject: [PATCH 11/24] Update docs --- pyiron_workflow/node.py | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/pyiron_workflow/node.py b/pyiron_workflow/node.py index 982ae1cd..96856fe0 100644 --- a/pyiron_workflow/node.py +++ b/pyiron_workflow/node.py @@ -142,6 +142,8 @@ class Node(HasToDict, ABC): owning this, if any. ready (bool): Whether the inputs are all ready and the node is neither already running nor already failed. + run_args (dict): **Abstract** the argmuments to use for actually running the + node. Must be specified in child classes. running (bool): Whether the node has called `run` and has not yet received output from this call. (Default is False.) signals (pyiron_workflow.io.Signals): A container for input and output @@ -154,11 +156,19 @@ class Node(HasToDict, ABC): initialized. Methods: + __call__: Update input values (optional) then run the node (without firing off + .the `ran` signal, so nothing happens farther downstream). disconnect: Remove all connections, including signals. draw: Use graphviz to visualize the node, its IO and, if composite in nature, its internal structure. - on_run: **Abstract.** Do the thing. - run: A wrapper to handle all the infrastructure around executing `on_run`. + execute: Run the node, but right here, right now, and with the input it + currently has. + on_run: **Abstract.** Do the thing. What thing must be specified by child + classes. + pull: Run everything upstream, then run this node (but don't fire off the `ran` + signal, so nothing happens farther downstream). + run: Run the node function from `on_run`. Handles status, whether to run on an + executor, firing the `ran` signal, and callbacks (if an executor is used). set_input_values: Allows input channels' values to be updated without any running. """ @@ -346,6 +356,19 @@ def pull(self): return self.run(then_emit_output_signals=False) def __call__(self, **kwargs) -> None: + """ + Update the input, then run without firing the `ran` signal. + + Note that since input fetching happens _after_ the input values are updated, + if there is a connected data value it will get used instead of what is specified + here. If you really want to set a particular state and then run this can be + accomplished with `.inputs.fetch()` then `.set_input_values(...)` then + `.execute()` (or `.run(...)` with the flags you want). + + Args: + **kwargs: Keyword arguments matching input channel labels; used to update + the input before running. + """ self.set_input_values(**kwargs) return self.run() From 3ff3a49293e6ae72bc5ba8854960e8abee338868 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Mon, 23 Oct 2023 11:13:23 -0700 Subject: [PATCH 12/24] Add `update`'s power to `run` and get rid of it --- pyiron_workflow/function.py | 18 ++++++++++-------- pyiron_workflow/node.py | 19 +++++++++++++++---- tests/unit/test_function.py | 9 +++++++-- 3 files changed, 32 insertions(+), 14 deletions(-) diff --git a/pyiron_workflow/function.py b/pyiron_workflow/function.py index 377c23dc..aaea64b7 100644 --- a/pyiron_workflow/function.py +++ b/pyiron_workflow/function.py @@ -173,9 +173,7 @@ class Function(Node): using good variable names and returning those variables instead of using `output_labels`. If we force the node to `run()` (or call it) with bad types, it will raise an - error. - But, if we use the gentler `update()`, it will check types first and simply - return `None` if the input is not all `ready`. + error: >>> from typing import Union >>> >>> def hinted_example( @@ -186,13 +184,17 @@ class Function(Node): ... return p1, m1 >>> >>> plus_minus_1 = Function(hinted_example, x="not an int") - >>> plus_minus_1.update() - >>> plus_minus_1.outputs.to_value_dict() - {'p1': , - 'm1': } + >>> plus_minus_1.run() + ValueError: hinted_example received a run command but is not ready. The node + should be neither running nor failed, and all input values should conform to + type hints: + running: False + failed: False + x ready: False + y ready: True 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. + wrong type and so (by default) the run raises an error right away. 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 diff --git a/pyiron_workflow/node.py b/pyiron_workflow/node.py index 96856fe0..649aee0b 100644 --- a/pyiron_workflow/node.py +++ b/pyiron_workflow/node.py @@ -247,6 +247,7 @@ def run( first_fetch_input: bool = True, then_emit_output_signals: bool = True, force_local_execution: bool = False, + check_readiness: bool = True, ): """ Update the input (with whatever is currently available -- does _not_ trigger @@ -263,6 +264,8 @@ def run( (e.g. `ran`) afterwards. (Default is True.) force_local_execution (bool): Whether to ignore any executor settings and force the computation to run locally. (Default is False.) + check_readiness (bool): Whether to raise an exception if the node is not + `ready` to run after fetching new input. (Default is True.) Returns: (Any | Future): The result of running the node, or a futures object (if @@ -270,6 +273,18 @@ def run( """ if first_fetch_input: self.inputs.fetch() + if check_readiness and not self.ready: + input_readiness = "\n".join( + [f"{k} ready: {v.ready}" for k, v in self.inputs.items()] + ) + raise ValueError( + f"{self.label} received a run command but is not ready. The node " + f"should be neither running nor failed, and all input values should" + f" conform to type hints:\n" + f"running: {self.running}\n" + f"failed: {self.failed}\n" + + input_readiness + ) return self._run( finished_callback=self._finish_run_and_emit_ran if then_emit_output_signals else self._finish_run, @@ -398,10 +413,6 @@ def _build_signal_channels(self) -> Signals: signals.output.ran = OutputSignal("ran", self) return signals - def update(self) -> Any | tuple | Future | None: - if self.ready: - return self.run() - @property def working_directory(self): if self._working_directory is None: diff --git a/tests/unit/test_function.py b/tests/unit/test_function.py index 4f295899..28b8fbb8 100644 --- a/tests/unit/test_function.py +++ b/tests/unit/test_function.py @@ -286,7 +286,7 @@ def with_self(self, x: float) -> float: msg="Expected 'self' to be filtered out of node input, but found it in the " "input labels" ) - node.inputs.x = 1 + node.inputs.x = 1.0 node.run() self.assertEqual( node.outputs.output.value, @@ -489,7 +489,12 @@ def all_floats(x=1.1, y=1.1, z=1.1, omega=NotData, extra_there=None) -> float: ref = reference() floats = all_floats() ref() - floats() + floats.run( + check_readiness=False, + # We force-skip the readiness check since we are explicitly _trying_ to + # have one of the inputs be `NotData` -- a value which triggers the channel + # to be "not ready" + ) ref._copy_values(floats) self.assertEqual( From 307c77a0bac67bc2208da4c44a3afcf899a930db Mon Sep 17 00:00:00 2001 From: liamhuber Date: Mon, 23 Oct 2023 11:19:46 -0700 Subject: [PATCH 13/24] Update the demo notebook --- notebooks/workflow_example.ipynb | 224 ++++++++++++++----------------- 1 file changed, 99 insertions(+), 125 deletions(-) diff --git a/notebooks/workflow_example.ipynb b/notebooks/workflow_example.ipynb index f6fd2ab9..229760f8 100644 --- a/notebooks/workflow_example.ipynb +++ b/notebooks/workflow_example.ipynb @@ -119,8 +119,7 @@ "metadata": {}, "outputs": [], "source": [ - "# pm_node.run()\n", - "pm_node.update()" + "# pm_node.run()" ] }, { @@ -128,9 +127,7 @@ "id": "48b0db5a-548e-4195-8361-76763ddf0474", "metadata": {}, "source": [ - "Using the softer `update()` call checks to make sure the input is `ready` before moving on to `run()`, avoiding this error. In this case, `update()` sees we have no input an aborts by returning `None`.\n", - "\n", - "(Note: If you _do_ swap `update()` to `run()` in this cell, not only will you get the expected error, but `pm_node` will also set its `failed` attribute to `True` -- this will prevent it from being `ready` again until you manually reset `pm_node.failed = False`.)" + "Not only will you get the expected error, but `pm_node` will also set its `failed` attribute to `True` -- this will prevent it from being `ready` again until you manually reset `pm_node.failed = False`." ] }, { @@ -176,7 +173,7 @@ "id": "c54a691e-a075-4d41-bc0f-3a990857a27a", "metadata": {}, "source": [ - "Alternatively, the `run()` command (and `update()` when it proceeds to execution) just return the function's return value:" + "Alternatively, the `run()` command just return the function's return value:" ] }, { @@ -241,7 +238,7 @@ "id": "58ed9b25-6dde-488d-9582-d49d405793c6", "metadata": {}, "source": [ - "This node also exploits type hinting! `run()` will always force the execution, but `update()` will not only check if the data is there, but also if it is the right type:" + "This node also exploits type hinting! `run()` will check that input values conform to type hints before computing anything. Failing at this stage won't actually cause the node to have a `failed` status, so you can just re-run it once the input is fixed." ] }, { @@ -249,46 +246,27 @@ "execution_count": 10, "id": "ac0fe993-6c82-48c8-a780-cbd0c97fc386", "metadata": {}, - "outputs": [], - "source": [ - "adder_node.inputs.x = \"not an integer\"\n", - "adder_node.inputs.x.type_hint, type(adder_node.inputs.x.value)\n", - "adder_node.update()\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": [ - "Since the execution never happened, the output is unchanged" - ] - }, - { - "cell_type": "code", - "execution_count": 11, - "id": "bcbd17f1-a3e4-44f0-bde1-cbddc51c5d73", - "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "1" + "(int, str)" ] }, - "execution_count": 11, + "execution_count": 10, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "adder_node.outputs.sum_.value" + "adder_node.inputs.x = \"not an integer\"\n", + "adder_node.inputs.x.type_hint, type(adder_node.inputs.x.value)\n", + "# adder_node.run()" ] }, { "cell_type": "code", - "execution_count": 12, + "execution_count": 11, "id": "15742a49-4c23-4d4a-84d9-9bf19677544c", "metadata": {}, "outputs": [ @@ -298,14 +276,14 @@ "3" ] }, - "execution_count": 12, + "execution_count": 11, "metadata": {}, "output_type": "execute_result" } ], "source": [ "adder_node.inputs.x = 2\n", - "adder_node.update()" + "adder_node.run()" ] }, { @@ -318,7 +296,7 @@ }, { "cell_type": "code", - "execution_count": 13, + "execution_count": 12, "id": "0c8f09a7-67c4-4c6c-a021-e3fea1a16576", "metadata": {}, "outputs": [ @@ -328,7 +306,7 @@ "30" ] }, - "execution_count": 13, + "execution_count": 12, "metadata": {}, "output_type": "execute_result" } @@ -348,7 +326,7 @@ }, { "cell_type": "code", - "execution_count": 14, + "execution_count": 13, "id": "69b59737-9e09-4b4b-a0e2-76a09de02c08", "metadata": {}, "outputs": [ @@ -358,7 +336,7 @@ "31" ] }, - "execution_count": 14, + "execution_count": 13, "metadata": {}, "output_type": "execute_result" } @@ -391,7 +369,7 @@ }, { "cell_type": "code", - "execution_count": 15, + "execution_count": 14, "id": "61b43a9b-8dad-48b7-9194-2045e465793b", "metadata": {}, "outputs": [], @@ -401,7 +379,7 @@ }, { "cell_type": "code", - "execution_count": 16, + "execution_count": 15, "id": "647360a9-c971-4272-995c-aa01e5f5bb83", "metadata": {}, "outputs": [ @@ -438,7 +416,7 @@ }, { "cell_type": "code", - "execution_count": 17, + "execution_count": 16, "id": "b8c845b7-7088-43d7-b106-7a6ba1c571ec", "metadata": {}, "outputs": [ @@ -482,7 +460,7 @@ }, { "cell_type": "code", - "execution_count": 18, + "execution_count": 17, "id": "2e418abf-7059-4e1e-9b9f-b3dc0a4b5e35", "metadata": { "tags": [] @@ -532,7 +510,7 @@ }, { "cell_type": "code", - "execution_count": 19, + "execution_count": 18, "id": "59c29856-c77e-48a1-9f17-15d4c58be588", "metadata": {}, "outputs": [ @@ -568,7 +546,7 @@ }, { "cell_type": "code", - "execution_count": 20, + "execution_count": 19, "id": "1a4e9693-0980-4435-aecc-3331d8b608dd", "metadata": {}, "outputs": [], @@ -580,7 +558,7 @@ }, { "cell_type": "code", - "execution_count": 21, + "execution_count": 20, "id": "7c4d314b-33bb-4a67-bfb9-ed77fba3949c", "metadata": {}, "outputs": [ @@ -619,7 +597,7 @@ }, { "cell_type": "code", - "execution_count": 22, + "execution_count": 21, "id": "61ae572f-197b-4a60-8d3e-e19c1b9cc6e2", "metadata": {}, "outputs": [ @@ -659,24 +637,24 @@ }, { "cell_type": "code", - "execution_count": 23, + "execution_count": 22, "id": "6569014a-815b-46dd-8b47-4e1cd4584b3b", "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "array([0.6816222 , 0.60285251, 0.31984666, 0.38336884, 0.95586544,\n", - " 0.20915899, 0.73614411, 0.67259937, 0.84499503, 0.10539287])" + "array([0.91077351, 0.33860412, 0.59806048, 0.66528464, 0.80125293,\n", + " 0.31981677, 0.54395521, 0.4926537 , 0.52626431, 0.7848854 ])" ] }, - "execution_count": 23, + "execution_count": 22, "metadata": {}, "output_type": "execute_result" }, { "data": { - "image/png": "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", + "image/png": "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", "text/plain": [ "
" ] @@ -732,7 +710,7 @@ }, { "cell_type": "code", - "execution_count": 24, + "execution_count": 23, "id": "1cd000bd-9b24-4c39-9cac-70a3291d0660", "metadata": {}, "outputs": [], @@ -759,7 +737,7 @@ }, { "cell_type": "code", - "execution_count": 25, + "execution_count": 24, "id": "7964df3c-55af-4c25-afc5-9e07accb606a", "metadata": {}, "outputs": [ @@ -800,7 +778,7 @@ }, { "cell_type": "code", - "execution_count": 26, + "execution_count": 25, "id": "809178a5-2e6b-471d-89ef-0797db47c5ad", "metadata": {}, "outputs": [ @@ -854,7 +832,7 @@ }, { "cell_type": "code", - "execution_count": 27, + "execution_count": 26, "id": "52c48d19-10a2-4c48-ae81-eceea4129a60", "metadata": {}, "outputs": [ @@ -864,7 +842,7 @@ "{'ay': 3, 'a + b + 2': 7}" ] }, - "execution_count": 27, + "execution_count": 26, "metadata": {}, "output_type": "execute_result" } @@ -884,7 +862,7 @@ }, { "cell_type": "code", - "execution_count": 28, + "execution_count": 27, "id": "bb35ba3e-602d-4c9c-b046-32da9401dd1c", "metadata": {}, "outputs": [ @@ -894,7 +872,7 @@ "(7, 3)" ] }, - "execution_count": 28, + "execution_count": 27, "metadata": {}, "output_type": "execute_result" } @@ -913,7 +891,7 @@ }, { "cell_type": "code", - "execution_count": 29, + "execution_count": 28, "id": "2b0d2c85-9049-417b-8739-8a8432a1efbe", "metadata": {}, "outputs": [ @@ -932,127 +910,127 @@ "clustersimple\n", "\n", "simple: Workflow\n", + "\n", + "clustersimpleInputs\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "Inputs\n", + "\n", "\n", "clustersimpleOutputs\n", "\n", - "\n", + "\n", "\n", "\n", "\n", "\n", - "\n", + "\n", "Outputs\n", "\n", "\n", "clustersimplea\n", "\n", - "\n", + "\n", "\n", "\n", "\n", "\n", - "\n", + "\n", "a: AddOne\n", "\n", "\n", "clustersimpleaInputs\n", "\n", - "\n", + "\n", "\n", "\n", "\n", "\n", - "\n", + "\n", "Inputs\n", "\n", "\n", "clustersimpleaOutputs\n", "\n", - "\n", + "\n", "\n", "\n", "\n", "\n", - "\n", + "\n", "Outputs\n", "\n", "\n", "clustersimpleb\n", "\n", - "\n", + "\n", "\n", "\n", "\n", "\n", - "\n", + "\n", "b: AddOne\n", "\n", "\n", "clustersimplebInputs\n", "\n", - "\n", + "\n", "\n", "\n", "\n", "\n", - "\n", + "\n", "Inputs\n", "\n", "\n", "clustersimplebOutputs\n", "\n", - "\n", + "\n", "\n", "\n", "\n", "\n", - "\n", + "\n", "Outputs\n", "\n", "\n", "clustersimplesum\n", "\n", - "\n", + "\n", "\n", "\n", "\n", "\n", - "\n", + "\n", "sum: AddNode\n", "\n", "\n", "clustersimplesumInputs\n", "\n", - "\n", + "\n", "\n", "\n", "\n", "\n", - "\n", + "\n", "Inputs\n", "\n", "\n", "clustersimplesumOutputs\n", "\n", - "\n", + "\n", "\n", "\n", "\n", "\n", - "\n", + "\n", "Outputs\n", "\n", - "\n", - "clustersimpleInputs\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "Inputs\n", - "\n", "\n", "\n", "clustersimpleInputsrun\n", @@ -1231,10 +1209,10 @@ "\n" ], "text/plain": [ - "" + "" ] }, - "execution_count": 29, + "execution_count": 28, "metadata": {}, "output_type": "execute_result" } @@ -1255,14 +1233,14 @@ }, { "cell_type": "code", - "execution_count": 30, + "execution_count": 29, "id": "ae500d5e-e55b-432c-8b5f-d5892193cdf5", "metadata": {}, "outputs": [ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "11fa1336d10a42f4936ce22a299f191d", + "model_id": "a289b513c50d41989670c5b4ac9df823", "version_major": 2, "version_minor": 0 }, @@ -1289,10 +1267,10 @@ { "data": { "text/plain": [ - "" + "" ] }, - "execution_count": 30, + "execution_count": 29, "metadata": {}, "output_type": "execute_result" }, @@ -1333,7 +1311,7 @@ }, { "cell_type": "code", - "execution_count": 31, + "execution_count": 30, "id": "2114d0c3-cdad-43c7-9ffa-50c36d56d18f", "metadata": {}, "outputs": [ @@ -1541,10 +1519,10 @@ "\n" ], "text/plain": [ - "" + "" ] }, - "execution_count": 31, + "execution_count": 30, "metadata": {}, "output_type": "execute_result" } @@ -1565,7 +1543,7 @@ }, { "cell_type": "code", - "execution_count": 32, + "execution_count": 31, "id": "c71a8308-f8a1-4041-bea0-1c841e072a6d", "metadata": {}, "outputs": [], @@ -1575,7 +1553,7 @@ }, { "cell_type": "code", - "execution_count": 33, + "execution_count": 32, "id": "2b9bb21a-73cd-444e-84a9-100e202aa422", "metadata": {}, "outputs": [ @@ -1593,7 +1571,7 @@ "13" ] }, - "execution_count": 33, + "execution_count": 32, "metadata": {}, "output_type": "execute_result" } @@ -1632,7 +1610,7 @@ }, { "cell_type": "code", - "execution_count": 34, + "execution_count": 33, "id": "3668f9a9-adca-48a4-84ea-13add965897c", "metadata": {}, "outputs": [ @@ -1642,7 +1620,7 @@ "{'intermediate': 102, 'plus_three': 103}" ] }, - "execution_count": 34, + "execution_count": 33, "metadata": {}, "output_type": "execute_result" } @@ -1680,7 +1658,7 @@ }, { "cell_type": "code", - "execution_count": 35, + "execution_count": 34, "id": "9aaeeec0-5f88-4c94-a6cc-45b56d2f0111", "metadata": {}, "outputs": [], @@ -1710,7 +1688,7 @@ }, { "cell_type": "code", - "execution_count": 36, + "execution_count": 35, "id": "a832e552-b3cc-411a-a258-ef21574fc439", "metadata": {}, "outputs": [], @@ -1737,7 +1715,7 @@ }, { "cell_type": "code", - "execution_count": 37, + "execution_count": 36, "id": "b764a447-236f-4cb7-952a-7cba4855087d", "metadata": {}, "outputs": [ @@ -2961,10 +2939,10 @@ "\n" ], "text/plain": [ - "" + "" ] }, - "execution_count": 37, + "execution_count": 36, "metadata": {}, "output_type": "execute_result" } @@ -2975,7 +2953,7 @@ }, { "cell_type": "code", - "execution_count": 38, + "execution_count": 37, "id": "b51bef25-86c5-4d57-80c1-ab733e703caf", "metadata": {}, "outputs": [ @@ -2996,7 +2974,7 @@ }, { "cell_type": "code", - "execution_count": 39, + "execution_count": 38, "id": "091e2386-0081-436c-a736-23d019bd9b91", "metadata": {}, "outputs": [ @@ -3037,7 +3015,7 @@ }, { "cell_type": "code", - "execution_count": 40, + "execution_count": 39, "id": "4cdffdca-48d3-4486-9045-48102c7e5f31", "metadata": {}, "outputs": [ @@ -3075,7 +3053,7 @@ }, { "cell_type": "code", - "execution_count": 41, + "execution_count": 40, "id": "ed4a3a22-fc3a-44c9-9d4f-c65bc1288889", "metadata": {}, "outputs": [ @@ -3097,7 +3075,7 @@ }, { "cell_type": "code", - "execution_count": 42, + "execution_count": 41, "id": "5a985cbf-c308-4369-9223-b8a37edb8ab1", "metadata": {}, "outputs": [ @@ -3187,7 +3165,7 @@ }, { "cell_type": "code", - "execution_count": 43, + "execution_count": 42, "id": "0b373764-b389-4c24-8086-f3d33a4f7fd7", "metadata": {}, "outputs": [ @@ -3201,7 +3179,7 @@ " 17.230249999999995]" ] }, - "execution_count": 43, + "execution_count": 42, "metadata": {}, "output_type": "execute_result" } @@ -3238,7 +3216,7 @@ }, { "cell_type": "code", - "execution_count": 44, + "execution_count": 43, "id": "0dd04b4c-e3e7-4072-ad34-58f2c1e4f596", "metadata": {}, "outputs": [ @@ -3297,7 +3275,7 @@ }, { "cell_type": "code", - "execution_count": 45, + "execution_count": 44, "id": "2dfb967b-41ac-4463-b606-3e315e617f2a", "metadata": {}, "outputs": [ @@ -3321,7 +3299,7 @@ }, { "cell_type": "code", - "execution_count": 46, + "execution_count": 45, "id": "2e87f858-b327-4f6b-9237-c8a557f29aeb", "metadata": {}, "outputs": [ @@ -3329,12 +3307,8 @@ "name": "stdout", "output_type": "stream", "text": [ - "0.406 > 0.2\n", - "0.999 > 0.2\n", - "0.827 > 0.2\n", - "0.417 > 0.2\n", - "0.120 <= 0.2\n", - "Finally 0.120\n" + "0.064 <= 0.2\n", + "Finally 0.064\n" ] } ], From fadf4da8e676fde942e3e441a82718ae7b674f41 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Mon, 23 Oct 2023 11:27:25 -0700 Subject: [PATCH 14/24] Don't check readiness when `execute`ing Really let it force stuff by default. --- pyiron_workflow/node.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyiron_workflow/node.py b/pyiron_workflow/node.py index 649aee0b..c2b939b0 100644 --- a/pyiron_workflow/node.py +++ b/pyiron_workflow/node.py @@ -355,7 +355,8 @@ def execute(self): return self.run( first_fetch_input=False, then_emit_output_signals=False, - force_local_execution=True + force_local_execution=True, + check_readiness=False, ) def pull(self): From db58d5f3a3ee8a5e631e0c173288b8c958d719d0 Mon Sep 17 00:00:00 2001 From: pyiron-runner Date: Mon, 23 Oct 2023 18:59:37 +0000 Subject: [PATCH 15/24] Format black --- pyiron_workflow/node.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pyiron_workflow/node.py b/pyiron_workflow/node.py index c2b939b0..8ae656e7 100644 --- a/pyiron_workflow/node.py +++ b/pyiron_workflow/node.py @@ -282,11 +282,11 @@ def run( f"should be neither running nor failed, and all input values should" f" conform to type hints:\n" f"running: {self.running}\n" - f"failed: {self.failed}\n" - + input_readiness + f"failed: {self.failed}\n" + input_readiness ) return self._run( - finished_callback=self._finish_run_and_emit_ran if then_emit_output_signals + finished_callback=self._finish_run_and_emit_ran + if then_emit_output_signals else self._finish_run, force_local_execution=force_local_execution, ) From 1447f14a03728ada29b480ae29753f3a80a267d2 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Mon, 23 Oct 2023 12:26:02 -0700 Subject: [PATCH 16/24] Refactor: extract topology stuff to its own module --- pyiron_workflow/composite.py | 54 +------------------- pyiron_workflow/topology.py | 99 ++++++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 52 deletions(-) create mode 100644 pyiron_workflow/topology.py diff --git a/pyiron_workflow/composite.py b/pyiron_workflow/composite.py index 1bb1abc2..9fdb51d9 100644 --- a/pyiron_workflow/composite.py +++ b/pyiron_workflow/composite.py @@ -10,12 +10,12 @@ from typing import Literal, Optional, TYPE_CHECKING from bidict import bidict -from toposort import toposort_flatten, CircularDependencyError from pyiron_workflow.interfaces import Creator, Wrappers from pyiron_workflow.io import Outputs, Inputs from pyiron_workflow.node import Node from pyiron_workflow.node_package import NodePackage +from pyiron_workflow.topology import nodes_to_execution_order from pyiron_workflow.util import logger, DotDict, SeabornColors if TYPE_CHECKING: @@ -201,63 +201,13 @@ def set_run_signals_to_dag_execution(self): def _set_run_connections_and_starting_nodes_according_to_linear_dag(self): # This is the most primitive sort of topological exploitation we can do # It is not efficient if the nodes have executors and can run in parallel - try: - # Topological sorting ensures that all input dependencies have been - # executed before the node depending on them gets run - # The flattened part is just that we don't care about topological - # generations that are mutually independent (inefficient but easier for now) - execution_order = toposort_flatten(self.get_data_digraph()) - except CircularDependencyError as e: - raise ValueError( - f"Detected a cycle in the data flow topology, unable to automate the " - f"execution of non-DAGs: cycles found among {e.data}" - ) + execution_order = nodes_to_execution_order(*self.nodes.values()) for i, label in enumerate(execution_order[:-1]): next_node = execution_order[i + 1] self.nodes[label] > self.nodes[next_node] self.starting_nodes = [self.nodes[execution_order[0]]] - def get_data_digraph(self) -> dict[str, set[str]]: - """ - Builds a directed graph of node labels based on data connections between nodes - directly owned by this composite -- i.e. does not worry about data connections - which are entirely internal to an owned sub-graph. - - Returns: - dict[str, set[str]]: A dictionary of nodes and the nodes they depend on for - data. - - Raises: - ValueError: When a node appears in its own input. - """ - digraph = {} - - for node in self.nodes.values(): - node_dependencies = [] - for channel in node.inputs: - locally_scoped_dependencies = [] - for upstream in channel.connections: - if upstream.node.parent is self: - locally_scoped_dependencies.append(upstream.node.label) - elif channel.node.get_first_shared_parent(upstream.node) is self: - locally_scoped_dependencies.append( - upstream.node.get_parent_proximate_to(self).label - ) - node_dependencies.extend(locally_scoped_dependencies) - node_dependencies = set(node_dependencies) - if node.label in node_dependencies: - # the toposort library has a - # [known issue](https://gitlab.com/ericvsmith/toposort/-/issues/3) - # That self-dependency isn't caught, so we catch it manually here. - raise ValueError( - f"Detected a cycle in the data flow topology, unable to automate " - f"the execution of non-DAGs: {node.label} appears in its own input." - ) - digraph[node.label] = node_dependencies - - return digraph - def _build_io( self, i_or_o: Literal["inputs", "outputs"], diff --git a/pyiron_workflow/topology.py b/pyiron_workflow/topology.py new file mode 100644 index 00000000..ab140b64 --- /dev/null +++ b/pyiron_workflow/topology.py @@ -0,0 +1,99 @@ +""" +A submodule for getting our node classes talking nicely with an external tool for +topological analysis. Such analyses are useful for automating execution flows based on +data flow dependencies. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from toposort import toposort_flatten, CircularDependencyError + +if TYPE_CHECKING: + from pyiron_workflow.node import Node + + +def nodes_to_data_digraph(*nodes: Node) -> dict[str, set[str]]: + """ + Maps a set of nodes to a digraph of their data dependency in the format of label + keys and set of label values for upstream nodes. + + Returns: + dict[str, set[str]]: A dictionary of nodes and the nodes they depend on for + data. + + Raises: + ValueError: When a node appears in its own input. + ValueError: If the nodes do not all have the same parent. + ValueError: If one of the nodes has an upstream data connection whose node has + a different parent. + """ + digraph = {} + + parent = nodes[0].parent + if not all(n.parent is parent for n in nodes): + raise ValueError( + "Nodes in a data digraph must all be siblings -- i.e. have the same " + "`parent` attribute." + ) + + for node in nodes: + node_dependencies = [] + for channel in node.inputs: + locally_scoped_dependencies = [] + for upstream in channel.connections: + if upstream.node.parent is parent: + locally_scoped_dependencies.append(upstream.node.label) + else: + raise ValueError( + f"Nodes in a data digraph must all be siblings, but the " + f"{channel.label} channel of {node.label} has a connection to " + f"the {upstream.label} channel of {upstream.node.label} with " + f"parents {node.parent} and {upstream.node.parent}, " + f"respectively" + ) + node_dependencies.extend(locally_scoped_dependencies) + node_dependencies = set(node_dependencies) + if node.label in node_dependencies: + # the toposort library has a + # [known issue](https://gitlab.com/ericvsmith/toposort/-/issues/3) + # That self-dependency isn't caught, so we catch it manually here. + raise ValueError( + f"Detected a cycle in the data flow topology, unable to automate " + f"the execution of non-DAGs: {node.label} appears in its own input." + ) + digraph[node.label] = node_dependencies + + return digraph + + +def nodes_to_execution_order(*nodes: Node) -> list[str]: + """ + Given a set of nodes that all have the same parent, returns a list of corresponding + node labels giving an execution order that guarantees the executing node always has + data from all its upstream nodes. + + Args: + *nodes (Node): The nodes whose data topology to analyze + + Returns: + (list[str]): The labels in safe execution order. + + Raises: + CircularDependencyError: If the data dependency is not a Directed Acyclic Graph + """ + try: + # Topological sorting ensures that all input dependencies have been + # executed before the node depending on them gets run + # The flattened part is just that we don't care about topological + # generations that are mutually independent (inefficient but easier for now) + execution_order = toposort_flatten( + nodes_to_data_digraph(*nodes) + ) + except CircularDependencyError as e: + raise ValueError( + f"Detected a cycle in the data flow topology, unable to automate the " + f"execution of non-DAGs: cycles found among {e.data}" + ) + return execution_order From 04ec97933573e554a61c6ef37c79ddeacad10053 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Mon, 23 Oct 2023 12:28:57 -0700 Subject: [PATCH 17/24] Restore what you broke if it fails --- pyiron_workflow/composite.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/pyiron_workflow/composite.py b/pyiron_workflow/composite.py index 9fdb51d9..00b4adf0 100644 --- a/pyiron_workflow/composite.py +++ b/pyiron_workflow/composite.py @@ -194,9 +194,16 @@ def set_run_signals_to_dag_execution(self): Raises: ValueError: When the data connections do not form a DAG. """ - self.disconnect_run() - self._set_run_connections_and_starting_nodes_according_to_linear_dag() - # TODO: Replace this linear setup with something more powerful + disconnected = self.disconnect_run() + try: + self._set_run_connections_and_starting_nodes_according_to_linear_dag() + # TODO: Replace this linear setup with something more powerful + except Exception as e: + # Restore whatever you broke + for c1, c2 in disconnected: + c1.connect(c2) + # Then + raise e def _set_run_connections_and_starting_nodes_according_to_linear_dag(self): # This is the most primitive sort of topological exploitation we can do From f457eecc99846093e828b8778f7ed3fefdb214c1 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Mon, 23 Oct 2023 12:53:51 -0700 Subject: [PATCH 18/24] Move the actual reconnecting over to the topology module --- pyiron_workflow/composite.py | 30 ++++-------------------- pyiron_workflow/topology.py | 44 ++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 25 deletions(-) diff --git a/pyiron_workflow/composite.py b/pyiron_workflow/composite.py index 00b4adf0..270992e0 100644 --- a/pyiron_workflow/composite.py +++ b/pyiron_workflow/composite.py @@ -15,7 +15,7 @@ from pyiron_workflow.io import Outputs, Inputs from pyiron_workflow.node import Node from pyiron_workflow.node_package import NodePackage -from pyiron_workflow.topology import nodes_to_execution_order +from pyiron_workflow.topology import set_run_connections_according_to_linear_dag from pyiron_workflow.util import logger, DotDict, SeabornColors if TYPE_CHECKING: @@ -189,31 +189,11 @@ def disconnect_run(self) -> list[tuple[Channel, Channel]]: def set_run_signals_to_dag_execution(self): """ Disconnects all `signals.input.run` connections among children and attempts to - reconnect these according to the DAG flow of the data. - - Raises: - ValueError: When the data connections do not form a DAG. + reconnect these according to the DAG flow of the data. On success, sets the + starting nodes to just be the upstream-most node in this linear DAG flow. """ - disconnected = self.disconnect_run() - try: - self._set_run_connections_and_starting_nodes_according_to_linear_dag() - # TODO: Replace this linear setup with something more powerful - except Exception as e: - # Restore whatever you broke - for c1, c2 in disconnected: - c1.connect(c2) - # Then - raise e - - def _set_run_connections_and_starting_nodes_according_to_linear_dag(self): - # This is the most primitive sort of topological exploitation we can do - # It is not efficient if the nodes have executors and can run in parallel - execution_order = nodes_to_execution_order(*self.nodes.values()) - - for i, label in enumerate(execution_order[:-1]): - next_node = execution_order[i + 1] - self.nodes[label] > self.nodes[next_node] - self.starting_nodes = [self.nodes[execution_order[0]]] + _, upstream_most_node = set_run_connections_according_to_linear_dag(self.nodes) + self.starting_nodes = [upstream_most_node] def _build_io( self, diff --git a/pyiron_workflow/topology.py b/pyiron_workflow/topology.py index ab140b64..e10074a8 100644 --- a/pyiron_workflow/topology.py +++ b/pyiron_workflow/topology.py @@ -11,6 +11,7 @@ from toposort import toposort_flatten, CircularDependencyError if TYPE_CHECKING: + from pyiron_workflow.channels import InputSignal, OutputSignal from pyiron_workflow.node import Node @@ -97,3 +98,46 @@ def nodes_to_execution_order(*nodes: Node) -> list[str]: f"execution of non-DAGs: cycles found among {e.data}" ) return execution_order + + +def set_run_connections_according_to_linear_dag( + nodes: dict[str, Node] +) -> tuple[list[tuple[InputSignal, OutputSignal]], Node]: + """ + Given a set of nodes that all have the same parent, have no upstream data + connections outside the nodes provided, and have acyclic data flow, disconnects all + their `run` and `ran` signals, then sets these signals to a linear execution that + guarantees downstream nodes are always executed after upstream nodes. Returns one + of the upstream-most nodes. + + In the event an exception is encountered, any disconnected connections are repaired + before it is raised. + + Args: + nodes (dict[str, Node]): A dictionary of node labels and the node the label is + from, whose connections will be set according to data flow. + + Returns: + (list[tuple[Channel, Channel]]): Any `run`/`ran` pairs that were disconnected. + (Node): The 0th node in the execution order, i.e. on that has no + dependencies. + """ + disconnected_pairs = [] + for node in nodes.values(): + disconnected_pairs.extend(node.signals.disconnect_run()) + + try: + # This is the most primitive sort of topological exploitation we can do + # It is not efficient if the nodes have executors and can run in parallel + execution_order = nodes_to_execution_order(*nodes.values()) + + for i, label in enumerate(execution_order[:-1]): + next_node = execution_order[i + 1] + nodes[label] > nodes[next_node] + + return disconnected_pairs, nodes[execution_order[0]] + except Exception as e: + # Restore whatever you broke + for c1, c2 in disconnected_pairs: + c1.connect(c2) + raise e From abf6c5cb352c19039f1f171da1caf880754bd877 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Mon, 23 Oct 2023 13:09:09 -0700 Subject: [PATCH 19/24] Use the dictionary of nodes instead of list Which lets us be a little more robust checking for inconsistencies --- pyiron_workflow/topology.py | 44 +++++++++++++++++++++++-------------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/pyiron_workflow/topology.py b/pyiron_workflow/topology.py index e10074a8..a1b58519 100644 --- a/pyiron_workflow/topology.py +++ b/pyiron_workflow/topology.py @@ -15,11 +15,15 @@ from pyiron_workflow.node import Node -def nodes_to_data_digraph(*nodes: Node) -> dict[str, set[str]]: +def nodes_to_data_digraph(nodes: dict[str, Node]) -> dict[str, set[str]]: """ Maps a set of nodes to a digraph of their data dependency in the format of label keys and set of label values for upstream nodes. + Args: + nodes (dict[str, Node]): A label-keyed dictionary of nodes to convert into a + string-based dictionary of digraph connections based on data flow. + Returns: dict[str, set[str]]: A dictionary of nodes and the nodes they depend on for data. @@ -32,28 +36,35 @@ def nodes_to_data_digraph(*nodes: Node) -> dict[str, set[str]]: """ digraph = {} - parent = nodes[0].parent - if not all(n.parent is parent for n in nodes): + parent = next(iter(nodes.values())).parent # Just grab any one + if not all(n.parent is parent for n in nodes.values()): raise ValueError( "Nodes in a data digraph must all be siblings -- i.e. have the same " "`parent` attribute." ) - for node in nodes: + for node in nodes.values(): node_dependencies = [] for channel in node.inputs: locally_scoped_dependencies = [] for upstream in channel.connections: - if upstream.node.parent is parent: - locally_scoped_dependencies.append(upstream.node.label) - else: + try: + upstream_node = nodes[upstream.node.label] + except KeyError as e: + raise KeyError( + f"The {channel.label} channel of {node.label} has a connection " + f"to {upstream.label} channel of {upstream.node.label}, but " + f"{upstream.node.label} was not found among nodes. All nodes " + f"in the data flow dependency tree must be included.") + if upstream_node is not upstream.node: raise ValueError( - f"Nodes in a data digraph must all be siblings, but the " - f"{channel.label} channel of {node.label} has a connection to " - f"the {upstream.label} channel of {upstream.node.label} with " - f"parents {node.parent} and {upstream.node.parent}, " - f"respectively" + f"The {channel.label} channel of {node.label} has a connection " + f"to {upstream.label} channel of {upstream.node.label}, but " + f"that channel's node is not the same as the nodes passed " + f"here. All nodes in the data flow dependency tree must be " + f"included." ) + locally_scoped_dependencies.append(upstream.node.label) node_dependencies.extend(locally_scoped_dependencies) node_dependencies = set(node_dependencies) if node.label in node_dependencies: @@ -69,14 +80,15 @@ def nodes_to_data_digraph(*nodes: Node) -> dict[str, set[str]]: return digraph -def nodes_to_execution_order(*nodes: Node) -> list[str]: +def nodes_to_execution_order(nodes: dict[str, Node]) -> list[str]: """ Given a set of nodes that all have the same parent, returns a list of corresponding node labels giving an execution order that guarantees the executing node always has data from all its upstream nodes. Args: - *nodes (Node): The nodes whose data topology to analyze + nodes (dict[str, Node]): A label-keyed dictionary of nodes from whom to build + an execution order based on topological analysis of data flow. Returns: (list[str]): The labels in safe execution order. @@ -90,7 +102,7 @@ def nodes_to_execution_order(*nodes: Node) -> list[str]: # The flattened part is just that we don't care about topological # generations that are mutually independent (inefficient but easier for now) execution_order = toposort_flatten( - nodes_to_data_digraph(*nodes) + nodes_to_data_digraph(nodes) ) except CircularDependencyError as e: raise ValueError( @@ -129,7 +141,7 @@ def set_run_connections_according_to_linear_dag( try: # This is the most primitive sort of topological exploitation we can do # It is not efficient if the nodes have executors and can run in parallel - execution_order = nodes_to_execution_order(*nodes.values()) + execution_order = nodes_to_execution_order(nodes) for i, label in enumerate(execution_order[:-1]): next_node = execution_order[i + 1] From 5a8da61e49cc124c003ae53bea1756ad4c689fda Mon Sep 17 00:00:00 2001 From: liamhuber Date: Mon, 23 Oct 2023 13:12:52 -0700 Subject: [PATCH 20/24] Provide the rest of the run signature to Workflow.run --- pyiron_workflow/workflow.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/pyiron_workflow/workflow.py b/pyiron_workflow/workflow.py index 5817fa7e..36eee604 100644 --- a/pyiron_workflow/workflow.py +++ b/pyiron_workflow/workflow.py @@ -203,7 +203,13 @@ def inputs(self) -> Inputs: def outputs(self) -> Outputs: return self._build_outputs() - def run(self): + def run( + self, + first_fetch_input: bool = True, + then_emit_output_signals: bool = True, + force_local_execution: bool = False, + check_readiness: bool = True, + ): if self.automate_execution: self.set_run_signals_to_dag_execution() return super().run() From 22a0ed87ffa504beef9d382772ec2857cfb09059 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Mon, 23 Oct 2023 14:23:51 -0700 Subject: [PATCH 21/24] Implement pull --- pyiron_workflow/node.py | 46 ++++++++++++++++++++++++++++++++++------- 1 file changed, 38 insertions(+), 8 deletions(-) diff --git a/pyiron_workflow/node.py b/pyiron_workflow/node.py index 8ae656e7..d2f7a78b 100644 --- a/pyiron_workflow/node.py +++ b/pyiron_workflow/node.py @@ -16,6 +16,7 @@ from pyiron_workflow.files import DirectoryObject from pyiron_workflow.has_to_dict import HasToDict from pyiron_workflow.io import Signals, InputSignal, OutputSignal +from pyiron_workflow.topology import set_run_connections_according_to_linear_dag from pyiron_workflow.util import SeabornColors if TYPE_CHECKING: @@ -362,14 +363,43 @@ def execute(self): def pull(self): """ Use topological analysis to build a tree of all upstream dependencies; run them - first, then run this node to get an up-to-date result. Does _not_ fire the `ran` - signal afterwards. - """ - raise NotImplementedError - # Need to implement everything for on-the-fly construction of the upstream - # graph and its execution - # Then, - return self.run(then_emit_output_signals=False) + first, then run this node to get an up-to-date result. Does not trigger any + downstream executions. + """ + label_map = {} + nodes = {} + for node in self.get_nodes_in_data_tree(): + modified_label = node.label + str(id(node)) + label_map[modified_label] = node.label + node.label = modified_label # Ensure each node has a unique label + # This is necessary when the nodes do not have a workflow and may thus have + # arbitrary labels. + # This is pretty ugly; it would be nice to not depend so heavily on labels. + # Maybe we could switch a bunch of stuff to rely on the unique ID? + nodes[modified_label] = node + disconnected_pairs, starter = set_run_connections_according_to_linear_dag(nodes) + try: + self.signals.disconnect_run() # Don't let anything upstream trigger this + starter.run() # Now push from the top + return self.run() # Finally, run here and return the result + # Emitting won't matter since we already disconnected this one + finally: + # No matter what, restore the original connections and labels afterwards + for modified_label, node in nodes.items(): + node.label = label_map[modified_label] + node.signals.disconnect_run() + for c1, c2 in disconnected_pairs: + c1.connect(c2) + + def get_nodes_in_data_tree(self) -> set[Node]: + """ + Get a set of all nodes from this one and upstream through data connections. + """ + nodes = set([self]) + for channel in self.inputs: + for connection in channel.connections: + nodes = nodes.union(connection.node.get_nodes_in_data_tree()) + return nodes def __call__(self, **kwargs) -> None: """ From 411845557f809b5621ede10bdcc4dc7aec83db2e Mon Sep 17 00:00:00 2001 From: liamhuber Date: Mon, 23 Oct 2023 14:23:56 -0700 Subject: [PATCH 22/24] :bug: also disconnect `ran` when making a dag! Otherwise `pull` might trigger stuff farther downstream --- pyiron_workflow/topology.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/pyiron_workflow/topology.py b/pyiron_workflow/topology.py index a1b58519..2d6bfbac 100644 --- a/pyiron_workflow/topology.py +++ b/pyiron_workflow/topology.py @@ -11,7 +11,7 @@ from toposort import toposort_flatten, CircularDependencyError if TYPE_CHECKING: - from pyiron_workflow.channels import InputSignal, OutputSignal + from pyiron_workflow.channels import SignalChannel from pyiron_workflow.node import Node @@ -114,7 +114,7 @@ def nodes_to_execution_order(nodes: dict[str, Node]) -> list[str]: def set_run_connections_according_to_linear_dag( nodes: dict[str, Node] -) -> tuple[list[tuple[InputSignal, OutputSignal]], Node]: +) -> tuple[list[tuple[SignalChannel, SignalChannel]], Node]: """ Given a set of nodes that all have the same parent, have no upstream data connections outside the nodes provided, and have acyclic data flow, disconnects all @@ -130,13 +130,15 @@ def set_run_connections_according_to_linear_dag( from, whose connections will be set according to data flow. Returns: - (list[tuple[Channel, Channel]]): Any `run`/`ran` pairs that were disconnected. + (list[tuple[SignalChannel, SignalChannel]]): Any `run`/`ran` pairs that were + disconnected. (Node): The 0th node in the execution order, i.e. on that has no dependencies. """ disconnected_pairs = [] for node in nodes.values(): disconnected_pairs.extend(node.signals.disconnect_run()) + disconnected_pairs.extend(node.signals.output.ran.disconnect_all()) try: # This is the most primitive sort of topological exploitation we can do From 346663c2623d38d3a9180f06e1e3f4530fea440d Mon Sep 17 00:00:00 2001 From: liamhuber Date: Mon, 23 Oct 2023 14:38:37 -0700 Subject: [PATCH 23/24] Add integration tests Not the exaustive unit testing that I'd like, but still better than nothing --- tests/integration/test_pull.py | 84 ++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 tests/integration/test_pull.py diff --git a/tests/integration/test_pull.py b/tests/integration/test_pull.py new file mode 100644 index 00000000..75e85aca --- /dev/null +++ b/tests/integration/test_pull.py @@ -0,0 +1,84 @@ +import unittest + +from pyiron_workflow.workflow import Workflow + + +class TestPullingOutput(unittest.TestCase): + def test_without_workflow(self): + from pyiron_workflow import Workflow + + @Workflow.wrap_as.single_value_node("sum") + def x_plus_y(x: int = 0, y: int = 0) -> int: + return x + y + + node = x_plus_y( + x=x_plus_y(0, 1), + y=x_plus_y(2, 3) + ) + self.assertEqual(6, node.pull()) + + for n in [ + node, + node.inputs.x.connections[0].node, + node.inputs.y.connections[0].node, + ]: + self.assertFalse( + n.signals.connected, + msg="Connections should be unwound after the pull is done" + ) + self.assertEqual( + "x_plus_y", + n.label, + msg="Original labels should be restored after the pull is done" + ) + + def test_pulling_from_inside_a_macro(self): + @Workflow.wrap_as.single_value_node("sum") + def x_plus_y(x: int = 0, y: int = 0) -> int: + # print("EXECUTING") + return x + y + + @Workflow.wrap_as.macro_node() + def b2_leaves_a1_alone(macro): + macro.a1 = x_plus_y(0, 0) + macro.a2 = x_plus_y(0, 1) + macro.b1 = x_plus_y(macro.a1, macro.a2) + macro.b2 = x_plus_y(macro.a2, 10) + + wf = Workflow("demo") + wf.upstream = x_plus_y() + wf.macro = b2_leaves_a1_alone(a2__x=wf.upstream) + + # Pulling b1 -- executes a1, a2, b2 + self.assertEqual(1, wf.macro.b1.pull()) + # >>> EXECUTING + # >>> EXECUTING + # >>> EXECUTING + # >>> 1 + + # Pulling b2 -- executes a2, a1 + self.assertEqual(11, wf.macro.b2.pull()) + # >>> EXECUTING + # >>> EXECUTING + # >>> 11 + + # Updated inputs get reflected in the pull + wf.macro.set_input_values(a1__x=100, a2__x=-100) + self.assertEqual(-89, wf.macro.b2.pull()) + # >>> EXECUTING + # >>> EXECUTING + # >>> -89 + + # Connections are restored after a pull + # Crazy negative value of a2 gets written over by pulling in the upstream + # connection value + # Running wf -- executes upstream, macro (is silent), a1, a2, b1, b2 + out = wf() + self.assertEqual(101, out.macro__b1__sum) + self.assertEqual(11, out.macro__b2__sum) + # >>> EXECUTING + # >>> EXECUTING + # >>> EXECUTING + # >>> EXECUTING + # >>> EXECUTING + # >>> {'macro__b1__sum': 101, 'macro__b2__sum': 11} \ No newline at end of file From dd830e362894a94bd507446da653aec96f2589cc Mon Sep 17 00:00:00 2001 From: pyiron-runner Date: Mon, 23 Oct 2023 21:41:54 +0000 Subject: [PATCH 24/24] Format black --- pyiron_workflow/topology.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/pyiron_workflow/topology.py b/pyiron_workflow/topology.py index 2d6bfbac..06d53631 100644 --- a/pyiron_workflow/topology.py +++ b/pyiron_workflow/topology.py @@ -55,7 +55,8 @@ def nodes_to_data_digraph(nodes: dict[str, Node]) -> dict[str, set[str]]: f"The {channel.label} channel of {node.label} has a connection " f"to {upstream.label} channel of {upstream.node.label}, but " f"{upstream.node.label} was not found among nodes. All nodes " - f"in the data flow dependency tree must be included.") + f"in the data flow dependency tree must be included." + ) if upstream_node is not upstream.node: raise ValueError( f"The {channel.label} channel of {node.label} has a connection " @@ -101,9 +102,7 @@ def nodes_to_execution_order(nodes: dict[str, Node]) -> list[str]: # executed before the node depending on them gets run # The flattened part is just that we don't care about topological # generations that are mutually independent (inefficient but easier for now) - execution_order = toposort_flatten( - nodes_to_data_digraph(nodes) - ) + execution_order = toposort_flatten(nodes_to_data_digraph(nodes)) except CircularDependencyError as e: raise ValueError( f"Detected a cycle in the data flow topology, unable to automate the "