diff --git a/.binder/environment.yml b/.binder/environment.yml index 855c200c2..95475d32e 100644 --- a/.binder/environment.yml +++ b/.binder/environment.yml @@ -2,6 +2,7 @@ channels: - conda-forge dependencies: - ase =3.22.1 +- cloudpickle - coveralls - coverage - codacy-coverage diff --git a/.ci_support/environment.yml b/.ci_support/environment.yml index 8adc737f5..9e6f710a7 100644 --- a/.ci_support/environment.yml +++ b/.ci_support/environment.yml @@ -2,6 +2,7 @@ channels: - conda-forge dependencies: - ase =3.22.1 +- cloudpickle - coveralls - coverage - codacy-coverage diff --git a/docs/environment.yml b/docs/environment.yml index 79e6a9c23..9623164f9 100644 --- a/docs/environment.yml +++ b/docs/environment.yml @@ -4,6 +4,7 @@ dependencies: - ipykernel - nbsphinx - ase =3.22.1 +- cloudpickle - coveralls - coverage - codacy-coverage diff --git a/notebooks/workflow_example.ipynb b/notebooks/workflow_example.ipynb index 50843a9e8..e273a9812 100644 --- a/notebooks/workflow_example.ipynb +++ b/notebooks/workflow_example.ipynb @@ -4,12 +4,14 @@ "cell_type": "code", "execution_count": 1, "id": "8dee8129-6b23-4abf-90d2-217d71b8ba7a", - "metadata": {}, + "metadata": { + "tags": [] + }, "outputs": [ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "d57449473dbc42f2997863543b5171c6", + "model_id": "00c1cb12911741a18f9c06ba09e74ae6", "version_major": 2, "version_minor": 0 }, @@ -34,9 +36,9 @@ "- How to instantiate a node\n", "- How to make reusable node classes\n", "- How to connect node inputs and outputs together\n", + "- Flow control (i.e. signal channels vs data channels)\n", "- Defining new nodes from special node classes (Fast and SingleValue)\n", "- The five ways of adding nodes to a workflow\n", - "- Flow control (i.e. signal channels vs data channels)\n", "- Using pre-defined nodes " ] }, @@ -47,7 +49,9 @@ "source": [ "## Instantiating a node\n", "\n", - "Simple nodes can be defined on-the-fly by passing any callable to the `Function(Node)` class, along with a string (tuple of strings) giving names for the output value(s)." + "Simple nodes can be defined on-the-fly by passing any callable to the `Function(Node)` class. This transforms the function into a node instance which has input and output, can be connected to other nodes in a workflow, and can run the function it stores.\n", + "\n", + "Input and output channels are _automatically_ extracted from the signature and return value(s) of the function. (Note: \"Nodized\" functions must have _at most_ one `return` expression!)" ] }, { @@ -60,7 +64,7 @@ "def plus_minus_one(x):\n", " return x+1, x-1\n", "\n", - "pm_node = Function(plus_minus_one, \"p1\", \"m1\")" + "pm_node = Function(plus_minus_one)" ] }, { @@ -81,7 +85,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "['x'] ['p1', 'm1']\n" + "['x'] ['x+1', 'x-1']\n" ] } ], @@ -94,8 +98,7 @@ "id": "22ee2a49-47d1-4cec-bb25-8441ea01faf7", "metadata": {}, "source": [ - "The output is still empty (`NotData`) because we haven't `run()` the node.\n", - "If we try that now though, we'll just get a type error because the input is not set! " + "The output is still empty (`NotData`) because we haven't `run()` the node:" ] }, { @@ -108,12 +111,30 @@ "name": "stdout", "output_type": "stream", "text": [ - "{'p1': , 'm1': }\n" + "{'x+1': , 'x-1': }\n" ] } ], "source": [ - "print(pm_node.outputs.to_value_dict())\n" + "print(pm_node.outputs.to_value_dict())" + ] + }, + { + "cell_type": "markdown", + "id": "0374e277-55ab-45d2-8058-b06365bd07af", + "metadata": {}, + "source": [ + "If we try that now though, we'll just get a type error because the input is not set! " + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "05196cd8-97c7-4f08-ae3a-ad6a076512f7", + "metadata": {}, + "outputs": [], + "source": [ + "# pm_node.run()" ] }, { @@ -124,12 +145,12 @@ "By default, a softer `update()` call is made at instantiation and whenever the node input is updated.\n", "This call checks to make sure the input is `ready` before moving on to `run()`. \n", "\n", - "If we update the input, we'll give the node enough data to work with and it will automatically update the output" + "If we update the input, we'll give the node enough data to work with and it will automatically update the output:" ] }, { "cell_type": "code", - "execution_count": 5, + "execution_count": 6, "id": "b1500a40-f4f2-4c06-ad78-aaebcf3e9a50", "metadata": {}, "outputs": [ @@ -137,7 +158,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "{'p1': 6, 'm1': 4}\n" + "{'x+1': 6, 'x-1': 4}\n" ] } ], @@ -151,12 +172,14 @@ "id": "df4520d7-856e-4bc8-817f-5b2e22c1ddce", "metadata": {}, "source": [ - "We can be stricter and force the node to wait for an explicit `run()` call by modifying the `run_on_updates` and `update_on_instantiation` flags." + "We can be stricter and force the node to wait for an explicit `run()` call by modifying the `run_on_updates` and `update_on_instantiation` flags. \n", + "\n", + "Let's also take the opportunity to give our output channel a better name so we can get it by dot-access." ] }, { "cell_type": "code", - "execution_count": 6, + "execution_count": 7, "id": "ab1ac28a-6e69-491f-882f-da4a43162dd7", "metadata": {}, "outputs": [ @@ -166,18 +189,19 @@ "pyiron_contrib.workflow.channels.NotData" ] }, - "execution_count": 6, + "execution_count": 7, "metadata": {}, "output_type": "execute_result" } ], "source": [ "def adder(x: int, y: int = 1) -> int:\n", - " return x + y\n", + " sum_ = x + y\n", + " return sum_\n", "\n", - "adder_node = Function(adder, \"sum\", run_on_updates=False)\n", + "adder_node = Function(adder, run_on_updates=False)\n", "adder_node.inputs.x = 1\n", - "adder_node.outputs.sum.value # We use `value` to see the data the channel holds" + "adder_node.outputs.sum_.value # We use `value` to see the data the channel holds" ] }, { @@ -191,7 +215,7 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": 8, "id": "dc41a447-15fd-4df2-b60a-0935d81d469e", "metadata": {}, "outputs": [ @@ -201,14 +225,14 @@ "2" ] }, - "execution_count": 7, + "execution_count": 8, "metadata": {}, "output_type": "execute_result" } ], "source": [ "adder_node.run()\n", - "adder_node.outputs.sum.value" + "adder_node.outputs.sum_.value" ] }, { @@ -222,7 +246,7 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": 9, "id": "ac0fe993-6c82-48c8-a780-cbd0c97fc386", "metadata": {}, "outputs": [ @@ -232,7 +256,7 @@ "(int, str)" ] }, - "execution_count": 8, + "execution_count": 9, "metadata": {}, "output_type": "execute_result" } @@ -254,7 +278,7 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": 10, "id": "bcbd17f1-a3e4-44f0-bde1-cbddc51c5d73", "metadata": {}, "outputs": [ @@ -264,13 +288,13 @@ "2" ] }, - "execution_count": 9, + "execution_count": 10, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "adder_node.outputs.sum.value" + "adder_node.outputs.sum_.value" ] }, { @@ -283,7 +307,7 @@ }, { "cell_type": "code", - "execution_count": 10, + "execution_count": 11, "id": "15742a49-4c23-4d4a-84d9-9bf19677544c", "metadata": {}, "outputs": [ @@ -293,14 +317,81 @@ "3" ] }, - "execution_count": 10, + "execution_count": 11, "metadata": {}, "output_type": "execute_result" } ], "source": [ "adder_node.inputs.x.update(2)\n", - "adder_node.outputs.sum.value" + "adder_node.outputs.sum_.value" + ] + }, + { + "cell_type": "markdown", + "id": "416ba898-21ee-4638-820f-0f04a98a6706", + "metadata": {}, + "source": [ + "We can also set new input as any valid combination of kwargs and/or args at both instantiation or on call:" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "0c8f09a7-67c4-4c6c-a021-e3fea1a16576", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "30" + ] + }, + "execution_count": 12, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "adder_node(10, y=20)\n", + "adder_node.outputs.sum_.value" + ] + }, + { + "cell_type": "markdown", + "id": "c0997630-c053-42bb-8c0d-332f8bc26216", + "metadata": {}, + "source": [ + "Finally, when running (or updating or calling when those result in a run -- i.e. the node is set to run on updates and is ready) a function node returns the wrapped function output directly:" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "69b59737-9e09-4b4b-a0e2-76a09de02c08", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "31" + ] + }, + "execution_count": 13, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "adder_node(15, 16)" + ] + }, + { + "cell_type": "markdown", + "id": "f233f3f7-9576-4400-8e92-a1f6109d7f9b", + "metadata": {}, + "source": [ + "Note for advanced users: when the node has an executor set, running returns a futures object for the calculation, whose `.result()` will eventually be the function output." ] }, { @@ -319,7 +410,7 @@ }, { "cell_type": "code", - "execution_count": 11, + "execution_count": 14, "id": "61b43a9b-8dad-48b7-9194-2045e465793b", "metadata": {}, "outputs": [], @@ -329,7 +420,7 @@ }, { "cell_type": "code", - "execution_count": 12, + "execution_count": 15, "id": "647360a9-c971-4272-995c-aa01e5f5bb83", "metadata": {}, "outputs": [ @@ -344,9 +435,10 @@ } ], "source": [ - "@function_node(\"diff\")\n", + "@function_node()\n", "def subtract_node(x: int | float = 2, y: int | float = 1) -> int | float:\n", - " return x - y\n", + " diff = x - y\n", + " return diff\n", "\n", "sn = subtract_node()\n", "print(\"class name =\", sn.__class__.__name__)\n", @@ -366,7 +458,7 @@ }, { "cell_type": "code", - "execution_count": 13, + "execution_count": 16, "id": "8fb0671b-045a-4d71-9d35-f0beadc9cf3a", "metadata": {}, "outputs": [ @@ -376,13 +468,13 @@ "-10" ] }, - "execution_count": 13, + "execution_count": 16, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "subtract_node(x=10, y=20).outputs.diff.value" + "subtract_node(10, 20).outputs.diff.value" ] }, { @@ -397,7 +489,7 @@ }, { "cell_type": "code", - "execution_count": 14, + "execution_count": 17, "id": "5ce91f42-7aec-492c-94fb-2320c971cd79", "metadata": {}, "outputs": [ @@ -410,15 +502,16 @@ } ], "source": [ - "@function_node(\"sum\")\n", + "@function_node()\n", "def add_node(x: int | float = 1, y: int | float = 1) -> int | float:\n", - " return x + y\n", + " sum_ = x + y\n", + " return sum_\n", "\n", "add1 = add_node()\n", - "add2 = add_node(x=2, y=2)\n", - "sub = subtract_node(x=add1.outputs.sum, y=add2.outputs.sum)\n", + "add2 = add_node(2, 2)\n", + "sub = subtract_node(x=add1.outputs.sum_, y=add2.outputs.sum_)\n", "print(\n", - " f\"{add1.outputs.sum.value} - {add2.outputs.sum.value} = {sub.outputs.diff.value}\"\n", + " f\"{add1.outputs.sum_.value} - {add2.outputs.sum_.value} = {sub.outputs.diff.value}\"\n", ")" ] }, @@ -432,7 +525,7 @@ }, { "cell_type": "code", - "execution_count": 15, + "execution_count": 18, "id": "20360fe7-b422-4d78-9bd1-de233f28c8df", "metadata": {}, "outputs": [ @@ -447,7 +540,7 @@ "source": [ "add1.inputs.x = 10\n", "print(\n", - " f\"{add1.outputs.sum.value} - {add2.outputs.sum.value} = {sub.outputs.diff.value}\"\n", + " f\"{add1.outputs.sum_.value} - {add2.outputs.sum_.value} = {sub.outputs.diff.value}\"\n", ")" ] }, @@ -465,18 +558,19 @@ }, { "cell_type": "code", - "execution_count": 16, + "execution_count": 19, "id": "1a4e9693-0980-4435-aecc-3331d8b608dd", "metadata": {}, "outputs": [], "source": [ "import numpy as np\n", + "\n", "from pyiron_contrib.workflow.function import single_value_node" ] }, { "cell_type": "code", - "execution_count": 17, + "execution_count": 20, "id": "7c4d314b-33bb-4a67-bfb9-ed77fba3949c", "metadata": {}, "outputs": [ @@ -491,86 +585,18 @@ } ], "source": [ - "@single_value_node(\"linspace\")\n", + "@single_value_node()\n", "def linspace_node(\n", " start: int | float = 0, stop: int | float = 1, num: int = 50\n", "):\n", - " return np.linspace(start=start, stop=stop, num=num)\n", + " linspace = np.linspace(start=start, stop=stop, num=num)\n", + " return linspace\n", "\n", "lin = linspace_node()\n", "\n", "print(type(lin.outputs.linspace.value)) # Output is just what we expect\n", "print(lin[1:4]) # Gets items from the output\n", - "print(lin.mean()) # Finds the method on the output" - ] - }, - { - "cell_type": "markdown", - "id": "a1a9daa5-9c12-4c2f-b8bd-a54a5fc60feb", - "metadata": {}, - "source": [ - "# Workflows\n", - "\n", - "Typically, you will have a group of nodes working together with their connections.\n", - "We call these groups workflows, and offer a `Workflow(Node)` object as a single point of entry -- i.e. most of the time you shouldn't need the node imports used above, because the decorators are available right on the workflow class." - ] - }, - { - "cell_type": "code", - "execution_count": 18, - "id": "1cd000bd-9b24-4c39-9cac-70a3291d0660", - "metadata": {}, - "outputs": [], - "source": [ - "from pyiron_contrib.workflow.workflow import Workflow\n", - "\n", - "@Workflow.wrap_as.single_value_node(\"is_greater\")\n", - "def greater_than_half(x: int | float | bool = 0) -> bool:\n", - " \"\"\"The functionality doesn't matter here, it's just an example\"\"\"\n", - " return x > 0.5" - ] - }, - { - "cell_type": "markdown", - "id": "ceef526f-3583-4d87-a69d-1ac3d2e706d2", - "metadata": {}, - "source": [ - "## Adding nodes to a workflow\n", - "\n", - "All five of the following approaches are equivalent ways to add a node to a workflow:" - ] - }, - { - "cell_type": "code", - "execution_count": 19, - "id": "7964df3c-55af-4c25-afc5-9e07accb606a", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "n1 n1 n1 (GreaterThanHalf) output single-value: False\n", - "n3 n3 n3 (GreaterThanHalf) output single-value: False\n", - "n4 n4 n4 (GreaterThanHalf) output single-value: False\n", - "n5 n5 n5 (GreaterThanHalf) output single-value: False\n" - ] - } - ], - "source": [ - "from pyiron_contrib.workflow.function import Slow\n", - "\n", - "n1 = greater_than_half(label=\"n1\")\n", - "\n", - "wf = Workflow(\"my_wf\", n1) # As args at init\n", - "wf.add.Slow(lambda: x + 1, \"p1\", label=\"n2\") # Instantiating from the class with a lambda function\n", - "# (Slow since we don't have an x default)\n", - "wf.add(greater_than_half(label=\"n3\")) # Instantiating then passing to node adder\n", - "wf.n4 = greater_than_half(label=\"will_get_overwritten_with_n4\") # Set attribute to instance\n", - "greater_than_half(label=\"n5\", parent=wf) # By passing the workflow to the node\n", - "\n", - "for k, v in wf.nodes.items():\n", - " print(k, v.label, v)" + "print(lin.mean()) # Finds the method on the output -- a special feature of SingleValueNode" ] }, { @@ -591,7 +617,7 @@ }, { "cell_type": "code", - "execution_count": 20, + "execution_count": 21, "id": "2e418abf-7059-4e1e-9b9f-b3dc0a4b5e35", "metadata": {}, "outputs": [ @@ -604,17 +630,18 @@ } ], "source": [ - "@function_node(\"y\")\n", + "@function_node()\n", "def linear(x):\n", " return x\n", "\n", - "@function_node(\"z\", run_on_updates=False)\n", - "def times_two(y):\n", - " return 2 * y\n", + "@function_node(run_on_updates=False)\n", + "def times_two(x):\n", + " double = 2 * x\n", + " return double\n", "\n", "l = linear(x=1)\n", - "t2 = times_two(y=l.outputs.y)\n", - "print(t2.inputs.y, t2.outputs.z)" + "t2 = times_two(x=l.outputs.x)\n", + "print(t2.inputs.x, t2.outputs.double)" ] }, { @@ -631,7 +658,7 @@ }, { "cell_type": "code", - "execution_count": 21, + "execution_count": 22, "id": "3310eac4-04f6-421b-9824-19bb2d680be6", "metadata": {}, "outputs": [ @@ -644,7 +671,7 @@ } ], "source": [ - "@function_node(\"void\")\n", + "@function_node()\n", "def control():\n", " return\n", "\n", @@ -652,7 +679,15 @@ "l.signals.input.run = c.signals.output.ran\n", "t2.signals.input.run = l.signals.output.ran\n", "c.run()\n", - "print(t2.outputs.z.value)" + "print(t2.outputs.double.value)" + ] + }, + { + "cell_type": "markdown", + "id": "003ed16e-c493-4465-9f08-492f9c51f764", + "metadata": {}, + "source": [ + "`Function` and its children always push out data updates _before_ triggering their `ran` signal." ] }, { @@ -665,7 +700,7 @@ }, { "cell_type": "code", - "execution_count": 22, + "execution_count": 23, "id": "7a6f2bce-6b5e-4321-9457-0a6790d2202a", "metadata": {}, "outputs": [], @@ -675,13 +710,13 @@ }, { "cell_type": "code", - "execution_count": 23, + "execution_count": 24, "id": "6569014a-815b-46dd-8b47-4e1cd4584b3b", "metadata": {}, "outputs": [ { "data": { - "image/png": "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", + "image/png": "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xrqA16GzV9UUZKWGrSbCCAAAMSQ+zqGKkmxJ8gskw88rSrLD2m+EMAIAQIxZkeNWzZpcZbh8D8VkuJyqWZMb9j4jND0DACAGrchxa3l2Bh1YAQCAPfFxDhXOm267DA7TAAAAuwgjAADAKsIIAACwijACAACsIowAAACrCCMAAMAqwggAALCKMAIAAKwijAAAAKsIIwAAwCrCCAAAsIowAgAArCKMAAAAqwgjAADAKsIIAACwijACAACsIowAAACrCCMAAMAqwggAALCKMAIAAKwijAAAAKsIIwAAwCrCCAAAsIowAgAArCKMAAAAqwgjAADAKsIIAACwijACAACsIowAAACrCCMAAMAqwggAALCKMAIAAKwijAAAAKsSbBcAAOdjcMioua1HnX39Skt2qiArVfFxDttlAQgAYQRAxGo45lFlXas83v6RMbfLqYqSbK3IcVusDEAgOEwDICI1HPNo/c7DPkFEkjq8/Vq/87AajnksVQYgUIQRABFncMiosq5VZpSfDY9V1rVqcGi0GQAmG8IIgIjT3Nbjt0fkXEaSx9uv5rae8BUFIGiEEQARp7Nv7CASzDwAdhFGAESctGTnhM4DYBdhBEDEKchKldvl1FgX8Dp09qqagqzUcJYFIEiEEQARJz7OoYqSbEnyCyTDzytKsuk3AkQIwgiAiLQix62aNbnKcPkeislwOVWzJpc+I0AEoekZgIi1Iset5dkZdGAFIhxhBEBEi49zqHDedNtlADgPHKYBAABWBRVGqqurlZWVJafTqby8PDU1NX3n/IGBAW3ZskVz5sxRUlKS5s2bpx07dgRVMAAAiC4BH6apra3Vxo0bVV1draVLl+rZZ5/VypUr1draqosvvnjUbVavXq3PPvtM27dv1w9/+EN1dnbqzJkz5108AACIfA5jTEA3b1iyZIlyc3NVU1MzMrZw4UKtWrVKVVVVfvMbGhp044036vjx40pNDe6a/97eXrlcLnm9XqWkpAT1OwAAQHiN9/s7oMM0p0+fVktLi4qLi33Gi4uLdeDAgVG3efPNN5Wfn6+HH35Ys2bN0oIFC3Tffffp66+/HvN1BgYG1Nvb6/MAAADRKaDDNF1dXRocHFR6errPeHp6ujo6Okbd5vjx49q3b5+cTqdef/11dXV16c4771RPT8+Y541UVVWpsrIykNIAAECECuoEVofD9xp+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39bVV18dltqjVTB/I263W7NmzfK51frChQtljNHJkyc1f/78kNYczYJZj6qqKi1dulT333+/JOnyyy/XtGnTtGzZMj344IPsXQ+BSb9nJDExUXl5eWpsbPQZb2xsVFFR0ajbFBYW+s1/++23lZ+frylTpoSs1lgQzHpIZ/eI3HrrrXrllVc47jqBAl2PlJQUvf/++zp69OjIo6ysTD/60Y909OhRLVmyJFylR61g/kaWLl2qU6dO6YsvvhgZ++ijjxQXF6fZs2eHtN5oF8x6fPXVV4qL8/16jI+Pl/T/97Jjgtk6czYQw5dlbd++3bS2tpqNGzeaadOmmf/+97/GGGM2b95sbr755pH5w5f23nvvvaa1tdVs376dS3snUKDr8corr5iEhATz9NNPG4/HM/L4/PPPbb2FqBLoenwbV9NMvEDXpK+vz8yePdv88pe/NB988IHZu3evmT9/vrn99tttvYWoEuh6vPjiiyYhIcFUV1ebTz75xOzbt8/k5+ebgoICW28h6kVEGDHGmKefftrMmTPHJCYmmtzcXLN3796Rn91yyy3mqquu8pn/7rvvmsWLF5vExERzySWXmJqamjBXHN0CWY+rrrrKSPJ73HLLLeEvPEoF+vdxLsJIaAS6Jh9++KG55pprzNSpU83s2bPNpk2bzFdffRXmqqNXoOvxxBNPmOzsbDN16lTjdrvNr371K3Py5MkwVx07HMawzwkAANgz6c8ZAQAA0Y0wAgAArCKMAAAAqwgjAADAKsIIAACwijACAACsIowAAACrCCMAAMAqwggAALCKMAIAAKwijAAAAKsIIwAAwKr/Bwq4+PKt2NvcAAAAAElFTkSuQmCC", "text/plain": [ "
" ] @@ -691,13 +726,15 @@ } ], "source": [ - "@single_value_node(\"array\")\n", + "@single_value_node()\n", "def noise(length: int = 1):\n", - " return np.random.rand(length)\n", + " array = np.random.rand(length)\n", + " return array\n", "\n", - "@function_node(\"fig\")\n", + "@function_node()\n", "def plot(x, y):\n", - " return plt.scatter(x, y)\n", + " fig = plt.scatter(x, y)\n", + " return fig\n", "\n", "x = noise(length=10)\n", "y = noise(length=10)\n", @@ -719,7 +756,7 @@ }, { "cell_type": "code", - "execution_count": 24, + "execution_count": 25, "id": "25f0495a-e85f-43b7-8a70-a2c9cbd51ebb", "metadata": {}, "outputs": [ @@ -729,7 +766,7 @@ "(False, False)" ] }, - "execution_count": 24, + "execution_count": 25, "metadata": {}, "output_type": "execute_result" } @@ -740,13 +777,35 @@ }, { "cell_type": "code", - "execution_count": 25, + "execution_count": 26, "id": "449ce797-be62-4211-b483-c717a3d70583", "metadata": {}, "outputs": [ { "data": { - "image/png": "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iooKHT58OORz3nrrLS1cuFBPPvmkZs+erfnz5+vhhx/WN998M+H7DA8Pa2BgIOgBAADC40xxqLqyUNJI8Djf6M/VlYXG9huxFUbOnDkjv9+vnJycoPacnBz19PSEfM6JEyd08OBBdXR06M0339SuXbt04MAB3X///RO+T21trdxu99gjLy/PTpkAAOACy4s8ql9drFx38FRMrtul+tXFRvcZCWvTM4cjODlZljWubVQgEJDD4dC+ffvkdrslSTt37tTtt9+u3bt3a9q0aeOes23bNm3ZsmXs54GBAQIJAACXaXmRR8sKc+N7B9aZM2fK6XSOGwXp7e0dN1oyyuPxaPbs2WNBRBq5x8SyLJ06dUrz5s0b95z09HSlp6fbKQ0AAEyCM8WhsrkzTJcRxNY0TVpamkpKStTS0hLU3tLSovLy8pDPWbx4sU6fPq0vv/xyrO2TTz5RSkqK5syZE0bJAAAgkdjeZ2TLli168cUXtWfPHn388cd66KGH1N3drQ0bNkgamWJZu3btWP8777xTM2bM0D333KPOzk699957euSRR3TvvfeGnKIBAADJxfY9I1VVVerr69OOHTvk9XpVVFSkpqYm5efnS5K8Xq+6u7vH+n/rW99SS0uLfvnLX2rhwoWaMWOGVq1apccff3zq/goAABC3bO8zYgL7jAAAEH8iss8IAADAVCOMAAAAowgjAADAKMIIAAAwijACAACMIowAAACjCCMAAMAowggAADCKMAIAAIwijAAAAKMIIwAAwCjCCAAAMIowAgAAjCKMAAAAowgjAADAKMIIAAAwijACAACMIowAAACjCCMAAMAowggAADCKMAIAAIwijAAAAKMIIwAAwCjCCAAAMIowAgAAjCKMAAAAowgjAADAKMIIAAAwijACAACMIowAAACjCCMAAMAowggAADCKMAIAAIwijAAAAKMIIwAAwCjCCAAAMIowAgAAjCKMAAAAowgjAADAKMIIAAAwijACAACMIowAAACjCCMAAMAowggAADCKMAIAAIwijAAAAKNSTRcAAIhf/oCltq5+9Q4OKTvDpdKCLDlTHKbLQpwhjAAAwtLc4VVNY6e8vqGxNo/bperKQi0v8hisDPGGaRoAgG3NHV5t3Hs0KIhIUo9vSBv3HlVzh9dQZYhHhBEAgC3+gKWaxk5ZIX432lbT2Cl/IFQPYDzCCADA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", + "text/plain": [ + "(True, False)" + ] + }, + "execution_count": 26, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "x.inputs.length = 20\n", + "f.inputs.x.ready, f.inputs.y.ready" + ] + }, + { + "cell_type": "code", + "execution_count": 27, + "id": "7008b0fc-3644-401c-b49f-9c40f9d89ac4", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAiMAAAGdCAYAAADAAnMpAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjcuMSwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/bCgiHAAAACXBIWXMAAA9hAAAPYQGoP6dpAAAnaElEQVR4nO3df0zd133/8dcFbG6SmTth13AdE/faS1YoahJApNi1qmUxsRORpuoUpsxxktpVcNs5Dmu2UE+heJFQutZy0wTctE6iyI7Hmh9tkRgNUlYHx96YMZ7qEimdzYZ/XIIA9UKSguPL+f7hL8Q3XGw+15d77ufe50O6f3A4H/O+H2N/Xvec8zkfjzHGCAAAwJIM2wUAAID0RhgBAABWEUYAAIBVhBEAAGAVYQQAAFhFGAEAAFYRRgAAgFWEEQAAYFWW7QLmYnJyUufOndOiRYvk8XhslwMAAObAGKOxsTEtW7ZMGRmzj3+4IoycO3dOBQUFtssAAAAxOH36tJYvXz7r910RRhYtWiTp4pvJycmxXA0AAJiL0dFRFRQUTF/HZ+OKMDI1NZOTk0MYAQDAZa60xIIFrAAAwCrHYeTtt99WVVWVli1bJo/Ho1/84hdXPObgwYMqLS2V1+vVypUrtWfPnlhqBQAAKchxGPnwww91880369lnn51T/76+Pt11111au3atenp69N3vflfbtm3Ta6+95rhYAACQehyvGdmwYYM2bNgw5/579uzRDTfcoN27d0uSCgsLdfToUf3gBz/Q1772Nac/HgAApJh5XzNy5MgRVVZWRrTdeeedOnr0qD7++OOox0xMTGh0dDTiBQAAUtO8h5GBgQHl5eVFtOXl5enChQsaGhqKekxjY6N8Pt/0iz1GAABIXQm5m+bTt/QYY6K2T6mrq1MoFJp+nT59et5rBAAAdsz7PiP5+fkaGBiIaBscHFRWVpYWL14c9Zjs7GxlZ2fPd2kAACAJzHsYqaioUGtra0Tbm2++qbKyMi1YsGC+fzwAIAmFJ426+kY0ODaupYu8Kg/kKjODZ4+lK8dh5IMPPtD//M//TH/d19en48ePKzc3VzfccIPq6up09uxZvfzyy5KkmpoaPfvss6qtrdU3vvENHTlyRHv37tWBAwfi9y4AAK7RfiKohtZeBUPj021+n1f1VUVaX+y3WBlscbxm5OjRo7r11lt16623SpJqa2t166236sknn5QkBYNB9ff3T/cPBAJqa2vTb37zG91yyy36p3/6Jz3zzDPc1gsAaaj9RFBb9x2LCCKSNBAa19Z9x9R+ImipMtjkMVOrSZPY6OiofD6fQqEQz6YBAJcKTxp96em3ZgSRKR5J+T6vDv3D7UzZpIi5Xr95Ng0AICG6+kZmDSKSZCQFQ+Pq6htJXFFICoQRAEBCDI7NHkRi6YfUQRgBACTE0kXeuPZD6iCMAAASojyQK7/Pq9lWg3h08a6a8kBuIstCEiCMAAASIjPDo/qqIkmaEUimvq6vKmLxahoijAAAEmZ9sV/NG0uU74ucisn3edW8sYR9RtLUvO/ACrgJu0IC8299sV/rivL5t4ZphBHg/2NXSCBxMjM8qlgV/flkSD9M0wBiV0gAsIkwgrQXnjRqaO1VtK2Ip9oaWnsVnkz6zYoBwJUII0h77AoJAHYRRpD22BUSAOwijCDtsSskANhFGEHaY1dIALCLMIK0x66QAGAXYQQQu0ICgE1segb8f+wKCQB2EEaAS7ArJAAkHmHkKvAcEwAArh5hJEY8xwQAgPhgAWsMeI4JAADxQxhxiOeYAAAQX4QRh3iOyZWFJ42OnBzWL4+f1ZGTwwQzAMBlsWbEIZ5jcnmspQEAOMXIiEM8x2R2rKUBAMSCMOIQzzGJjrU0AIBYEUYc4jkm0bGWBgAQK8JIDHiOyUyspQEAxIoFrDHiOSaRWEsDAIgVYeQq8ByTT0ytpRkIjUddN+LRxZGjdFtLAwC4MqZpEBespQEAxIowgrhhLQ0AIBZM0yCuWEsDAHCKMIK4Yy0NAMAJpmkAAIBVhBEAAGAVYQQAAFhFGAEAAFYRRgAAgFWEEQAAYBVhBAAAWEUYAQAAVhFGAACAVYQRAABgFWEEAABYRRgBAABWEUYAAIBVhBEAAGAVYQQAAFhFGAEAAFYRRgAAgFWEEQAAYBVhBAAAWEUYAQAAVhFGAACAVYQRAABgFWEEAABYRRgBAABWxRRGmpqaFAgE5PV6VVpaqs7Ozsv2379/v26++WZde+218vv9evjhhzU8PBxTwQAAILU4DiMtLS3avn27duzYoZ6eHq1du1YbNmxQf39/1P6HDh3Spk2btHnzZv3ud7/Tz3/+c/3Xf/2XtmzZctXFAwAA93McRnbt2qXNmzdry5YtKiws1O7du1VQUKDm5uao/f/jP/5Dn/3sZ7Vt2zYFAgF96Utf0iOPPKKjR49edfEAAMD9HIWR8+fPq7u7W5WVlRHtlZWVOnz4cNRjVq9erTNnzqitrU3GGL3//vt69dVXdffdd8/6cyYmJjQ6OhrxAlJdeNLoyMlh/fL4WR05OazwpLFdEgAkRJaTzkNDQwqHw8rLy4toz8vL08DAQNRjVq9erf3796u6ulrj4+O6cOGC7rnnHv34xz+e9ec0NjaqoaHBSWmAq7WfCKqhtVfB0Ph0m9/nVX1VkdYX+y1WBgDzL6YFrB6PJ+JrY8yMtim9vb3atm2bnnzySXV3d6u9vV19fX2qqamZ9c+vq6tTKBSafp0+fTqWMgFXaD8R1NZ9xyKCiCQNhMa1dd8xtZ8IWqoMABLD0cjIkiVLlJmZOWMUZHBwcMZoyZTGxkatWbNGjz/+uCTpC1/4gq677jqtXbtWTz31lPz+mZ/6srOzlZ2d7aQ0wJXCk0YNrb2KNiFjJHkkNbT2al1RvjIzogd+AHA7RyMjCxcuVGlpqTo6OiLaOzo6tHr16qjHfPTRR8rIiPwxmZmZki6OqADprKtvZMaIyKWMpGBoXF19I4krCgASzPE0TW1trX72s5/phRde0LvvvqvHHntM/f3909MudXV12rRp03T/qqoqvf7662pubtapU6f0zjvvaNu2bSovL9eyZcvi904AFxocmz2IxNIPANzI0TSNJFVXV2t4eFg7d+5UMBhUcXGx2tratGLFCklSMBiM2HPkoYce0tjYmJ599ln93d/9nf70T/9Ut99+u55++un4vQvApZYu8sa1HwC4kce4YK5kdHRUPp9PoVBIOTk5tssB4iY8afSlp9/SQGg86roRj6R8n1eH/uF21owAiLvwpFFX34gGx8a1dJFX5YHcuP5fM9frt+OREQDxk5nhUX1VkbbuOyaPFBFIpv47qK8qIogAiLtk2lKAB+UBlq0v9qt5Y4nyfZFTMfk+r5o3lrDPCIC4S7YtBRgZAZLA+mK/1hXlz+twKQBIybmlAGEESBKZGR5VrFpsuwwAKc7JlgKJ+j+JaRoAANJIMm4pQBgBACCNJOOWAoQRAADSSHkgV36fV7OtBvHo4l015YHchNVEGAEAII1MbSkgaUYgsbWlAGEEAIA0k2xbCnA3DQAAaSiZthQgjAAAkKaSZUsBpmkAAIBVhBEAAGAVYQQAAFhFGAEAAFYRRgAAgFWEEQAAYBVhBAAAWEUYAQAAVhFGAACAVYQRAABgFdvBA3MQnjRJ8fwGAEhFhBHgCtpPBNXQ2qtgaHy6ze/zqr6qKOFPtgSAVMQ0DXAZ7SeC2rrvWEQQkaSB0Li27jum9hNBS5UBQOogjACzCE8aNbT2ykT53lRbQ2uvwpPRegAA5oowAsyiq29kxojIpYykYGhcXX0jiSsKAFIQYQSYxeDY7EEkln4AgOgII8Asli7yxrUfACA6wggwi/JArvw+r2a7gdeji3fVlAdyE1kWAKQcwggwi8wMj+qriiRpRiCZ+rq+qoj9RgDgKhFGgMtYX+xX88YS5fsip2LyfV41byxhnxEAiAM2PQOuYH2xX+uK8tmBFQDmCWEEmIPMDI8qVi22XQYApCSmaQAAgFWEEQAAYBVhBAAAWEUYAQAAVhFGAACAVYQRAABgFWEEAABYRRgBAABWEUYAAIBVhBEAAGAVYQQAAFhFGAEAAFYRRgAAgFWEEQAAYBVhBAAAWEUYAQAAVhFGAACAVYQRAABgFWEEAABYRRgBAABWEUYAAIBVhBEAAGAVYQQAAFhFGAEAAFYRRgAAgFUxhZGmpiYFAgF5vV6Vlpaqs7Pzsv0nJia0Y8cOrVixQtnZ2Vq1apVeeOGFmAoGAACpJcvpAS0tLdq+fbuampq0Zs0a/eQnP9GGDRvU29urG264Ieox9913n95//33t3btXf/Znf6bBwUFduHDhqosHAADu5zHGGCcH3HbbbSopKVFzc/N0W2Fhoe699141NjbO6N/e3q6//uu/1qlTp5SbmxtTkaOjo/L5fAqFQsrJyYnpzwAAAIk11+u3o2ma8+fPq7u7W5WVlRHtlZWVOnz4cNRjfvWrX6msrEzf//73df311+umm27Sd77zHf3xj3+c9edMTExodHQ04gUAAFKTo2maoaEhhcNh5eXlRbTn5eVpYGAg6jGnTp3SoUOH5PV69cYbb2hoaEjf/OY3NTIyMuu6kcbGRjU0NDgpDQAAuFRMC1g9Hk/E18aYGW1TJicn5fF4tH//fpWXl+uuu+7Srl279NJLL806OlJXV6dQKDT9On36dCxlAgAAF3A0MrJkyRJlZmbOGAUZHBycMVoyxe/36/rrr5fP55tuKywslDFGZ86c0Y033jjjmOzsbGVnZzspDQAAuJSjkZGFCxeqtLRUHR0dEe0dHR1avXp11GPWrFmjc+fO6YMPPphue++995SRkaHly5fHUDIAAEgljqdpamtr9bOf/UwvvPCC3n33XT322GPq7+9XTU2NpItTLJs2bZruf//992vx4sV6+OGH1dvbq7fffluPP/64vv71r+uaa66J3zsBAACu5Hifkerqag0PD2vnzp0KBoMqLi5WW1ubVqxYIUkKBoPq7++f7v8nf/In6ujo0N/+7d+qrKxMixcv1n333aennnoqfu8CAAC4luN9RmxgnxEAANxnrtdvxyMjAADAnvCkUVffiAbHxrV0kVflgVxlZkS/o9UtCCMAALhE+4mgGlp7FQyNT7f5fV7VVxVpfbHfYmVXh6f2AgDgAu0ngtq671hEEJGkgdC4tu47pvYTQUuVXT3CCAAASS48adTQ2qtoizyn2hpaexWeTPploFERRgAASHJdfSMzRkQuZSQFQ+Pq6htJXFFxRBgBACDJDY7NHkRi6ZdsCCMAACS5pYu8ce2XbAgjAAAkufJArvw+r2a7gdeji3fVlAdyE1lW3KRtGAlPGh05OaxfHj+rIyeHXbvoBwCQ+jIzPKqvKpKkGYFk6uv6qiLX7jeSlvuMpOp92gAAOxKxEdn6Yr+aN5bMuH7lp8D1K+22g5+6T/vTb3rqV6Z5Y4mr/0IBAImV6A+4btqBda7X77QKI+FJoy89/dast0d5dDFhHvqH25P2LxYAkDz4gHt5c71+p9WakVS/TxsAkDipvhFZIqVVGEn1+7QBAInDB9z4Saswkur3aQMAEocPuPGTVmEk1e/TBgAkDh9w4yetwkiq36cNAEgcPuDGT1qFEemT+7TzfZFJNd/nTftVzwCAueMDbvyk1a29l3LTfdoAgOTFRpqzY58RAAAShA+40c31+p2W28EDABBPmRkeVaxabLsM10q7NSMAACC5EEYAAIBVTNMAuCrMlQO4WoQRADHjLgIA8cA0DYCYTD2t9NPP5hgIjWvrvmNqPxG0VBkAtyGMAHCMp5UCiCfCCADHeFopgHgijABwjKeVAognwggAx3haKYB4IowAcIynlQKIJ8IIAMd4WimAeCKMAIjJ+mK/mjeWKN8XORWT7/OqeWMJ+4wAmDM2PQMQs/XFfq0rymcHVgBXhTAC4KrwtFIAV4tpGgAAYBVhBAAAWEUYAQAAVhFGAACAVYQRAABgFWEEAABYRRgBAABWEUYAAIBVhBEAAGAVYQQAAFhFGAEAAFYRRgAAgFWEEQAAYBVhBAAAWEUYAQAAVhFGAACAVYQRAABgVZbtAgAAwNULTxp19Y1ocGxcSxd5VR7IVWaGx3ZZc0IYAQDA5dpPBNXQ2qtgaHy6ze/zqr6qSOuL/RYrmxumaQAAcLH2E0Ft3XcsIohI0kBoXFv3HVP7iaClyuaOMAIAgEuFJ40aWntlonxvqq2htVfhyWg9kgdhBAAAl+rqG5kxInIpIykYGldX30jiiooBYQQAAJcaHJs9iMTSz5aYwkhTU5MCgYC8Xq9KS0vV2dk5p+PeeecdZWVl6ZZbbonlxwIAgEssXeSNaz9bHIeRlpYWbd++XTt27FBPT4/Wrl2rDRs2qL+//7LHhUIhbdq0SX/5l38Zc7EAAOAT5YFc+X1ezXYDr0cX76opD+QmsizHHIeRXbt2afPmzdqyZYsKCwu1e/duFRQUqLm5+bLHPfLII7r//vtVUVERc7EAAOATmRke1VcVSdKMQDL1dX1VUdLvN+IojJw/f17d3d2qrKyMaK+srNThw4dnPe7FF1/UyZMnVV9fH1uVAAAgqvXFfjVvLFG+L3IqJt/nVfPGElfsM+Jo07OhoSGFw2Hl5eVFtOfl5WlgYCDqMb///e/1xBNPqLOzU1lZc/txExMTmpiYmP56dHTUSZkAAKSV9cV+rSvKT68dWD2eyDdnjJnRJknhcFj333+/GhoadNNNN835z29sbFRDQ0MspQEAkJYyMzyqWLXYdhkxcTRNs2TJEmVmZs4YBRkcHJwxWiJJY2NjOnr0qL797W8rKytLWVlZ2rlzp/77v/9bWVlZeuutt6L+nLq6OoVCoenX6dOnnZQJAABcxNHIyMKFC1VaWqqOjg599atfnW7v6OjQV77ylRn9c3Jy9Nvf/jairampSW+99ZZeffVVBQKBqD8nOztb2dnZTkoDAAAu5Xiapra2Vg888IDKyspUUVGh559/Xv39/aqpqZF0cVTj7Nmzevnll5WRkaHi4uKI45cuXSqv1zujHQAApCfHYaS6ulrDw8PauXOngsGgiouL1dbWphUrVkiSgsHgFfccAQAAmOIxxiT303N08W4an8+nUCiknJwc2+UAAIA5mOv1m2fTAAAAqwgjAADAKsIIAACwijACAACsIowAAACrCCMAAMAqwggAALCKMAIAAKwijAAAAKsIIwAAwCrCCAAAsIowAgAArHL81F4AuFrhSaOuvhENjo1r6SKvygO5yszw2C4LgCWEEQAJ1X4iqIbWXgVD49Ntfp9X9VVFWl/st1gZAFuYpgGQMO0ngtq671hEEJGkgdC4tu47pvYTQUuVAbCJMAIgIcKTRg2tvTJRvjfV1tDaq/BktB4AUhlhBEBCdPWNzBgRuZSRFAyNq6tvJHFFAUgKhBEACTE4NnsQiaUfgNRBGAGQEEsXeePaD0Dq4G4aAAlRHsiV3+fVQGg86roRj6R838XbfIFP43bw1EYYAZAQmRke1VcVaeu+Y/JIEYFk6pJSX1XEBQYzcDt46mOaBkDCrC/2q3ljifJ9kVMx+T6vmjeWcGHBDNwOnh4YGQGQUOuL/VpXlM+QO67oSreDe3TxdvB1Rfn8/rgcYQRAwmVmeFSxarHtMpDknNwOzu+TuzFNAwBIStwOnj4IIwCApMTt4OmDMAIASEpTt4PPthrEo4t31XA7uPsRRpA2wpNGR04O65fHz+rIyWGegQIkuanbwSXNCCTcDp5aWMCKtMA+BYA7Td0O/ul/v/n8+00pHmNM0n88HB0dlc/nUygUUk5Oju1y4DJT+xR8+hd96rMU+1sgXblpV1M31YpPzPX6zcgIUhr7FADRuW20kNvBUxtrRpDSeGw9MBO7miLZEEaQ0tinAIh0pdFC6eJoIQu8kUiEEaQ09ikAIjFaiGREGEFKY58CIBKjhUhGhBGkNPYpACIxWohkRBhByuOx9cAnGC1EMuLWXqQFHlsPXDQ1Wrh13zF5pIiFrIwWwhY2PQOANOS2fUbgTmx6BgCYFaOFSCaEEQBIU+xqimTBAlYAAGAVYQQAAFhFGAEAAFYRRgAAgFWEEQAAYBVhBAAAWEUYAQAAVhFGAACAVYQRAABgFWEEAABYRRgBAABWEUYAAIBVhBEAAGAVYQQAAFhFGAEAAFYRRgAAgFWEEQAAYFVMYaSpqUmBQEBer1elpaXq7Oycte/rr7+udevW6TOf+YxycnJUUVGhX//61zEXDAAAUovjMNLS0qLt27drx44d6unp0dq1a7Vhwwb19/dH7f/2229r3bp1amtrU3d3t/7iL/5CVVVV6unpueriAQCA+3mMMcbJAbfddptKSkrU3Nw83VZYWKh7771XjY2Nc/ozPv/5z6u6ulpPPvnknPqPjo7K5/MpFAopJyfHSbkAAMCSuV6/HY2MnD9/Xt3d3aqsrIxor6ys1OHDh+f0Z0xOTmpsbEy5ublOfjQAAEhRWU46Dw0NKRwOKy8vL6I9Ly9PAwMDc/ozfvjDH+rDDz/UfffdN2ufiYkJTUxMTH89OjrqpEwAAOAiMS1g9Xg8EV8bY2a0RXPgwAF973vfU0tLi5YuXTprv8bGRvl8vulXQUFBLGUCAAAXcBRGlixZoszMzBmjIIODgzNGSz6tpaVFmzdv1r/+67/qjjvuuGzfuro6hUKh6dfp06edlAkAAFzEURhZuHChSktL1dHREdHe0dGh1atXz3rcgQMH9NBDD+mVV17R3XfffcWfk52drZycnIgXAABITY7WjEhSbW2tHnjgAZWVlamiokLPP/+8+vv7VVNTI+niqMbZs2f18ssvS7oYRDZt2qQf/ehH+uIXvzg9qnLNNdfI5/PF8a0AAAA3chxGqqurNTw8rJ07dyoYDKq4uFhtbW1asWKFJCkYDEbsOfKTn/xEFy5c0Le+9S1961vfmm5/8MEH9dJLL139OwAAAJcVnjTq6hvR4Ni4li7yqjyQq8yMK6/1TBTH+4zYwD4jAADEpv1EUA2tvQqGxqfb/D6v6quKtL7YP68/e172GQEAAO7RfiKorfuORQQRSRoIjWvrvmNqPxG0VFkkwggApJHwpNGRk8P65fGzOnJyWOHJpB8cR4zCk0YNrb2K9jc81dbQ2psUvwOO14wAANzJ5nA9Eq+rb2TGiMiljKRgaFxdfSOqWLU4cYVFwcgIAKQBtwzXI34Gx2YPIrH0m0+EEQBIcW4arp8v6Tg9tXSRN6795hPTNACQ4tw0XD8f0nV6qjyQK7/Pq4HQeNQg6pGU77t4m69tjIwAQIpz03B9vKXz9FRmhkf1VUWSLgaPS019XV9VlBT7jRBGACDFuWm4Pp6YnpLWF/vVvLFE+b7Iv9t8n1fNG0uSZmSIaRoASHFuGq6Pp3SfnpqyvtivdUX5Sb0DK2EEAFLc1HD91n3H5JEiAkmyDdfHUzpPT31aZoYnqQMX0zQAkAbcMlwfT+k6PeVGjIwAQJpww3B9PKXr9JQbEUYAII0k+3B9PKXr9JQbMU0DAEhZ6Tg95UaMjAAAUlq6TU+5EWEEAJDy0ml6yo2YpgEAAFYRRgAAgFWEEQAAYBVhBAAAWEUYAQAAVhFGAACAVYQRAABgFWEEAABYRRgBAABWEUYAAIBVhBEAAGAVYQQAAFhFGAEAAFYRRgAAgFWEEQAAYBVhBAAAWEUYAQAAVhFGAACAVYQRAABgFWEEAABYRRgBAABWEUYAAIBVhBEAAGAVYQQAAFhFGAEAAFYRRgAAgFWEEQAAYBVhBAAAWEUYAQAAVhFGAACAVYQRAABgFWEEAABYRRgBAABWEUYAAIBVhBEAAGAVYQQAAFhFGAEAAFYRRgAAgFWEEQAAYBVhBAAAWJVluwAAQOKFJ426+kY0ODaupYu8Kg/kKjPDY7sspCnCCACkmfYTQTW09ioYGp9u8/u8qq8q0vpiv8XKkK6YpgGANNJ+Iqit+45FBBFJGgiNa+u+Y2o/EbRUGdJZTGGkqalJgUBAXq9XpaWl6uzsvGz/gwcPqrS0VF6vVytXrtSePXtiKhYAELvwpFFDa69MlO9NtTW09io8Ga0HMH8ch5GWlhZt375dO3bsUE9Pj9auXasNGzaov78/av++vj7dddddWrt2rXp6evTd735X27Zt02uvvXbVxQMA5q6rb2TGiMiljKRgaFxdfSOJKwpQDGFk165d2rx5s7Zs2aLCwkLt3r1bBQUFam5ujtp/z549uuGGG7R7924VFhZqy5Yt+vrXv64f/OAHV108AGDuBsdmDyKx9APixVEYOX/+vLq7u1VZWRnRXllZqcOHD0c95siRIzP633nnnTp69Kg+/vjjqMdMTExodHQ04gUAuDpLF3nj2g+IF0dhZGhoSOFwWHl5eRHteXl5GhgYiHrMwMBA1P4XLlzQ0NBQ1GMaGxvl8/mmXwUFBU7KBABEUR7Ild/n1Ww38Hp08a6a8kBuIssCYlvA6vFE/iobY2a0Xal/tPYpdXV1CoVC06/Tp0/HUiYA4BKZGR7VVxVJ0oxAMvV1fVUR+40g4RyFkSVLligzM3PGKMjg4OCM0Y8p+fn5UftnZWVp8eLFUY/Jzs5WTk5OxAsAcPXWF/vVvLFE+b7IqZh8n1fNG0vYZwRWONr0bOHChSotLVVHR4e++tWvTrd3dHToK1/5StRjKioq1NraGtH25ptvqqysTAsWLIihZADA1Vhf7Ne6onx2YEXScLwDa21trR544AGVlZWpoqJCzz//vPr7+1VTUyPp4hTL2bNn9fLLL0uSampq9Oyzz6q2tlbf+MY3dOTIEe3du1cHDhyI7zsBAMxZZoZHFauij04DieY4jFRXV2t4eFg7d+5UMBhUcXGx2tratGLFCklSMBiM2HMkEAiora1Njz32mJ577jktW7ZMzzzzjL72ta/F710AAADX8pip1aRJbHR0VD6fT6FQiPUjAAC4xFyv3zybBgAAWEUYAQAAVhFGAACAVYQRAABgFWEEAABYRRgBAABWEUYAAIBVjjc9s2FqK5TR0VHLlQAAgLmaum5faUszV4SRsbExSVJBQYHlSgAAgFNjY2Py+Xyzft8VO7BOTk7q3LlzWrRokTye6A9yGh0dVUFBgU6fPs0urQnGubeHc28P594ezr0dsZx3Y4zGxsa0bNkyZWTMvjLEFSMjGRkZWr58+Zz65uTk8MtpCefeHs69PZx7ezj3djg975cbEZnCAlYAAGAVYQQAAFiVMmEkOztb9fX1ys7Otl1K2uHc28O5t4dzbw/n3o75PO+uWMAKAABSV8qMjAAAAHcijAAAAKsIIwAAwCrCCAAAsMpVYaSpqUmBQEBer1elpaXq7Oy8bP+DBw+qtLRUXq9XK1eu1J49exJUaepxcu5ff/11rVu3Tp/5zGeUk5OjiooK/frXv05gtanD6e/8lHfeeUdZWVm65ZZb5rfAFOb03E9MTGjHjh1asWKFsrOztWrVKr3wwgsJqja1OD33+/fv180336xrr71Wfr9fDz/8sIaHhxNUbep4++23VVVVpWXLlsnj8egXv/jFFY+J23XWuMS//Mu/mAULFpif/vSnpre31zz66KPmuuuuM//3f/8Xtf+pU6fMtddeax599FHT29trfvrTn5oFCxaYV199NcGVu5/Tc//oo4+ap59+2nR1dZn33nvP1NXVmQULFphjx44luHJ3c3rep/zhD38wK1euNJWVlebmm29OTLEpJpZzf88995jbbrvNdHR0mL6+PvOf//mf5p133klg1anB6bnv7Ow0GRkZ5kc/+pE5deqU6ezsNJ///OfNvffem+DK3a+trc3s2LHDvPbaa0aSeeONNy7bP57XWdeEkfLyclNTUxPR9rnPfc488cQTUfv//d//vfnc5z4X0fbII4+YL37xi/NWY6pyeu6jKSoqMg0NDfEuLaXFet6rq6vNP/7jP5r6+nrCSIycnvt/+7d/Mz6fzwwPDyeivJTm9Nz/8z//s1m5cmVE2zPPPGOWL18+bzWmg7mEkXheZ10xTXP+/Hl1d3ersrIyor2yslKHDx+OesyRI0dm9L/zzjt19OhRffzxx/NWa6qJ5dx/2uTkpMbGxpSbmzsfJaakWM/7iy++qJMnT6q+vn6+S0xZsZz7X/3qVyorK9P3v/99XX/99brpppv0ne98R3/84x8TUXLKiOXcr169WmfOnFFbW5uMMXr//ff16quv6u67705EyWktntdZVzwob2hoSOFwWHl5eRHteXl5GhgYiHrMwMBA1P4XLlzQ0NCQ/H7/vNWbSmI595/2wx/+UB9++KHuu++++SgxJcVy3n//+9/riSeeUGdnp7KyXPFPOynFcu5PnTqlQ4cOyev16o033tDQ0JC++c1vamRkhHUjDsRy7levXq39+/erurpa4+PjunDhgu655x79+Mc/TkTJaS2e11lXjIxM8Xg8EV8bY2a0Xal/tHZcmdNzP+XAgQP63ve+p5aWFi1dunS+yktZcz3v4XBY999/vxoaGnTTTTclqryU5uR3fnJyUh6PR/v371d5ebnuuusu7dq1Sy+99BKjIzFwcu57e3u1bds2Pfnkk+ru7lZ7e7v6+vpUU1OTiFLTXryus674+LRkyRJlZmbOSMaDg4MzUtmU/Pz8qP2zsrK0ePHieas11cRy7qe0tLRo8+bN+vnPf6477rhjPstMOU7P+9jYmI4ePaqenh59+9vflnTxAmmMUVZWlt58803dfvvtCand7WL5nff7/br++usjHpVeWFgoY4zOnDmjG2+8cV5rThWxnPvGxkatWbNGjz/+uCTpC1/4gq677jqtXbtWTz31FKPg8yie11lXjIwsXLhQpaWl6ujoiGjv6OjQ6tWrox5TUVExo/+bb76psrIyLViwYN5qTTWxnHvp4ojIQw89pFdeeYW52xg4Pe85OTn67W9/q+PHj0+/ampq9Od//uc6fvy4brvttkSV7nqx/M6vWbNG586d0wcffDDd9t577ykjI0PLly+f13pTSSzn/qOPPlJGRuSlLDMzU9Inn9IxP+J6nXW85NWSqdu99u7da3p7e8327dvNddddZ/73f//XGGPME088YR544IHp/lO3HD322GOmt7fX7N27l1t7Y+T03L/yyismKyvLPPfccyYYDE6//vCHP9h6C67k9Lx/GnfTxM7puR8bGzPLly83f/VXf2V+97vfmYMHD5obb7zRbNmyxdZbcC2n5/7FF180WVlZpqmpyZw8edIcOnTIlJWVmfLycltvwbXGxsZMT0+P6enpMZLMrl27TE9Pz/Rt1fN5nXVNGDHGmOeee86sWLHCLFy40JSUlJiDBw9Of+/BBx80X/7ylyP6/+Y3vzG33nqrWbhwofnsZz9rmpubE1xx6nBy7r/85S8bSTNeDz74YOILdzmnv/OXIoxcHafn/t133zV33HGHueaaa8zy5ctNbW2t+eijjxJcdWpweu6feeYZU1RUZK655hrj9/vN3/zN35gzZ84kuGr3+/d///fL/t89n9dZjzGMYwEAAHtcsWYEAACkLsIIAACwijACAACsIowAAACrCCMAAMAqwggAALCKMAIAAKwijAAAAKsIIwAAwCrCCAAAsIowAgAArCKMAAAAq/4foA0IgyVTI5cAAAAASUVORK5CYII=", "text/plain": [ "
" ] @@ -756,7 +815,6 @@ } ], "source": [ - "x.inputs.length = 20\n", "y.inputs.length = 20" ] }, @@ -768,6 +826,194 @@ "Note that in the second cell, `f` is trying to update itself as soon as its inputs are ready, so if we _hadn't_ set the `f.inputs.y` channel to wait for an update, we would have gotten an error from the plotting command due to the mis-matched lengths of the x- and y-arrays." ] }, + { + "cell_type": "markdown", + "id": "5dc12164-b663-405b-872f-756996f628bd", + "metadata": {}, + "source": [ + "# Workflows\n", + "\n", + "The case where we have groups of connected nodes working together is our normal, intended use case.\n", + "We offer a formal way to group these objects together as a `Workflow(Node)` object.\n", + "`Workflow` also offers us a single point of entry to the codebase -- i.e. most of the time you shouldn't need the node imports used above, because the decorators are available right on the workflow class.\n", + "\n", + "We will also see here that we can our node output channels using the `output_labels: Optional[str | list[str] | tuple[str]` kwarg, in case they don't have a convenient name to start with.\n", + "This way we can always have convenient dot-based access (and tab completion) instead of having to access things by string-based keys." + ] + }, + { + "cell_type": "code", + "execution_count": 28, + "id": "1cd000bd-9b24-4c39-9cac-70a3291d0660", + "metadata": {}, + "outputs": [], + "source": [ + "from pyiron_contrib.workflow.workflow import Workflow\n", + "\n", + "@Workflow.wrap_as.single_value_node(output_labels=\"is_greater\")\n", + "def greater_than_half(x: int | float | bool = 0) -> bool:\n", + " \"\"\"The functionality doesn't matter here, it's just an example\"\"\"\n", + " return x > 0.5" + ] + }, + { + "cell_type": "markdown", + "id": "8f17751c-f5bf-4b13-8275-0685d8a1629e", + "metadata": {}, + "source": [ + "## Adding nodes to a workflow\n", + "\n", + "All five of the following approaches are equivalent ways to add a node to a workflow:" + ] + }, + { + "cell_type": "code", + "execution_count": 29, + "id": "7964df3c-55af-4c25-afc5-9e07accb606a", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "n1 n1 n1 (GreaterThanHalf) output single-value: False\n", + "n3 n3 n3 (GreaterThanHalf) output single-value: False\n", + "n4 n4 n4 (GreaterThanHalf) output single-value: False\n", + "n5 n5 n5 (GreaterThanHalf) output single-value: False\n" + ] + } + ], + "source": [ + "from pyiron_contrib.workflow.function import Slow\n", + "\n", + "n1 = greater_than_half(label=\"n1\")\n", + "\n", + "wf = Workflow(\"my_wf\", n1) # As args at init\n", + "wf.add.Slow(lambda: x + 1, output_labels=\"p1\", label=\"n2\") # Instantiating from the class with a lambda function\n", + "# (Slow since we don't have an x default)\n", + "wf.add(greater_than_half(label=\"n3\")) # Instantiating then passing to node adder\n", + "wf.n4 = greater_than_half(label=\"will_get_overwritten_with_n4\") # Set attribute to instance\n", + "greater_than_half(label=\"n5\", parent=wf) # By passing the workflow to the node\n", + "\n", + "for k, v in wf.nodes.items():\n", + " print(k, v.label, v)" + ] + }, + { + "cell_type": "markdown", + "id": "dd5768a4-1810-4675-9389-bceb053cddfa", + "metadata": {}, + "source": [ + "Workflows have inputs and outputs just like function nodes, but these are dynamically created to map to all _unconnected_ input and output for their underlying graph:" + ] + }, + { + "cell_type": "code", + "execution_count": 30, + "id": "809178a5-2e6b-471d-89ef-0797db47c5ad", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "2\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/Users/huber/work/pyiron/pyiron_contrib/pyiron_contrib/workflow/io.py:80: UserWarning: Assigning a channel with the label sum_ to the io key sum_sum_\n", + " warn(\n" + ] + } + ], + "source": [ + "wf = Workflow(\"simple\")\n", + "\n", + "@Workflow.wrap_as.single_value_node()\n", + "def add_one(x):\n", + " y = x + 1\n", + " return y\n", + "\n", + "wf.a = add_one(0)\n", + "wf.b = add_one(0)\n", + "wf.sum = add_node(wf.a, wf.b) \n", + "# Remember, with single value nodes we can pass the whole node instead of an output channel!\n", + "\n", + "print(wf.outputs.sum_sum_.value)" + ] + }, + { + "cell_type": "markdown", + "id": "18ba07ca-f1f9-4f05-98db-d5612f9acbb6", + "metadata": {}, + "source": [ + "Unlike function nodes, workflow input has no intrinsic order. We can still update it by calling the workflow, but we _need_ to use keyword and not positional arguments. Runs of the workflow (which typically happen when the workflow is updated or called) return a dot-accessible dictionary based on the output channels:" + ] + }, + { + "cell_type": "code", + "execution_count": 31, + "id": "52c48d19-10a2-4c48-ae81-eceea4129a60", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/Users/huber/work/pyiron/pyiron_contrib/pyiron_contrib/workflow/io.py:80: UserWarning: Assigning a channel with the label x to the io key a_x\n", + " warn(\n", + "/Users/huber/work/pyiron/pyiron_contrib/pyiron_contrib/workflow/io.py:80: UserWarning: Assigning a channel with the label x to the io key b_x\n", + " warn(\n" + ] + }, + { + "data": { + "text/plain": [ + "{'sum_sum_': 7}" + ] + }, + "execution_count": 31, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "out = wf(a_x=2, b_x=3)\n", + "out" + ] + }, + { + "cell_type": "code", + "execution_count": 32, + "id": "bb35ba3e-602d-4c9c-b046-32da9401dd1c", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "7" + ] + }, + "execution_count": 32, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "out.sum_sum_" + ] + }, + { + "cell_type": "markdown", + "id": "0d6c7e6a-d39d-4c03-9f73-d506d7975fea", + "metadata": {}, + "source": [ + "(Note, you might see warnings from the workflow IO. This is fine, it's just letting us know that its keys don't match up with the channel labels. We don't see it until we call the input because workflows generate their IO panels dynamically on request to account for the fact that connections may change.)" + ] + }, { "cell_type": "markdown", "id": "2671dc36-42a4-466b-848d-067ef7bd1d1d", @@ -784,7 +1030,7 @@ }, { "cell_type": "code", - "execution_count": 26, + "execution_count": 33, "id": "ae500d5e-e55b-432c-8b5f-d5892193cdf5", "metadata": {}, "outputs": [ @@ -792,9 +1038,9 @@ "name": "stderr", "output_type": "stream", "text": [ - "/Users/huber/work/pyiron/pyiron_contrib/pyiron_contrib/workflow/composite.py:177: UserWarning: Reassigning the node bulk_structure to the label structure when adding it to the parent with_prebuilt.\n", + "/Users/huber/work/pyiron/pyiron_contrib/pyiron_contrib/workflow/composite.py:224: UserWarning: Reassigning the node bulk_structure to the label structure when adding it to the parent with_prebuilt.\n", " warn(\n", - "/Users/huber/work/pyiron/pyiron_contrib/pyiron_contrib/workflow/composite.py:177: UserWarning: Reassigning the node lammps to the label engine when adding it to the parent with_prebuilt.\n", + "/Users/huber/work/pyiron/pyiron_contrib/pyiron_contrib/workflow/composite.py:224: UserWarning: Reassigning the node lammps to the label engine when adding it to the parent with_prebuilt.\n", " warn(\n" ] }, @@ -802,22 +1048,22 @@ "name": "stdout", "output_type": "stream", "text": [ - "The job JUSTAJOBNAME was saved and received the ID: 9553\n" + "The job JUSTAJOBNAME was saved and received the ID: 9558\n" ] }, { "name": "stderr", "output_type": "stream", "text": [ - "/Users/huber/work/pyiron/pyiron_contrib/pyiron_contrib/workflow/composite.py:177: UserWarning: Reassigning the node calc_md to the label calc when adding it to the parent with_prebuilt.\n", + "/Users/huber/work/pyiron/pyiron_contrib/pyiron_contrib/workflow/composite.py:224: UserWarning: Reassigning the node calc_md to the label calc when adding it to the parent with_prebuilt.\n", " warn(\n", - "/Users/huber/work/pyiron/pyiron_contrib/pyiron_contrib/workflow/composite.py:177: UserWarning: Reassigning the node scatter to the label plot when adding it to the parent with_prebuilt.\n", + "/Users/huber/work/pyiron/pyiron_contrib/pyiron_contrib/workflow/composite.py:224: UserWarning: Reassigning the node scatter to the label plot when adding it to the parent with_prebuilt.\n", " warn(\n" ] }, { "data": { - "image/png": "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", + "image/png": "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d7vd8nq9re4jFApp9erVWrdunUaNGiVJKi4uVk5OjrZs2aKxY8dGewwAAKCH6dCbZEOhkCQpLS1y1tytW7cqMzNTl112mYYNG6annnpKmZmZkqTy8nKdOHFCY8aMceqzs7Pl9/u1Y8eOVgNKOBxWOBw+43vr6uo60nwAANCFTp23jTHnrG13QDHG6JFHHtFtt90mv9/vrB8/frzuuusu5ebmqqqqSr/4xS80YsQIlZeXy+12KxgMKjExUX369InYX1ZWloLBYKvfVVRUpGXLlp2xPicnp73NBwAA3aS+vl4ej+esNe0OKA8//LD+/Oc/a/v27RHrp02b5vzZ7/dr4MCBys3N1dtvv60pU6a0uT9jjFyu1ie5W7x4sR555BHn55aWFh09elTp6eltfqa96urqlJOTo+rqaqWmpnbqvvH/6OeuQT93Dfq569DXXeNC9bMxRvX19crOzj5nbbsCyrx58/TWW2/pgw8+0BVXXHHWWp/Pp9zcXB06dEiS5PV61dTUpNra2ohRlJqaGg0dOrTVfbjdbrnd7oh1l112WXuaft5SU1P5x98F6OeuQT93Dfq569DXXeNC9PO5Rk5OieopHmOMHn74Yb355pt6//33lZeXd87PHDlyRNXV1fL5fJKkgoICJSQkqKyszKkJBAKqrKxsM6AAAICLS1QjKHPnztX69ev1+9//XikpKc49Ix6PR7169VJDQ4OWLl2qO++8Uz6fT59++qmWLFmijIwM3XHHHU7t7NmztWDBAqWnpystLU0LFy5Ufn6+81QPAAC4uEUVUFauXClJGj58eMT6V199VbNmzVJcXJwqKiq0du1aHTt2TD6fT7fffrveeOMNpaSkOPUrVqxQfHy8pk6dqsbGRo0cOVJr1qxRXFxcx4+og9xut5544okzLimhc9HPXYN+7hr0c9ehr7uGDf3sMufzrA8AAEAXYi4eAABgHQIKAACwDgEFAABYh4ACAACsQ0D5O//+7/+uvLw8JSUlqaCgQH/84x+7u0kxpaioSDfffLNSUlKUmZmpH//4x/roo48iaowxWrp0qbKzs9WrVy8NHz5cBw4ciKgJh8OaN2+eMjIylJycrMmTJ+vw4cNdeSgxpaioSC6XS4WFhc46+rlzfPHFF5oxY4bS09PVu3dv3XjjjSovL3e2088d99133+lf//VflZeXp169eumqq67S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", "text/plain": [ "
" ] diff --git a/pyiron_contrib/workflow/composite.py b/pyiron_contrib/workflow/composite.py index bacc934e8..77fd539a2 100644 --- a/pyiron_contrib/workflow/composite.py +++ b/pyiron_contrib/workflow/composite.py @@ -57,6 +57,9 @@ class Composite(Node, ABC): By default, `run()` will be called on all owned nodes have output connections but no input connections (i.e. the upstream-most nodes), but this can be overridden to specify particular nodes to use instead. + The `run()` method (and `update()`, and calling the workflow, when these result in + a run), return a new dot-accessible dictionary of keys and values created from the + composite output IO panel. Does not specify `input` and `output` as demanded by the parent class; this requirement is still passed on to children. @@ -92,15 +95,29 @@ def __init__( label: str, *args, parent: Optional[Composite] = None, + run_on_updates: bool = True, strict_naming: bool = True, **kwargs, ): - super().__init__(*args, label=label, parent=parent, **kwargs) + super().__init__( + *args, label=label, parent=parent, run_on_updates=run_on_updates, **kwargs + ) self.strict_naming: bool = strict_naming self.nodes: DotDict[str:Node] = DotDict() self.add: NodeAdder = NodeAdder(self) self.starting_nodes: None | list[Node] = None + @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, @@ -115,12 +132,22 @@ def upstream_nodes(self) -> list[Node]: if node.outputs.connected and not node.inputs.connected ] + @property def on_run(self): + return self.run_graph + + @staticmethod + def run_graph(self): starting_nodes = ( self.upstream_nodes if self.starting_nodes is None else self.starting_nodes ) for node in starting_nodes: node.run() + return DotDict(self.outputs.to_value_dict()) + + @property + def run_args(self) -> dict: + return {"self": self} def add_node(self, node: Node, label: Optional[str] = None) -> None: """ diff --git a/pyiron_contrib/workflow/function.py b/pyiron_contrib/workflow/function.py index 72a426b5b..01bb04fe9 100644 --- a/pyiron_contrib/workflow/function.py +++ b/pyiron_contrib/workflow/function.py @@ -9,6 +9,7 @@ from pyiron_contrib.workflow.has_channel import HasChannel from pyiron_contrib.workflow.io import Inputs, Outputs, Signals from pyiron_contrib.workflow.node import Node +from pyiron_contrib.workflow.output_parser import ParseOutput if TYPE_CHECKING: from pyiron_contrib.workflow.composite import Composite @@ -18,8 +19,11 @@ class Function(Node): """ Function nodes wrap an arbitrary python function. - Node IO, including type hints, is generated automatically from the provided function - and (in the case of labeling output channels) the provided output labels. + Node IO, including type hints, is generated automatically from the provided + function. + Input data for the wrapped function can be provided as any valid combination of + `*arg` and `**kwarg` at both initialization and on calling the node. + On running, the function node executes this wrapped function with its current input and uses the results to populate the node output. @@ -29,16 +33,20 @@ class Function(Node): is currently no way to mix-and-match, i.e. to have multiple return values at least one of which is a tuple.) - The node label (unless otherwise provided), IO types, and input defaults for the - node are produced _automatically_ from introspection of the node function. - Additional properties like storage priority (present but doesn't do anything yet) - and ontological type (not yet present) can be set using kwarg dictionaries with - keys corresponding to the channel labels (i.e. the node arguments of the node - function, or the output labels provided). + The node label (unless otherwise provided), IO channel names, IO types, and input + defaults for the node are produced _automatically_ from introspection of the node + function. + Explicit output labels can be provided to modify the number of return values (from + $N$ to 1 in case you _want_ a tuple returned) and to dodge constraints on the + automatic scraping routine (namely, that there be _at most_ one `return` + expression). + (Additional properties like storage priority and ontological type are forthcoming + as kwarg dictionaries with keys corresponding to the channel labels (i.e. the node + arguments of the node function, or the output labels provided).) Actual function node instances can either be instances of the base node class, in - which case the callable node function and output labels *must* be provided, in - addition to other data, OR they can be instances of children of this class. + which case the callable node function *must* be provided OR they can be instances + of children of this class. Those children may define some or all of the node behaviour at the class level, and modify their signature accordingly so this is not available for alteration by the user, e.g. the node function and output labels may be hard-wired. @@ -47,6 +55,8 @@ class Function(Node): nodes should be both functional (always returning the same output given the same input) and idempotent (not modifying input data in-place, but creating copies where necessary and returning new objects as output). + 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. By default, function nodes will attempt to run whenever one or more inputs is updated, and will attempt to update on initialization (after setting _all_ initial @@ -54,11 +64,19 @@ class Function(Node): 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. + then `ran` signal fires: run, process and push result, ran. + + After a node is instantiated, its input can be updated as `*args` and/or `**kwargs` + on call. + This invokes an `update()` call, which can in turn invoke `run()` if + `run_on_updates` is set to `True`. + `run()` returns the output of the executed function, or a futures object if the + node is set to use an executor. + Calling the node or executing an `update()` returns the same thing as running, if + the node is run, or `None` if it is not set to run on updates or not ready to run. Args: node_function (callable): The function determining the behaviour of the node. - *output_labels (str): A name for each return value of the node function. label (str): The node's label. (Defaults to the node function's name.) run_on_updates (bool): Whether to run when you are updated and all your input is ready. (Default is True). @@ -70,6 +88,18 @@ class Function(Node): called. This can be used to create sets of input data _all_ of which must be updated before the node is ready to produce output again. (Default is None, which makes the list empty.) + output_labels (Optional[str | list[str] | tuple[str]]): A name for each return + value of the node function OR a single label. (Default is None, which + scrapes output labels automatically from the source code of the wrapped + function.) This can be useful when returned values are not well named, e.g. + to make the output channel dot-accessible if it would otherwise have a label + that requires item-string-based access. Additionally, specifying a _single_ + label for a wrapped function that returns a tuple of values ensures that a + _single_ output channel (holding the tuple) is created, instead of one + channel for each return value. The default approach of extracting labels + from the function source code also requires that the function body contain + _at most_ one `return` expression, so providing explicit labels can be used + to circumvent this (at your own risk). **kwargs: Any additional keyword arguments whose keyword matches the label of an input channel will have their value assigned to that channel. @@ -98,9 +128,9 @@ class Function(Node): >>> def mwe(x, y): ... return x+1, y-1 >>> - >>> plus_minus_1 = Function(mwe, "p1", "m1") + >>> plus_minus_1 = Function(mwe) >>> - >>> print(plus_minus_1.outputs.p1) + >>> print(plus_minus_1.outputs["x+1"]) There is no output because we haven't given our function any input, it has @@ -121,21 +151,33 @@ class Function(Node): Once we update `y`, all the input is ready and the automatic `update()` call will be allowed to proceed to a `run()` call, which succeeds and updates the - output: - >>> plus_minus_1.inputs.x = 3 + output. + The final thing we need to do is disable the `failed` status we got from our + last run call + >>> plus_minus_1.failed = False + >>> plus_minus_1.inputs.y = 3 >>> plus_minus_1.outputs.to_value_dict() - {'p1': 3, 'm1': 2} + {'x+1': 3, 'y-1': 2} - We can also, optionally, provide initial values for some or all of the input - >>> plus_minus_1 = Function(mwe, "p1", "m1", x=1) + We can also, optionally, provide initial values for some or all of the input and + labels for the output: + >>> plus_minus_1 = Function(mwe, output_labels=("p1", "m1"), x=1) >>> plus_minus_1.inputs.y = 2 # Automatically triggers an update call now >>> plus_minus_1.outputs.to_value_dict() {'p1': 2, 'm1': 1} + Input data can be provided to both initialization and on call as ordered args + or keyword kwargs. + When running, updating, or calling the node, the output of the wrapped function + (if it winds up getting run in the conditional cases of updating and calling) is + returned: + >>> plus_minus_1(2, y=3) + (3, 2) + Finally, we might stop these updates from happening automatically, even when all the input data is present and available: >>> plus_minus_1 = Function( - ... mwe, "p1", "m1", + ... mwe, output_labels=("p1", "m1"), ... x=0, y=0, ... run_on_updates=False, update_on_instantiation=False ... ) @@ -144,14 +186,13 @@ class Function(Node): With these flags set, the node requires us to manually call a run: >>> plus_minus_1.run() - >>> plus_minus_1.outputs.to_value_dict() - {'p1': 1, 'm1': -1} + (-1, 1) So function nodes have the most basic level of protection that they won't run if they haven't seen any input data. However, we could still get them to raise an error by providing the _wrong_ data: - >>> plus_minus_1 = Function(mwe, "p1", "m1", x=1, y="can't add to an int") + >>> plus_minus_1 = Function(mwe, x=1, y="can't add to an int") TypeError Here everything tries to run automatically, but we get an error from adding the @@ -167,15 +208,19 @@ class Function(Node): return hint. Our treatment of type hints is **not infinitely robust**, but covers a wide variety of common use cases. + Note that getting "good" (i.e. dot-accessible) output labels can be achieved by + using good variable names and returning those variables instead of using + `output_labels`: >>> from typing import Union >>> >>> def hinted_example( ... x: Union[int, float], ... y: int | float = 1 ... ) -> tuple[int, int | float]: - ... return x+1, y-1 + ... p1, m1 = x+1, y-1 + ... return p1, m1 >>> - >>> plus_minus_1 = Function(hinted_example, "p1", "m1", x="not an int") + >>> plus_minus_1 = Function(hinted_example, x="not an int") >>> plus_minus_1.outputs.to_value_dict() {'p1': , 'm1': } @@ -206,7 +251,7 @@ class Function(Node): and returns a node class: >>> from pyiron_contrib.workflow.function import function_node >>> - >>> @function_node("p1", "m1") + >>> @function_node(output_labels=("p1", "m1")) ... def my_mwe_node( ... x: int | float, y: int | float = 1 ... ) -> tuple[int | float, int | float]: @@ -236,7 +281,6 @@ class Function(Node): ... ): ... super().__init__( ... self.alphabet_mod_three, - ... "letter", ... label=label, ... run_on_updates=run_on_updates, ... update_on_instantiation=update_on_instantiation, @@ -245,7 +289,8 @@ class Function(Node): ... ... @staticmethod ... def alphabet_mod_three(i: int) -> Literal["a", "b", "c"]: - ... return ["a", "b", "c"][i % 3] + ... letter = ["a", "b", "c"][i % 3] + ... return letter Note that we've overridden the default value for `update_on_instantiation` above. @@ -262,12 +307,12 @@ class Function(Node): >>> class Adder(Function): ... @staticmethod ... def adder(x: int = 0, y: int = 0) -> int: - ... return x + y + ... sum = x + y + ... return sum ... ... __init__ = partialmethod( ... Function.__init__, ... adder, - ... "sum", ... ) Finally, let's put it all together by using both of these nodes at once. @@ -308,27 +353,27 @@ class Function(Node): def __init__( self, node_function: callable, - *output_labels: str, + *args, label: Optional[str] = None, run_on_updates: bool = True, update_on_instantiation: bool = True, channels_requiring_update_after_run: Optional[list[str]] = None, parent: Optional[Composite] = None, + output_labels: Optional[str | list[str] | tuple[str]] = None, **kwargs, ): super().__init__( label=label if label is not None else node_function.__name__, parent=parent, + run_on_updates=run_on_updates, # **kwargs, ) - if len(output_labels) == 0: - raise ValueError("Nodes must have at least one output label.") self.node_function = node_function self._inputs = None self._outputs = None - self._output_labels = output_labels + self._output_labels = self._get_output_labels(output_labels) # TODO: Parse output labels from the node function in case output_labels is None self.signals = self._build_signal_channels() @@ -340,18 +385,38 @@ def __init__( ) self._verify_that_channels_requiring_update_all_exist() - self.run_on_updates = False - # Temporarily disable running on updates to set all initial values at once - for k, v in kwargs.items(): - if k in self.inputs.labels: - self.inputs[k] = v - elif k not in self._init_keywords: - warnings.warn(f"The keyword '{k}' was received but not used.") - self.run_on_updates = run_on_updates # Restore provided value + self._batch_update_input(*args, **kwargs) if update_on_instantiation: self.update() + def _get_output_labels(self, output_labels: str | list[str] | tuple[str] | None): + """ + If output labels are provided, turn convert them to a list if passed as a + string and return them, else scrape them from the source channel. + + Note: When the user explicitly provides output channels, they are taking + responsibility that these are correct, e.g. in terms of quantity, order, etc. + """ + if output_labels is None: + return self._scrape_output_labels() + elif isinstance(output_labels, str): + return [output_labels] + else: + return output_labels + + def _scrape_output_labels(self): + """ + Inspect the source code to scrape out strings representing the returned values. + _Only_ works for functions with a single `return` expression in their body. + + Will return expressions and function calls just fine, thus best practice is to + create well-named variables and return those so that the output labels stay + dot-accessible. + """ + parsed_outputs = ParseOutput(self.node_function).output + return [] if parsed_outputs is None else parsed_outputs + @property def _input_args(self): return inspect.signature(self.node_function).parameters @@ -475,6 +540,12 @@ 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" + ) kwargs["self"] = self return kwargs @@ -491,14 +562,42 @@ def process_run_result(self, function_output): for channel_name in self.channels_requiring_update_after_run: self.inputs[channel_name].wait_for_update() - if len(self.outputs) == 1: + if len(self.outputs) == 0: + return + elif len(self.outputs) == 1: function_output = (function_output,) for out, value in zip(self.outputs, function_output): out.update(value) - def __call__(self) -> None: - self.run() + def _convert_input_args_and_kwargs_to_input_kwargs(self, *args, **kwargs): + reverse_keys = list(self._input_args.keys())[::-1] + if len(args) > len(reverse_keys): + raise ValueError( + f"Received {len(args)} positional arguments, but the node {self.label}" + f"only accepts {len(reverse_keys)} inputs." + ) + + positional_keywords = reverse_keys[-len(args) :] if len(args) > 0 else [] # -0: + if len(set(positional_keywords).intersection(kwargs.keys())) > 0: + raise ValueError( + f"Cannot use {set(positional_keywords).intersection(kwargs.keys())} " + f"as both positional _and_ keyword arguments; args {args}, kwargs {kwargs}, reverse_keys {reverse_keys}, positional_keyworkds {positional_keywords}" + ) + + for arg in args: + key = positional_keywords.pop() + kwargs[key] = arg + + return kwargs + + def _batch_update_input(self, *args, **kwargs): + kwargs = self._convert_input_args_and_kwargs_to_input_kwargs(*args, **kwargs) + return super()._batch_update_input(**kwargs) + + def __call__(self, *args, **kwargs) -> None: + kwargs = self._convert_input_args_and_kwargs_to_input_kwargs(*args, **kwargs) + return super().__call__(**kwargs) def to_dict(self): return { @@ -523,20 +622,22 @@ class Slow(Function): def __init__( self, node_function: callable, - *output_labels: str, + *args, label: Optional[str] = None, run_on_updates=False, update_on_instantiation=False, parent: Optional[Workflow] = None, + output_labels: Optional[str | list[str] | tuple[str]] = None, **kwargs, ): super().__init__( node_function, - *output_labels, + *args, label=label, run_on_updates=run_on_updates, update_on_instantiation=update_on_instantiation, parent=parent, + output_labels=output_labels, **kwargs, ) @@ -546,36 +647,41 @@ class SingleValue(Function, HasChannel): A node that _must_ return only a single value. Attribute and item access is modified to finally attempt access on the output value. + Note that this means any attributes/method available on the output value become + available directly at the node level (at least those which don't conflict with the + existing node namespace). """ def __init__( self, node_function: callable, - *output_labels: str, + *args, label: Optional[str] = None, run_on_updates=True, update_on_instantiation=True, parent: Optional[Workflow] = None, + output_labels: Optional[str | list[str] | tuple[str]] = None, **kwargs, ): - self.ensure_there_is_only_one_return_value(output_labels) super().__init__( node_function, - *output_labels, + *args, label=label, run_on_updates=run_on_updates, update_on_instantiation=update_on_instantiation, parent=parent, + output_labels=output_labels, **kwargs, ) - @classmethod - def ensure_there_is_only_one_return_value(cls, output_labels): + def _get_output_labels(self, output_labels: str | list[str] | tuple[str] | None): + output_labels = super()._get_output_labels(output_labels) if len(output_labels) > 1: raise ValueError( - f"{cls.__name__} must only have a single return value, but got " - f"multiple output labels: {output_labels}" + f"{self.__class__.__name__} must only have a single return value, but " + f"got multiple output labels: {output_labels}" ) + return output_labels @property def single_value(self): @@ -601,15 +707,16 @@ def __str__(self): ) -def function_node(*output_labels: str, **node_class_kwargs): +def function_node(**node_class_kwargs): """ A decorator for dynamically creating node classes from functions. Decorates a function. - Takes an output label for each returned value of the function. - Returns a `Function` subclass whose name is the camel-case version of the function node, - and whose signature is modified to exclude the node function and output labels + Returns a `Function` subclass whose name is the camel-case version of the function + node, and whose signature is modified to exclude the node function and output labels (which are explicitly defined in the process of using the decorator). + + Optionally takes any keyword arguments of `Function`. """ def as_node(node_function: callable): @@ -620,7 +727,6 @@ def as_node(node_function: callable): "__init__": partialmethod( Function.__init__, node_function, - *output_labels, **node_class_kwargs, ) }, @@ -629,13 +735,15 @@ def as_node(node_function: callable): return as_node -def slow_node(*output_labels: str, **node_class_kwargs): +def slow_node(**node_class_kwargs): """ A decorator for dynamically creating slow node classes from functions. Unlike normal nodes, slow nodes do update themselves on initialization and do not run themselves when they get updated -- i.e. they will not run when their input changes, `run()` must be explicitly called. + + Optionally takes any keyword arguments of `Slow`. """ def as_slow_node(node_function: callable): @@ -646,7 +754,6 @@ def as_slow_node(node_function: callable): "__init__": partialmethod( Slow.__init__, node_function, - *output_labels, **node_class_kwargs, ) }, @@ -655,15 +762,16 @@ def as_slow_node(node_function: callable): return as_slow_node -def single_value_node(*output_labels: str, **node_class_kwargs): +def single_value_node(**node_class_kwargs): """ A decorator for dynamically creating fast node classes from functions. Unlike normal nodes, fast nodes _must_ have default values set for all their inputs. + + Optionally takes any keyword arguments of `SingleValueNode`. """ def as_single_value_node(node_function: callable): - SingleValue.ensure_there_is_only_one_return_value(output_labels) return type( node_function.__name__.title().replace("_", ""), # fnc_name to CamelCase (SingleValue,), # Define parentage @@ -671,7 +779,6 @@ def as_single_value_node(node_function: callable): "__init__": partialmethod( SingleValue.__init__, node_function, - *output_labels, **node_class_kwargs, ) }, diff --git a/pyiron_contrib/workflow/node.py b/pyiron_contrib/workflow/node.py index 76e67733e..e5f2ec3d7 100644 --- a/pyiron_contrib/workflow/node.py +++ b/pyiron_contrib/workflow/node.py @@ -5,9 +5,10 @@ from __future__ import annotations +import warnings from abc import ABC, abstractmethod from concurrent.futures import Future -from typing import Optional, TYPE_CHECKING +from typing import Any, Optional, TYPE_CHECKING from pyiron_contrib.executors import CloudpickleProcessPoolExecutor from pyiron_contrib.workflow.files import DirectoryObject @@ -44,6 +45,16 @@ class Node(HasToDict, ABC): 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. + The `run()` method returns a representation of the node output (possible a futures + object, if the node is running on an executor), and consequently `update()` also + returns this output if the node is `ready` and has `run_on_updates = True`. + + Calling an already instantiated node allows its input channels to be updated using + keyword arguments corresponding to the channel labels, performing a batch-update of + all supplied input and then calling `update()`. + As such, calling the node _also_ returns a representation of the output (or `None` + if the node is not set to run on updates, or is otherwise unready to run). + Nodes have a status, which is currently represented by the `running` and `failed` boolean flags. Their value is controlled automatically in the defined `run` and `finish_run` @@ -153,7 +164,7 @@ def outputs(self) -> Outputs: @property @abstractmethod - def on_run(self) -> callable[..., tuple]: + def on_run(self) -> callable[..., Any | tuple]: """ What the node actually does! """ @@ -166,7 +177,7 @@ def run_args(self) -> dict: """ return {} - def process_run_result(self, run_output: tuple) -> None: + def process_run_result(self, run_output: Any | tuple) -> None: """ What to _do_ with the results of `on_run` once you have them. @@ -175,7 +186,7 @@ def process_run_result(self, run_output: tuple) -> None: """ pass - def run(self) -> None: + def run(self) -> Any | tuple | Future: """ Executes the functionality of the node defined in `on_run`. Handles the status of the node, and communicating with any remote @@ -194,10 +205,11 @@ def run(self) -> None: self.running = False self.failed = True raise e - self.finish_run(run_output) + return self.finish_run(run_output) elif isinstance(self.executor, CloudpickleProcessPoolExecutor): self.future = self.executor.submit(self.on_run, **self.run_args) self.future.add_done_callback(self.finish_run) + return self.future else: raise NotImplementedError( "We currently only support executing the node functionality right on " @@ -205,7 +217,7 @@ def run(self) -> None: "pyiron_contrib.workflow.util.CloudpickleProcessPoolExecutor." ) - def finish_run(self, run_output: tuple | Future): + def finish_run(self, run_output: tuple | Future) -> Any | tuple: """ Switch the node status, process the run result, then fire the ran signal. @@ -223,6 +235,7 @@ def finish_run(self, run_output: tuple | Future): try: self.process_run_result(run_output) self.signals.output.ran() + return run_output except Exception as e: self.failed = True raise e @@ -233,9 +246,9 @@ def _build_signal_channels(self) -> Signals: signals.output.ran = OutputSignal("ran", self) return signals - def update(self) -> None: + def update(self) -> Any | tuple | Future | None: if self.run_on_updates and self.ready: - self.run() + return self.run() @property def working_directory(self): @@ -275,3 +288,28 @@ def fully_connected(self): and self.outputs.fully_connected and self.signals.fully_connected ) + + def _batch_update_input(self, **kwargs): + """ + Temporarily disable running on updates to set all input values at once. + + Args: + **kwargs: input label - input value (including channels for connection) + pairs. + """ + run_on_updates, self.run_on_updates = self.run_on_updates, False + 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, e.g. " + f"`my_node_instance.run_on_updates = False`." + ) + self.run_on_updates = run_on_updates # Restore provided value + + def __call__(self, **kwargs) -> None: + self._batch_update_input(**kwargs) + return self.update() diff --git a/pyiron_contrib/workflow/node_library/atomistics.py b/pyiron_contrib/workflow/node_library/atomistics.py index 1da3bdac6..7ff060c01 100644 --- a/pyiron_contrib/workflow/node_library/atomistics.py +++ b/pyiron_contrib/workflow/node_library/atomistics.py @@ -10,12 +10,12 @@ from pyiron_contrib.workflow.function import single_value_node, slow_node -@single_value_node("structure") +@single_value_node(output_labels="structure") def bulk_structure(element: str = "Fe", cubic: bool = False, repeat: int = 1) -> Atoms: return _StructureFactory().bulk(element, cubic=cubic).repeat(repeat) -@single_value_node("job") +@single_value_node(output_labels="job") def lammps(structure: Optional[Atoms] = None) -> LammpsJob: pr = Project(".") job = pr.atomistics.job.Lammps("NOTAREALNAME") @@ -82,20 +82,22 @@ def _run_and_remove_job(job, modifier: Optional[callable] = None, **modifier_kwa @slow_node( - "cells", - "displacements", - "energy_pot", - "energy_tot", - "force_max", - "forces", - "indices", - "positions", - "pressures", - "steps", - "temperature", - "total_displacements", - "unwrapped_positions", - "volume", + output_labels=[ + "cells", + "displacements", + "energy_pot", + "energy_tot", + "force_max", + "forces", + "indices", + "positions", + "pressures", + "steps", + "temperature", + "total_displacements", + "unwrapped_positions", + "volume", + ] ) def calc_static( job: AtomisticGenericJob, @@ -104,20 +106,22 @@ def calc_static( @slow_node( - "cells", - "displacements", - "energy_pot", - "energy_tot", - "force_max", - "forces", - "indices", - "positions", - "pressures", - "steps", - "temperature", - "total_displacements", - "unwrapped_positions", - "volume", + output_labels=[ + "cells", + "displacements", + "energy_pot", + "energy_tot", + "force_max", + "forces", + "indices", + "positions", + "pressures", + "steps", + "temperature", + "total_displacements", + "unwrapped_positions", + "volume", + ] ) def calc_md( job: AtomisticGenericJob, diff --git a/pyiron_contrib/workflow/node_library/standard.py b/pyiron_contrib/workflow/node_library/standard.py index a920e2538..1a2d11e21 100644 --- a/pyiron_contrib/workflow/node_library/standard.py +++ b/pyiron_contrib/workflow/node_library/standard.py @@ -8,7 +8,7 @@ from pyiron_contrib.workflow.function import single_value_node -@single_value_node("fig") +@single_value_node(output_labels="fig") def scatter( x: Optional[list | np.ndarray] = None, y: Optional[list | np.ndarray] = None ): diff --git a/pyiron_contrib/workflow/output_parser.py b/pyiron_contrib/workflow/output_parser.py new file mode 100644 index 000000000..2f88e71e2 --- /dev/null +++ b/pyiron_contrib/workflow/output_parser.py @@ -0,0 +1,98 @@ +""" +Inspects code to automatically parse return values as strings +""" + +import ast +import inspect +import re +from textwrap import dedent + + +def _remove_spaces_until_character(string): + pattern = r"\s+(?=\s)" + modified_string = re.sub(pattern, "", string) + return modified_string + + +class ParseOutput: + """ + Given a function with at most one `return` expression, inspects the source code and + parses a list of strings containing the returned values. + If the function returns `None`, the parsed value is also `None`. + This parsed value is evaluated at instantiation and stored in the `output` + attribute. + In case more than one `return` expression is found, a `ValueError` is raised. + """ + + def __init__(self, function): + self._func = function + self._source = None + self._output = self.get_parsed_output() + + @property + def func(self): + return self._func + + @property + def dedented_source_string(self): + return dedent(inspect.getsource(self.func)) + + @property + def node_return(self): + tree = ast.parse(self.dedented_source_string) + returns = [] + for node in ast.walk(tree): + if isinstance(node, ast.Return): + returns.append(node) + + if len(returns) > 1: + raise ValueError( + f"{self.__class__.__name__} can only parse callables with at most one " + f"return value, but ast.walk found {len(returns)}." + ) + + try: + return returns[0] + except IndexError: + return None + + @property + def source(self): + if self._source is None: + self._source = self.dedented_source_string.split("\n")[:-1] + return self._source + + def get_string(self, node): + string = "" + for ll in range(node.lineno - 1, node.end_lineno): + if ll == node.lineno - 1 == node.end_lineno - 1: + string += _remove_spaces_until_character( + self.source[ll][node.col_offset : node.end_col_offset] + ) + elif ll == node.lineno - 1: + string += _remove_spaces_until_character( + self.source[ll][node.col_offset :] + ) + elif ll == node.end_lineno - 1: + string += _remove_spaces_until_character( + self.source[ll][: node.end_col_offset] + ) + else: + string += _remove_spaces_until_character(self.source[ll]) + return string + + @property + def output(self): + return self._output + + def get_parsed_output(self): + if self.node_return is None or self.node_return.value is None: + return + elif isinstance(self.node_return.value, ast.Tuple): + return [self.get_string(s) for s in self.node_return.value.dims] + else: + out = [self.get_string(self.node_return.value)] + if out == ["None"]: + return + else: + return out diff --git a/pyiron_contrib/workflow/workflow.py b/pyiron_contrib/workflow/workflow.py index 9ce81342e..2856090ff 100644 --- a/pyiron_contrib/workflow/workflow.py +++ b/pyiron_contrib/workflow/workflow.py @@ -34,16 +34,17 @@ class Workflow(Composite): >>> from pyiron_contrib.workflow.workflow import Workflow >>> from pyiron_contrib.workflow.function import Function >>> - >>> def fnc(x=0): return x + 1 + >>> def fnc(x=0): + ... return x + 1 >>> - >>> n1 = Function(fnc, "x", label="n1") + >>> n1 = Function(fnc, label="n1") >>> >>> wf = Workflow("my_workflow", n1) # As *args at instantiation - >>> wf.add(Function(fnc, "x", label="n2")) # Passing a node to the add caller - >>> wf.add.Function(fnc, "y", label="n3") # Instantiating from add - >>> wf.n4 = Function(fnc, "y", label="whatever_n4_gets_used") + >>> wf.add(Function(fnc, label="n2")) # Passing a node to the add caller + >>> wf.add.Function(fnc, label="n3") # Instantiating from add + >>> wf.n4 = Function(fnc, label="whatever_n4_gets_used") >>> # By attribute assignment - >>> Function(fnc, "x", label="n5", parent=wf) + >>> Function(fnc, label="n5", parent=wf) >>> # By instantiating the node with a workflow By default, the node naming scheme is strict, so if you try to add a node to a @@ -51,10 +52,10 @@ class Workflow(Composite): at instantiation with the `strict_naming` kwarg, or afterwards by assigning a bool to this property. When deactivated, repeated assignments to the same label just get appended with an index: - >>> wf.deactivate_strict_naming() - >>> wf.my_node = Function(fnc, "y", x=0) - >>> wf.my_node = Function(fnc, "y", x=1) - >>> wf.my_node = Function(fnc, "y", x=2) + >>> wf.strict_naming = False + >>> wf.my_node = Function(fnc, x=0) + >>> wf.my_node = Function(fnc, x=1) + >>> wf.my_node = Function(fnc, x=2) >>> print(wf.my_node.inputs.x, wf.my_node0.inputs.x, wf.my_node1.inputs.x) 0, 1, 2 @@ -63,7 +64,7 @@ class Workflow(Composite): workflow (cf. the `Node` docs for more detail on the node types). Let's use these to explore a workflow's input and output, which are dynamically generated from the unconnected IO of its nodes: - >>> @Workflow.wrap_as.function_node("y") + >>> @Workflow.wrap_as.function_node(output_labels="y") >>> def plus_one(x: int = 0): ... return x + 1 >>> @@ -84,6 +85,18 @@ class Workflow(Composite): >>> print(wf.outputs.second_y.value) 2 + These input keys can be used when calling the workflow to update the input. In + our example, the nodes update automatically when their input gets updated, so + all we need to do to see updated workflow output is update the input: + >>> out = wf(first_x=10) + >>> out + {'second_y': 12} + + Note: this _looks_ like a dictionary, but has some extra convenience that we + can dot-access data: + >>> out.second_y + 12 + Workflows also give access to packages of pre-built nodes under different namespaces, e.g. >>> wf = Workflow("with_prebuilt") @@ -117,8 +130,15 @@ class Workflow(Composite): integrity of workflows when they're used somewhere else? """ - def __init__(self, label: str, *nodes: Node, strict_naming=True): - super().__init__(label=label, parent=None, strict_naming=strict_naming) + def __init__( + self, label: str, *nodes: Node, run_on_updates: bool = True, strict_naming=True + ): + super().__init__( + label=label, + parent=None, + run_on_updates=run_on_updates, + strict_naming=strict_naming, + ) for node in nodes: self.add_node(node) diff --git a/setup.py b/setup.py index f28b0caa0..9813be8c5 100644 --- a/setup.py +++ b/setup.py @@ -44,6 +44,9 @@ 'pyiron_atomistics==0.3.0', 'pycp2k==0.2.2', ], + 'executors': [ + 'cloudpickle', + ], 'fenics': [ 'fenics==2019.1.0', 'mshr==2019.1.0', @@ -54,6 +57,7 @@ 'moto==4.1.12' ], 'workflow': [ + 'cloudpickle', 'python>=3.10', 'ipython', 'typeguard==4.0.0' diff --git a/tests/integration/test_workflow.py b/tests/integration/test_workflow.py index a8f2f4d58..9b018c1bb 100644 --- a/tests/integration/test_workflow.py +++ b/tests/integration/test_workflow.py @@ -16,7 +16,7 @@ def test_cyclic_graphs(self): TODO: Update once logical switches are included in the node library """ - @Workflow.wrap_as.single_value_node("rand") + @Workflow.wrap_as.single_value_node() def numpy_randint(low=0, high=20): rand = np.random.randint(low=low, high=high) print(f"Generating random number between {low} and {high}...{rand}!") @@ -29,7 +29,11 @@ class GreaterThanLimitSwitch(Function): """ def __init__(self, **kwargs): - super().__init__(self.greater_than, "value_gt_limit", **kwargs) + super().__init__( + self.greater_than, + output_labels="value_gt_limit", + **kwargs + ) self.signals.output.true = OutputSignal("true", self) self.signals.output.false = OutputSignal("false", self) @@ -50,7 +54,7 @@ def process_run_result(self, function_output): print(f"{self.inputs.value.value} <= {self.inputs.limit.value}") self.signals.output.false() - @Workflow.wrap_as.single_value_node("sqrt") + @Workflow.wrap_as.single_value_node() def numpy_sqrt(value=0): sqrt = np.sqrt(value) print(f"sqrt({value}) = {sqrt}") diff --git a/tests/unit/workflow/test_function.py b/tests/unit/workflow/test_function.py index 7af6c4243..4d40382ad 100644 --- a/tests/unit/workflow/test_function.py +++ b/tests/unit/workflow/test_function.py @@ -1,8 +1,10 @@ -import unittest +from concurrent.futures import Future from sys import version_info from typing import Optional, Union +import unittest import warnings +from pyiron_contrib.executors import CloudpickleProcessPoolExecutor from pyiron_contrib.workflow.channels import NotData from pyiron_contrib.workflow.files import DirectoryObject from pyiron_contrib.workflow.function import ( @@ -15,24 +17,62 @@ def throw_error(x: Optional[int] = None): def plus_one(x=1) -> Union[int, float]: - return x + 1 + y = x + 1 + return y def no_default(x, y): return x + y + 1 +def returns_multiple(x, y): + return x, y, x + y + + +def void(): + pass + + +def multiple_branches(x): + if x < 10: + return True + else: + return False + + @unittest.skipUnless(version_info[0] == 3 and version_info[1] >= 10, "Only supported for 3.10+") class TestFunction(unittest.TestCase): + def test_instantiation(self): + with self.subTest("Void function is allowable"): + void_node = Function(void) + self.assertEqual(len(void_node.outputs), 0) + + with self.subTest("Args and kwargs at initialization"): + node = Function(returns_multiple, 1, y=2) + self.assertEqual( + node.inputs.x.value, + 1, + msg="Should be able to set function input as args" + ) + self.assertEqual( + node.inputs.y.value, + 2, + msg="Should be able to set function input as kwargs" + ) + + with self.assertRaises(ValueError): + # Can't pass more args than the function takes + Function(returns_multiple, 1, 2, 3) + def test_defaults(self): - with_defaults = Function(plus_one, "y") + with_defaults = Function(plus_one) self.assertEqual( with_defaults.inputs.x.value, 1, msg=f"Expected to get the default provided in the underlying function but " f"got {with_defaults.inputs.x.value}", ) - without_defaults = Function(no_default, "sum_plus_one") + without_defaults = Function(no_default) self.assertIs( without_defaults.inputs.x.value, NotData, @@ -45,17 +85,32 @@ def test_defaults(self): "defaults, the node should not be ready!" ) - def test_failure_without_output_labels(self): - with self.assertRaises( - ValueError, - msg="Instantiated nodes should demand at least one output label" - ): - Function(plus_one) + def test_label_choices(self): + with self.subTest("Automatically scrape output labels"): + n = Function(plus_one) + self.assertListEqual(n.outputs.labels, ["y"]) + + with self.subTest("Allow overriding them"): + n = Function(no_default, output_labels=("sum_plus_one",)) + self.assertListEqual(n.outputs.labels, ["sum_plus_one"]) + + with self.subTest("Allow forcing _one_ output channel"): + n = Function(returns_multiple, output_labels="its_a_tuple") + self.assertListEqual(n.outputs.labels, ["its_a_tuple"]) + + with self.subTest("Fail on multiple return values"): + with self.assertRaises(ValueError): + # Can't automatically parse output labels from a function with multiple + # return expressions + Function(multiple_branches) + + with self.subTest("Override output label scraping"): + switch = Function(multiple_branches, output_labels="bool") + self.assertListEqual(switch.outputs.labels, ["bool"]) def test_instantiation_update(self): no_update = Function( plus_one, - "y", run_on_updates=True, update_on_instantiation=False ) @@ -68,13 +123,12 @@ def test_instantiation_update(self): update = Function( plus_one, - "y", run_on_updates=True, update_on_instantiation=True ) self.assertEqual(2, update.outputs.y.value) - default = Function(plus_one, "y") + default = Function(plus_one) self.assertEqual( 2, default.outputs.y.value, @@ -83,29 +137,29 @@ def test_instantiation_update(self): ) with self.assertRaises(TypeError): - run_without_value = Function(no_default, "z") + run_without_value = Function(no_default) run_without_value.run() # None + None + 1 -> error with self.assertRaises(TypeError): - run_without_value = Function(no_default, "z", x=1) + run_without_value = Function(no_default, x=1) run_without_value.run() # 1 + None + 1 -> error - deferred_update = Function(no_default, "z", x=1, y=1) + deferred_update = Function(no_default, x=1, y=1) deferred_update.run() self.assertEqual( - deferred_update.outputs.z.value, + deferred_update.outputs["x + y + 1"].value, 3, msg="By default, all initial values should be parsed before triggering " "an update" ) def test_input_kwargs(self): - node = Function(plus_one, "y", x=2) + node = Function(plus_one, x=2) self.assertEqual(3, node.outputs.y.value, msg="Initialize from value") - node2 = Function(plus_one, "y", x=node.outputs.y) + node2 = Function(plus_one, x=node.outputs.y) node.update() self.assertEqual(4, node2.outputs.y.value, msg="Initialize from connection") @@ -120,35 +174,38 @@ def test_automatic_updates(self): node.inputs.x.update(1) def test_signals(self): - @function_node("y") + @function_node() def linear(x): return x - @function_node("z") + @function_node() def times_two(y): return 2 * y l = linear(x=1) t2 = times_two( - update_on_instantiation=False, run_automatically=False, y=l.outputs.y + update_on_instantiation=False, + run_automatically=False, + output_labels=["double"], + y=l.outputs.x ) self.assertIs( - t2.outputs.z.value, + t2.outputs.double.value, NotData, msg=f"Without updates, expected the output to be {NotData} but got " - f"{t2.outputs.z.value}" + f"{t2.outputs.double.value}" ) # Nodes should _all_ have the run and ran signals t2.signals.input.run = l.signals.output.ran l.run() self.assertEqual( - t2.outputs.z.value, 2, + t2.outputs.double.value, 2, msg="Running the upstream node should trigger a run here" ) def test_statuses(self): - n = Function(plus_one, "p1", run_on_updates=False) + n = Function(plus_one, run_on_updates=False) self.assertTrue(n.ready) self.assertFalse(n.running) self.assertFalse(n.failed) @@ -200,7 +257,7 @@ def with_self(self, x: float) -> float: self.some_counter = 1 return x + 0.1 - node = Function(with_self, "output") + node = Function(with_self, output_labels="output") self.assertTrue( "x" in node.inputs.labels, msg=f"Expected to find function input 'x' in the node input but got " @@ -224,20 +281,142 @@ def with_self(self, x: float) -> float: msg="Function functions should be able to modify attributes on the node object." ) + node.executor = CloudpickleProcessPoolExecutor + 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.run() + def with_messed_self(x: float, self) -> float: return x + 0.1 with warnings.catch_warnings(record=True) as warning_list: - node = Function(with_messed_self, "output") + node = Function(with_messed_self) self.assertTrue("self" in node.inputs.labels) self.assertEqual(len(warning_list), 1) + def test_call(self): + node = Function(no_default, output_labels="output", run_on_updates=False) + + with self.subTest("Ensure desired failures occur"): + with self.assertRaises(ValueError): + # More input args than there are input channels + node(1, 2, 3) + + with self.assertRaises(ValueError): + # Using input as an arg _and_ a kwarg + node(1, y=2, x=3) + + with self.subTest("Make sure data updates work as planned"): + node(1, y=2) + self.assertEqual( + node.inputs.x.value, 1, msg="__call__ should accept args to update input" + ) + self.assertEqual( + node.inputs.y.value, 2, msg="__call__ should accept kwargs to update input" + ) + self.assertEqual( + node.outputs.output.value, NotData, msg="__call__ should not run things" + ) + node.run_on_updates = True + node(3) # Implicitly test partial update + self.assertEqual( + no_default(3, 2), + node.outputs.output.value, + msg="__call__ should invoke update s.t. run gets called if run_on_updates" + ) + + with self.subTest("Check that node kwargs can also be updated"): + with self.assertWarns(Warning): + node(4, run_on_updates=False, y=5) + + self.assertTupleEqual( + (node.inputs.x.value, node.inputs.y.value), + (4, 5), + msg="The warning should not prevent other data from being parsed" + ) + + with self.assertWarns(Warning): + # It's also fine if you just have a typo in your kwarg or whatever, + # there should just be a warning that the data didn't get updated + node(some_randome_kwaaaaarg="foo") + + def test_return_value(self): + node = Function(plus_one) + + with self.subTest("Run on main process"): + return_on_call = node(1) + self.assertEqual( + return_on_call, + plus_one(1), + msg="Run output should be returned on call" + ) + + return_on_update = node.update() + self.assertEqual( + return_on_update, + plus_one(1), + msg="Run output should be returned on update" + ) + + node.run_on_updates = False + return_on_update_without_run = node.update() + self.assertIsNone( + return_on_update_without_run, + msg="When not running on updates, the update should not return anything" + ) + return_on_call_without_run = node(2) + self.assertIsNone( + return_on_call_without_run, + msg="When not running on updates, the call should not return anything" + ) + return_on_explicit_run = node.run() + self.assertEqual( + return_on_explicit_run, + plus_one(2), + msg="On explicit run, the most recent input data should be used and the " + "result should be returned" + ) + + with self.subTest("Run on executor"): + node.executor = CloudpickleProcessPoolExecutor() + node.run_on_updates = False + + return_on_update_without_run = node.update() + self.assertIsNone( + return_on_update_without_run, + msg="When not running on updates, the update should not return " + "anything whether there is an executor or not" + ) + return_on_explicit_run = node.run() + self.assertIsInstance( + return_on_explicit_run, + Future, + msg="Running with an executor should return the future" + ) + with self.assertRaises(RuntimeError): + # The executor run should take a second + # So we can double check that attempting to run while already running + # raises an error + node.run() + node.future.result() # Wait for the remote execution to finish + + node.run_on_updates = True + return_on_update_with_run = node.update() + self.assertIsInstance( + return_on_update_with_run, + Future, + msg="Updating should return the same as run when we get a run from the " + "update, obviously..." + ) + node.future.result() # Wait for the remote execution to finish @unittest.skipUnless(version_info[0] == 3 and version_info[1] >= 10, "Only supported for 3.10+") class TestSlow(unittest.TestCase): def test_instantiation(self): - slow = Slow(plus_one, "y") + slow = Slow(plus_one) self.assertIs( slow.outputs.y.value, NotData, @@ -257,14 +436,35 @@ def test_instantiation(self): f"{slow.outputs.y.value}" ) + node = Slow(no_default, 1, y=2, output_labels="output") + node.run() + self.assertEqual( + no_default(1, 2), + node.outputs.output.value, + msg="Slow nodes should allow input initialization by arg and kwarg" + ) + node(2, y=3) + node.run() + self.assertEqual( + no_default(2, 3), + node.outputs.output.value, + msg="Slow nodes should allow input update on call by arg and kwarg" + ) + @unittest.skipUnless(version_info[0] == 3 and version_info[1] >= 10, "Only supported for 3.10+") class TestSingleValue(unittest.TestCase): def test_instantiation(self): - has_defaults_and_one_return = SingleValue(plus_one, "y") + node = SingleValue(no_default, 1, y=2, output_labels="output") + self.assertEqual( + no_default(1, 2), + node.outputs.output.value, + msg="Single value node should allow function input by arg and kwarg" + ) with self.assertRaises(ValueError): - too_many_labels = SingleValue(plus_one, "z", "excess_label") + # Too many labels + SingleValue(plus_one, output_labels=["z", "excess_label"]) def test_item_and_attribute_access(self): class Foo: @@ -280,7 +480,7 @@ def __getitem__(self, item): def returns_foo() -> Foo: return Foo() - svn = SingleValue(returns_foo, "foo") + svn = SingleValue(returns_foo, output_labels="foo") self.assertEqual( svn.some_attribute, @@ -310,14 +510,24 @@ def returns_foo() -> Foo: ) def test_repr(self): - svn = SingleValue(plus_one, "y") - self.assertEqual( - svn.__repr__(), svn.outputs.y.value.__repr__(), - msg="SingleValueNodes should have their output as their representation" - ) + with self.subTest("Filled data"): + svn = SingleValue(plus_one) + self.assertEqual( + svn.__repr__(), svn.outputs.y.value.__repr__(), + msg="SingleValueNodes should have their output as their representation" + ) + + with self.subTest("Not data"): + svn = SingleValue(no_default, output_labels="output") + self.assertIs(svn.outputs.output.value, NotData) + self.assertTrue( + svn.__repr__().endswith(NotData.__name__), + msg="When the output is still not data, the representation should " + "indicate this" + ) def test_str(self): - svn = SingleValue(plus_one, "y") + svn = SingleValue(plus_one) self.assertTrue( str(svn).endswith(str(svn.single_value)), msg="SingleValueNodes should have their output as a string in their string " @@ -325,18 +535,9 @@ def test_str(self): "actually still a Function and not just the value you're seeing.)" ) - def test_repr(self): - svn = SingleValue(no_default, "output") - self.assertIs(svn.outputs.output.value, NotData) - self.assertTrue( - svn.__repr__().endswith(NotData.__name__), - msg="When the output is still not data, the representation should indicate " - "this" - ) - def test_easy_output_connection(self): - svn = SingleValue(plus_one, "y") - regular = Function(plus_one, "y") + svn = SingleValue(plus_one) + regular = Function(plus_one) regular.inputs.x = svn @@ -353,7 +554,7 @@ def test_easy_output_connection(self): "case default->plus_one->plus_one = 1 + 1 +1 = 3" ) - at_instantiation = Function(plus_one, "y", x=svn) + at_instantiation = Function(plus_one, x=svn) self.assertIn( svn.outputs.y, at_instantiation.inputs.x.connections, msg="The parsing of SingleValue output as a connection should also work" @@ -361,7 +562,7 @@ def test_easy_output_connection(self): ) def test_channels_requiring_update_after_run(self): - @single_value_node("sum") + @single_value_node(output_labels="sum") def my_node(x: int = 0, y: int = 0, z: int = 0): return x + y + z @@ -413,7 +614,7 @@ def my_node(x: int = 0, y: int = 0, z: int = 0): ) def test_working_directory(self): - n_f = Function(plus_one, "output") + n_f = Function(plus_one) self.assertTrue(n_f._working_directory is None) self.assertIsInstance(n_f.working_directory, DirectoryObject) self.assertTrue(str(n_f.working_directory.path).endswith(n_f.label)) diff --git a/tests/unit/workflow/test_node_package.py b/tests/unit/workflow/test_node_package.py index c8492437c..4e89db0b4 100644 --- a/tests/unit/workflow/test_node_package.py +++ b/tests/unit/workflow/test_node_package.py @@ -5,7 +5,7 @@ from pyiron_contrib.workflow.workflow import Workflow -@Workflow.wrap_as.function_node("x") +@Workflow.wrap_as.function_node() def dummy(x: int = 0): return x @@ -41,7 +41,7 @@ def test_update(self): with self.assertRaises(TypeError): self.package.available_name = "But we can still only assign node classes" - @Workflow.wrap_as.function_node("y") + @Workflow.wrap_as.function_node(output_label="y") def add(x: int = 0): return x + 1 @@ -53,9 +53,10 @@ def add(x: int = 0): old_dummy_instance = self.package.Dummy(label="old_dummy_instance") - @Workflow.wrap_as.function_node("y") + @Workflow.wrap_as.function_node() def dummy(x: int = 0): - return x + 1 + y = x + 1 + return y self.package.update(dummy) diff --git a/tests/unit/workflow/test_output_parser.py b/tests/unit/workflow/test_output_parser.py new file mode 100644 index 000000000..84b63b3de --- /dev/null +++ b/tests/unit/workflow/test_output_parser.py @@ -0,0 +1,90 @@ +from sys import version_info +import unittest + +import numpy as np + +from pyiron_contrib.workflow.output_parser import ParseOutput + + +@unittest.skipUnless( + version_info[0] == 3 and version_info[1] >= 10, "Only supported for 3.10+" +) +class TestParseOutput(unittest.TestCase): + def test_parsing(self): + with self.subTest("Single return"): + def identity(x): + return x + self.assertListEqual(ParseOutput(identity).output, ["x"]) + + with self.subTest("Expression return"): + def add(x, y): + return x + y + self.assertListEqual(ParseOutput(add).output, ["x + y"]) + + with self.subTest("Weird whitespace"): + def add_with_whitespace(x, y): + return x + y + self.assertListEqual(ParseOutput(add_with_whitespace).output, ["x + y"]) + + with self.subTest("Multiple expressions"): + def add_and_subtract(x, y): + return x + y, x - y + self.assertListEqual( + ParseOutput(add_and_subtract).output, + ["x + y", "x - y"] + ) + + with self.subTest("Best-practice (well-named return vars)"): + def md(job): + temperature = job.output.temperature + energy = job.output.energy + return temperature, energy + self.assertListEqual(ParseOutput(md).output, ["temperature", "energy"]) + + with self.subTest("Function call returns"): + def function_return(i, j): + return ( + np.arange( + i, dtype=int + ), + np.shape(i, j) + ) + self.assertListEqual( + ParseOutput(function_return).output, + ["np.arange( i, dtype=int )", "np.shape(i, j)"] + ) + + with self.subTest("Methods too"): + class Foo: + def add(self, x, y): + return x + y + self.assertListEqual(ParseOutput(Foo.add).output, ["x + y"]) + + def test_void(self): + with self.subTest("No return"): + def no_return(): + pass + self.assertIsNone(ParseOutput(no_return).output) + + with self.subTest("Empty return"): + def empty_return(): + return + self.assertIsNone(ParseOutput(empty_return).output) + + with self.subTest("Return None explicitly"): + def none_return(): + return None + self.assertIsNone(ParseOutput(none_return).output) + + def test_multiple_branches(self): + def bifurcating(x): + if x > 5: + return True + else: + return False + with self.assertRaises(ValueError): + ParseOutput(bifurcating) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/unit/workflow/test_workflow.py b/tests/unit/workflow/test_workflow.py index db35843ed..7a8efac73 100644 --- a/tests/unit/workflow/test_workflow.py +++ b/tests/unit/workflow/test_workflow.py @@ -5,11 +5,13 @@ from pyiron_contrib.workflow.channels import NotData from pyiron_contrib.workflow.files import DirectoryObject from pyiron_contrib.workflow.function import Function +from pyiron_contrib.workflow.util import DotDict from pyiron_contrib.workflow.workflow import Workflow -def fnc(x=0): - return x + 1 +def plus_one(x=0): + y = x + 1 + return y @unittest.skipUnless(version_info[0] == 3 and version_info[1] >= 10, "Only supported for 3.10+") @@ -19,10 +21,10 @@ def test_node_addition(self): wf = Workflow("my_workflow") # Validate the four ways to add a node - wf.add(Function(fnc, "x", label="foo")) - wf.add.Function(fnc, "y", label="bar") - wf.baz = Function(fnc, "y", label="whatever_baz_gets_used") - Function(fnc, "x", label="qux", parent=wf) + wf.add(Function(plus_one, label="foo")) + wf.add.Function(plus_one, label="bar") + wf.baz = Function(plus_one, label="whatever_baz_gets_used") + Function(plus_one, label="qux", parent=wf) self.assertListEqual(list(wf.nodes.keys()), ["foo", "bar", "baz", "qux"]) wf.boa = wf.qux self.assertListEqual( @@ -33,14 +35,13 @@ def test_node_addition(self): wf.strict_naming = False # Validate name incrementation - wf.add(Function(fnc, "x", label="foo")) - wf.add.Function(fnc, "y", label="bar") + wf.add(Function(plus_one, label="foo")) + wf.add.Function(plus_one, label="bar") wf.baz = Function( - fnc, - "y", + plus_one, label="without_strict_you_can_override_by_assignment" ) - Function(fnc, "x", label="boa", parent=wf) + Function(plus_one, label="boa", parent=wf) self.assertListEqual( list(wf.nodes.keys()), [ @@ -52,16 +53,16 @@ def test_node_addition(self): wf.strict_naming = True # Validate name preservation with self.assertRaises(AttributeError): - wf.add(Function(fnc, "x", label="foo")) + wf.add(Function(plus_one, label="foo")) with self.assertRaises(AttributeError): - wf.add.Function(fnc, "y", label="bar") + wf.add.Function(plus_one, label="bar") with self.assertRaises(AttributeError): - wf.baz = Function(fnc, "y", label="whatever_baz_gets_used") + wf.baz = Function(plus_one, label="whatever_baz_gets_used") with self.assertRaises(AttributeError): - Function(fnc, "x", label="boa", parent=wf) + Function(plus_one, label="boa", parent=wf) def test_node_packages(self): wf = Workflow("my_workflow") @@ -80,8 +81,8 @@ def test_node_packages(self): def test_double_workfloage_and_node_removal(self): wf1 = Workflow("one") - wf1.add.Function(fnc, "y", label="node1") - node2 = Function(fnc, "y", label="node2", parent=wf1, x=wf1.node1.outputs.y) + wf1.add.Function(plus_one, label="node1") + node2 = Function(plus_one, label="node2", parent=wf1, x=wf1.node1.outputs.y) self.assertTrue(node2.connected) wf2 = Workflow("two") @@ -95,9 +96,9 @@ def test_double_workfloage_and_node_removal(self): def test_workflow_io(self): wf = Workflow("wf") - wf.add.Function(fnc, "y", label="n1") - wf.add.Function(fnc, "y", label="n2") - wf.add.Function(fnc, "y", label="n3") + wf.add.Function(plus_one, label="n1") + wf.add.Function(plus_one, label="n2") + wf.add.Function(plus_one, label="n3") with self.subTest("Workflow IO should be drawn from its nodes"): self.assertEqual(len(wf.inputs), 3) @@ -111,7 +112,7 @@ def test_workflow_io(self): self.assertEqual(len(wf.outputs), 1) def test_node_decorator_access(self): - @Workflow.wrap_as.function_node("y") + @Workflow.wrap_as.function_node(output_labels="y") def plus_one(x: int = 0) -> int: return x + 1 @@ -122,8 +123,10 @@ def test_working_directory(self): self.assertTrue(wf._working_directory is None) self.assertIsInstance(wf.working_directory, DirectoryObject) self.assertTrue(str(wf.working_directory.path).endswith(wf.label)) - wf.add.Function(fnc, "output") - self.assertTrue(str(wf.fnc.working_directory.path).endswith(wf.fnc.label)) + wf.add.Function(plus_one) + self.assertTrue( + str(wf.plus_one.working_directory.path).endswith(wf.plus_one.label) + ) wf.working_directory.delete() def test_no_parents(self): @@ -143,15 +146,24 @@ def test_no_parents(self): # In both cases, we satisfy the spec that workflow's can't have parents wf2.parent = wf + def test_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" + def test_parallel_execution(self): wf = Workflow("wf") - @Workflow.wrap_as.single_value_node("five", run_on_updates=False) + @Workflow.wrap_as.single_value_node(run_on_updates=False) def five(sleep_time=0.): sleep(sleep_time) - return 5 + five = 5 + return five - @Workflow.wrap_as.single_value_node("sum") + @Workflow.wrap_as.single_value_node(output_labels="sum") def sum(a, b): return a + b @@ -188,6 +200,80 @@ def sum(a, b): "callback, and downstream nodes should proceed" ) + def test_call(self): + wf = Workflow("wf") + + wf.a = wf.add.SingleValue(plus_one) + wf.b = wf.add.SingleValue(plus_one) + + @Workflow.wrap_as.single_value_node(output_labels="sum") + def sum_(a, b): + return a + b + + wf.sum = sum_(wf.a, wf.b) + self.assertEqual( + wf.a.outputs.y.value + wf.b.outputs.y.value, + wf.sum.outputs.sum.value, + msg="Sanity check" + ) + wf(a_x=42, b_x=42) + self.assertEqual( + plus_one(42) + plus_one(42), + wf.sum.outputs.sum.value, + msg="Workflow should accept input channel kwargs and update inputs " + "accordingly" + # Since the nodes run automatically, there is no need for wf.run() here + ) + + with self.assertRaises(TypeError): + # IO is not ordered, so args make no sense for a workflow call + # We _must_ use kwargs + wf(42, 42) + + def test_return_value(self): + wf = Workflow("wf") + wf.run_on_updates = True + wf.a = wf.add.SingleValue(plus_one) + wf.b = wf.add.SingleValue(plus_one, x=wf.a) + + with self.subTest("Run on main process"): + return_on_call = wf(a_x=1) + self.assertEqual( + return_on_call, + DotDict({"b_y": 1 + 2}), + msg="Run output should be returned on call. Expecting a DotDict of " + "output values" + ) + + return_on_update = wf.update() + self.assertEqual( + return_on_update.b_y, + 1 + 2, + msg="Run output should be returned on update" + ) + + wf.run_on_updates = False + return_on_update_without_run = wf.update() + self.assertIsNone( + return_on_update_without_run, + msg="When not running on updates, the update should not return anything" + ) + return_on_call_without_run = wf(a_x=2) + self.assertIsNone( + return_on_call_without_run, + msg="When not running on updates, the call should not return anything" + ) + return_on_explicit_run = wf.run() + self.assertEqual( + return_on_explicit_run["b_y"], + 2 + 2, + msg="On explicit run, the most recent input data should be used and the " + "result should be returned" + ) + + # Note: We don't need to test running on an executor, because Workflows can't + # do that yet + if __name__ == '__main__': unittest.main()