diff --git a/notebooks/workflow_example.ipynb b/notebooks/workflow_example.ipynb index 1ef82d6f8..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": "e23eaad8312941fbbaa0683e71bc8ed6", + "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 " ] }, @@ -325,6 +327,73 @@ "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." + ] + }, { "cell_type": "markdown", "id": "07a22cee-e340-4551-bb81-07d8be1d152b", @@ -341,7 +410,7 @@ }, { "cell_type": "code", - "execution_count": 12, + "execution_count": 14, "id": "61b43a9b-8dad-48b7-9194-2045e465793b", "metadata": {}, "outputs": [], @@ -351,7 +420,7 @@ }, { "cell_type": "code", - "execution_count": 13, + "execution_count": 15, "id": "647360a9-c971-4272-995c-aa01e5f5bb83", "metadata": {}, "outputs": [ @@ -389,7 +458,7 @@ }, { "cell_type": "code", - "execution_count": 14, + "execution_count": 16, "id": "8fb0671b-045a-4d71-9d35-f0beadc9cf3a", "metadata": {}, "outputs": [ @@ -399,13 +468,13 @@ "-10" ] }, - "execution_count": 14, + "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" ] }, { @@ -420,7 +489,7 @@ }, { "cell_type": "code", - "execution_count": 15, + "execution_count": 17, "id": "5ce91f42-7aec-492c-94fb-2320c971cd79", "metadata": {}, "outputs": [ @@ -439,7 +508,7 @@ " return sum_\n", "\n", "add1 = add_node()\n", - "add2 = add_node(x=2, y=2)\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", @@ -456,7 +525,7 @@ }, { "cell_type": "code", - "execution_count": 16, + "execution_count": 18, "id": "20360fe7-b422-4d78-9bd1-de233f28c8df", "metadata": {}, "outputs": [ @@ -489,7 +558,7 @@ }, { "cell_type": "code", - "execution_count": 17, + "execution_count": 19, "id": "1a4e9693-0980-4435-aecc-3331d8b608dd", "metadata": {}, "outputs": [], @@ -501,7 +570,7 @@ }, { "cell_type": "code", - "execution_count": 18, + "execution_count": 20, "id": "7c4d314b-33bb-4a67-bfb9-ed77fba3949c", "metadata": {}, "outputs": [ @@ -530,77 +599,6 @@ "print(lin.mean()) # Finds the method on the output -- a special feature of SingleValueNode" ] }, - { - "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.\n", - "\n", - "We can also rename our node output channels using the `output_labels: Optional[str | list[str] | tuple[str]` kwarg, which we'll see here" - ] - }, - { - "cell_type": "code", - "execution_count": 19, - "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": "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": 20, - "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": "9b9220b0-833d-4c6a-9929-5dfa60a47d14", @@ -718,7 +716,7 @@ "outputs": [ { "data": { - "image/png": "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DBs2zDz55JMRnnxgnK67y+HDh83pp59uioqKzHnnnReZYUPI6brfeustI8l89NFHxufzdb+OHTsW4ckHJpjf909/+lMzadIkU11dberr6827775rtm7dGsGpB8bpmg8fPhzwO25sbDRpaWmmtLQ0soMPkNN1b9682cTFxZlHH33U7N2712zevNmcc845ZubMmRGefGCcrruiosKkpKSYF1980XzyySdm9erV5qSTTjLr1q2L8OSRETUxkp+fb0pKSgK2nX322Wbx4sX9/h45OTmmrKws1KOFVSjW/fOf/9zMmjUr1KOFVbDrLi4uNn/4wx9MaWlpVMaI03V3xch///vfCEwXPk7X/frrrxuPx2MOHjwYifHCYqD/bq9du9a4XC7z6aefhmO8sHG67vvvv9+cfvrpAdsee+wxk5mZGbYZw8HpugsKCswdd9wRsO22224zkydPDtuMNkXFZZqjR4+qpqZGRUVFAduLioq0bdu2fn2Pzs5Otba2Ki0tLRwjhkUo1r1r1y5t27ZNF198cThGDItg1/3MM8/ok08+UWlpabhHDIuB/L4vuOACeb1eXXbZZXrrrbfCOWbIBbPudevWaeLEibrvvvs0evRonXXWWbrjjjv09ddfR2LkAQvFv9srVqzQ5ZdfrjFjxoRjxLAIZt2FhYXat2+fNmzYIGOMvvjiC7388su66qqrIjFySASz7vb2drnd7oBtycnJ2r59u7755puwzWpLVMTIgQMH1NHRofT09IDt6enpampq6tf3ePDBB/XVV1/pmmuuCceIYTGQdWdmZiopKUkTJ07UzTffrPnz54dz1JAKZt0ff/yxFi9erFWrVikhISr+Muoeglm31+vVU089pVdeeUWvvvqqxo0bp8suu0xvv/12JEYOiWDWvXfvXm3ZskW1tbVau3atHnnkEb388su6+eabIzHygA30v2k+n0+vv/56VP17LQW37sLCQq1atUrFxcVKTExURkaGTj75ZD3++OORGDkkgln3FVdcoaefflo1NTUyxmjHjh2qqqrSN998owMHDkRi7IiKqv9qu1yugK+NMT229Wb16tW6++679dprr2nkyJHhGi9sgln35s2b9eWXX+qdd97R4sWLdcYZZ+iXv/xlOMcMuf6uu6OjQ9ddd53Kysp01llnRWq8sHHy+x43bpzGjRvX/XVBQYEaGxv1wAMP6H/+53/COmeoOVl3Z2enXC6XVq1a1f3Xkz/00EO6+uqrtXz5ciUnJ4d93lAI9r9pzz77rE4++WTNnDkzTJOFl5N119XV6dZbb9Vdd92lK664Qj6fT3feeadKSkq0YsWKSIwbMk7W/cc//lFNTU268MILZYxRenq65syZo/vuu0/x8fGRGDeiouLMyIgRIxQfH9+jIJubm3uU5netWbNG8+bN01/+8hddfvnl4Rwz5Aay7uzsbP34xz/WjTfeqNtvv1133313GCcNLafrbm1t1Y4dO3TLLbcoISFBCQkJWrZsmf79738rISFBf//73yM1+oAM5Pf9bRdeeKE+/vjjUI8XNsGs2+v1avTo0d0hIknjx4+XMUb79u0L67yhMJDftTFGVVVVuv7665WYmBjOMUMumHWXl5dr8uTJuvPOO3XuuefqiiuuUEVFhaqqquTz+SIx9oAFs+7k5GRVVVXpyJEj+vTTT9XQ0KDTTjtNKSkpGjFiRCTGjqioiJHExETl5eWpuro6YHt1dbUKCwv7fN/q1as1Z84cvfDCC1F1fbFLsOv+LmOM2tvbQz1e2Dhdd2pqqt577z3t3r27+1VSUqJx48Zp9+7dmjRpUqRGH5BQ/b537dolr9cb6vHCJph1T548Wfv379eXX37ZvW3Pnj2Ki4tTZmZmWOcNhYH8rjdt2qT//Oc/mjdvXjhHDItg1n3kyBHFxQUeqrrODJgo+XteB/L7PuGEE5SZman4+Hi9+OKL+slPftLjf48hwcKHZoPSdVvUihUrTF1dnVm4cKE58cQTuz9JvnjxYnP99dd37//CCy+YhIQEs3z58oDb4Q4fPmxrCUFxuu4nnnjCrFu3zuzZs8fs2bPHVFVVmdTUVLN06VJbSwiK03V/V7TeTeN03Q8//LBZu3at2bNnj6mtrTWLFy82kswrr7xiawlBcbru1tZWk5mZaa6++mrz/vvvm02bNpkzzzzTzJ8/39YSHAv2n/FZs2aZSZMmRXrckHG67meeecYkJCSYiooK88knn5gtW7aYiRMnmvz8fFtLCIrTdX/00Ufm+eefN3v27DHvvvuuKS4uNmlpaaa+vt7SCsIramLEGGOWL19uxowZYxITE82ECRPMpk2buv/shhtuMBdffHH31xdffLGR1ON1ww03RH7wAXKy7scee8ycc845ZtiwYSY1NdVccMEFpqKiwnR0dFiYfGCcrPu7ojVGjHG27nvvvdeMHTvWuN1u86Mf/chcdNFF5m9/+5uFqQfO6e/7gw8+MJdffrlJTk42mZmZZtGiRebIkSMRnnpgnK758OHDJjk52Tz11FMRnjS0nK77scceMzk5OSY5Odl4vV7zq1/9yuzbty/CUw+ck3XX1dWZ888/3yQnJ5vU1FTzs5/9zHz44YcWpo4MlzFRcp4LAAAMSUPwwhMAAIgmxAgAALCKGAEAAFYRIwAAwCpiBAAAWEWMAAAAq4gRAABgFTECAACsIkYAAIBVxAgAALCKGAEAAFb9LzGeNuwnas6eAAAAAElFTkSuQmCC", + "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": [ "
" ] @@ -807,7 +805,7 @@ "outputs": [ { "data": { - "image/png": "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X7jXZtiXX35pZs2aZcrLy82VV14Zn2JTTCTX5pZbbjHz5s0zDQ0NprW11fzzn/80e/fujWPVqSHca7N7926TlpZmnnnmGXPs2DGze/duc/nll5vFixfHuXJnq6urM+vWrTNvvfWWkWTeeeedsx5/7Ngxc8EFF5iHHnrItLS0mJdeesmMGzfOvPnmm2G9L4HFQUpKSkxlZWVA22WXXWYeffTRkF+joKDAVFdXR7u0lBfptVmyZIn57W9/a6qqqggsMRLutfnLX/5iPB6P6erqikd5KS3ca/O73/3OzJo1K6Dt2WefNdOnT49ZjakulMDyq1/9ylx22WUBbffdd5+5+uqrw3ovhoQc4uTJkzp48KDKy8sD2svLy7Vv376QXmNwcFC9vb3Kzs6ORYkpK9Jr88orr+jo0aOqqqqKdYkpK5Jr895772nu3Ll66qmnNG3aNF1yySV6+OGH9e2338aj5JQRybUpKyvTiRMnVFdXJ2OMvvjiC7355pu6+eab41EyxrB///6g63jjjTfqwIED+u6770J+naS4WzPOrbOzUwMDA8rNzQ1oz83NVXt7e0iv8fvf/15ff/21brvttliUmLIiuTaffPKJHn30Ue3evVsZGXxMYyWSa3Ps2DHt2bNHbrdb77zzjjo7O3X//feru7ubeSxRFMm1KSsr09atW7VkyRL19fXp1KlTuuWWW/Tcc8/Fo2SMob29fdTreOrUKXV2dsrr9Yb0OvSwOIzL5Qp4bowJahvNtm3b9Pjjj2v79u2aMmVKrMpLaaFem4GBAd1xxx2qrq7WJZdcEq/yUlo4n5vBwUG5XC5t3bpVJSUluummm/T000/r1VdfpZclBsK5Ni0tLVq5cqUee+wxHTx4UPX19WptbVVlZWU8SsVZjHYdR2s/G351c4jJkycrPT096DePjo6OoGR7pu3bt+uee+7RG2+8oRtuuCGWZaakcK9Nb2+vDhw4oKamJj344IOShr4kjTHKyMjQBx98oOuvvz4utTtdJJ8br9eradOmyePxjLTNmTNHxhidOHFCF198cUxrThWRXJuamhpdc801euSRRyRJV1xxhSZOnKj58+friSeeCPk3eURXXl7eqNcxIyNDOTk5Ib8OPSwOMX78eBUXF6uhoSGgvaGhQWVlZWOet23bNt111116/fXXGeeNkXCvTVZWlj788EM1NzePPCorK3XppZequblZ8+bNi1fpjhfJ5+aaa67R559/rq+++mqk7eOPP1ZaWpqmT58e03pTSSTX5ptvvlFaWuDXWnp6uqT//UaP+CstLQ26jh988IHmzp2rcePGhf5CYU3RhdWGlwBu2rTJtLS0mFWrVpmJEyea//73v8YYYx599FFz5513jhz/+uuvm4yMDLNhwwbj8/lGHl9++WWifgTHCvfanIlVQrET7rXp7e0106dPN7feeqv56KOPzM6dO83FF19s7r333kT9CI4V7rV55ZVXTEZGhtm4caM5evSo2bNnj5k7d64pKSlJ1I/gSL29vaapqck0NTUZSebpp582TU1NI8vNz7wuw8uaV69ebVpaWsymTZtY1gxjNmzYYGbOnGnGjx9vioqKzM6dO0f+bPny5ebaa68deX7ttdcaSUGP5cuXx7/wFBDOtTkTgSW2wr02R44cMTfccIOZMGGCmT59ulmzZo355ptv4lx1agj32jz77LOmoKDATJgwwXi9XvOzn/3MnDhxIs5VO9vf/va3s353jHZd/v73v5sf/vCHZvz48eaiiy4ytbW1Yb+vyxj6yQAAgN2YwwIAAKxHYAEAANYjsAAAAOsRWAAAgPUILAAAwHoEFgAAYD0CCwAAsB6BBQAAWI/AAgAArEdgAQAA1iOwAAAA6/0/dvCXVJIj9t8AAAAASUVORK5CYII=", + "image/png": "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dOTIEe3du1cHDhyI7zsBAMxZZoZHFauij04DieY4jFRXV2t4eFg7d+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": [ "
" ] @@ -828,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", @@ -844,7 +1030,7 @@ }, { "cell_type": "code", - "execution_count": 28, + "execution_count": 33, "id": "ae500d5e-e55b-432c-8b5f-d5892193cdf5", "metadata": {}, "outputs": [ @@ -852,9 +1038,9 @@ "name": "stderr", "output_type": "stream", "text": [ - "/Users/huber/work/pyiron/pyiron_contrib/pyiron_contrib/workflow/composite.py:193: 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:193: 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" ] }, @@ -869,9 +1055,9 @@ "name": "stderr", "output_type": "stream", "text": [ - "/Users/huber/work/pyiron/pyiron_contrib/pyiron_contrib/workflow/composite.py:193: 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:193: 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" ] }, diff --git a/pyiron_contrib/workflow/composite.py b/pyiron_contrib/workflow/composite.py index bacc934e8..c27b3210f 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,33 @@ 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 +136,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 7ff972963..decc53a59 100644 --- a/pyiron_contrib/workflow/function.py +++ b/pyiron_contrib/workflow/function.py @@ -21,6 +21,8 @@ class Function(Node): Function nodes wrap an arbitrary python function. 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. @@ -64,6 +66,15 @@ class Function(Node): call, such that output data gets pushed after the node stops running but before 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. label (str): The node's label. (Defaults to the node function's name.) @@ -155,6 +166,14 @@ class Function(Node): >>> 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( @@ -167,8 +186,7 @@ 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. @@ -335,6 +353,7 @@ class Function(Node): def __init__( self, node_function: callable, + *args, label: Optional[str] = None, run_on_updates: bool = True, update_on_instantiation: bool = True, @@ -346,6 +365,7 @@ def __init__( super().__init__( label=label if label is not None else node_function.__name__, parent=parent, + run_on_updates=run_on_updates, # **kwargs, ) @@ -365,14 +385,7 @@ 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() @@ -527,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 @@ -551,8 +570,34 @@ def process_run_result(self, 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 { @@ -577,6 +622,7 @@ class Slow(Function): def __init__( self, node_function: callable, + *args, label: Optional[str] = None, run_on_updates=False, update_on_instantiation=False, @@ -586,6 +632,7 @@ def __init__( ): super().__init__( node_function, + *args, label=label, run_on_updates=run_on_updates, update_on_instantiation=update_on_instantiation, @@ -608,6 +655,7 @@ class SingleValue(Function, HasChannel): def __init__( self, node_function: callable, + *args, label: Optional[str] = None, run_on_updates=True, update_on_instantiation=True, @@ -617,6 +665,7 @@ def __init__( ): super().__init__( node_function, + *args, label=label, run_on_updates=run_on_updates, update_on_instantiation=update_on_instantiation, 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/workflow.py b/pyiron_contrib/workflow/workflow.py index 8c9f7936d..e2b543e80 100644 --- a/pyiron_contrib/workflow/workflow.py +++ b/pyiron_contrib/workflow/workflow.py @@ -85,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") @@ -118,8 +130,19 @@ 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/tests/unit/workflow/test_function.py b/tests/unit/workflow/test_function.py index b7750c33c..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 ( @@ -45,6 +47,23 @@ def test_instantiation(self): 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) self.assertEqual( @@ -137,10 +156,10 @@ def test_instantiation_update(self): ) 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") @@ -262,6 +281,13 @@ 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 @@ -271,6 +297,121 @@ def with_messed_self(x: float, self) -> float: 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): @@ -295,11 +436,31 @@ 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): - SingleValue(plus_one) + 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 diff --git a/tests/unit/workflow/test_workflow.py b/tests/unit/workflow/test_workflow.py index b9dd2fd4a..7a8efac73 100644 --- a/tests/unit/workflow/test_workflow.py +++ b/tests/unit/workflow/test_workflow.py @@ -5,10 +5,11 @@ 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): +def plus_one(x=0): y = x + 1 return y @@ -20,10 +21,10 @@ def test_node_addition(self): wf = Workflow("my_workflow") # Validate the four ways to add a node - wf.add(Function(fnc, label="foo")) - wf.add.Function(fnc, label="bar") - wf.baz = Function(fnc, label="whatever_baz_gets_used") - Function(fnc, 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( @@ -34,13 +35,13 @@ def test_node_addition(self): wf.strict_naming = False # Validate name incrementation - wf.add(Function(fnc, label="foo")) - wf.add.Function(fnc, label="bar") + wf.add(Function(plus_one, label="foo")) + wf.add.Function(plus_one, label="bar") wf.baz = Function( - fnc, + plus_one, label="without_strict_you_can_override_by_assignment" ) - Function(fnc, 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, label="foo")) + wf.add(Function(plus_one, label="foo")) with self.assertRaises(AttributeError): - wf.add.Function(fnc, label="bar") + wf.add.Function(plus_one, label="bar") with self.assertRaises(AttributeError): - wf.baz = Function(fnc, label="whatever_baz_gets_used") + wf.baz = Function(plus_one, label="whatever_baz_gets_used") with self.assertRaises(AttributeError): - Function(fnc, 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, label="node1") - node2 = Function(fnc, 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, label="n1") - wf.add.Function(fnc, label="n2") - wf.add.Function(fnc, 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) @@ -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) - 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,6 +146,14 @@ 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") @@ -189,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()