diff --git a/notebooks/workflow_example.ipynb b/notebooks/workflow_example.ipynb index 06e536ed2..f6fd2ab9a 100644 --- a/notebooks/workflow_example.ipynb +++ b/notebooks/workflow_example.ipynb @@ -666,8 +666,8 @@ { "data": { "text/plain": [ - "array([0.1153697 , 0.29712504, 0.22636199, 0.1263152 , 0.00630191,\n", - " 0.64039423, 0.73223408, 0.76977259, 0.62491999, 0.52663026])" + "array([0.6816222 , 0.60285251, 0.31984666, 0.38336884, 0.95586544,\n", + " 0.20915899, 0.73614411, 0.67259937, 0.84499503, 0.10539287])" ] }, "execution_count": 23, @@ -676,7 +676,7 @@ }, { "data": { - "image/png": "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rq4uZy9qYshPf/pTM2XKFPPkk0+a5uZmc9ddd5mpU6ea//7v/x62//Hjx80555xj7rrrLtPc3GyefPJJM2XKFPPCCy9McOXhcTrelpYWc+edd5r//M//NJdffrm56667JrbgMXA61rvuuss8+OCD5uDBg+bYsWOmvLzcTJkyxRw+fHiCK3fO6VgPHz5snnvuOXP06FHT0tJinn32WXPOOeeYrVu3TnDlzjkd64BPPvnEXHTRRaa4uNhcdtllE1PsGDkd6+uvv24kmffee8/4/f7B4/Tp0xNcuXPhvK/f+ta3TH5+vqmrqzMtLS3mN7/5jXn77bcnsOrwOB3rJ598EvJ+trW1menTp5uKioqJLTwMTsdaX19vEhISzOOPP26OHz9u6uvrzcKFC83KlSsdvW5MBZVFixaZ0tLSkLZ58+aZjRs3Dtv/X//1X828efNC2m699VZz5ZVXjluNkeR0vF901VVXRVVQGctYByxYsMBUVlZGurSIi8RY/+7v/s6sWrUq0qVFXLhjLSkpMf/2b/9mKioqoiaoOB3rQFD53//93wmoLrKcjvXVV181Ho/HdHV1TUR5ETXWv9eXX37ZuFwu84c//GE8yosop2P94Q9/aC666KKQtieeeMLMnj3b0evGzNJPX1+fGhsbVVxcHNJeXFyshoaGYc/51a9+NaT/tddeq0OHDunzzz8ft1ojIZzxRqtIjLW/v189PT2aPn36eJQYMZEYa1NTkxoaGnTVVVeNR4kRE+5Yn376aX3wwQeqqKgY7xIjZizv6xVXXCGv16slS5bo9ddfH88yIyKcsb7yyivKy8vTD37wA51//vm65JJLdM899+hPf/rTRJQctkj8vW7fvl3XXHONsrKyxqPEiAlnrIWFhfrwww+1b98+GWP08ccf64UXXtB1113n6LWj4ksJR6Ozs1PBYFDp6ekh7enp6Wpvbx/2nPb29mH7nz59Wp2dnfJ6veNW71iFM95oFYmxPvzww/rjH/+oG264YTxKjJixjHX27Nn6n//5H50+fVr333+/1q9fP56ljlk4Y33//fe1ceNG1dfXKykpev75CmesXq9X27Ztk8/nU29vr5599lktWbJEb7zxhr75zW9ORNlhCWesx48f14EDB+R2u/Xyyy+rs7NT3/3ud3Xy5Emrr1MZ679Nfr9fr776qp577rnxKjFiwhlrYWGhdu/erZKSEp06dUqnT5/Wt771Lf3oRz9y9NrR85c+Si6XK+SxMWZI25/rP1y7rZyON5qFO9Y9e/bo/vvv189//nPNmjVrvMqLqHDGWl9fr08//VS//vWvtXHjRn3ta1/Td77znfEsMyJGO9ZgMKibbrpJlZWVuuSSSyaqvIhy8r7OnTtXc+fOHXxcUFCgtrY2PfTQQ1YHlQFOxtrf3y+Xy6Xdu3cPftPuI488ouuvv15btmxRamrquNc7FuH+2/TMM8/o3HPP1cqVK8epsshzMtbm5mbdeeeduu+++3TttdfK7/fr3nvvVWlpqbZv3z7q14yZoDJz5kwlJiYOSXYdHR1DEuCAjIyMYfsnJSVpxowZ41ZrJIQz3mg1lrHW1tZq3bp1ev7553XNNdeMZ5kRMZaxZmdnS5K+/vWv6+OPP9b9999vdVBxOtaenh4dOnRITU1NuuOOOySd+YAzxigpKUmvvfaarr766gmp3alI/b1eeeWV2rVrV6TLi6hwxur1enX++ecPhhRJmj9/vowx+vDDD3XxxRePa83hGsv7aozRjh07tHr1aiUnJ49nmRERzlirqqpUVFSke++9V5J06aWXaurUqVq8eLEeeOCBUa9axMw1KsnJyfL5fKqrqwtpr6urU2Fh4bDnFBQUDOn/2muvKS8vT1OmTBm3WiMhnPFGq3DHumfPHq1du1bPPfec4zXRyRKp99UYo97e3kiXF1FOx5qWlqbf/va3OnLkyOBRWlqquXPn6siRI8rPz5+o0h2L1Pva1NRk9ZK0FN5Yi4qK9NFHH+nTTz8dbDt27JgSEhI0e/bsca13LMbyvr755pv6/e9/r3Xr1o1niRETzlg/++wzJSSExozExERJ/3/1YlQcXXpruYFbp7Zv326am5tNWVmZmTp16uDV1Bs3bjSrV68e7D9we/Ldd99tmpubzfbt26Py9uTRjtcYY5qamkxTU5Px+XzmpptuMk1NTeadd96ZjPIdcTrW5557ziQlJZktW7aE3Ar4ySefTNYQRs3pWH/84x+bV155xRw7dswcO3bM7Nixw6SlpZlNmzZN1hBGLZzf4S+Kprt+nI710UcfNS+//LI5duyYOXr0qNm4caORZF588cXJGsKoOR1rT0+PmT17trn++uvNO++8Y958801z8cUXm/Xr10/WEEYt3N/hVatWmfz8/Ikud0ycjvXpp582SUlJprq62nzwwQfmwIEDJi8vzyxatMjR68ZUUDHGmC1btpisrCyTnJxscnNzzZtvvjn4s5tvvtlcddVVIf3feOMNc8UVV5jk5GRz4YUXmpqamgmueGycjlfSkCMrK2tiiw6Tk7FeddVVw4715ptvnvjCw+BkrE888YRZuHChOeecc0xaWpq54oorTHV1tQkGg5NQuXNOf4e/KJqCijHOxvrggw+aOXPmGLfbbb7yla+Yb3zjG+YXv/jFJFQdHqfv67vvvmuuueYak5qaambPnm02bNhgPvvsswmuOjxOx/rJJ5+Y1NRUs23btgmudOycjvWJJ54wCxYsMKmpqcbr9Zp/+Id/MB9++KGj13QZ42T+BQAAYOLEzDUqAAAg9hBUAACAtQgqAADAWgQVAABgLYIKAACwFkEFAABYi6ACAACsRVABAADWIqgAAABrEVQAAIC1CCoAAMBaBBUAAGCt/wdxUOz/6a0clAAAAABJRU5ErkJggg==", + "image/png": "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", "text/plain": [ "
" ] @@ -932,127 +932,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,7 +1231,7 @@ "\n" ], "text/plain": [ - "" + "" ] }, "execution_count": 29, @@ -1262,7 +1262,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "5ee2f89cadea46a8926434ab39f44805", + "model_id": "11fa1336d10a42f4936ce22a299f191d", "version_major": 2, "version_minor": 0 }, @@ -1275,7 +1275,7 @@ "name": "stderr", "output_type": "stream", "text": [ - "/Users/huber/work/pyiron/pyiron_workflow/pyiron_workflow/channels.py:159: UserWarning: The channel run was not connected to ran, andthus could not disconnect from it.\n", + "/Users/huber/work/pyiron/pyiron_workflow/pyiron_workflow/channels.py:158: UserWarning: The channel run was not connected to ran, andthus could not disconnect from it.\n", " warn(\n" ] }, @@ -1289,7 +1289,7 @@ { "data": { "text/plain": [ - "" + "" ] }, "execution_count": 30, @@ -1541,7 +1541,7 @@ "\n" ], "text/plain": [ - "" + "" ] }, "execution_count": 31, @@ -1583,7 +1583,7 @@ "name": "stderr", "output_type": "stream", "text": [ - "/Users/huber/work/pyiron/pyiron_workflow/pyiron_workflow/channels.py:159: UserWarning: The channel run was not connected to ran, andthus could not disconnect from it.\n", + "/Users/huber/work/pyiron/pyiron_workflow/pyiron_workflow/channels.py:158: UserWarning: The channel run was not connected to ran, andthus could not disconnect from it.\n", " warn(\n" ] }, @@ -1703,6 +1703,7 @@ "\n", "@Workflow.wrap_as.single_value_node()\n", "def per_atom_energy_difference(structure1, energy1, structure2, energy2):\n", + " # The unrelaxed structure is fine, we're just using it to get n_atoms\n", " de = (energy2[-1]/len(structure2)) - (energy1[-1]/len(structure1))\n", " return de" ] @@ -2960,7 +2961,7 @@ "\n" ], "text/plain": [ - "" + "" ] }, "execution_count": 37, @@ -3003,7 +3004,7 @@ "name": "stderr", "output_type": "stream", "text": [ - "/Users/huber/work/pyiron/pyiron_workflow/pyiron_workflow/channels.py:159: UserWarning: The channel run was not connected to ran, andthus could not disconnect from it.\n", + "/Users/huber/work/pyiron/pyiron_workflow/pyiron_workflow/channels.py:158: UserWarning: The channel run was not connected to ran, andthus could not disconnect from it.\n", " warn(\n" ] }, @@ -3044,16 +3045,21 @@ "name": "stderr", "output_type": "stream", "text": [ - "/Users/huber/work/pyiron/pyiron_workflow/pyiron_workflow/channels.py:159: UserWarning: The channel job was not connected to job, andthus could not disconnect from it.\n", + "/Users/huber/work/pyiron/pyiron_workflow/pyiron_workflow/channels.py:158: UserWarning: The channel job was not connected to job, andthus could not disconnect from it.\n", + " warn(\n", + "/Users/huber/work/pyiron/pyiron_workflow/pyiron_workflow/channels.py:158: UserWarning: The channel run was not connected to ran, andthus could not disconnect from it.\n", + " warn(\n", + "/Users/huber/work/pyiron/pyiron_workflow/pyiron_workflow/channels.py:158: UserWarning: The channel element was not connected to user_input, andthus could not disconnect from it.\n", " warn(\n", - "/Users/huber/work/pyiron/pyiron_workflow/pyiron_workflow/channels.py:159: UserWarning: The channel energy_pot was not connected to energy1, andthus could not disconnect from it.\n", + "/Users/huber/work/pyiron/pyiron_workflow/pyiron_workflow/channels.py:158: UserWarning: The channel structure was not connected to structure1, andthus could not disconnect from it.\n", " warn(\n", - "/Users/huber/work/pyiron/pyiron_workflow/pyiron_workflow/channels.py:159: UserWarning: The channel run was not connected to ran, andthus could not disconnect from it.\n", + "/Users/huber/work/pyiron/pyiron_workflow/pyiron_workflow/channels.py:158: UserWarning: The channel energy was not connected to energy1, andthus could not disconnect from it.\n", " warn(\n" ] } ], "source": [ + "replacee = wf.min_phase1.calc \n", "wf.min_phase1.calc = Macro.create.atomistics.CalcStatic" ] }, @@ -3085,7 +3091,7 @@ ], "source": [ "# Bad guess\n", - "out = wf(element=\"Al\", phase1=\"fcc\", phase2=\"hcp\", lattice_guess1=3, lattice_guess2=3)\n", + "out = wf(element=\"Al\", phase1=\"fcc\", phase2=\"hcp\", lattice_guess1=3, lattice_guess2=3.1)\n", "print(f\"{wf.inputs.element.value}: E({wf.inputs.phase2.value}) - E({wf.inputs.phase1.value}) = {out.compare__de:.2f} eV/atom\")" ] }, @@ -3099,7 +3105,7 @@ "name": "stderr", "output_type": "stream", "text": [ - "/Users/huber/work/pyiron/pyiron_workflow/pyiron_workflow/channels.py:159: UserWarning: The channel run was not connected to ran, andthus could not disconnect from it.\n", + "/Users/huber/work/pyiron/pyiron_workflow/pyiron_workflow/channels.py:158: UserWarning: The channel run was not connected to ran, andthus could not disconnect from it.\n", " warn(\n" ] }, @@ -3115,7 +3121,7 @@ ], "source": [ "# Good guess\n", - "out = wf(element=\"Al\", phase1=\"fcc\", phase2=\"hcp\", lattice_guess1=4.05, lattice_guess2=3)\n", + "out = wf(element=\"Al\", phase1=\"fcc\", phase2=\"hcp\", lattice_guess1=4.05, lattice_guess2=3.2)\n", "print(f\"{wf.inputs.element.value}: E({wf.inputs.phase2.value}) - E({wf.inputs.phase1.value}) = {out.compare__de:.2f} eV/atom\")" ] }, @@ -3240,9 +3246,9 @@ "name": "stderr", "output_type": "stream", "text": [ - "/Users/huber/work/pyiron/pyiron_workflow/pyiron_workflow/channels.py:159: UserWarning: The channel run was not connected to true, andthus could not disconnect from it.\n", + "/Users/huber/work/pyiron/pyiron_workflow/pyiron_workflow/channels.py:158: UserWarning: The channel run was not connected to true, andthus could not disconnect from it.\n", " warn(\n", - "/Users/huber/work/pyiron/pyiron_workflow/pyiron_workflow/channels.py:159: UserWarning: The channel run was not connected to ran, andthus could not disconnect from it.\n", + "/Users/huber/work/pyiron/pyiron_workflow/pyiron_workflow/channels.py:158: UserWarning: The channel run was not connected to ran, andthus could not disconnect from it.\n", " warn(\n" ] } @@ -3323,21 +3329,12 @@ "name": "stdout", "output_type": "stream", "text": [ - "0.885 > 0.2\n", - "0.790 > 0.2\n", - "0.395 > 0.2\n", - "0.593 > 0.2\n", - "0.220 > 0.2\n", - "0.440 > 0.2\n", - "0.523 > 0.2\n", - "0.407 > 0.2\n", - "0.479 > 0.2\n", - "0.883 > 0.2\n", - "0.607 > 0.2\n", - "0.767 > 0.2\n", - "0.768 > 0.2\n", - "0.012 <= 0.2\n", - "Finally 0.012\n" + "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" ] } ], diff --git a/pyiron_workflow/channels.py b/pyiron_workflow/channels.py index 9f8a82a91..4cfee805a 100644 --- a/pyiron_workflow/channels.py +++ b/pyiron_workflow/channels.py @@ -279,11 +279,54 @@ def __init__( node: Node, default: typing.Optional[typing.Any] = NotData, type_hint: typing.Optional[typing.Any] = None, + value_receiver: typing.Optional[InputData] = None, ): super().__init__(label=label, node=node) + self._value = NotData + self._value_receiver = None self.default = default self.value = default self.type_hint = type_hint + self.value_receiver = value_receiver + + @property + def value(self): + return self._value + + @value.setter + def value(self, new_value): + if self.value_receiver is not None: + self.value_receiver.value = new_value + self._value = new_value + + @property + def value_receiver(self) -> InputData | OutputData | None: + """ + Another data channel of the same type to whom new values are always pushed + (without type checking of any sort, not even when forming the couple!) + + Useful for macros, so that the IO of owned nodes and IO at the macro level can + be kept synchronized. + """ + return self._value_receiver + + @value_receiver.setter + def value_receiver(self, new_partner: InputData | OutputData | None): + if new_partner is not None: + if not isinstance(new_partner, self.__class__): + raise TypeError( + f"The {self.__class__.__name__} {self.label} got a coupling " + f"partner {new_partner} but requires something of the same type" + ) + + if new_partner is self: + raise ValueError( + f"{self.__class__.__name__} {self.label} cannot couple to itself" + ) + + new_partner.value = self.value + + self._value_receiver = new_partner @property def generic_type(self) -> type[Channel]: @@ -375,6 +418,7 @@ def __init__( node: Node, default: typing.Optional[typing.Any] = NotData, type_hint: typing.Optional[typing.Any] = None, + value_receiver: typing.Optional[InputData] = None, strict_connections: bool = True, ): super().__init__( @@ -382,6 +426,7 @@ def __init__( node=node, default=default, type_hint=type_hint, + value_receiver=value_receiver, ) self.strict_connections = strict_connections diff --git a/pyiron_workflow/composite.py b/pyiron_workflow/composite.py index cd1132593..1bb1abc29 100644 --- a/pyiron_workflow/composite.py +++ b/pyiron_workflow/composite.py @@ -5,7 +5,7 @@ from __future__ import annotations -from abc import ABC +from abc import ABC, abstractmethod from functools import partial from typing import Literal, Optional, TYPE_CHECKING @@ -19,7 +19,7 @@ from pyiron_workflow.util import logger, DotDict, SeabornColors if TYPE_CHECKING: - from pyiron_workflow.channels import Channel + from pyiron_workflow.channels import Channel, InputData, OutputData class Composite(Node, ABC): @@ -106,7 +106,7 @@ def __init__( self._outputs_map = None self.inputs_map = inputs_map self.outputs_map = outputs_map - self.nodes: DotDict[str:Node] = DotDict() + self.nodes: DotDict[str, Node] = DotDict() self.starting_nodes: list[Node] = [] self._creator = self.create self.create = self._owned_creator # Override the create method from the class @@ -138,17 +138,6 @@ def _owned_creator(self): """ return OwnedCreator(self, self._creator) - @property - def executor(self) -> None: - return None - - @executor.setter - def executor(self, new_executor): - if new_executor is not None: - raise NotImplementedError( - "Running composite nodes with an executor is not yet supported" - ) - def to_dict(self): return { "label": self.label, @@ -170,10 +159,21 @@ def run_args(self) -> dict: return {"_nodes": self.nodes, "_starting_nodes": self.starting_nodes} def process_run_result(self, run_output): - # self.nodes = run_output - # Running on an executor will require a more sophisticated idea than above + if run_output is not self.nodes: + # Then we probably ran on a parallel process and have an unpacked future + self._update_children(run_output) return DotDict(self.outputs.to_value_dict()) + def _update_children(self, children_from_another_process: DotDict[str, Node]): + """ + If you receive a new dictionary of children, e.g. from unpacking a futures + object of your own children you sent off to another process for computation, + replace your own nodes with them, and set yourself as their parent. + """ + for child in children_from_another_process.values(): + child.parent = self + self.nodes = children_from_another_process + def disconnect_run(self) -> list[tuple[Channel, Channel]]: """ Disconnect all `signals.input.run` connections on all child nodes. @@ -260,29 +260,76 @@ def get_data_digraph(self) -> dict[str, set[str]]: def _build_io( self, - io: Inputs | Outputs, - target: Literal["inputs", "outputs"], - key_map: dict[str, str] | None, + i_or_o: Literal["inputs", "outputs"], + key_map: dict[str, str | None] | None, ) -> Inputs | Outputs: + """ + Build an IO panel for exposing child node IO to the outside world at the level + of the composite node's IO. + + Args: + target [Literal["inputs", "outputs"]]: Whether this is I or O. + key_map [dict[str, str]|None]: A map between the default convention for + mapping child IO to composite IO (`"{node.label}__{channel.label}"`) and + whatever label you actually want to expose to the composite user. Also + allows non-standards channel exposure, i.e. exposing + internally-connected channels (which would not normally be exposed) by + providing a string-to-string map, or suppressing unconnected channels + (which normally would be exposed) by providing a string-None map. + + Returns: + (Inputs|Outputs): The populated panel. + """ key_map = {} if key_map is None else key_map + io = Inputs() if i_or_o == "inputs" else Outputs() for node in self.nodes.values(): - panel = getattr(node, target) + panel = getattr(node, i_or_o) for channel_label in panel.labels: channel = panel[channel_label] default_key = f"{node.label}__{channel_label}" try: - if key_map[default_key] is not None: - io[key_map[default_key]] = channel + io_panel_key = key_map[default_key] + if io_panel_key is not None: + io[io_panel_key] = self._get_linking_channel( + channel, io_panel_key + ) except KeyError: if not channel.connected: - io[default_key] = channel + io[default_key] = self._get_linking_channel( + channel, default_key + ) return io + @abstractmethod + def _get_linking_channel( + self, + child_reference_channel: InputData | OutputData, + composite_io_key: str, + ) -> InputData | OutputData: + """ + Returns the channel that will be the link between the provided child channel, + and the composite's IO at the given key. + + The returned channel should be fully compatible with the provided child channel, + i.e. same type, same type hint... (For instance, the child channel itself is a + valid return, which would create a composite IO panel that works by reference.) + + Args: + child_reference_channel (InputData | OutputData): The child channel + composite_io_key (str): The key under which this channel will be stored on + the composite's IO. + + Returns: + (Channel): A channel with the same type, type hint, etc. as the reference + channel passed in. + """ + pass + def _build_inputs(self) -> Inputs: - return self._build_io(Inputs(), "inputs", self.inputs_map) + return self._build_io("inputs", self.inputs_map) def _build_outputs(self) -> Outputs: - return self._build_io(Outputs(), "outputs", self.outputs_map) + return self._build_io("outputs", self.outputs_map) def add(self, node: Node, label: Optional[str] = None) -> None: """ @@ -377,7 +424,7 @@ def remove(self, node: Node | str) -> list[tuple[Channel, Channel]]: del self.nodes[node.label] return disconnected - def replace(self, owned_node: Node | str, replacement: Node | type[Node]): + def replace(self, owned_node: Node | str, replacement: Node | type[Node]) -> Node: """ Replaces a node currently owned with a new node instance. The replacement must not belong to any other parent or have any connections. @@ -385,6 +432,12 @@ def replace(self, owned_node: Node | str, replacement: Node | type[Node]): channel labels need to match precisely, but additional channels may be present. After replacement, the new node will have the old node's connections, label, and belong to this composite. + The labels are swapped, such that the replaced node gets the name of its + replacement (which might be silly, but is useful in case you want to revert the + change and swap the replaced node back in!) + + If replacement fails for some reason, the replacement and replacing node are + both returned to their original state, and the composite is left unchanged. Args: owned_node (Node|str): The node to replace or its label. @@ -420,13 +473,17 @@ def replace(self, owned_node: Node | str, replacement: Node | type[Node]): f"got {replacement}" ) - replacement.copy_io(owned_node) - replacement.label = owned_node.label + replacement.copy_io(owned_node) # If the replacement is incompatible, we'll + # fail here before we've changed the parent at all. Since the replacement was + # first guaranteed to be an unconnected orphan, there is not yet any permanent + # damage is_starting_node = owned_node in self.starting_nodes self.remove(owned_node) + replacement.label, owned_node.label = owned_node.label, replacement.label self.add(replacement) if is_starting_node: self.starting_nodes.append(replacement) + return owned_node def __setattr__(self, key: str, node: Node): if isinstance(node, Node) and key != "parent": @@ -501,6 +558,16 @@ def __getattr__(self, item): return value + def __getstate__(self): + # Compatibility with python <3.11 + return self.__dict__ + + def __setstate__(self, state): + # Because we override getattr, we need to use __dict__ assignment directly in + # __setstate__ + self.__dict__["_parent"] = state["_parent"] + self.__dict__["_creator"] = state["_creator"] + class OwnedNodePackage: """ @@ -517,3 +584,9 @@ def __getattr__(self, item): if issubclass(value, Node): value = partial(value, parent=self._parent) return value + + def __getstate__(self): + return self.__dict__ + + def __setstate__(self, state): + self.__dict__ = state diff --git a/pyiron_workflow/function.py b/pyiron_workflow/function.py index ca43c0323..34b85c91c 100644 --- a/pyiron_workflow/function.py +++ b/pyiron_workflow/function.py @@ -474,11 +474,10 @@ def on_run(self): def run_args(self) -> dict: kwargs = self.inputs.to_value_dict() if "self" in self._input_args: - if self.executor is not None: - raise NotImplementedError( - f"The node {self.label} cannot be run on an executor because it " - f"uses the `self` argument and this functionality is not yet " - f"implemented" + if self.executor: + raise ValueError( + f"Function node {self.label} uses the `self` argument, but this " + f"can't yet be run with executors" ) kwargs["self"] = self return kwargs diff --git a/pyiron_workflow/interfaces.py b/pyiron_workflow/interfaces.py index 36cd2c858..02d826de8 100644 --- a/pyiron_workflow/interfaces.py +++ b/pyiron_workflow/interfaces.py @@ -8,9 +8,7 @@ from pyiron_base.interfaces.singleton import Singleton -# from pyiron_contrib.executors import CloudpickleProcessPoolExecutor as Executor # from pympipool.mpi.executor import PyMPISingleTaskExecutor as Executor - from pyiron_workflow.executors import CloudpickleProcessPoolExecutor as Executor from pyiron_workflow.function import ( @@ -60,23 +58,17 @@ def Workflow(self): @property def standard(self): - try: - return self._standard - except AttributeError: - from pyiron_workflow.node_library.standard import nodes + from pyiron_workflow.node_package import NodePackage + from pyiron_workflow.node_library.standard import nodes - self.register("_standard", *nodes) - return self._standard + return NodePackage(*nodes) @property def atomistics(self): - try: - return self._atomistics - except AttributeError: - from pyiron_workflow.node_library.atomistics import nodes + from pyiron_workflow.node_package import NodePackage + from pyiron_workflow.node_library.atomistics import nodes - self.register("_atomistics", *nodes) - return self._atomistics + return NodePackage(*nodes) @property def meta(self): @@ -87,11 +79,15 @@ def meta(self): return self._meta def register(self, domain: str, *nodes: list[type[Node]]): - if domain in self.__dir__(): - raise AttributeError(f"{domain} is already an attribute of {self}") - from pyiron_workflow.node_package import NodePackage - - setattr(self, domain, NodePackage(*nodes)) + raise NotImplementedError( + "Registering new node packages is currently not playing well with " + "executors. We hope to return this feature soon." + ) + # if domain in self.__dir__(): + # raise AttributeError(f"{domain} is already an attribute of {self}") + # from pyiron_workflow.node_package import NodePackage + # + # setattr(self, domain, NodePackage(*nodes)) class Wrappers(metaclass=Singleton): diff --git a/pyiron_workflow/io.py b/pyiron_workflow/io.py index 20e1ca79c..f9b341dbf 100644 --- a/pyiron_workflow/io.py +++ b/pyiron_workflow/io.py @@ -161,6 +161,15 @@ def to_dict(self): "channels": {l: c.to_dict() for l, c in self.channel_dict.items()}, } + def __getstate__(self): + # Compatibility with python <3.11 + return self.__dict__ + + def __setstate__(self, state): + # Because we override getattr, we need to use __dict__ assignment directly in + # __setstate__ the same way we need it in __init__ + self.__dict__["channel_dict"] = state["channel_dict"] + class DataIO(IO, ABC): """ diff --git a/pyiron_workflow/macro.py b/pyiron_workflow/macro.py index cdea3244a..1fb567565 100644 --- a/pyiron_workflow/macro.py +++ b/pyiron_workflow/macro.py @@ -8,6 +8,7 @@ from functools import partialmethod from typing import Optional, TYPE_CHECKING +from pyiron_workflow.channels import InputData, OutputData from pyiron_workflow.composite import Composite from pyiron_workflow.io import Outputs, Inputs @@ -184,6 +185,39 @@ def __init__( self.update_input(**kwargs) + def _get_linking_channel( + self, + child_reference_channel: InputData | OutputData, + composite_io_key: str, + ) -> InputData | OutputData: + """ + Build IO by value: create a new channel just like the child's channel. + + In the case of input data, we also form a value link from the composite channel + down to the child channel, so that the child will stay up-to-date. + """ + composite_channel = child_reference_channel.__class__( + label=composite_io_key, + node=self, + default=child_reference_channel.default, + type_hint=child_reference_channel.type_hint, + ) + composite_channel.value = child_reference_channel.value + + if isinstance(composite_channel, InputData): + composite_channel.strict_connections = ( + child_reference_channel.strict_connections + ) + composite_channel.value_receiver = child_reference_channel + elif isinstance(composite_channel, OutputData): + child_reference_channel.value_receiver = composite_channel + else: + raise TypeError( + "This should not be an accessible state, please contact the developers" + ) + + return composite_channel + @property def inputs(self) -> Inputs: return self._inputs @@ -192,9 +226,47 @@ def inputs(self) -> Inputs: def outputs(self) -> Outputs: return self._outputs + def _update_children(self, children_from_another_process): + super()._update_children(children_from_another_process) + self._rebuild_data_io() + def _rebuild_data_io(self): - self._inputs = self._build_inputs() - self._outputs = self._build_outputs() + """ + Try to rebuild the IO. + + If an error is encountered, revert back to the existing IO then raise it. + """ + old_inputs = self.inputs + old_outputs = self.outputs + connection_changes = [] # For reversion if there's an error + try: + self._inputs = self._build_inputs() + self._outputs = self._build_outputs() + for old, new in [(old_inputs, self.inputs), (old_outputs, self.outputs)]: + for old_channel in old: + if old_channel.connected: + # If the old channel was connected to stuff, we'd better still + # have a corresponding channel and be able to copy these, or we + # should fail hard. + # But, if it wasn't connected, we don't even care whether or not + # we still have a corresponding channel to copy to + new_channel = new[old_channel.label] + new_channel.copy_connections(old_channel) + swapped_conenctions = old_channel.disconnect_all() # Purge old + connection_changes.append( + (new_channel, old_channel, swapped_conenctions) + ) + except Exception as e: + for new_channel, old_channel, swapped_conenctions in connection_changes: + new_channel.disconnect(*swapped_conenctions) + old_channel.connect(*swapped_conenctions) + self._inputs = old_inputs + self._outputs = old_outputs + e.message = ( + f"Unable to rebuild IO for {self.label}; reverting to old IO." + f"{e.message}" + ) + raise e def _configure_graph_execution(self): run_signals = self.disconnect_run() @@ -223,11 +295,17 @@ def _reconnect_run(self, run_signal_pairs_to_restore): pairs[0].connect(pairs[1]) def replace(self, owned_node: Node | str, replacement: Node | type[Node]): - super().replace(owned_node=owned_node, replacement=replacement) - # Make sure node-level IO is pointing to the new node - self._rebuild_data_io() - # This is brute-force overkill since only the replaced node needs to be updated - # but it's not particularly expensive + replaced_node = super().replace(owned_node=owned_node, replacement=replacement) + try: + # Make sure node-level IO is pointing to the new node and that macro-level + # IO gets safely reconstructed + self._rebuild_data_io() + except Exception as e: + # If IO can't be successfully rebuilt using this node, revert changes and + # raise the exception + self.replace(replacement, replaced_node) # Guaranteed to work since + # replacement in the other direction was already a success + raise e def to_workfow(self): raise NotImplementedError diff --git a/pyiron_workflow/meta.py b/pyiron_workflow/meta.py index 948d4bb1d..2f8020e1c 100644 --- a/pyiron_workflow/meta.py +++ b/pyiron_workflow/meta.py @@ -159,7 +159,7 @@ def make_loop(macro): # Connect each body node output to the output interface's respective input for body_node, inp in zip(body_nodes, interface.inputs): inp.connect(body_node.outputs[label]) - if body_node.executor is not None: + if body_node.executor: raise NotImplementedError( "Right now the output interface gets run after each body node," "if the body nodes can run asynchronously we need something " diff --git a/pyiron_workflow/node.py b/pyiron_workflow/node.py index 26c50b128..1199d3802 100644 --- a/pyiron_workflow/node.py +++ b/pyiron_workflow/node.py @@ -12,6 +12,7 @@ from pyiron_workflow.channels import NotData from pyiron_workflow.draw import Node as GraphvizNode +from pyiron_workflow.executors import CloudpickleProcessPoolExecutor as Executor from pyiron_workflow.files import DirectoryObject from pyiron_workflow.has_to_dict import HasToDict from pyiron_workflow.io import Signals, InputSignal, OutputSignal @@ -103,8 +104,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. + Nodes can be run on the main python process that owns them, or by setting their + `executor` attribute to `True`, in which case a + `pyiron_workflow.executors.CloudPickleExecutor` will be used to run the node on a + new process on a single core (in the future, the interface will look a little + different and you'll have more options than that). In case they are run with an executor, their `future` attribute will be populated with the resulting future object. WARNING: Executors are currently only working when the node executable function does @@ -182,7 +186,10 @@ def __init__( # TODO: Provide support for actually computing stuff with the executor self.signals = self._build_signal_channels() self._working_directory = None - self.executor = None + self.executor = False + # We call it an executor, but it's just whether to use one. + # This is a simply stop-gap as we work out more sophisticated ways to reference + # (or create) an executor process without ever trying to pickle a `_thread.lock` self.future: None | Future = None @property @@ -291,13 +298,14 @@ def _run(self, finished_callback: callable) -> Any | tuple | Future: Handles the status of the node, and communicating with any remote computing resources. """ - if self.executor is None: + if not self.executor: run_output = self.on_run(**self.run_args) return finished_callback(run_output) else: # Just blindly try to execute -- as we nail down the executor interaction # we'll want to fail more cleanly here. - self.future = self.executor.submit(self.on_run, **self.run_args) + executor = Executor() + self.future = executor.submit(self.on_run, **self.run_args) self.future.add_done_callback(finished_callback) return self.future @@ -605,3 +613,31 @@ def replace_with(self, other: Node | type[Node]): self.parent.replace(self, other) else: warnings.warn(f"Could not replace {self.label}, as it has no parent.") + + def __getstate__(self): + state = self.__dict__ + state["parent"] = None + # I am not at all confident that removing the parent here is the _right_ + # solution. + # In order to run composites on a parallel process, we ship off just the nodes + # and starting nodes. + # When the parallel process returns these, they're obviously different + # instances, so we re-parent them back to the receiving composite. + # At the same time, we want to make sure that the _old_ children get orphaned. + # Of course, we could do that directly in the composite method, but it also + # works to do it here. + # Something I like about this, is it also means that when we ship groups of + # nodes off to another process with cloudpickle, they're definitely not lugging + # along their parent, its connections, etc. with them! + # This is all working nicely as demonstrated over in the macro test suite. + # However, I have a bit of concern that when we start thinking about + # serialization for storage instead of serialization to another process, this + # might introduce a hard-to-track-down bug. + # For now, it works and I'm going to be super pragmatic and go for it, but + # for the record I am admitting that the current shallowness of my understanding + # may cause me/us headaches in the future. + # -Liam + return self.__dict__ + + def __setstate__(self, state): + self.__dict__ = state diff --git a/pyiron_workflow/util.py b/pyiron_workflow/util.py index 61dae6c7c..eff32b27d 100644 --- a/pyiron_workflow/util.py +++ b/pyiron_workflow/util.py @@ -13,6 +13,13 @@ def __setattr__(self, key, value): def __dir__(self): return set(super().__dir__() + list(self.keys())) + def __getstate__(self): + return self.__dict__ + + def __setstate__(self, state): + for k, v in state.items(): + self.__dict__[k] = v + class SeabornColors: """ diff --git a/pyiron_workflow/workflow.py b/pyiron_workflow/workflow.py index 4ee1be006..5817fa7e7 100644 --- a/pyiron_workflow/workflow.py +++ b/pyiron_workflow/workflow.py @@ -15,6 +15,7 @@ if TYPE_CHECKING: from bidict import bidict + from pyiron_workflow.channels import InputData, OutputData from pyiron_workflow.node import Node @@ -184,6 +185,16 @@ def __init__( for node in nodes: self.add(node) + def _get_linking_channel( + self, + child_reference_channel: InputData | OutputData, + composite_io_key: str, + ) -> InputData | OutputData: + """ + Build IO by reference: just return the child's channel itself. + """ + return child_reference_channel + @property def inputs(self) -> Inputs: return self._build_inputs() diff --git a/tests/unit/test_channels.py b/tests/unit/test_channels.py index f747350c5..c6e711fe5 100644 --- a/tests/unit/test_channels.py +++ b/tests/unit/test_channels.py @@ -234,6 +234,44 @@ def test_ready(self): self.ni1.value = "Not numeric at all" self.assertFalse(self.ni1.ready) + def test_input_coupling(self): + self.assertNotEqual( + self.ni2.value, + 2, + msg="Ensure we start from a setup that the next test is meaningful" + ) + self.ni1.value = 2 + self.ni1.value_receiver = self.ni2 + self.assertEqual( + self.ni2.value, + 2, + msg="Coupled value should get updated on coupling" + ) + self.ni1.value = 3 + self.assertEqual( + self.ni2.value, + 3, + msg="Coupled value should get updated after partner update" + ) + self.ni2.value = 4 + self.assertEqual( + self.ni1.value, + 3, + msg="Coupling is uni-directional, the partner should not push values back" + ) + + with self.assertRaises( + TypeError, + msg="Only input data channels are valid partners" + ): + self.ni1.value_receiver = self.no + + with self.assertRaises( + ValueError, + msg="Must not couple to self to avoid infinite recursion" + ): + self.ni1.value_receiver = self.ni1 + class TestSignalChannels(TestCase): def setUp(self) -> None: diff --git a/tests/unit/test_function.py b/tests/unit/test_function.py index 01cf516d4..4f2958996 100644 --- a/tests/unit/test_function.py +++ b/tests/unit/test_function.py @@ -4,11 +4,6 @@ import unittest import warnings -# from pyiron_contrib.executors import CloudpickleProcessPoolExecutor as Executor -# from pympipool.mpi.executor import PyMPISingleTaskExecutor as Executor - -from pyiron_workflow.executors import CloudpickleProcessPoolExecutor as Executor - from pyiron_workflow.channels import NotData, ChannelConnectionError from pyiron_workflow.files import DirectoryObject from pyiron_workflow.function import ( @@ -304,12 +299,14 @@ def with_self(self, x: float) -> float: msg="Function functions should be able to modify attributes on the node object." ) - node.executor = Executor() - with self.assertRaises(NotImplementedError): - # Submitting node_functions that use self is still raising - # TypeError: cannot pickle '_thread.lock' object - # For now we just fail cleanly + node.executor = True + with self.assertRaises( + ValueError, + msg="We haven't implemented any way to update a function node's `self` when" + "it runs on an executor, so trying to do so should fail hard" + ): node.run() + node.executor = False def with_messed_self(x: float, self) -> float: return x + 0.1 @@ -398,7 +395,7 @@ def test_return_value(self): ) with self.subTest("Run on executor"): - node.executor = Executor() + node.executor = True return_on_explicit_run = node.run() self.assertIsInstance( diff --git a/tests/unit/test_macro.py b/tests/unit/test_macro.py index b8d811339..73bae0f59 100644 --- a/tests/unit/test_macro.py +++ b/tests/unit/test_macro.py @@ -1,6 +1,8 @@ +from concurrent.futures import Future from functools import partialmethod -import unittest from sys import version_info +from time import sleep +import unittest from pyiron_workflow.channels import NotData from pyiron_workflow.function import SingleValue @@ -318,7 +320,7 @@ def add_two(x): msg="Replacement should be reflected in the starting nodes" ) self.assertIs( - macro.inputs.one__x, + macro.inputs.one__x.value_receiver, new_starter.inputs.x, msg="Replacement should be reflected in composite IO" ) @@ -367,6 +369,189 @@ def add_two_incompatible_io(not_x): ): macro.two = add_two_incompatible_io + def test_macro_connections_after_replace(self): + # If the macro-level IO is going to change after replacing a child, + # it had better still be able to recreate all the macro-level IO connections + # For macro IO channels that weren't connected, we don't really care + # If it fails to replace, it had better revert to its original state + + macro = Macro(add_three_macro) + downstream = SingleValue(add_one, x=macro.outputs.three__result) + macro > downstream + macro(one__x=0) + # Or once pull exists: macro.one__x = 0; downstream.pull() + self.assertEqual( + 0 + (1 + 1 + 1) + 1, + downstream.outputs.result.value, + msg="Sanity check that our test setup is what we want: macro->single" + ) + + def add_two(x): + result = x + 2 + return result + compatible_replacement = SingleValue(add_two) + + macro.replace(macro.three, compatible_replacement) + macro(one__x=0) + self.assertEqual( + len(downstream.inputs.x.connections), + 1, + msg="After replacement, the downstream node should still have exactly one " + "connection to the macro" + ) + self.assertIs( + downstream.inputs.x.connections[0], + macro.outputs.three__result, + msg="The one connection should be the living, updated macro IO channel" + ) + self.assertEqual( + 0 + (1 + 1 + 2) + 1, + downstream.outputs.result.value, + msg="The whole flow should still function after replacement, but with the " + "new behaviour (and extra 1 added)" + ) + + def different_signature(x): + # When replacing the final node of add_three_macro, the rebuilt IO will + # no longer have three__result, but rather three__changed_output_label, + # which will break existing macro-level IO if the macro output is connected + changed_output_label = x + 3 + return changed_output_label + + incompatible_replacement = SingleValue( + different_signature, + label="original_label" + ) + with self.assertRaises( + AttributeError, + msg="macro.three__result is connected output, but can't be found in the " + "rebuilt IO, so an exception is expected" + ): + macro.replace(macro.three, incompatible_replacement) + self.assertIs( + macro.three, + compatible_replacement, + msg="Failed replacements should get reverted, putting the original node " + "back" + ) + self.assertIs( + macro.three.outputs.result.value_receiver, + macro.outputs.three__result, + msg="Failed replacements should get reverted, restoring the link between " + "child IO and macro IO" + ) + self.assertIs( + downstream.inputs.x.connections[0], + macro.outputs.three__result, + msg="Failed replacements should get reverted, and macro IO should be as " + "it was before" + ) + self.assertFalse( + incompatible_replacement.connected, + msg="Failed replacements should get reverted, leaving the replacement in " + "its original state" + ) + self.assertEqual( + "original_label", + incompatible_replacement.label, + msg="Failed replacements should get reverted, leaving the replacement in " + "its original state" + ) + macro(one__x=1) # Fresh input to make sure updates are actually going through + self.assertEqual( + 1 + (1 + 1 + 2) + 1, + downstream.outputs.result.value, + msg="Final integration test that replacements get reverted, the macro " + "function and downstream results should be the same as before" + ) + + downstream.disconnect() + macro.replace(macro.three, incompatible_replacement) + self.assertIs( + macro.three, + incompatible_replacement, + msg="Since it is only incompatible with the external connections and we " + "broke those first, replacement is expected to work fine now" + ) + macro(one__x=2) + self.assertEqual( + 2 + (1 + 1 + 3), + macro.outputs.three__changed_output_label.value, + msg="For all to be working, we need the result with the new behaviour " + "at its new location" + ) + + def test_with_executor(self): + macro = Macro(add_three_macro) + downstream = SingleValue(add_one, x=macro.outputs.three__result) + macro > downstream # Later we can just pull() instead + + original_one = macro.one + macro.executor = True + + self.assertIs( + NotData, + macro.outputs.three__result.value, + msg="Sanity check that test is in right starting condition" + ) + + result = macro(one__x=0) + self.assertIsInstance( + result, + Future, + msg="Should be running as a parallel process" + ) + self.assertIs( + NotData, + downstream.outputs.result.value, + msg="Downstream events should not yet have triggered either, we should wait" + "for the callback when the result is ready" + ) + + returned_nodes = result.result() # Wait for the process to finish + self.assertIsNot( + original_one, + returned_nodes.one, + msg="Executing in a parallel process should be returning new instances" + ) + # self.assertIs( + # returned_nodes.one, + # macro.nodes.one, + # msg="Returned nodes should be taken as children" + # ) # You can't do this, result.result() is returning new instances each call + self.assertIs( + macro, + macro.nodes.one.parent, + msg="Returned nodes should get the macro as their parent" + # Once upon a time there was some evidence that this test was failing + # stochastically, but I just ran the whole test suite 6 times and this test + # 8 times and it always passed fine, so maybe the issue is resolved... + ) + self.assertIsNone( + original_one.parent, + msg="Original nodes should be orphaned" + # Note: At time of writing, this is accomplished in Node.__getstate__, + # which feels a bit dangerous... + ) + self.assertEqual( + 0 + 3, + macro.outputs.three__result.value, + msg="And of course we expect the calculation to actually run" + ) + self.assertIs( + downstream.inputs.x.connections[0], + macro.outputs.three__result, + msg="The macro should still be connected to " + ) + sleep(0.2) # Give a moment for the ran signal to emit and downstream to run + # I'm a bit surprised this sleep is necessary + self.assertEqual( + 0 + 3 + 1, + downstream.outputs.result.value, + msg="The finishing callback should also fire off the ran signal triggering" + "downstream execution" + ) + if __name__ == '__main__': unittest.main() diff --git a/tests/unit/test_workflow.py b/tests/unit/test_workflow.py index 698cf71d8..0c0e09ae8 100644 --- a/tests/unit/test_workflow.py +++ b/tests/unit/test_workflow.py @@ -1,6 +1,7 @@ -import unittest +from concurrent.futures import Future from sys import version_info from time import sleep +import unittest from bidict import ValueDuplicationError @@ -194,13 +195,50 @@ def test_no_parents(self): # Setting a non-None value to parent raises the type error from the setter wf2.parent = wf - def test_executor(self): + def test_with_executor(self): + wf = Workflow("wf") - with self.assertRaises(NotImplementedError): - # Submitting callables that use self is still raising - # TypeError: cannot pickle '_thread.lock' object - # For now we just fail cleanly - wf.executor = "literally anything other than None should raise the error" + wf.a = wf.create.SingleValue(plus_one) + wf.b = wf.create.SingleValue(plus_one, x=wf.a) + + original_a = wf.a + wf.executor = True + + self.assertIs( + NotData, + wf.outputs.b__y.value, + msg="Sanity check that test is in right starting condition" + ) + + result = wf(a__x=0) + self.assertIsInstance( + result, + Future, + msg="Should be running as a parallel process" + ) + + returned_nodes = result.result() # Wait for the process to finish + self.assertIsNot( + original_a, + returned_nodes.a, + msg="Executing in a parallel process should be returning new instances" + ) + self.assertIs( + wf, + wf.nodes.a.parent, + msg="Returned nodes should get the macro as their parent" + ) + self.assertIsNone( + original_a.parent, + msg="Original nodes should be orphaned" + # Note: At time of writing, this is accomplished in Node.__getstate__, + # which feels a bit dangerous... + ) + self.assertEqual( + 0 + 1 + 1, + wf.outputs.b__y.value, + msg="And of course we expect the calculation to actually run" + ) def test_parallel_execution(self): wf = Workflow("wf")