diff --git a/.binder/environment.yml b/.binder/environment.yml index 7fa935ee..391bc04f 100644 --- a/.binder/environment.yml +++ b/.binder/environment.yml @@ -10,6 +10,7 @@ dependencies: - bagofholding =0.1.12 - ipython - ipytree =0.2.2 +- python-graphviz =0.21 - python-workflow-definition =0.1.5 - numpy =2.4.6 - python =3.14 diff --git a/.ci_support/environment-optional.yml b/.ci_support/environment-optional.yml index 4261aa03..d1bf4c2a 100644 --- a/.ci_support/environment-optional.yml +++ b/.ci_support/environment-optional.yml @@ -4,4 +4,5 @@ dependencies: - bagofholding =0.1.12 - ipython - ipytree =0.2.2 +- python-graphviz =0.21 - python-workflow-definition =0.1.5 \ No newline at end of file diff --git a/docs/environment.yml b/docs/environment.yml index a6fed4ba..4b7c6e43 100644 --- a/docs/environment.yml +++ b/docs/environment.yml @@ -16,4 +16,5 @@ dependencies: - bagofholding =0.1.12 - ipython - ipytree =0.2.2 +- python-graphviz =0.21 - python-workflow-definition =0.1.5 diff --git a/notebooks/user-guide.ipynb b/notebooks/user-guide.ipynb index 8ce73219..f0dfcce4 100644 --- a/notebooks/user-guide.ipynb +++ b/notebooks/user-guide.ipynb @@ -37,8 +37,8 @@ { "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:23.767768Z", - "start_time": "2026-07-21T21:51:23.479375Z" + "end_time": "2026-09-08T00:49:22.548577Z", + "start_time": "2026-09-08T00:49:22.246134Z" } }, "cell_type": "code", @@ -101,8 +101,8 @@ { "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:23.836617Z", - "start_time": "2026-07-21T21:51:23.782495Z" + "end_time": "2026-09-08T00:49:22.583265Z", + "start_time": "2026-09-08T00:49:22.564678Z" } }, "cell_type": "code", @@ -163,8 +163,8 @@ "id": "fbb2ac82", "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:23.882735Z", - "start_time": "2026-07-21T21:51:23.849547Z" + "end_time": "2026-09-08T00:49:22.637275Z", + "start_time": "2026-09-08T00:49:22.602013Z" } }, "source": [ @@ -204,8 +204,8 @@ { "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:23.918867Z", - "start_time": "2026-07-21T21:51:23.885779Z" + "end_time": "2026-09-08T00:49:22.656137Z", + "start_time": "2026-09-08T00:49:22.639351Z" } }, "cell_type": "code", @@ -240,8 +240,8 @@ "id": "4ce6917d", "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:23.973753Z", - "start_time": "2026-07-21T21:51:23.922083Z" + "end_time": "2026-09-08T00:49:22.689278Z", + "start_time": "2026-09-08T00:49:22.670350Z" } }, "source": [ @@ -302,8 +302,8 @@ "id": "cac0a1bd", "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:24.010920Z", - "start_time": "2026-07-21T21:51:23.977160Z" + "end_time": "2026-09-08T00:49:22.726585Z", + "start_time": "2026-09-08T00:49:22.705208Z" } }, "source": [ @@ -343,8 +343,8 @@ "id": "9f659bd7", "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:24.057186Z", - "start_time": "2026-07-21T21:51:24.025458Z" + "end_time": "2026-09-08T00:49:22.761232Z", + "start_time": "2026-09-08T00:49:22.743329Z" } }, "source": [ @@ -376,8 +376,8 @@ { "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:24.129600Z", - "start_time": "2026-07-21T21:51:24.071851Z" + "end_time": "2026-09-08T00:49:22.795874Z", + "start_time": "2026-09-08T00:49:22.776489Z" } }, "cell_type": "code", @@ -416,8 +416,8 @@ "id": "467bd6b8", "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:24.163059Z", - "start_time": "2026-07-21T21:51:24.146302Z" + "end_time": "2026-09-08T00:49:22.827586Z", + "start_time": "2026-09-08T00:49:22.810176Z" } }, "source": [ @@ -451,8 +451,8 @@ "id": "0d46e437", "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:24.202160Z", - "start_time": "2026-07-21T21:51:24.173678Z" + "end_time": "2026-09-08T00:49:22.857797Z", + "start_time": "2026-09-08T00:49:22.840823Z" } }, "source": [ @@ -505,8 +505,8 @@ "id": "77276c40", "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:24.225185Z", - "start_time": "2026-07-21T21:51:24.204422Z" + "end_time": "2026-09-08T00:49:22.889028Z", + "start_time": "2026-09-08T00:49:22.872367Z" } }, "source": [ @@ -585,8 +585,8 @@ "id": "b8f03ee2", "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:24.267259Z", - "start_time": "2026-07-21T21:51:24.237111Z" + "end_time": "2026-09-08T00:49:22.920459Z", + "start_time": "2026-09-08T00:49:22.904409Z" } }, "source": [ @@ -642,8 +642,8 @@ "id": "66ac7ffa", "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:24.326074Z", - "start_time": "2026-07-21T21:51:24.270928Z" + "end_time": "2026-09-08T00:49:22.952760Z", + "start_time": "2026-09-08T00:49:22.934113Z" } }, "source": [ @@ -689,8 +689,8 @@ { "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:24.403098Z", - "start_time": "2026-07-21T21:51:24.362880Z" + "end_time": "2026-09-08T00:49:23.001834Z", + "start_time": "2026-09-08T00:49:22.966371Z" } }, "cell_type": "code", @@ -755,8 +755,8 @@ { "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:24.425577Z", - "start_time": "2026-07-21T21:51:24.416066Z" + "end_time": "2026-09-08T00:49:23.032535Z", + "start_time": "2026-09-08T00:49:23.003357Z" } }, "cell_type": "code", @@ -810,8 +810,8 @@ "id": "67cf6163", "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:24.466967Z", - "start_time": "2026-07-21T21:51:24.437046Z" + "end_time": "2026-09-08T00:49:23.088351Z", + "start_time": "2026-09-08T00:49:23.056918Z" } }, "source": [ @@ -872,8 +872,8 @@ { "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:24.485208Z", - "start_time": "2026-07-21T21:51:24.468952Z" + "end_time": "2026-09-08T00:49:23.107045Z", + "start_time": "2026-09-08T00:49:23.090463Z" } }, "cell_type": "code", @@ -911,8 +911,8 @@ { "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:24.512894Z", - "start_time": "2026-07-21T21:51:24.497371Z" + "end_time": "2026-09-08T00:49:23.143009Z", + "start_time": "2026-09-08T00:49:23.123099Z" } }, "cell_type": "code", @@ -949,8 +949,8 @@ { "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:24.546717Z", - "start_time": "2026-07-21T21:51:24.525191Z" + "end_time": "2026-09-08T00:49:23.163112Z", + "start_time": "2026-09-08T00:49:23.143863Z" } }, "cell_type": "code", @@ -979,8 +979,8 @@ { "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:24.589111Z", - "start_time": "2026-07-21T21:51:24.557278Z" + "end_time": "2026-09-08T00:49:23.208617Z", + "start_time": "2026-09-08T00:49:23.176735Z" } }, "cell_type": "code", @@ -1021,8 +1021,8 @@ { "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:24.643020Z", - "start_time": "2026-07-21T21:51:24.590907Z" + "end_time": "2026-09-08T00:49:23.227060Z", + "start_time": "2026-09-08T00:49:23.213254Z" } }, "cell_type": "code", @@ -1078,8 +1078,8 @@ { "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:24.665520Z", - "start_time": "2026-07-21T21:51:24.644242Z" + "end_time": "2026-09-08T00:49:23.265325Z", + "start_time": "2026-09-08T00:49:23.228107Z" } }, "cell_type": "code", @@ -1164,8 +1164,8 @@ "id": "d39608c2", "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:24.691129Z", - "start_time": "2026-07-21T21:51:24.667850Z" + "end_time": "2026-09-08T00:49:23.284954Z", + "start_time": "2026-09-08T00:49:23.267481Z" } }, "source": "linear.flowrep_recipe(x=3, slope=2, intercept=1)", @@ -1183,6 +1183,42 @@ ], "execution_count": 23 }, + { + "metadata": {}, + "cell_type": "markdown", + "source": [ + "A recipe can be visualised directly with `.draw()`:" + ], + "id": "62afc97568577440" + }, + { + "cell_type": "code", + "id": "80ca0e65ffd4f939", + "metadata": { + "ExecuteTime": { + "end_time": "2026-09-08T00:49:23.813950Z", + "start_time": "2026-09-08T00:49:23.298183Z" + } + }, + "source": [ + "# NBVAL_IGNORE_OUTPUT\n", + "linear.flowrep_recipe.draw()" + ], + "outputs": [ + { + "data": { + "image/svg+xml": "\n\n\n\n\n\n\n\ncluster_\n\nworkflow\n__main__.linear\n\n\n\ninputs.x\n\nx\n\n\n\n\nmultiply_0\n\nmultiply_0\n__main__.multiply\n\n\nx\n\n\nproduct\n\n\ny\n\n\n\n\ninputs.x:e->multiply_0:w\n\n\n\n\n\ninputs.slope\n\nslope\n\n\n\n\ninputs.slope:e->multiply_0:w\n\n\n\n\n\ninputs.intercept\n\nintercept\n\n\n\n\nadd_0\n\nadd_0\n__main__.add\n\n\na\n\n\nresult\n\n\nb\n\n\n\n\ninputs.intercept:e->add_0:w\n\n\n\n\n\noutputs.result\n\nresult\n\n\n\n\nmultiply_0:e->add_0:w\n\n\n\n\n\nadd_0:e->outputs.result:w\n\n\n\n\n\n", + "text/plain": [ + "" + ] + }, + "execution_count": 24, + "metadata": {}, + "output_type": "execute_result" + } + ], + "execution_count": 24 + }, { "cell_type": "markdown", "id": "ad9b6689", @@ -1215,8 +1251,8 @@ { "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:24.719179Z", - "start_time": "2026-07-21T21:51:24.693439Z" + "end_time": "2026-09-08T00:49:23.861383Z", + "start_time": "2026-09-08T00:49:23.831547Z" } }, "cell_type": "code", @@ -1256,7 +1292,7 @@ ] } ], - "execution_count": 24 + "execution_count": 25 }, { "cell_type": "markdown", @@ -1272,6 +1308,71 @@ "its own unique label; but non-sibling nodes are free to have the same label." ] }, + { + "metadata": {}, + "cell_type": "markdown", + "source": [ + "Nesting is where `depth` matters: `.draw(depth=0)` leaves nested workflows closed,\n", + "`.draw(depth=1)` opens one more level." + ], + "id": "3ea561c5cd649219" + }, + { + "cell_type": "code", + "id": "26ec1c16979aa5db", + "metadata": { + "ExecuteTime": { + "end_time": "2026-09-08T00:49:24.040207Z", + "start_time": "2026-09-08T00:49:23.873708Z" + } + }, + "source": [ + "# NBVAL_IGNORE_OUTPUT\n", + "recipe.draw(depth=0)" + ], + "outputs": [ + { + "data": { + "image/svg+xml": "\n\n\n\n\n\n\n\ncluster_\n\nworkflow\n__main__.evaluate_both\n\n\n\ninputs.x\n\nx\n\n\n\n\nlinear_0\n\nlinear_0\n__main__.linear\n\n\nx\n\n\nresult\n\n\nslope\n\n\nintercept\n\n\n\n\ninputs.x:e->linear_0:w\n\n\n\n\n\nquadratic_0\n\nquadratic_0\n__main__.quadratic\n\n\nx\n\n\nresult\n\n\na\n\n\nb\n\n\nc\n\n\n\n\ninputs.x:e->quadratic_0:w\n\n\n\n\n\ninputs.slope\n\nslope\n\n\n\n\ninputs.slope:e->linear_0:w\n\n\n\n\n\ninputs.intercept\n\nintercept\n\n\n\n\ninputs.intercept:e->linear_0:w\n\n\n\n\n\ninputs.a\n\na\n\n\n\n\ninputs.a:e->quadratic_0:w\n\n\n\n\n\ninputs.b\n\nb\n\n\n\n\ninputs.b:e->quadratic_0:w\n\n\n\n\n\ninputs.c\n\nc\n\n\n\n\ninputs.c:e->quadratic_0:w\n\n\n\n\n\noutputs.lin\n\nlin\n\n\n\n\noutputs.quad\n\nquad\n\n\n\n\nlinear_0:e->outputs.lin:w\n\n\n\n\n\nquadratic_0:e->outputs.quad:w\n\n\n\n\n\n", + "text/plain": [ + "" + ] + }, + "execution_count": 26, + "metadata": {}, + "output_type": "execute_result" + } + ], + "execution_count": 26 + }, + { + "cell_type": "code", + "id": "01872b7c31aaedd3", + "metadata": { + "ExecuteTime": { + "end_time": "2026-09-08T00:49:24.208054Z", + "start_time": "2026-09-08T00:49:24.049713Z" + } + }, + "source": [ + "# NBVAL_IGNORE_OUTPUT\n", + "recipe.draw(depth=1)" + ], + "outputs": [ + { + "data": { + "image/svg+xml": "\n\n\n\n\n\n\n\ncluster_\n\nworkflow\n__main__.evaluate_both\n\n\ncluster_linear_0\n\nlinear_0\n__main__.linear\n\n\ncluster_quadratic_0\n\nquadratic_0\n__main__.quadratic\n\n\n\ninputs.x\n\nx\n\n\n\n\nlinear_0.inputs.x\n\nx\n\n\n\n\ninputs.x:e->linear_0.inputs.x:w\n\n\n\n\n\nquadratic_0.inputs.x\n\nx\n\n\n\n\ninputs.x:e->quadratic_0.inputs.x:w\n\n\n\n\n\ninputs.slope\n\nslope\n\n\n\n\nlinear_0.inputs.slope\n\nslope\n\n\n\n\ninputs.slope:e->linear_0.inputs.slope:w\n\n\n\n\n\ninputs.intercept\n\nintercept\n\n\n\n\nlinear_0.inputs.intercept\n\nintercept\n\n\n\n\ninputs.intercept:e->linear_0.inputs.intercept:w\n\n\n\n\n\ninputs.a\n\na\n\n\n\n\nquadratic_0.inputs.a\n\na\n\n\n\n\ninputs.a:e->quadratic_0.inputs.a:w\n\n\n\n\n\ninputs.b\n\nb\n\n\n\n\nquadratic_0.inputs.b\n\nb\n\n\n\n\ninputs.b:e->quadratic_0.inputs.b:w\n\n\n\n\n\ninputs.c\n\nc\n\n\n\n\nquadratic_0.inputs.c\n\nc\n\n\n\n\ninputs.c:e->quadratic_0.inputs.c:w\n\n\n\n\n\noutputs.lin\n\nlin\n\n\n\n\noutputs.quad\n\nquad\n\n\n\n\nlinear_0.multiply_0\n\nmultiply_0\n__main__.multiply\n\n\nx\n\n\nproduct\n\n\ny\n\n\n\n\nlinear_0.inputs.x:e->linear_0.multiply_0:w\n\n\n\n\n\nlinear_0.inputs.slope:e->linear_0.multiply_0:w\n\n\n\n\n\nlinear_0.add_0\n\nadd_0\n__main__.add\n\n\na\n\n\nresult\n\n\nb\n\n\n\n\nlinear_0.inputs.intercept:e->linear_0.add_0:w\n\n\n\n\n\nlinear_0.outputs.result\n\nresult\n\n\n\n\nlinear_0.outputs.result:e->outputs.lin:w\n\n\n\n\n\nlinear_0.multiply_0:e->linear_0.add_0:w\n\n\n\n\n\nlinear_0.add_0:e->linear_0.outputs.result:w\n\n\n\n\n\nquadratic_0.multiply_0\n\nmultiply_0\n__main__.multiply\n\n\nx\n\n\nproduct\n\n\ny\n\n\n\n\nquadratic_0.inputs.x:e->quadratic_0.multiply_0:w\n\n\n\n\n\nquadratic_0.inputs.x:e->quadratic_0.multiply_0:w\n\n\n\n\n\nquadratic_0.multiply_2\n\nmultiply_2\n__main__.multiply\n\n\nx\n\n\nproduct\n\n\ny\n\n\n\n\nquadratic_0.inputs.x:e->quadratic_0.multiply_2:w\n\n\n\n\n\nquadratic_0.multiply_1\n\nmultiply_1\n__main__.multiply\n\n\nx\n\n\nproduct\n\n\ny\n\n\n\n\nquadratic_0.inputs.a:e->quadratic_0.multiply_1:w\n\n\n\n\n\nquadratic_0.inputs.b:e->quadratic_0.multiply_2:w\n\n\n\n\n\nquadratic_0.add_1\n\nadd_1\n__main__.add\n\n\na\n\n\nresult\n\n\nb\n\n\n\n\nquadratic_0.inputs.c:e->quadratic_0.add_1:w\n\n\n\n\n\nquadratic_0.outputs.result\n\nresult\n\n\n\n\nquadratic_0.outputs.result:e->outputs.quad:w\n\n\n\n\n\nquadratic_0.multiply_0:e->quadratic_0.multiply_1:w\n\n\n\n\n\nquadratic_0.add_0\n\nadd_0\n__main__.add\n\n\na\n\n\nresult\n\n\nb\n\n\n\n\nquadratic_0.multiply_1:e->quadratic_0.add_0:w\n\n\n\n\n\nquadratic_0.multiply_2:e->quadratic_0.add_0:w\n\n\n\n\n\nquadratic_0.add_0:e->quadratic_0.add_1:w\n\n\n\n\n\nquadratic_0.add_1:e->quadratic_0.outputs.result:w\n\n\n\n\n\n", + "text/plain": [ + "" + ] + }, + "execution_count": 27, + "metadata": {}, + "output_type": "execute_result" + } + ], + "execution_count": 27 + }, { "cell_type": "markdown", "id": "e90699f6", @@ -1290,8 +1391,8 @@ "id": "55716aa9", "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:24.760163Z", - "start_time": "2026-07-21T21:51:24.735163Z" + "end_time": "2026-09-08T00:49:24.232809Z", + "start_time": "2026-09-08T00:49:24.219164Z" } }, "source": [ @@ -1317,7 +1418,7 @@ ] } ], - "execution_count": 25 + "execution_count": 28 }, { "cell_type": "markdown", @@ -1346,8 +1447,8 @@ { "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:24.797889Z", - "start_time": "2026-07-21T21:51:24.763111Z" + "end_time": "2026-09-08T00:49:24.250998Z", + "start_time": "2026-09-08T00:49:24.241200Z" } }, "cell_type": "code", @@ -1369,12 +1470,12 @@ "2.0" ] }, - "execution_count": 26, + "execution_count": 29, "metadata": {}, "output_type": "execute_result" } ], - "execution_count": 26 + "execution_count": 29 }, { "metadata": {}, @@ -1385,8 +1486,8 @@ { "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:24.813300Z", - "start_time": "2026-07-21T21:51:24.798946Z" + "end_time": "2026-09-08T00:49:24.266388Z", + "start_time": "2026-09-08T00:49:24.259105Z" } }, "cell_type": "code", @@ -1403,12 +1504,12 @@ " 'constant': 0.5}" ] }, - "execution_count": 27, + "execution_count": 30, "metadata": {}, "output_type": "execute_result" } ], - "execution_count": 27 + "execution_count": 30 }, { "metadata": {}, @@ -1419,8 +1520,8 @@ { "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:24.892264Z", - "start_time": "2026-07-21T21:51:24.848788Z" + "end_time": "2026-09-08T00:49:24.282612Z", + "start_time": "2026-09-08T00:49:24.274482Z" } }, "cell_type": "code", @@ -1446,7 +1547,7 @@ ] } ], - "execution_count": 28 + "execution_count": 31 }, { "metadata": {}, @@ -1461,8 +1562,8 @@ { "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:24.921325Z", - "start_time": "2026-07-21T21:51:24.895968Z" + "end_time": "2026-09-08T00:49:24.299233Z", + "start_time": "2026-09-08T00:49:24.290393Z" } }, "cell_type": "code", @@ -1490,12 +1591,12 @@ "{'scaled': OutputDataPort(value=8, annotation=None)}" ] }, - "execution_count": 29, + "execution_count": 32, "metadata": {}, "output_type": "execute_result" } ], - "execution_count": 29 + "execution_count": 32 }, { "metadata": {}, @@ -1511,8 +1612,8 @@ { "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:24.938180Z", - "start_time": "2026-07-21T21:51:24.923500Z" + "end_time": "2026-09-08T00:49:24.314173Z", + "start_time": "2026-09-08T00:49:24.307787Z" } }, "cell_type": "code", @@ -1531,7 +1632,7 @@ ], "id": "e092fad543b9bfb9", "outputs": [], - "execution_count": 30 + "execution_count": 33 }, { "cell_type": "markdown", @@ -1545,8 +1646,8 @@ { "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:24.972647Z", - "start_time": "2026-07-21T21:51:24.951356Z" + "end_time": "2026-09-08T00:49:24.321110Z", + "start_time": "2026-09-08T00:49:24.315052Z" } }, "cell_type": "code", @@ -1565,7 +1666,7 @@ ] } ], - "execution_count": 31 + "execution_count": 34 }, { "metadata": { @@ -1591,8 +1692,8 @@ { "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:25.000572Z", - "start_time": "2026-07-21T21:51:24.975014Z" + "end_time": "2026-09-08T00:49:24.337902Z", + "start_time": "2026-09-08T00:49:24.328946Z" } }, "cell_type": "code", @@ -1605,12 +1706,12 @@ "{'this': 'this default', 'that': 'that default', 'the_other': array([0, 2])}" ] }, - "execution_count": 32, + "execution_count": 35, "metadata": {}, "output_type": "execute_result" } ], - "execution_count": 32 + "execution_count": 35 }, { "metadata": {}, @@ -1666,8 +1767,8 @@ { "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:25.032638Z", - "start_time": "2026-07-21T21:51:25.002893Z" + "end_time": "2026-09-08T00:49:24.354632Z", + "start_time": "2026-09-08T00:49:24.345586Z" } }, "cell_type": "code", @@ -1680,12 +1781,12 @@ "(None, None)" ] }, - "execution_count": 33, + "execution_count": 36, "metadata": {}, "output_type": "execute_result" } ], - "execution_count": 33 + "execution_count": 36 }, { "cell_type": "markdown", @@ -1696,8 +1797,8 @@ { "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:25.061541Z", - "start_time": "2026-07-21T21:51:25.043833Z" + "end_time": "2026-09-08T00:49:24.370898Z", + "start_time": "2026-09-08T00:49:24.362493Z" } }, "cell_type": "code", @@ -1735,7 +1836,7 @@ ] } ], - "execution_count": 34 + "execution_count": 37 }, { "metadata": { @@ -1759,8 +1860,8 @@ "id": "13f799b9", "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:25.089661Z", - "start_time": "2026-07-21T21:51:25.063740Z" + "end_time": "2026-09-08T00:49:24.388114Z", + "start_time": "2026-09-08T00:49:24.379120Z" } }, "source": [ @@ -1850,7 +1951,7 @@ ] } ], - "execution_count": 35 + "execution_count": 38 }, { "cell_type": "markdown", @@ -1872,8 +1973,8 @@ "id": "e67cac8d", "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:25.116259Z", - "start_time": "2026-07-21T21:51:25.091800Z" + "end_time": "2026-09-08T00:49:24.405113Z", + "start_time": "2026-09-08T00:49:24.396613Z" } }, "source": [ @@ -1892,7 +1993,7 @@ ] } ], - "execution_count": 36 + "execution_count": 39 }, { "cell_type": "markdown", @@ -1928,8 +2029,8 @@ { "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:25.144293Z", - "start_time": "2026-07-21T21:51:25.118452Z" + "end_time": "2026-09-08T00:49:24.423440Z", + "start_time": "2026-09-08T00:49:24.413257Z" } }, "cell_type": "code", @@ -1970,7 +2071,7 @@ ] } ], - "execution_count": 37 + "execution_count": 40 }, { "cell_type": "markdown", @@ -2097,8 +2198,8 @@ { "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:25.175417Z", - "start_time": "2026-07-21T21:51:25.146576Z" + "end_time": "2026-09-08T00:49:24.440071Z", + "start_time": "2026-09-08T00:49:24.431292Z" } }, "cell_type": "code", @@ -2144,7 +2245,7 @@ ] } ], - "execution_count": 38 + "execution_count": 41 }, { "cell_type": "markdown", @@ -2157,6 +2258,43 @@ "variable) and output `doubled` (what gets appended)." ] }, + { + "metadata": {}, + "cell_type": "markdown", + "source": [ + "The `nested` badge on the body's `item` port in the drawing below is information the\n", + "printout above doesn't show:" + ], + "id": "3c4ea6eb5f591c86" + }, + { + "cell_type": "code", + "id": "81cce7c2fbd6a1f1", + "metadata": { + "ExecuteTime": { + "end_time": "2026-09-08T00:49:24.619656Z", + "start_time": "2026-09-08T00:49:24.447628Z" + } + }, + "source": [ + "# NBVAL_IGNORE_OUTPUT\n", + "for_node.draw()" + ], + "outputs": [ + { + "data": { + "image/svg+xml": "\n\n\n\n\n\n\n\ncluster_\n\nfor_each\n\n\ncluster_body\n\nbody_n\n\n\n\ninputs.items\n\nitems\n\n\n\n\nbody.inputs.item\n\nitem\nnested\n\n\n\n\ninputs.items:e->body.inputs.item:w\n\n\n\n\n\noutputs.results\n\nresults\n\n\n\n\nbody.mul_0\n\nmul_0\nflowrep.std.mul\n\n\na\n\n\nproduct\n\n\nb\n\n\n\n\nbody.inputs.item:e->body.mul_0:w\n\n\n\n\n\nbody.outputs.doubled\n\ndoubled\n\n\n\n\nbody.outputs.doubled:e->outputs.results:w\n\n\n\n\n\nbody.mul_0:e->body.outputs.doubled:w\n\n\n\n\n\nbody.constant_0\n\nconstant_0\n2\n\n\nconstant\n\n\n\n\nbody.constant_0:e->body.mul_0:w\n\n\n\n\n\n", + "text/plain": [ + "" + ] + }, + "execution_count": 42, + "metadata": {}, + "output_type": "execute_result" + } + ], + "execution_count": 42 + }, { "metadata": {}, "cell_type": "markdown", @@ -2173,8 +2311,8 @@ { "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:25.218743Z", - "start_time": "2026-07-21T21:51:25.191380Z" + "end_time": "2026-09-08T00:49:24.695899Z", + "start_time": "2026-09-08T00:49:24.656627Z" } }, "cell_type": "code", @@ -2209,7 +2347,7 @@ ] } ], - "execution_count": 39 + "execution_count": 43 }, { "cell_type": "markdown", @@ -2234,8 +2372,8 @@ { "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:25.247174Z", - "start_time": "2026-07-21T21:51:25.221234Z" + "end_time": "2026-09-08T00:49:24.730154Z", + "start_time": "2026-09-08T00:49:24.711628Z" } }, "cell_type": "code", @@ -2264,7 +2402,7 @@ ] } ], - "execution_count": 40 + "execution_count": 44 }, { "cell_type": "markdown", @@ -2280,8 +2418,8 @@ { "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:25.273394Z", - "start_time": "2026-07-21T21:51:25.249446Z" + "end_time": "2026-09-08T00:49:24.782105Z", + "start_time": "2026-09-08T00:49:24.745839Z" } }, "cell_type": "code", @@ -2315,7 +2453,7 @@ ] } ], - "execution_count": 41 + "execution_count": 45 }, { "metadata": { @@ -2372,8 +2510,8 @@ { "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:25.300690Z", - "start_time": "2026-07-21T21:51:25.275613Z" + "end_time": "2026-09-08T00:49:24.818984Z", + "start_time": "2026-09-08T00:49:24.799031Z" } }, "cell_type": "code", @@ -2419,7 +2557,7 @@ ] } ], - "execution_count": 42 + "execution_count": 46 }, { "cell_type": "markdown", @@ -2472,8 +2610,8 @@ { "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:25.335325Z", - "start_time": "2026-07-21T21:51:25.303083Z" + "end_time": "2026-09-08T00:49:24.869943Z", + "start_time": "2026-09-08T00:49:24.834194Z" } }, "cell_type": "code", @@ -2512,16 +2650,16 @@ "output_type": "stream", "text": [ "Traceback (most recent call last):\n", - " File \"/var/folders/nn/6kd6nhmj2rx7610kd9r_8h0m0000gn/T/ipykernel_46295/3523357707.py\", line 16, in \n", + " File \"/var/folders/nn/6kd6nhmj2rx7610kd9r_8h0m0000gn/T/ipykernel_13569/3523357707.py\", line 16, in \n", " conditional_availability(-1)\n", - " File \"/var/folders/nn/6kd6nhmj2rx7610kd9r_8h0m0000gn/T/ipykernel_46295/3523357707.py\", line 9, in conditional_availability\n", + " File \"/var/folders/nn/6kd6nhmj2rx7610kd9r_8h0m0000gn/T/ipykernel_13569/3523357707.py\", line 9, in conditional_availability\n", " downstream = fr.std.identity(result)\n", " ^^^^^^\n", "UnboundLocalError: cannot access local variable 'result' where it is not associated with a value\n" ] } ], - "execution_count": 43 + "execution_count": 47 }, { "cell_type": "markdown", @@ -2549,8 +2687,8 @@ { "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:25.382571Z", - "start_time": "2026-07-21T21:51:25.336298Z" + "end_time": "2026-09-08T00:49:24.904725Z", + "start_time": "2026-09-08T00:49:24.885947Z" } }, "cell_type": "code", @@ -2568,7 +2706,7 @@ ], "id": "f170e946bc3d3e53", "outputs": [], - "execution_count": 44 + "execution_count": 48 }, { "metadata": { @@ -2602,8 +2740,8 @@ "metadata": { "lines_to_next_cell": 2, "ExecuteTime": { - "end_time": "2026-07-21T21:51:25.433491Z", - "start_time": "2026-07-21T21:51:25.404969Z" + "end_time": "2026-09-08T00:49:24.937635Z", + "start_time": "2026-09-08T00:49:24.917364Z" } }, "source": [ @@ -2640,7 +2778,7 @@ ] } ], - "execution_count": 45 + "execution_count": 49 }, { "cell_type": "code", @@ -2648,8 +2786,8 @@ "metadata": { "lines_to_next_cell": 2, "ExecuteTime": { - "end_time": "2026-07-21T21:51:25.464631Z", - "start_time": "2026-07-21T21:51:25.435352Z" + "end_time": "2026-09-08T00:49:24.972230Z", + "start_time": "2026-09-08T00:49:24.951542Z" } }, "source": [ @@ -2674,7 +2812,7 @@ ] } ], - "execution_count": 46 + "execution_count": 50 }, { "cell_type": "markdown", @@ -2725,8 +2863,8 @@ { "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:25.492415Z", - "start_time": "2026-07-21T21:51:25.466920Z" + "end_time": "2026-09-08T00:49:25.097055Z", + "start_time": "2026-09-08T00:49:24.986713Z" } }, "cell_type": "code", @@ -2777,7 +2915,7 @@ ] } ], - "execution_count": 47 + "execution_count": 51 }, { "cell_type": "markdown", @@ -2814,8 +2952,8 @@ "id": "d76836d6", "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:25.524289Z", - "start_time": "2026-07-21T21:51:25.495690Z" + "end_time": "2026-09-08T00:49:25.170604Z", + "start_time": "2026-09-08T00:49:25.116830Z" } }, "source": [ @@ -2831,7 +2969,7 @@ ] } ], - "execution_count": 48 + "execution_count": 52 }, { "cell_type": "markdown", @@ -2842,8 +2980,8 @@ { "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:25.564103Z", - "start_time": "2026-07-21T21:51:25.539359Z" + "end_time": "2026-09-08T00:49:25.214732Z", + "start_time": "2026-09-08T00:49:25.172797Z" } }, "cell_type": "code", @@ -2861,7 +2999,7 @@ ] } ], - "execution_count": 49 + "execution_count": 53 }, { "metadata": { @@ -2894,8 +3032,8 @@ { "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:25.590666Z", - "start_time": "2026-07-21T21:51:25.566349Z" + "end_time": "2026-09-08T00:49:25.253059Z", + "start_time": "2026-09-08T00:49:25.216523Z" } }, "cell_type": "code", @@ -2918,7 +3056,7 @@ "output_type": "stream", "text": [ "Traceback (most recent call last):\n", - " File \"/var/folders/nn/6kd6nhmj2rx7610kd9r_8h0m0000gn/T/ipykernel_46295/3041652459.py\", line 4, in \n", + " File \"/var/folders/nn/6kd6nhmj2rx7610kd9r_8h0m0000gn/T/ipykernel_13569/3041652459.py\", line 4, in \n", " @fr.atomic(forbid_main=True)\n", " ^^^^^^^^^^^^^^^^^^^^^^^^^^^\n", " File \"/Users/liamhuber/dev/pyiron/flowrep/src/flowrep/parsers/parser_helpers.py\", line 64, in deferred\n", @@ -2938,7 +3076,7 @@ ] } ], - "execution_count": 50 + "execution_count": 54 }, { "metadata": { @@ -2978,8 +3116,8 @@ "id": "3efa3180", "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:25.616954Z", - "start_time": "2026-07-21T21:51:25.592962Z" + "end_time": "2026-09-08T00:49:25.290347Z", + "start_time": "2026-09-08T00:49:25.254929Z" } }, "source": [ @@ -2999,7 +3137,7 @@ ] } ], - "execution_count": 51 + "execution_count": 55 }, { "cell_type": "markdown", @@ -3017,8 +3155,8 @@ { "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:25.645234Z", - "start_time": "2026-07-21T21:51:25.619153Z" + "end_time": "2026-09-08T00:49:25.308539Z", + "start_time": "2026-09-08T00:49:25.292292Z" } }, "cell_type": "code", @@ -3043,7 +3181,7 @@ ] } ], - "execution_count": 52 + "execution_count": 56 }, { "cell_type": "markdown", @@ -3063,8 +3201,8 @@ { "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:25.675143Z", - "start_time": "2026-07-21T21:51:25.647468Z" + "end_time": "2026-09-08T00:49:25.324399Z", + "start_time": "2026-09-08T00:49:25.309756Z" } }, "cell_type": "code", @@ -3074,7 +3212,7 @@ ], "id": "e0ac6cf3086d983a", "outputs": [], - "execution_count": 53 + "execution_count": 57 }, { "metadata": { @@ -3090,8 +3228,8 @@ { "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:25.700177Z", - "start_time": "2026-07-21T21:51:25.676895Z" + "end_time": "2026-09-08T00:49:25.364514Z", + "start_time": "2026-09-08T00:49:25.325354Z" } }, "cell_type": "code", @@ -3117,7 +3255,7 @@ ] } ], - "execution_count": 54 + "execution_count": 58 }, { "metadata": { @@ -3133,8 +3271,8 @@ { "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:25.726592Z", - "start_time": "2026-07-21T21:51:25.702370Z" + "end_time": "2026-09-08T00:49:25.401131Z", + "start_time": "2026-09-08T00:49:25.366200Z" } }, "cell_type": "code", @@ -3150,12 +3288,12 @@ "3" ] }, - "execution_count": 55, + "execution_count": 59, "metadata": {}, "output_type": "execute_result" } ], - "execution_count": 55 + "execution_count": 59 }, { "metadata": { @@ -3175,8 +3313,8 @@ { "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:25.753751Z", - "start_time": "2026-07-21T21:51:25.729660Z" + "end_time": "2026-09-08T00:49:25.422673Z", + "start_time": "2026-09-08T00:49:25.402434Z" } }, "cell_type": "code", @@ -3216,7 +3354,7 @@ ] } ], - "execution_count": 56 + "execution_count": 60 }, { "metadata": { @@ -3236,8 +3374,8 @@ { "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:25.787163Z", - "start_time": "2026-07-21T21:51:25.756223Z" + "end_time": "2026-09-08T00:49:25.454244Z", + "start_time": "2026-09-08T00:49:25.434370Z" } }, "cell_type": "code", @@ -3286,7 +3424,7 @@ ] } ], - "execution_count": 57 + "execution_count": 61 }, { "metadata": { @@ -3306,8 +3444,8 @@ { "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:25.814218Z", - "start_time": "2026-07-21T21:51:25.789715Z" + "end_time": "2026-09-08T00:49:25.497258Z", + "start_time": "2026-09-08T00:49:25.466554Z" } }, "cell_type": "code", @@ -3322,12 +3460,12 @@ " '_default_y': 4}" ] }, - "execution_count": 58, + "execution_count": 62, "metadata": {}, "output_type": "execute_result" } ], - "execution_count": 58 + "execution_count": 62 }, { "metadata": { @@ -3343,8 +3481,8 @@ { "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:25.860939Z", - "start_time": "2026-07-21T21:51:25.816535Z" + "end_time": "2026-09-08T00:49:25.546887Z", + "start_time": "2026-09-08T00:49:25.511639Z" } }, "cell_type": "code", @@ -3360,12 +3498,12 @@ "3" ] }, - "execution_count": 59, + "execution_count": 63, "metadata": {}, "output_type": "execute_result" } ], - "execution_count": 59 + "execution_count": 63 }, { "metadata": { @@ -3413,8 +3551,8 @@ { "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:25.917063Z", - "start_time": "2026-07-21T21:51:25.863708Z" + "end_time": "2026-09-08T00:49:25.598236Z", + "start_time": "2026-09-08T00:49:25.549243Z" } }, "cell_type": "code", @@ -3458,7 +3596,7 @@ ] } ], - "execution_count": 60 + "execution_count": 64 }, { "metadata": {}, @@ -3471,8 +3609,8 @@ "id": "5ccd8c05", "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:25.964625Z", - "start_time": "2026-07-21T21:51:25.919636Z" + "end_time": "2026-09-08T00:49:25.618360Z", + "start_time": "2026-09-08T00:49:25.600192Z" } }, "source": [ @@ -3504,7 +3642,7 @@ ] } ], - "execution_count": 61 + "execution_count": 65 }, { "cell_type": "markdown", @@ -3560,8 +3698,8 @@ "id": "2370bd6b", "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:25.978416Z", - "start_time": "2026-07-21T21:51:25.966952Z" + "end_time": "2026-09-08T00:49:25.647477Z", + "start_time": "2026-09-08T00:49:25.631485Z" } }, "source": [ @@ -3592,26 +3730,26 @@ " return scaled" ], "outputs": [], - "execution_count": 62 + "execution_count": 66 }, { "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:25.995299Z", - "start_time": "2026-07-21T21:51:25.978854Z" + "end_time": "2026-09-08T00:49:25.673022Z", + "start_time": "2026-09-08T00:49:25.658428Z" } }, "cell_type": "code", "source": "node_data = fr.tools.recipe2data(scaled_range.flowrep_recipe)", "id": "833e6b0ceb72e917", "outputs": [], - "execution_count": 63 + "execution_count": 67 }, { "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:26.031844Z", - "start_time": "2026-07-21T21:51:26.005677Z" + "end_time": "2026-09-08T00:49:25.709555Z", + "start_time": "2026-09-08T00:49:25.673728Z" } }, "cell_type": "code", @@ -3624,12 +3762,12 @@ "flowrep.prospective.workflow_recipe.WorkflowRecipe" ] }, - "execution_count": 64, + "execution_count": 68, "metadata": {}, "output_type": "execute_result" } ], - "execution_count": 64 + "execution_count": 68 }, { "metadata": { @@ -3650,8 +3788,8 @@ { "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:26.056910Z", - "start_time": "2026-07-21T21:51:26.033987Z" + "end_time": "2026-09-08T00:49:25.744949Z", + "start_time": "2026-09-08T00:49:25.711398Z" } }, "cell_type": "code", @@ -3678,7 +3816,7 @@ ] } ], - "execution_count": 65 + "execution_count": 69 }, { "metadata": { @@ -3703,8 +3841,8 @@ { "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:26.089621Z", - "start_time": "2026-07-21T21:51:26.059099Z" + "end_time": "2026-09-08T00:49:25.765309Z", + "start_time": "2026-09-08T00:49:25.746891Z" } }, "cell_type": "code", @@ -3717,12 +3855,12 @@ "NOT_DATA" ] }, - "execution_count": 66, + "execution_count": 70, "metadata": {}, "output_type": "execute_result" } ], - "execution_count": 66 + "execution_count": 70 }, { "metadata": {}, @@ -3733,8 +3871,8 @@ { "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:26.114460Z", - "start_time": "2026-07-21T21:51:26.091968Z" + "end_time": "2026-09-08T00:49:25.807722Z", + "start_time": "2026-09-08T00:49:25.777628Z" } }, "cell_type": "code", @@ -3747,12 +3885,12 @@ "1.0" ] }, - "execution_count": 67, + "execution_count": 71, "metadata": {}, "output_type": "execute_result" } ], - "execution_count": 67 + "execution_count": 71 }, { "metadata": { @@ -3768,8 +3906,8 @@ { "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:26.140250Z", - "start_time": "2026-07-21T21:51:26.116818Z" + "end_time": "2026-09-08T00:49:25.838530Z", + "start_time": "2026-09-08T00:49:25.823047Z" } }, "cell_type": "code", @@ -3787,12 +3925,12 @@ "(float, list[float])" ] }, - "execution_count": 68, + "execution_count": 72, "metadata": {}, "output_type": "execute_result" } ], - "execution_count": 68 + "execution_count": 72 }, { "metadata": { @@ -3808,8 +3946,8 @@ { "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:26.171898Z", - "start_time": "2026-07-21T21:51:26.142488Z" + "end_time": "2026-09-08T00:49:25.867642Z", + "start_time": "2026-09-08T00:49:25.849593Z" } }, "cell_type": "code", @@ -3830,12 +3968,12 @@ "(NOT_DATA, NOT_DATA, int, NOT_DATA, list[int])" ] }, - "execution_count": 69, + "execution_count": 73, "metadata": {}, "output_type": "execute_result" } ], - "execution_count": 69 + "execution_count": 73 }, { "metadata": { @@ -3856,8 +3994,8 @@ { "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:26.199455Z", - "start_time": "2026-07-21T21:51:26.182498Z" + "end_time": "2026-09-08T00:49:25.898443Z", + "start_time": "2026-09-08T00:49:25.879124Z" } }, "cell_type": "code", @@ -3876,13 +4014,13 @@ ] } ], - "execution_count": 70 + "execution_count": 74 }, { "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:26.226025Z", - "start_time": "2026-07-21T21:51:26.201552Z" + "end_time": "2026-09-08T00:49:25.926976Z", + "start_time": "2026-09-08T00:49:25.910261Z" } }, "cell_type": "code", @@ -3900,13 +4038,13 @@ ] } ], - "execution_count": 71 + "execution_count": 75 }, { "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:26.253404Z", - "start_time": "2026-07-21T21:51:26.229167Z" + "end_time": "2026-09-08T00:49:25.946146Z", + "start_time": "2026-09-08T00:49:25.927642Z" } }, "cell_type": "code", @@ -3924,7 +4062,7 @@ ] } ], - "execution_count": 72 + "execution_count": 76 }, { "metadata": { @@ -3949,8 +4087,8 @@ { "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:26.279116Z", - "start_time": "2026-07-21T21:51:26.255984Z" + "end_time": "2026-09-08T00:49:25.977517Z", + "start_time": "2026-09-08T00:49:25.961257Z" } }, "cell_type": "code", @@ -3973,12 +4111,12 @@ "(, {}, {}, {}, {})" ] }, - "execution_count": 73, + "execution_count": 77, "metadata": {}, "output_type": "execute_result" } ], - "execution_count": 73 + "execution_count": 77 }, { "metadata": { @@ -4000,8 +4138,8 @@ { "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:26.291303Z", - "start_time": "2026-07-21T21:51:26.281302Z" + "end_time": "2026-09-08T00:49:26.005125Z", + "start_time": "2026-09-08T00:49:25.991627Z" } }, "cell_type": "code", @@ -4019,7 +4157,7 @@ ], "id": "cc1e5c958dd87720", "outputs": [], - "execution_count": 74 + "execution_count": 78 }, { "metadata": { @@ -4035,8 +4173,8 @@ { "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:26.327749Z", - "start_time": "2026-07-21T21:51:26.302108Z" + "end_time": "2026-09-08T00:49:26.044462Z", + "start_time": "2026-09-08T00:49:26.016285Z" } }, "cell_type": "code", @@ -4056,7 +4194,7 @@ ] } ], - "execution_count": 75 + "execution_count": 79 }, { "metadata": { @@ -4072,8 +4210,8 @@ { "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:26.356972Z", - "start_time": "2026-07-21T21:51:26.330038Z" + "end_time": "2026-09-08T00:49:26.087170Z", + "start_time": "2026-09-08T00:49:26.046374Z" } }, "cell_type": "code", @@ -4092,12 +4230,12 @@ "pyiron_snippets.colors.SeabornColors" ] }, - "execution_count": 76, + "execution_count": 80, "metadata": {}, "output_type": "execute_result" } ], - "execution_count": 76 + "execution_count": 80 }, { "metadata": { @@ -4121,15 +4259,15 @@ { "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:26.379040Z", - "start_time": "2026-07-21T21:51:26.361168Z" + "end_time": "2026-09-08T00:49:26.109193Z", + "start_time": "2026-09-08T00:49:26.090219Z" } }, "cell_type": "code", "source": "ran_node = fr.tools.run_recipe(scaled_range.flowrep_recipe, n=3)", "id": "8fc856912a06a735", "outputs": [], - "execution_count": 77 + "execution_count": 81 }, { "metadata": { @@ -4148,8 +4286,8 @@ { "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:26.455849Z", - "start_time": "2026-07-21T21:51:26.380189Z" + "end_time": "2026-09-08T00:49:26.126195Z", + "start_time": "2026-09-08T00:49:26.110203Z" } }, "cell_type": "code", @@ -4162,12 +4300,12 @@ "[0.0, 1.0, 2.0]" ] }, - "execution_count": 78, + "execution_count": 82, "metadata": {}, "output_type": "execute_result" } ], - "execution_count": 78 + "execution_count": 82 }, { "metadata": { @@ -4183,8 +4321,8 @@ { "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:26.500273Z", - "start_time": "2026-07-21T21:51:26.468913Z" + "end_time": "2026-09-08T00:49:26.142502Z", + "start_time": "2026-09-08T00:49:26.127102Z" } }, "cell_type": "code", @@ -4210,7 +4348,45 @@ ] } ], - "execution_count": 79 + "execution_count": 83 + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": [ + "Retrospective data draws too, with the same method. Where printing shows the values,\n", + "drawing shows the shape: the for-node has collapsed from one representative `body_n`\n", + "prospectively to one concrete node per iteration retrospectively." + ], + "id": "7e7886e38e56cf6f" + }, + { + "cell_type": "code", + "id": "838c4719f577a6a3", + "metadata": { + "ExecuteTime": { + "end_time": "2026-09-08T00:49:26.305789Z", + "start_time": "2026-09-08T00:49:26.143499Z" + } + }, + "source": [ + "# NBVAL_IGNORE_OUTPUT\n", + "ran_for.draw()" + ], + "outputs": [ + { + "data": { + "image/svg+xml": "\n\n\n\n\n\n\n\ncluster_\n\nfor_each\n(no recorded edges)\n\n\n\ninputs.factor\n\nfactor\n\n\n\n\ninputs.items\n\nitems\n\n\n\n\noutputs.results\n\nresults\n\n\n\n\nbody_0\n\nbody_0\n\n\nitem\n\n\nscaled\n\n\nfactor\n\n\n\n\nbody_1\n\nbody_1\n\n\nitem\n\n\nscaled\n\n\nfactor\n\n\n\n\nbody_2\n\nbody_2\n\n\nitem\n\n\nscaled\n\n\nfactor\n\n\n\n\n", + "text/plain": [ + "" + ] + }, + "execution_count": 84, + "metadata": {}, + "output_type": "execute_result" + } + ], + "execution_count": 84 }, { "metadata": { @@ -4233,8 +4409,8 @@ { "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:26.555899Z", - "start_time": "2026-07-21T21:51:26.502613Z" + "end_time": "2026-09-08T00:49:26.327352Z", + "start_time": "2026-09-08T00:49:26.310319Z" } }, "cell_type": "code", @@ -4276,12 +4452,12 @@ "{'scaled': OutputDataPort(value=[0.0, 1.0, 2.0, 3.0, 4.0], annotation=list[float])}" ] }, - "execution_count": 80, + "execution_count": 85, "metadata": {}, "output_type": "execute_result" } ], - "execution_count": 80 + "execution_count": 85 }, { "metadata": { @@ -4297,8 +4473,8 @@ { "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:26.967488Z", - "start_time": "2026-07-21T21:51:26.558200Z" + "end_time": "2026-09-08T00:49:26.677341Z", + "start_time": "2026-09-08T00:49:26.340152Z" } }, "cell_type": "code", @@ -4309,7 +4485,7 @@ ], "id": "8d092cdcf9c0e517", "outputs": [], - "execution_count": 81 + "execution_count": 86 }, { "metadata": { @@ -4332,8 +4508,8 @@ { "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:27.006299Z", - "start_time": "2026-07-21T21:51:26.972187Z" + "end_time": "2026-09-08T00:49:26.713353Z", + "start_time": "2026-09-08T00:49:26.683278Z" } }, "cell_type": "code", @@ -4402,12 +4578,12 @@ " 'rescale_all_0.for_each_0.body_4.rescale_0.outputs.result']" ] }, - "execution_count": 82, + "execution_count": 87, "metadata": {}, "output_type": "execute_result" } ], - "execution_count": 82 + "execution_count": 87 }, { "metadata": { @@ -4423,8 +4599,8 @@ { "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:27.366845Z", - "start_time": "2026-07-21T21:51:27.008550Z" + "end_time": "2026-09-08T00:49:27.019082Z", + "start_time": "2026-09-08T00:49:26.725883Z" } }, "cell_type": "code", @@ -4440,12 +4616,12 @@ "(True, dict_keys(['for_each_0']))" ] }, - "execution_count": 83, + "execution_count": 88, "metadata": {}, "output_type": "execute_result" } ], - "execution_count": 83 + "execution_count": 88 }, { "metadata": { @@ -4461,8 +4637,8 @@ { "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:27.392958Z", - "start_time": "2026-07-21T21:51:27.371246Z" + "end_time": "2026-09-08T00:49:27.038801Z", + "start_time": "2026-09-08T00:49:27.023414Z" } }, "cell_type": "code", @@ -4478,12 +4654,12 @@ "(True, 1)" ] }, - "execution_count": 84, + "execution_count": 89, "metadata": {}, "output_type": "execute_result" } ], - "execution_count": 84 + "execution_count": 89 }, { "metadata": { @@ -4502,8 +4678,8 @@ { "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:27.478034Z", - "start_time": "2026-07-21T21:51:27.436653Z" + "end_time": "2026-09-08T00:49:27.081959Z", + "start_time": "2026-09-08T00:49:27.049913Z" } }, "cell_type": "code", @@ -4523,7 +4699,7 @@ ] } ], - "execution_count": 85 + "execution_count": 90 }, { "metadata": { @@ -4539,8 +4715,8 @@ { "metadata": { "ExecuteTime": { - "end_time": "2026-07-21T21:51:27.508887Z", - "start_time": "2026-07-21T21:51:27.480243Z" + "end_time": "2026-09-08T00:49:27.134241Z", + "start_time": "2026-09-08T00:49:27.083861Z" } }, "cell_type": "code", @@ -4558,15 +4734,15 @@ "application/vnd.jupyter.widget-view+json": { "version_major": 2, "version_minor": 0, - "model_id": "2a5e28e39c3f4403a97bce945ee5c0d1" + "model_id": "edd9ecaffad14b07bdefdad2f2cae161" } }, - "execution_count": 86, + "execution_count": 91, "metadata": {}, "output_type": "execute_result" } ], - "execution_count": 86 + "execution_count": 91 }, { "metadata": { diff --git a/pyproject.toml b/pyproject.toml index 20449e42..84d09d44 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,6 +59,9 @@ storage-widget = [ dataviewer = [ "ipython", ] +drawing = [ + "graphviz==0.21", +] [tool.hatch.build] include = [ diff --git a/src/flowrep/__init__.py b/src/flowrep/__init__.py index 2bff3b34..4c300136 100644 --- a/src/flowrep/__init__.py +++ b/src/flowrep/__init__.py @@ -8,6 +8,7 @@ from flowrep.api import atomic as atomic from flowrep.api import dataclass as dataclass +from flowrep.api import draw as draw from flowrep.api import parse_atomic as parse_atomic from flowrep.api import schemas as schemas from flowrep.api import std as std diff --git a/src/flowrep/api/__init__.py b/src/flowrep/api/__init__.py index 6c99c3ab..5e868a80 100644 --- a/src/flowrep/api/__init__.py +++ b/src/flowrep/api/__init__.py @@ -12,6 +12,7 @@ from flowrep.api import tools as tools from flowrep.api.tools import atomic as atomic from flowrep.api.tools import dataclass as dataclass +from flowrep.api.tools import draw as draw from flowrep.api.tools import parse_atomic as parse_atomic from flowrep.api.tools import parse_workflow as parse_workflow from flowrep.api.tools import workflow as workflow diff --git a/src/flowrep/api/tools.py b/src/flowrep/api/tools.py index 96d2cf35..8268cd07 100644 --- a/src/flowrep/api/tools.py +++ b/src/flowrep/api/tools.py @@ -11,6 +11,7 @@ ) from flowrep.converters.python_workflow_definition import flowrep2pwd as flowrep2pwd from flowrep.converters.python_workflow_definition import pwd2flowrep as pwd2flowrep +from flowrep.drawing import draw as draw from flowrep.parsers.atomic_parser import atomic as atomic from flowrep.parsers.atomic_parser import parse_atomic as parse_atomic from flowrep.parsers.dataclass_parser import dataclass as dataclass diff --git a/src/flowrep/base_models.py b/src/flowrep/base_models.py index f833d97c..9003f494 100644 --- a/src/flowrep/base_models.py +++ b/src/flowrep/base_models.py @@ -5,12 +5,15 @@ import keyword from collections.abc import Hashable from enum import StrEnum -from typing import Annotated, ClassVar, Self, TypeVar +from typing import TYPE_CHECKING, Annotated, ClassVar, Self, TypeVar import pydantic import pydantic_core from pyiron_snippets import versions +if TYPE_CHECKING: + import graphviz + class RecipeElementType(StrEnum): ATOMIC = "atomic" @@ -132,6 +135,29 @@ def _check_inputs_with_defaults_subset_of_inputs(self) -> Self: def validate_internal_data_completeness(self): return self + def draw(self, depth: int | None = None) -> graphviz.Digraph: + """ + Draw this recipe's topology, ports and labels as a graphviz graph. + + Renders inline in a Jupyter notebook, and also offers ``.render()``, + ``.pipe()`` and ``.source``. + + Args: + depth: How many generations of nested subgraph to expand below this + recipe's own children. The recipe itself always expands. + Defaults to 1. + + Returns: + The drawn graph. + + Raises: + ImportAlarmError: If the optional drawing dependency is missing. The + message names both the pip and conda install routes. + """ + from flowrep import drawing + + return drawing.draw(self, depth=depth) + @abc.abstractmethod def __call__(self, *args, **kwargs): raise NotImplementedError( diff --git a/src/flowrep/drawing/__init__.py b/src/flowrep/drawing/__init__.py new file mode 100644 index 00000000..e215a1ff --- /dev/null +++ b/src/flowrep/drawing/__init__.py @@ -0,0 +1,3 @@ +from flowrep.drawing.interface import draw as draw +from flowrep.drawing.interface import draw_prospective as draw_prospective +from flowrep.drawing.interface import draw_retrospective as draw_retrospective diff --git a/src/flowrep/drawing/interface.py b/src/flowrep/drawing/interface.py new file mode 100644 index 00000000..23b82329 --- /dev/null +++ b/src/flowrep/drawing/interface.py @@ -0,0 +1,55 @@ +""" +The public drawing callables. + +Named ``interface`` rather than ``draw`` so the module does not shadow the +:func:`draw` function re-exported alongside it. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from flowrep import base_models +from flowrep.drawing import prospective, render, retrospective +from flowrep.retrospective import datastructures + +if TYPE_CHECKING: + import graphviz + +PROSPECTIVE_DEPTH = 1 +RETROSPECTIVE_DEPTH = 0 + + +def draw_prospective( + graph: base_models.NodeRecipe, depth: int = PROSPECTIVE_DEPTH +) -> graphviz.Digraph: + """Draw a prospective recipe, expanding ``depth`` generations of subgraph.""" + return render.render(prospective.build(graph, depth=depth)) + + +def draw_retrospective( + graph: datastructures.NodeData, depth: int = RETROSPECTIVE_DEPTH +) -> graphviz.Digraph: + """Draw a retrospective data object, expanding ``depth`` generations.""" + return render.render(retrospective.build(graph, depth=depth)) + + +def draw( + graph: base_models.NodeRecipe | datastructures.NodeData, depth: int | None = None +) -> graphviz.Digraph: + """Draw either a recipe or a data object, dispatching on type. + + When ``depth`` is None the default of the dispatched-to drawer applies. + """ + if isinstance(graph, base_models.NodeRecipe): + return draw_prospective( + graph, depth=PROSPECTIVE_DEPTH if depth is None else depth + ) + if isinstance(graph, datastructures.NodeData): + return draw_retrospective( + graph, depth=RETROSPECTIVE_DEPTH if depth is None else depth + ) + raise TypeError( + f"Can only draw a {base_models.NodeRecipe.__name__} or a " + f"{datastructures.NodeData.__name__}, but got {type(graph).__name__}: {graph!r}" + ) diff --git a/src/flowrep/drawing/model.py b/src/flowrep/drawing/model.py new file mode 100644 index 00000000..5e4e27d0 --- /dev/null +++ b/src/flowrep/drawing/model.py @@ -0,0 +1,82 @@ +""" +A graphviz-free intermediate representation of a drawable graph. + +Builders (:mod:`flowrep.drawing.prospective`, :mod:`flowrep.drawing.retrospective`) +produce this; :mod:`flowrep.drawing.render` consumes it. Keeping the two apart +means topology logic is testable without the optional drawing dependency. +""" + +from __future__ import annotations + +import dataclasses +from collections.abc import Iterator + +from flowrep import base_models, lexical + + +@dataclasses.dataclass(frozen=True) +class DrawPort: + """A single IO port as it should appear inside a node box.""" + + label: str + hint: str | None = None + has_default: bool = False + badge: str | None = None + + +@dataclasses.dataclass(frozen=True) +class PortRef: + """An edge endpoint. An empty ``node_path`` means the enclosing node's own IO.""" + + node_path: str + io_type: base_models.IOTypes + port: str + + @property + def lexical_path(self) -> str: + return lexical.port_path(self.node_path, self.io_type, self.port) + + +@dataclasses.dataclass(frozen=True) +class DrawEdge: + source: PortRef + target: PortRef + conditional: bool = False + """One of several candidate sources; exactly one actualizes at runtime.""" + + +@dataclasses.dataclass(frozen=True) +class DrawGroup: + """A purely visual grouping of sibling nodes. Has no lexical path.""" + + label: str + members: tuple[str, ...] + + +@dataclasses.dataclass(frozen=True) +class DrawNode: + path: str + """Lexical path from the drawing root; empty for the root itself.""" + label: str + """Displayed name, which may differ from the path tail (e.g. ``body_n``).""" + kind: base_models.RecipeElementType + subtitle: str | None + inputs: tuple[DrawPort, ...] + outputs: tuple[DrawPort, ...] + children: tuple[DrawNode, ...] + edges: tuple[DrawEdge, ...] + groups: tuple[DrawGroup, ...] = () + note: str | None = None + + @property + def is_leaf(self) -> bool: + return not self.children + + def walk(self) -> Iterator[DrawNode]: + """Yield this node, then every descendant, depth-first.""" + yield self + for child in self.children: + yield from child.walk() + + +DrawGraph = DrawNode diff --git a/src/flowrep/drawing/prospective.py b/src/flowrep/drawing/prospective.py new file mode 100644 index 00000000..578c9d81 --- /dev/null +++ b/src/flowrep/drawing/prospective.py @@ -0,0 +1,373 @@ +""" +Build a graphviz-free :class:`~flowrep.drawing.model.DrawGraph` from a +prospective :class:`~flowrep.base_models.NodeRecipe`. + +Free of any graphviz import, so building a drawing works in a bare install. +Flow-control recipe types (``for_each``, ``while``, ``if``, ``try``) expand into +their condition and body nodes, subject to the ``depth`` limit, just like a +``WorkflowRecipe`` expands into its own nodes. +""" + +from __future__ import annotations + +import dataclasses +from collections.abc import Mapping + +from flowrep import base_models, edge_models, lexical +from flowrep.drawing import model, style +from flowrep.prospective import ( + atomic_recipe, + constant_recipe, + for_recipe, + helper_models, + if_recipe, + try_recipe, + while_recipe, + workflow_recipe, +) + + +def build(recipe: base_models.NodeRecipe, depth: int = 1) -> model.DrawGraph: + """Build a drawing of *recipe*, expanding composites down to *depth*. + + The root always expands (if it is a composite); ``depth`` counts further + generations below the root's children. Raises ``ValueError`` if ``depth`` + is negative. + """ + if depth < 0: + raise ValueError(f"depth must be >= 0, got {depth}") + return _build(recipe, path="", label=recipe.type.value, depth=depth) + + +def _build( + recipe: base_models.NodeRecipe, path: str, label: str, depth: int +) -> model.DrawNode: + """Build a single node, recursing into children when it is an expanded composite.""" + inputs, outputs = _ports(recipe) + children: tuple[model.DrawNode, ...] = () + edges: tuple[model.DrawEdge, ...] = () + groups: tuple[model.DrawGroup, ...] = () + match recipe: + case atomic_recipe.AtomicRecipe() | constant_recipe.ConstantRecipe(): + pass + case workflow_recipe.WorkflowRecipe(): + if depth >= 0: + children = _build_children(recipe.nodes, path, depth) + edges = _workflow_edges(recipe) + case for_recipe.ForEachRecipe(): + if depth >= 0: + children, edges = _build_for_each(recipe, path, depth) + case while_recipe.WhileRecipe(): + if depth >= 0: + children, edges = _build_while(recipe, path, depth) + case if_recipe.IfRecipe(): + if depth >= 0: + children, edges, groups = _build_if(recipe, path, depth) + case try_recipe.TryRecipe(): + if depth >= 0: + children, edges, groups = _build_try(recipe, path, depth) + case _: + raise TypeError(f"Unrecognized recipe type: {recipe}") + return model.DrawNode( + path=path, + label=label, + kind=recipe.type, + subtitle=style.subtitle_for(recipe), + inputs=inputs, + outputs=outputs, + children=children, + edges=edges, + groups=groups, + ) + + +def _ports( + recipe: base_models.NodeRecipe, +) -> tuple[tuple[model.DrawPort, ...], tuple[model.DrawPort, ...]]: + """Convert a recipe's port labels to draw ports. + + Prospectively there is no annotation to show, so ``hint`` is always + ``None``. ``badge`` starts ``None`` here; flow-control builders overwrite + it afterwards on the specific ports that carry a badge (an iterated body + input, or a condition's evaluated output). + """ + defaults = set(recipe.inputs_with_defaults) + inputs = tuple( + model.DrawPort(label=label, has_default=label in defaults) + for label in recipe.inputs + ) + outputs = tuple(model.DrawPort(label=label) for label in recipe.outputs) + return inputs, outputs + + +def _child_path(parent_path: str, label: str) -> str: + """The lexical path of a child node given its parent's path.""" + return lexical.join(parent_path, label) + + +def _build_children( + nodes: Mapping[base_models.Label, base_models.NodeRecipe], + parent_path: str, + depth: int, +) -> tuple[model.DrawNode, ...]: + """Build every child of a composite, one generation shallower.""" + return tuple( + _build( + child_recipe, _child_path(parent_path, child_label), child_label, depth - 1 + ) + for child_label, child_recipe in nodes.items() + ) + + +def _workflow_edges( + recipe: workflow_recipe.WorkflowRecipe, +) -> tuple[model.DrawEdge, ...]: + """All edges of a workflow recipe: parent-in, sibling, and parent-out.""" + return ( + _convert_input_edges(recipe.input_edges) + + _convert_sibling_edges(recipe.edges) + + _convert_output_edges(recipe.output_edges) + ) + + +def _convert_input_edges(edges: edge_models.InputEdges) -> tuple[model.DrawEdge, ...]: + """A parent input source, drawn to a real child target.""" + return tuple( + model.DrawEdge( + source=model.PortRef("", base_models.IOTypes.INPUTS, source.port), + target=model.PortRef(target.node, base_models.IOTypes.INPUTS, target.port), + ) + for target, source in edges.items() + ) + + +def _convert_sibling_edges(edges: edge_models.Edges) -> tuple[model.DrawEdge, ...]: + """A real child source, drawn to a real child target.""" + return tuple( + model.DrawEdge( + source=model.PortRef(source.node, base_models.IOTypes.OUTPUTS, source.port), + target=model.PortRef(target.node, base_models.IOTypes.INPUTS, target.port), + ) + for target, source in edges.items() + ) + + +def _convert_output_edges( + edges: edge_models.OutputEdges, *, conditional: bool = False +) -> tuple[model.DrawEdge, ...]: + """A real child source or a parent-input passthrough, drawn to a parent output.""" + return tuple( + model.DrawEdge( + source=_output_edge_source(source), + target=model.PortRef("", base_models.IOTypes.OUTPUTS, target.port), + conditional=conditional, + ) + for target, source in edges.items() + ) + + +def _output_edge_source( + source: edge_models.SourceHandle | edge_models.InputSource, +) -> model.PortRef: + """The source endpoint for an output edge: a child output, or a parent passthrough. + + A ``None`` node is what marks a handle as referring to the enclosing node's + own IO rather than to a child, so that -- not the handle's class -- is the + thing to branch on. + """ + if source.node is None: + return model.PortRef("", base_models.IOTypes.INPUTS, source.port) + return model.PortRef(source.node, base_models.IOTypes.OUTPUTS, source.port) + + +def _badge_port( + ports: tuple[model.DrawPort, ...], label: str, badge: str +) -> tuple[model.DrawPort, ...]: + """Rebuild *ports*, tagging the one named *label* with *badge*.""" + return tuple( + dataclasses.replace(port, badge=badge) if port.label == label else port + for port in ports + ) + + +def _evaluated_output(case: helper_models.ConditionalCase) -> str: + """The condition output that decides the case: explicit, or the sole one.""" + return case.condition_output or case.condition.recipe.outputs[0] + + +def _flatten_prospective_output_edges( + edges: Mapping[edge_models.OutputTarget, list[edge_models.SourceHandle]], +) -> tuple[model.DrawEdge, ...]: + """Every candidate source of a fan-in output, each drawn ``conditional=True``. + + Reuses :func:`_convert_output_edges` per candidate rather than duplicating + its endpoint logic. + """ + result: tuple[model.DrawEdge, ...] = () + for target, sources in edges.items(): + for source in sources: + result += _convert_output_edges({target: source}, conditional=True) + return result + + +def _build_for_each( + recipe: for_recipe.ForEachRecipe, path: str, depth: int +) -> tuple[tuple[model.DrawNode, ...], tuple[model.DrawEdge, ...]]: + """One badged body child, plus its input and (possibly transferred) output edges.""" + label = recipe.body_node.label + child = _build( + recipe.body_node.recipe, _child_path(path, label), f"{label}_n", depth - 1 + ) + for port_label, badge in ( + *((port, "nested") for port in recipe.nested_ports), + *((port, "zipped") for port in recipe.zipped_ports), + ): + child = dataclasses.replace( + child, inputs=_badge_port(child.inputs, port_label, badge) + ) + edges = _convert_input_edges(recipe.input_edges) + _convert_output_edges( + recipe.output_edges + ) + return (child,), edges + + +def _build_while( + recipe: while_recipe.WhileRecipe, path: str, depth: int +) -> tuple[tuple[model.DrawNode, ...], tuple[model.DrawEdge, ...]]: + """Condition and body children, the recipe's inferred back-edges, and the + conditional input-to-output fallback that stands in when the body never runs.""" + case = recipe.case + cond = _build( + case.condition.recipe, + _child_path(path, case.condition.label), + f"{case.condition.label}_i", + depth - 1, + ) + cond = dataclasses.replace( + cond, outputs=_badge_port(cond.outputs, _evaluated_output(case), "test") + ) + body = _build( + case.body.recipe, + _child_path(path, case.body.label), + f"{case.body.label}_i", + depth - 1, + ) + edges = ( + _convert_input_edges(recipe.input_edges) + + _convert_output_edges(recipe.output_edges) + + _convert_sibling_edges(recipe.body_body_edges) + + _convert_sibling_edges(recipe.body_condition_edges) + + _fallback_edges(recipe.outputs) + ) + return (cond, body), edges + + +def _fallback_edges(outputs: base_models.Labels) -> tuple[model.DrawEdge, ...]: + """A conditional parent-input-to-parent-output edge per while output. + + Valid because ``WhileRecipe`` validates ``outputs`` as a subset of + ``inputs``: each of these inputs is the fallback value if the body never + executes. + """ + return tuple( + model.DrawEdge( + source=model.PortRef("", base_models.IOTypes.INPUTS, label), + target=model.PortRef("", base_models.IOTypes.OUTPUTS, label), + conditional=True, + ) + for label in outputs + ) + + +def _build_if( + recipe: if_recipe.IfRecipe, path: str, depth: int +) -> tuple[ + tuple[model.DrawNode, ...], tuple[model.DrawEdge, ...], tuple[model.DrawGroup, ...] +]: + """Every case's condition and body, plus the else case, grouped by case.""" + children: list[model.DrawNode] = [] + groups: list[model.DrawGroup] = [] + for i, case in enumerate(recipe.cases): + cond, body = _build_conditional_case(case, path, depth) + children.extend((cond, body)) + groups.append( + model.DrawGroup(f"case {i}", (case.condition.label, case.body.label)) + ) + if recipe.else_case is not None: + else_case = recipe.else_case + children.append( + _build( + else_case.recipe, + _child_path(path, else_case.label), + else_case.label, + depth - 1, + ) + ) + groups.append(model.DrawGroup("else", (else_case.label,))) + edges = _convert_input_edges( + recipe.input_edges + ) + _flatten_prospective_output_edges(recipe.prospective_output_edges) + return tuple(children), edges, tuple(groups) + + +def _build_conditional_case( + case: helper_models.ConditionalCase, parent_path: str, depth: int +) -> tuple[model.DrawNode, model.DrawNode]: + """A case's condition (badged) and body, as plain (undecorated) children.""" + cond = _build( + case.condition.recipe, + _child_path(parent_path, case.condition.label), + case.condition.label, + depth - 1, + ) + cond = dataclasses.replace( + cond, outputs=_badge_port(cond.outputs, _evaluated_output(case), "test") + ) + body = _build( + case.body.recipe, + _child_path(parent_path, case.body.label), + case.body.label, + depth - 1, + ) + return cond, body + + +def _build_try( + recipe: try_recipe.TryRecipe, path: str, depth: int +) -> tuple[ + tuple[model.DrawNode, ...], tuple[model.DrawEdge, ...], tuple[model.DrawGroup, ...] +]: + """The try node, plus one body per exception case, grouped accordingly.""" + try_node = recipe.try_node + children = [ + _build( + try_node.recipe, + _child_path(path, try_node.label), + try_node.label, + depth - 1, + ) + ] + groups = [model.DrawGroup("try", (try_node.label,))] + for case in recipe.exception_cases: + children.append( + _build( + case.body.recipe, + _child_path(path, case.body.label), + case.body.label, + depth - 1, + ) + ) + groups.append(model.DrawGroup(_except_label(case), (case.body.label,))) + edges = _convert_input_edges( + recipe.input_edges + ) + _flatten_prospective_output_edges(recipe.prospective_output_edges) + return tuple(children), edges, tuple(groups) + + +def _except_label(case: helper_models.ExceptionCase) -> str: + """``"except "`` followed by each caught exception's bare name, comma-joined.""" + names = ", ".join( + exception.fully_qualified_name.rsplit(".", 1)[-1] + for exception in case.exceptions + ) + return f"except {names}" diff --git a/src/flowrep/drawing/render.py b/src/flowrep/drawing/render.py new file mode 100644 index 00000000..7360734a --- /dev/null +++ b/src/flowrep/drawing/render.py @@ -0,0 +1,302 @@ +""" +Render a graphviz-free :class:`~flowrep.drawing.model.DrawGraph` into a +``graphviz.Digraph``. + +This is the only module in :mod:`flowrep.drawing` that imports ``graphviz``. +Building a :class:`~flowrep.drawing.model.DrawGraph` +(:mod:`flowrep.drawing.prospective`, :mod:`flowrep.drawing.retrospective`) +works in a bare install; only calling :func:`render` requires the optional +``graphviz`` package and a Graphviz ``dot`` binary. +""" + +from __future__ import annotations + +from pyiron_snippets import import_alarm + +from flowrep import base_models, lexical +from flowrep.drawing import model, style + +with import_alarm.ImportAlarm( + "This tool requires the 'graphviz' package. Install it with " + "`pip install flowrep[drawing]` (which also needs the Graphviz `dot` " + "binary from your system package manager) or " + "`conda install -c conda-forge python-graphviz` (which bundles it).", + raise_exception=True, +) as _import_alarm: + import graphviz + +GRAPH_ATTR = { + "rankdir": "LR", + "compound": "true", + "bgcolor": "white", + "nodesep": "0.3", + "ranksep": "0.5", +} +NODE_ATTR = {"shape": "plaintext"} + +_TITLE_FONT_SIZE = "8" +_BADGE_FONT_SIZE = "7" +_GROUP_FONT_SIZE = "9" +_SPACER_WIDTH = "30" + + +@_import_alarm +def render(graph: model.DrawGraph) -> graphviz.Digraph: + """Render *graph* -- and everything nested inside it -- as a ``Digraph``.""" + digraph = graphviz.Digraph(graph_attr=dict(GRAPH_ATTR), node_attr=dict(NODE_ATTR)) + _emit_node(digraph, graph) + return digraph + + +def _escape(text: str) -> str: + """Escape ``&``, ``<`` and ``>`` for use inside an HTML-like label.""" + return text.replace("&", "&").replace("<", "<").replace(">", ">") + + +def _emit_node(context: graphviz.Digraph, node: model.DrawNode) -> None: + """Draw *node* -- a leaf box, or a cluster containing its children -- into *context*.""" + if node.is_leaf: + _emit_leaf(context, node) + else: + _emit_composite(context, node) + + +def _emit_leaf(context: graphviz.Digraph, node: model.DrawNode) -> None: + """Draw *node* as a single ``plaintext`` node holding an HTML-like table.""" + context.node(node.path, label=_leaf_label(node)) + + +def _leaf_label(node: model.DrawNode) -> str: + """The HTML-like ```` label for a leaf node box.""" + fill, line = style.NODE_PALETTE[node.kind] + rows = [f''] + row_count = max(len(node.inputs), len(node.outputs)) + for i in range(row_count): + left = _port_cell(node.inputs[i], "i") if i < len(node.inputs) else "" + right = ( + _port_cell(node.outputs[i], "o") if i < len(node.outputs) else "" + ) + rows.append(f'{left}{right}') + table = ( + f'
{_title_cell(node)}
{"".join(rows)}
' + ) + return f"<{table}>" + + +def _title_cell(node: model.DrawNode) -> str: + """The bold, wrapped label plus the optional monospace subtitle.""" + lines = style.wrap_label(node.label) + html = f"{'
'.join(_escape(line) for line in lines)}
" + if node.subtitle is not None: + html += ( + f'
' + f"{_escape(node.subtitle)}" + ) + return html + + +def _port_content(port: model.DrawPort) -> str: + """The (possibly italicised, possibly badged) label content of a port cell.""" + label = _escape(style.truncate_right(port.label, style.PORT_LABEL_MAX)) + if port.has_default: + label = f"{label}" + extra = port.badge if port.badge is not None else port.hint + if extra is not None: + label += ( + f'
{_escape(extra)}' + ) + return label + + +def _port_cell(port: model.DrawPort, prefix: str) -> str: + """A single rounded, grey-filled port cell, italicised and badged as needed.""" + return ( + f'' + f"{_port_content(port)}" + ) + + +def _emit_composite(context: graphviz.Digraph, node: model.DrawNode) -> None: + """Draw *node* as a filled cluster: its own IO boxes, its children, its edges.""" + with context.subgraph(name=f"cluster_{node.path}") as sub: + fill, line = style.NODE_PALETTE[node.kind] + sub.attr( + label=_cluster_label(node), + style="rounded,filled", + fillcolor=fill, + color=line, + penwidth="2", + margin="12", + ) + _emit_io_boxes(sub, node, node.inputs, base_models.IOTypes.INPUTS) + _emit_io_boxes(sub, node, node.outputs, base_models.IOTypes.OUTPUTS) + _emit_children(sub, node) + _emit_edges(sub, node) + + +def _cluster_label(node: model.DrawNode) -> str: + """The cluster's own title, mirroring a leaf's title cell, plus an optional note.""" + html = _title_cell(node) + if node.note is not None: + html += f"
{_escape(node.note)}" + return f"<{html}>" + + +def _emit_io_boxes( + context: graphviz.Digraph, + node: model.DrawNode, + ports: tuple[model.DrawPort, ...], + io_type: base_models.IOTypes, +) -> None: + """One small box per port in *ports*, rank-aligned together when there is more than one.""" + if not ports: + return + if len(ports) > 1: + with context.subgraph() as rank_group: + rank_group.attr(rank="same") + for port in ports: + _emit_io_box(rank_group, node, port, io_type) + else: + _emit_io_box(context, node, ports[0], io_type) + + +def _emit_io_box( + context: graphviz.Digraph, + node: model.DrawNode, + port: model.DrawPort, + io_type: base_models.IOTypes, +) -> None: + """A composite's own IO port, drawn as a single-cell grey box just inside the wall.""" + box_id = model.PortRef(node.path, io_type, port.label).lexical_path + table = ( + f'' + f'
{_port_content(port)}
' + ) + context.node(box_id, label=f"<{table}>") + + +def _emit_children(context: graphviz.Digraph, node: model.DrawNode) -> None: + """Draw every child, nesting grouped ones inside a dashed group cluster. + + Groups are emitted in reverse declaration order: under ``rankdir=LR``, + Graphviz stacks same-rank clusters bottom-up in declaration order, so + emitting the first-declared group last places it at the top, matching + evaluation order. + """ + by_label = {lexical.split(child.path)[-1]: child for child in node.children} + grouped_paths: set[str] = set() + for index, group in reversed(list(enumerate(node.groups))): + group_name = lexical.join(node.path, f"group_{index}") + with context.subgraph(name=f"cluster_{group_name}") as group_sub: + group_sub.attr( + label=f"<{_escape(group.label)}>", + fontsize=_GROUP_FONT_SIZE, + style="rounded,dashed", + color=style.GROUP_LINE, + ) + for member in group.members: + child = by_label[member] + _emit_node(group_sub, child) + grouped_paths.add(child.path) + for child in node.children: + if child.path not in grouped_paths: + _emit_node(context, child) + + +def _emit_edges(context: graphviz.Digraph, node: model.DrawNode) -> None: + """Draw every edge of *node*, cutting the ones that would close a cycle.""" + children_by_path = {child.path: child for child in node.children} + back_edges = _back_edges(node.edges) + for edge in node.edges: + tail = _endpoint(node, children_by_path, edge.source, compass="e") + head = _endpoint(node, children_by_path, edge.target, compass="w") + attrs: dict[str, str] = {} + if edge.conditional: + attrs["style"] = "dashed" + attrs["color"] = style.CONDITIONAL_LINE + if edge in back_edges: + attrs["constraint"] = "false" + context.edge(tail, head, **attrs) + + +def _endpoint( + composite: model.DrawNode, + children_by_path: dict[str, model.DrawNode], + ref: model.PortRef, + *, + compass: str, +) -> str: + """The ``node:port:compass`` string addressing *ref* from inside *composite*. + + An empty ``node_path`` addresses *composite*'s own IO box. Otherwise it + addresses a real child: a leaf child's own port cell, or -- when the + child is itself an expanded composite -- that child's own IO box, which + is as close as Graphviz gets to piercing a cluster wall. + """ + if not ref.node_path: + box_id = model.PortRef(composite.path, ref.io_type, ref.port).lexical_path + return f"{box_id}:p:{compass}" + child_path = lexical.join(composite.path, ref.node_path) + child = children_by_path[child_path] + if child.is_leaf: + prefix = "i" if ref.io_type is base_models.IOTypes.INPUTS else "o" + return f"{child_path}:{prefix}_{ref.port}:{compass}" + box_id = model.PortRef(child_path, ref.io_type, ref.port).lexical_path + return f"{box_id}:p:{compass}" + + +def _back_edges(edges: tuple[model.DrawEdge, ...]) -> set[model.DrawEdge]: + """Depth-first cycle detection over *edges*, keyed by sibling node path. + + ``while`` back-edges make a cluster's own edge set cyclic, which would + otherwise destroy ``rankdir=LR`` layout. This is a rendering-local layout + concern, not represented in the IR. + + A real child collapses its input and output pins into one node identity, + since the dependency that matters here is "does this child's output feed + back into one of its own ancestors". A composite's own IO does not + collapse that way: its inputs and outputs are the source and sink of the + whole cluster, and treating them as one node would flag every ordinary + input-to-output path as a cycle. + """ + adjacency: dict[str, list[tuple[str, model.DrawEdge]]] = {} + for edge in edges: + adjacency.setdefault(_node_key(edge.source), []).append( + (_node_key(edge.target), edge) + ) + all_nodes = {_node_key(edge.source) for edge in edges} | { + _node_key(edge.target) for edge in edges + } + visited: set[str] = set() + in_progress: set[str] = set() + back: set[model.DrawEdge] = set() + + def visit(current: str) -> None: + visited.add(current) + in_progress.add(current) + for target, edge in adjacency.get(current, []): + if target in in_progress: + back.add(edge) + elif target not in visited: + visit(target) + in_progress.discard(current) + + for start in all_nodes: + if start not in visited: + visit(start) + return back + + +def _node_key(ref: model.PortRef) -> str: + """The sibling-level node identity of *ref*, for cycle detection. + + A real child's own label, ignoring which of its ports is referenced; a + composite's own input and its own output are distinct pseudo-nodes. + """ + if ref.node_path: + return ref.node_path + return f"<{ref.io_type}>" diff --git a/src/flowrep/drawing/retrospective.py b/src/flowrep/drawing/retrospective.py new file mode 100644 index 00000000..fc38387a --- /dev/null +++ b/src/flowrep/drawing/retrospective.py @@ -0,0 +1,161 @@ +""" +Build a graphviz-free :class:`~flowrep.drawing.model.DrawGraph` from a +retrospective :class:`~flowrep.retrospective.datastructures.NodeData`. + +Free of any graphviz import, so building a drawing works in a bare install. +Every :class:`~flowrep.retrospective.datastructures.CompositeData` -- a run +``DagData`` as much as a run ``ForEachData``, ``IfData``, ``TryData`` or +``WhileData`` -- reads its ``.nodes``, ``.input_edges``, ``.edges`` and +``.output_edges`` identically. There is no per-flow-control-type branching; +the only dispatch is leaf versus composite. + +This module shares nothing with :mod:`flowrep.drawing.prospective` but the IR +(:mod:`flowrep.drawing.model`) and the shared label formatting in +:mod:`flowrep.drawing.style`. +""" + +from __future__ import annotations + +from collections.abc import Mapping + +from flowrep import base_models, edge_models, lexical +from flowrep.drawing import model, style +from flowrep.retrospective import datastructures + + +def build(data: datastructures.NodeData, depth: int = 0) -> model.DrawGraph: + """Build a drawing of *data*, expanding composites down to *depth*. + + The root always expands (if it is a composite); ``depth`` counts further + generations below the root's children. Raises ``ValueError`` if ``depth`` + is negative. + """ + if depth < 0: + raise ValueError(f"depth must be >= 0, got {depth}") + return _build(data, path="", label=data.recipe.type.value, depth=depth) + + +def _build( + data: datastructures.NodeData, path: str, label: str, depth: int +) -> model.DrawNode: + """Build a single node, recursing into children when it is an expanded composite.""" + inputs, outputs = _ports(data) + children: tuple[model.DrawNode, ...] = () + edges: tuple[model.DrawEdge, ...] = () + if isinstance(data, datastructures.CompositeData) and depth >= 0: + children = _build_children(data.nodes, path, depth) + edges = ( + _convert_input_edges(data.input_edges) + + _convert_sibling_edges(data.edges) + + _convert_output_edges(data.output_edges) + ) + return model.DrawNode( + path=path, + label=label, + kind=data.recipe.type, + subtitle=style.subtitle_for(data.recipe), + inputs=inputs, + outputs=outputs, + children=children, + edges=edges, + note=_note(children, edges), + ) + + +def _note( + children: tuple[model.DrawNode, ...], edges: tuple[model.DrawEdge, ...] +) -> str | None: + """A user-facing note when a composite expanded with children but no edges. + + The toy WfMS never records actualized edges for flow-control instances + (only ``DagData`` copies edges from its recipe), so a run flow-control node + otherwise draws as a set of unconnected boxes. + """ + if children and not edges: + return "(no recorded edges)" + return None + + +def _ports( + data: datastructures.NodeData, +) -> tuple[tuple[model.DrawPort, ...], tuple[model.DrawPort, ...]]: + """Convert a node's data ports to draw ports, annotation hints and all.""" + inputs = tuple( + model.DrawPort( + label=label, + hint=style.format_annotation(port.annotation), + has_default=port.default is not datastructures.NOT_DATA, + ) + for label, port in data.input_ports.items() + ) + outputs = tuple( + model.DrawPort(label=label, hint=style.format_annotation(port.annotation)) + for label, port in data.output_ports.items() + ) + return inputs, outputs + + +def _child_path(parent_path: str, label: str) -> str: + """The lexical path of a child node given its parent's path.""" + return lexical.join(parent_path, label) + + +def _build_children( + nodes: Mapping[base_models.Label, datastructures.NodeData], + parent_path: str, + depth: int, +) -> tuple[model.DrawNode, ...]: + """Build every child of a composite, one generation shallower.""" + return tuple( + _build( + child_data, _child_path(parent_path, child_label), child_label, depth - 1 + ) + for child_label, child_data in nodes.items() + ) + + +def _convert_input_edges(edges: edge_models.InputEdges) -> tuple[model.DrawEdge, ...]: + """A parent input source, drawn to a real child target.""" + return tuple( + model.DrawEdge( + source=model.PortRef("", base_models.IOTypes.INPUTS, source.port), + target=model.PortRef(target.node, base_models.IOTypes.INPUTS, target.port), + ) + for target, source in edges.items() + ) + + +def _convert_sibling_edges(edges: edge_models.Edges) -> tuple[model.DrawEdge, ...]: + """A real child source, drawn to a real child target.""" + return tuple( + model.DrawEdge( + source=model.PortRef(source.node, base_models.IOTypes.OUTPUTS, source.port), + target=model.PortRef(target.node, base_models.IOTypes.INPUTS, target.port), + ) + for target, source in edges.items() + ) + + +def _convert_output_edges(edges: edge_models.OutputEdges) -> tuple[model.DrawEdge, ...]: + """A real child source or a parent-input passthrough, drawn to a parent output.""" + return tuple( + model.DrawEdge( + source=_output_edge_source(source), + target=model.PortRef("", base_models.IOTypes.OUTPUTS, target.port), + ) + for target, source in edges.items() + ) + + +def _output_edge_source( + source: edge_models.SourceHandle | edge_models.InputSource, +) -> model.PortRef: + """The source endpoint for an output edge: a child output, or a parent passthrough. + + A ``None`` node is what marks a handle as referring to the enclosing node's + own IO rather than to a child, so that -- not the handle's class -- is the + thing to branch on. + """ + if source.node is None: + return model.PortRef("", base_models.IOTypes.INPUTS, source.port) + return model.PortRef(source.node, base_models.IOTypes.OUTPUTS, source.port) diff --git a/src/flowrep/drawing/style.py b/src/flowrep/drawing/style.py new file mode 100644 index 00000000..2505f2ed --- /dev/null +++ b/src/flowrep/drawing/style.py @@ -0,0 +1,91 @@ +""" +Visual constants and label formatting for graph drawings. + +Deliberately free of any graphviz import so it can be exercised without the +optional dependency. +""" + +from __future__ import annotations + +import typing + +from flowrep import base_models + +ELLIPSIS = "…" + +NODE_PALETTE: dict[base_models.RecipeElementType, tuple[str, str]] = { + base_models.RecipeElementType.ATOMIC: ("#dbeafe", "#1d4ed8"), + base_models.RecipeElementType.WORKFLOW: ("#dcfce7", "#15803d"), + base_models.RecipeElementType.CONSTANT: ("#ccfbf1", "#0f766e"), + base_models.RecipeElementType.FOR_EACH: ("#fef3c7", "#b45309"), + base_models.RecipeElementType.WHILE: ("#fce7f3", "#be185d"), + base_models.RecipeElementType.IF: ("#f3e8ff", "#7e22ce"), + base_models.RecipeElementType.TRY: ("#fee2e2", "#b91c1c"), +} + +IO_FILL = "#e5e7eb" +IO_LINE = "#4b5563" +CONDITIONAL_LINE = "#6b7280" +GROUP_LINE = "#9ca3af" +SUBTITLE_COLOUR = "#374151" +HINT_COLOUR = "#6b7280" + +NODE_LABEL_WRAP = 20 +SUBTITLE_MAX = 28 +PORT_LABEL_MAX = 14 +ANNOTATION_MAX = 16 + + +def truncate_right(text: str, limit: int) -> str: + """Trim the tail, marking the cut with a trailing ellipsis.""" + if len(text) <= limit: + return text + return text[: limit - 1] + ELLIPSIS + + +def truncate_left(text: str, limit: int) -> str: + """Trim the head, marking the cut with a leading ellipsis. + + Used for dotted paths, where the qualname at the end is the informative part. + """ + if len(text) <= limit: + return text + return ELLIPSIS + text[-(limit - 1) :] + + +def wrap_label(text: str, width: int = NODE_LABEL_WRAP) -> list[str]: + """Wrap an identifier, preferring breaks just after an underscore.""" + lines: list[str] = [] + remaining = text + while len(remaining) > width: + cut = remaining.rfind("_", 0, width + 1) + cut = cut + 1 if cut > 0 else width + lines.append(remaining[:cut]) + remaining = remaining[cut:] + lines.append(remaining) + return lines + + +def format_annotation(annotation: object | None) -> str | None: + """Render a type hint compactly, or ``None`` when there is nothing to show.""" + if annotation is None: + return None + if isinstance(annotation, type) and typing.get_origin(annotation) is None: + rendered = annotation.__name__ + else: + rendered = str(annotation).replace("typing.", "") + return truncate_right(rendered, ANNOTATION_MAX) + + +def subtitle_for(recipe: base_models.NodeRecipe) -> str | None: + """The monospace line beneath a node's label, or ``None`` when there is none. + + Dispatches on ``recipe.type`` rather than importing the recipe classes, so + this module stays free of any dependence on :mod:`flowrep.prospective`. + """ + if recipe.type is base_models.RecipeElementType.CONSTANT: + return truncate_right(repr(getattr(recipe, "constant", None)), SUBTITLE_MAX) + fully_qualified_name = getattr(recipe, "fully_qualified_name", None) + if fully_qualified_name is None: + return None + return truncate_left(fully_qualified_name, SUBTITLE_MAX) diff --git a/src/flowrep/edge_models.py b/src/flowrep/edge_models.py index d8307fe6..9a49d798 100644 --- a/src/flowrep/edge_models.py +++ b/src/flowrep/edge_models.py @@ -4,14 +4,14 @@ import pydantic -from flowrep import base_models +from flowrep import base_models, lexical class HandleModel(pydantic.BaseModel): model_config = pydantic.ConfigDict(frozen=True) node: base_models.Label | None port: base_models.Label - delimiter: ClassVar[str] = "." + delimiter: ClassVar[str] = lexical.DELIMITER @pydantic.model_serializer def serialize(self) -> str: diff --git a/src/flowrep/lexical.py b/src/flowrep/lexical.py new file mode 100644 index 00000000..ffb60db2 --- /dev/null +++ b/src/flowrep/lexical.py @@ -0,0 +1,28 @@ +""" +Shared helpers for "lexical" paths -- ``"."``-joined node labels, IO type +segments, and port names. + +:cls:`flowrep.base_models.RESERVED_NAMES` forbids nodes and ports from being +named ``inputs`` or ``outputs``, so a lexical path is unambiguous. +""" + +from __future__ import annotations + +from flowrep import base_models + +DELIMITER = "." + + +def join(*segments: str) -> str: + """Join path segments, ignoring empty ones (e.g. the root's empty path).""" + return DELIMITER.join(segment for segment in segments if segment) + + +def split(path: str) -> tuple[str, ...]: + """Split a lexical path into its segments; the empty path has none.""" + return tuple(path.split(DELIMITER)) if path else () + + +def port_path(node_path: str, io_type: base_models.IOTypes, port: str) -> str: + """The lexical path of a port on the node at ``node_path``.""" + return join(node_path, str(io_type), port) diff --git a/src/flowrep/retrospective/datastructures.py b/src/flowrep/retrospective/datastructures.py index 0214976d..1d364b7a 100644 --- a/src/flowrep/retrospective/datastructures.py +++ b/src/flowrep/retrospective/datastructures.py @@ -18,10 +18,22 @@ import inspect import types from collections.abc import Callable, MutableMapping -from typing import Any, Generic, Self, TypeVar, get_args, get_origin, get_type_hints +from typing import ( + TYPE_CHECKING, + Any, + Generic, + Self, + TypeVar, + get_args, + get_origin, + get_type_hints, +) from pyiron_snippets import retrieve, singleton +if TYPE_CHECKING: + import graphviz + from flowrep import base_models, edge_models from flowrep.prospective import ( atomic_recipe, @@ -101,6 +113,29 @@ def view(self, expanded: bool = False): """ return viewer.view(self, expanded=expanded) + def draw(self, depth: int | None = None) -> graphviz.Digraph: + """ + Draw this data object's topology, ports and labels as a graphviz graph. + + Where :meth:`view` shows the data, this shows the shape. Renders inline in + a Jupyter notebook, and also offers ``.render()``, ``.pipe()`` and + ``.source``. + + Args: + depth: How many generations of nested subgraph to expand below this + node's own children. The node itself always expands. Defaults to 0. + + Returns: + The drawn graph. + + Raises: + ImportAlarmError: If the optional drawing dependency is missing. The + message names both the pip and conda install routes. + """ + from flowrep import drawing + + return drawing.draw(self, depth=depth) + def _repr_json_(self): return self.view()._repr_json_() diff --git a/src/flowrep/retrospective/storage.py b/src/flowrep/retrospective/storage.py index 14d2155d..3adc16a2 100644 --- a/src/flowrep/retrospective/storage.py +++ b/src/flowrep/retrospective/storage.py @@ -12,7 +12,7 @@ from packaging import version from pyiron_snippets import import_alarm -from flowrep import base_models +from flowrep import base_models, lexical from flowrep.retrospective import datastructures, storage_widget with import_alarm.ImportAlarm( @@ -129,7 +129,7 @@ def _collect_lexical_paths( prefix: str, paths: list[str], ) -> None: - for io_type in tuple(base_models.IOTypes): + for io_type in base_models.IOTypes: io_storage = ( _path_to_input_ports(storage_path) if io_type == base_models.IOTypes.INPUTS @@ -137,7 +137,9 @@ def _collect_lexical_paths( ) port_names = bag.open_group(io_storage) for port in port_names: - paths.append(f"{prefix}{io_type}.{port}") + paths.append( + lexical.port_path(prefix.rstrip(lexical.DELIMITER), io_type, port) + ) nodes_storage = _path_to_nodes(storage_path) try: @@ -145,9 +147,9 @@ def _collect_lexical_paths( except KeyError: return for node in node_names: - lexical = f"{prefix}{node}" - paths.append(lexical) - _collect_lexical_paths(bag, f"{nodes_storage}/{node}", f"{lexical}.", paths) + node_path = f"{prefix}{node}" + paths.append(node_path) + _collect_lexical_paths(bag, f"{nodes_storage}/{node}", f"{node_path}.", paths) def _path_to_input_ports(path: str) -> str: diff --git a/tests/unit/drawing/__init__.py b/tests/unit/drawing/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/drawing/test_draw_methods.py b/tests/unit/drawing/test_draw_methods.py new file mode 100644 index 00000000..00e6d822 --- /dev/null +++ b/tests/unit/drawing/test_draw_methods.py @@ -0,0 +1,212 @@ +""" +The ``.draw()`` convenience methods hung off recipes and data objects. + +These are the shortest path to a picture, so they get their own coverage +separate from the :mod:`flowrep.drawing.interface` callables they delegate to. +""" + +import subprocess +import sys +import textwrap +import unittest + +from flowrep import drawing, edge_models, std +from flowrep.prospective import helper_models, while_recipe, workflow_recipe +from flowrep.retrospective import datastructures + +from flowrep_static import library + +try: + import graphviz # noqa: F401 + + _has_graphviz = True +except ImportError: + _has_graphviz = False + + +def _nested_workflow() -> workflow_recipe.WorkflowRecipe: + """A workflow whose child is itself a workflow, so ``depth`` actually bites.""" + inner = library.simple_workflow.flowrep_recipe + return workflow_recipe.WorkflowRecipe( + inputs=["p", "q"], + outputs=["r"], + nodes={"inner": inner}, + input_edges={ + edge_models.TargetHandle(node="inner", port="a"): edge_models.InputSource( + port="p" + ), + edge_models.TargetHandle(node="inner", port="b"): edge_models.InputSource( + port="q" + ), + }, + edges={}, + output_edges={ + edge_models.OutputTarget(port="r"): edge_models.SourceHandle( + node="inner", port=inner.outputs[0] + ) + }, + ) + + +@unittest.skipUnless(_has_graphviz, "graphviz not installed") +class TestRecipeDraw(unittest.TestCase): + def test_matches_the_module_level_drawer(self): + recipe = std.neg.flowrep_recipe + self.assertEqual(recipe.draw().source, drawing.draw_prospective(recipe).source) + + def test_default_depth_is_the_prospective_default(self): + recipe = _nested_workflow() + self.assertEqual( + recipe.draw().source, drawing.draw_prospective(recipe, depth=1).source + ) + + def test_explicit_depth_is_forwarded(self): + recipe = _nested_workflow() + self.assertEqual( + recipe.draw(depth=0).source, + drawing.draw_prospective(recipe, depth=0).source, + ) + + def test_depth_actually_changes_the_drawing(self): + recipe = _nested_workflow() + self.assertNotEqual(recipe.draw(depth=0).source, recipe.draw(depth=1).source) + + def test_negative_depth_rejected(self): + with self.assertRaises(ValueError): + std.neg.flowrep_recipe.draw(depth=-1) + + def test_available_on_a_flow_control_recipe(self): + """``draw`` lives on the base class, so every recipe type inherits it.""" + recipe = while_recipe.WhileRecipe( + inputs=["x"], + outputs=["x"], + case=helper_models.ConditionalCase( + condition=helper_models.LabeledRecipe( + label="cond", recipe=library.is_positive.flowrep_recipe + ), + body=helper_models.LabeledRecipe( + label="body", recipe=library.loop_inc.flowrep_recipe + ), + ), + input_edges={ + edge_models.TargetHandle( + node="cond", port="n" + ): edge_models.InputSource(port="x"), + edge_models.TargetHandle( + node="body", port="x" + ): edge_models.InputSource(port="x"), + }, + output_edges={ + edge_models.OutputTarget(port="x"): edge_models.SourceHandle( + node="body", port="y" + ) + }, + ) + self.assertIn("cond_i", recipe.draw().source) + + +@unittest.skipUnless(_has_graphviz, "graphviz not installed") +class TestDataDraw(unittest.TestCase): + def setUp(self): + self.data = datastructures.recipe2data(library.simple_workflow.flowrep_recipe) + + def test_matches_the_module_level_drawer(self): + self.assertEqual( + self.data.draw().source, drawing.draw_retrospective(self.data).source + ) + + def test_default_depth_is_the_retrospective_default(self): + self.assertEqual( + self.data.draw().source, + drawing.draw_retrospective(self.data, depth=0).source, + ) + + def test_explicit_depth_is_forwarded(self): + self.assertEqual( + self.data.draw(depth=1).source, + drawing.draw_retrospective(self.data, depth=1).source, + ) + + def test_negative_depth_rejected(self): + with self.assertRaises(ValueError): + self.data.draw(depth=-1) + + def test_sits_alongside_view(self): + """``view`` shows the data; ``draw`` shows the shape.""" + self.assertTrue(callable(self.data.view)) + self.assertTrue(callable(self.data.draw)) + + +_WITHOUT_GRAPHVIZ = textwrap.dedent(""" + import sys + + + class _BlockGraphviz: + def find_spec(self, name, path=None, target=None): + if name == "graphviz": + raise ImportError("graphviz is blocked for this test") + return None + + + sys.meta_path.insert(0, _BlockGraphviz()) + + from flowrep import std + from flowrep.retrospective import datastructures + + recipe = std.neg.flowrep_recipe + for drawable in (recipe, datastructures.recipe2data(recipe)): + try: + drawable.draw() + except Exception as error: + print(f"{type(drawable).__name__}|{type(error).__name__}|{error}") + else: + print(f"{type(drawable).__name__}|NO_ERROR|") + """) + + +class TestMissingDependencyMessage(unittest.TestCase): + """ + Both methods must fail helpfully, not cryptically, in a bare install. + + Run in a subprocess so blocking ``graphviz`` cannot corrupt the interpreter + state the rest of the suite shares. + """ + + @classmethod + def setUpClass(cls): + result = subprocess.run( + [sys.executable, "-c", _WITHOUT_GRAPHVIZ], + capture_output=True, + text=True, + check=True, + ) + cls.lines = [ + line.split("|", 2) for line in result.stdout.strip().splitlines() if line + ] + + def test_both_drawables_raise(self): + self.assertEqual(len(self.lines), 2) + for name, error, _ in self.lines: + with self.subTest(drawable=name): + self.assertEqual(error, "ImportAlarmError") + + def test_message_names_the_package(self): + for name, _, message in self.lines: + with self.subTest(drawable=name): + self.assertIn("graphviz", message) + + def test_message_names_the_pip_route_and_its_binary_caveat(self): + for name, _, message in self.lines: + with self.subTest(drawable=name): + self.assertIn("pip install flowrep[drawing]", message) + self.assertIn("dot", message) + + def test_message_names_the_conda_route(self): + for name, _, message in self.lines: + with self.subTest(drawable=name): + self.assertIn("conda install", message) + self.assertIn("python-graphviz", message) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/drawing/test_interface.py b/tests/unit/drawing/test_interface.py new file mode 100644 index 00000000..268c94d2 --- /dev/null +++ b/tests/unit/drawing/test_interface.py @@ -0,0 +1,151 @@ +import subprocess +import sys +import unittest + +import flowrep +from flowrep import edge_models, std +from flowrep.prospective import workflow_recipe +from flowrep.retrospective import datastructures + +from flowrep_static import library + +try: + import graphviz # noqa: F401 + + _has_graphviz = True +except ImportError: + _has_graphviz = False + + +def _nested_workflow() -> workflow_recipe.WorkflowRecipe: + """A workflow whose child is itself a workflow, so depth actually bites. + + ``simple_workflow`` alone will not do: its only child is atomic, and an + atomic node is a leaf at every depth, so depth 0 and depth 1 would render + identically. + """ + inner = library.simple_workflow.flowrep_recipe + return workflow_recipe.WorkflowRecipe( + inputs=["p", "q"], + outputs=["r"], + nodes={"inner": inner}, + input_edges={ + edge_models.TargetHandle(node="inner", port="a"): edge_models.InputSource( + port="p" + ), + edge_models.TargetHandle(node="inner", port="b"): edge_models.InputSource( + port="q" + ), + }, + edges={}, + output_edges={ + edge_models.OutputTarget(port="r"): edge_models.SourceHandle( + node="inner", port=inner.outputs[0] + ) + }, + ) + + +@unittest.skipUnless(_has_graphviz, "graphviz not installed") +class TestDispatch(unittest.TestCase): + def test_recipe_dispatches_prospective(self): + self.assertIn("neg", flowrep.draw(std.neg.flowrep_recipe).source) + + def test_data_dispatches_retrospective(self): + data = datastructures.recipe2data(library.decrement.flowrep_recipe) + self.assertIn("decrement", flowrep.draw(data).source) + + def test_unknown_type_raises(self): + with self.assertRaises(TypeError): + flowrep.draw("not a graph") + + +@unittest.skipUnless(_has_graphviz, "graphviz not installed") +class TestDepthDefaults(unittest.TestCase): + def setUp(self): + self.recipe = _nested_workflow() + + def test_prospective_default_is_one(self): + self.assertEqual( + flowrep.drawing.draw_prospective(self.recipe).source, + flowrep.drawing.draw_prospective(self.recipe, depth=1).source, + ) + + def test_explicit_depth_overrides(self): + shallow = flowrep.drawing.draw_prospective(self.recipe, depth=0).source + deep = flowrep.drawing.draw_prospective(self.recipe, depth=1).source + self.assertNotEqual(shallow, deep) + + def test_retrospective_default_is_zero(self): + data = datastructures.recipe2data(self.recipe) + self.assertEqual( + flowrep.drawing.draw_retrospective(data).source, + flowrep.drawing.draw_retrospective(data, depth=0).source, + ) + + def test_dispatcher_forwards_explicit_depth(self): + self.assertEqual( + flowrep.draw(self.recipe, depth=0).source, + flowrep.drawing.draw_prospective(self.recipe, depth=0).source, + ) + + +class TestExposure(unittest.TestCase): + def test_draw_is_top_level(self): + self.assertTrue(callable(flowrep.draw)) + + def test_draw_is_in_tools(self): + self.assertIs(flowrep.tools.draw, flowrep.draw) + + def test_specific_drawers_stay_in_the_subpackage(self): + self.assertFalse(hasattr(flowrep, "draw_prospective")) + self.assertTrue(callable(flowrep.drawing.draw_prospective)) + self.assertTrue(callable(flowrep.drawing.draw_retrospective)) + + +class TestImportSafetyWithoutGraphviz(unittest.TestCase): + """``import flowrep`` must succeed even without graphviz installed. + + ``flowrep/__init__.py`` transitively imports ``flowrep.drawing.render``, + which imports graphviz. The ``ImportAlarm(..., raise_exception=True)`` + guard there is meant to swallow the ``ImportError`` at import time and + re-raise only when a drawing callable is actually invoked. This is run in + a subprocess, with a meta path finder blocking graphviz, so it exercises a + genuinely fresh import rather than relying on already-imported modules. + """ + + _SCRIPT = """ +import sys +import importlib.abc + + +class _BlockGraphviz(importlib.abc.MetaPathFinder): + def find_spec(self, name, path, target=None): + if name == "graphviz" or name.startswith("graphviz."): + raise ImportError("graphviz blocked for test") + return None + + +sys.meta_path.insert(0, _BlockGraphviz()) + +import flowrep +from flowrep import std + +assert callable(flowrep.draw), "flowrep.draw must be importable without graphviz" + +try: + flowrep.draw(std.neg.flowrep_recipe) +except ImportError: + print("RAISED") +else: + print("DID NOT RAISE") +""" + + def test_import_succeeds_and_draw_raises_without_graphviz(self): + result = subprocess.run( + [sys.executable, "-c", self._SCRIPT], + capture_output=True, + text=True, + ) + self.assertEqual(result.returncode, 0, msg=result.stderr) + self.assertEqual(result.stdout.strip(), "RAISED") diff --git a/tests/unit/drawing/test_model.py b/tests/unit/drawing/test_model.py new file mode 100644 index 00000000..709d6649 --- /dev/null +++ b/tests/unit/drawing/test_model.py @@ -0,0 +1,83 @@ +import dataclasses +import unittest + +from flowrep import base_models +from flowrep.drawing import model + + +def _port(label: str) -> model.DrawPort: + return model.DrawPort(label=label, hint=None, has_default=False, badge=None) + + +def _leaf(path: str, label: str) -> model.DrawNode: + return model.DrawNode( + path=path, + label=label, + kind=base_models.RecipeElementType.ATOMIC, + subtitle=None, + inputs=(_port("x"),), + outputs=(_port("y"),), + children=(), + edges=(), + ) + + +class TestDrawNode(unittest.TestCase): + def test_is_frozen(self): + node = _leaf("a", "a") + with self.assertRaises(dataclasses.FrozenInstanceError): + node.path = "b" # type: ignore[misc] + + def test_leaf_has_no_children(self): + self.assertTrue(_leaf("a", "a").is_leaf) + + def test_composite_is_not_leaf(self): + parent = model.DrawNode( + path="", + label="wf", + kind=base_models.RecipeElementType.WORKFLOW, + subtitle=None, + inputs=(), + outputs=(), + children=(_leaf("a", "a"),), + edges=(), + ) + self.assertFalse(parent.is_leaf) + + def test_walk_is_self_then_descendants(self): + grandchild = _leaf("a.b", "b") + child = dataclasses.replace(_leaf("a", "a"), children=(grandchild,)) + root = dataclasses.replace(_leaf("", "root"), children=(child,)) + self.assertEqual([n.path for n in root.walk()], ["", "a", "a.b"]) + + def test_optional_fields_default(self): + node = _leaf("a", "a") + self.assertEqual(node.groups, ()) + self.assertIsNone(node.note) + + +class TestDrawEdge(unittest.TestCase): + def test_defaults_to_unconditional(self): + edge = model.DrawEdge( + source=model.PortRef("a", base_models.IOTypes.OUTPUTS, "y"), + target=model.PortRef("b", base_models.IOTypes.INPUTS, "x"), + ) + self.assertFalse(edge.conditional) + + def test_is_hashable(self): + """Edges land in sets during cycle detection.""" + edge = model.DrawEdge( + source=model.PortRef("a", base_models.IOTypes.OUTPUTS, "y"), + target=model.PortRef("b", base_models.IOTypes.INPUTS, "x"), + ) + self.assertEqual(len({edge, edge}), 1) + + +class TestPortRef(unittest.TestCase): + def test_lexical_path(self): + ref = model.PortRef("sub.body_0", base_models.IOTypes.INPUTS, "x") + self.assertEqual(ref.lexical_path, "sub.body_0.inputs.x") + + def test_empty_node_path_means_enclosing_node(self): + ref = model.PortRef("", base_models.IOTypes.OUTPUTS, "y") + self.assertEqual(ref.lexical_path, "outputs.y") diff --git a/tests/unit/drawing/test_prospective.py b/tests/unit/drawing/test_prospective.py new file mode 100644 index 00000000..1212bb3f --- /dev/null +++ b/tests/unit/drawing/test_prospective.py @@ -0,0 +1,198 @@ +import unittest +from typing import Literal + +import pydantic + +from flowrep import base_models, edge_models, std +from flowrep.drawing import prospective +from flowrep.prospective import ( + constant_recipe, + workflow_recipe, +) + +from flowrep_static import library + + +def _by_path(graph, path): + for node in graph.walk(): + if node.path == path: + return node + raise AssertionError(f"no node at {path!r}; have {[n.path for n in graph.walk()]}") + + +class _UnrecognizedRecipe(base_models.NodeRecipe): + """A well-formed recipe of a type the drawing builder has no branch for.""" + + type: Literal[base_models.RecipeElementType.ATOMIC] = pydantic.Field( + default=base_models.RecipeElementType.ATOMIC, frozen=True + ) + + def __call__(self, *args, **kwargs): + raise NotImplementedError() + + +class TestDepthValidation(unittest.TestCase): + def test_negative_depth_rejected(self): + with self.assertRaises(ValueError): + prospective.build(std.neg.flowrep_recipe, depth=-1) + + +class TestAtomic(unittest.TestCase): + def setUp(self): + self.graph = prospective.build(std.neg.flowrep_recipe) + + def test_root_path_is_empty(self): + self.assertEqual(self.graph.path, "") + + def test_atomic_is_always_a_leaf(self): + self.assertTrue(self.graph.is_leaf) + + def test_kind(self): + self.assertEqual(self.graph.kind, base_models.RecipeElementType.ATOMIC) + + def test_ports(self): + self.assertEqual([p.label for p in self.graph.inputs], ["a"]) + self.assertEqual([p.label for p in self.graph.outputs], ["negative"]) + + def test_subtitle_is_left_truncated_qualified_name(self): + self.assertTrue(self.graph.subtitle.endswith("neg")) + + def test_no_prospective_hints(self): + """Recipes carry no annotations; we do not import references to find any.""" + self.assertTrue(all(p.hint is None for p in self.graph.inputs)) + + +class TestDefaultsAreItalicised(unittest.TestCase): + def test_input_with_default_flagged(self): + graph = prospective.build(library.increment.flowrep_recipe) + flags = {p.label: p.has_default for p in graph.inputs} + self.assertFalse(flags["x"]) + self.assertTrue(flags["step"]) + + +class TestConstant(unittest.TestCase): + def test_subtitle_is_the_repr(self): + graph = prospective.build(constant_recipe.ConstantRecipe(constant=42)) + self.assertEqual(graph.subtitle, "42") + + def test_single_output_no_inputs(self): + graph = prospective.build(constant_recipe.ConstantRecipe(constant=42)) + self.assertEqual(graph.inputs, ()) + self.assertEqual([p.label for p in graph.outputs], ["constant"]) + + +class TestWorkflow(unittest.TestCase): + def setUp(self): + self.recipe = library.simple_workflow.flowrep_recipe + self.graph = prospective.build(self.recipe, depth=0) + + def test_children_present_at_depth_zero(self): + self.assertEqual( + sorted(c.path for c in self.graph.children), sorted(self.recipe.nodes) + ) + + def test_children_are_leaves_at_depth_zero(self): + self.assertTrue(all(c.is_leaf for c in self.graph.children)) + + def test_edge_count_matches_recipe(self): + expected = ( + len(self.recipe.input_edges) + + len(self.recipe.edges) + + len(self.recipe.output_edges) + ) + self.assertEqual(len(self.graph.edges), expected) + + def test_input_edge_sources_the_parent(self): + """A parent-input source is encoded as the empty node path.""" + target, source = next(iter(self.recipe.input_edges.items())) + match = [ + e + for e in self.graph.edges + if e.target.node_path == target.node and e.target.port == target.port + ] + self.assertEqual(len(match), 1) + self.assertEqual(match[0].source.node_path, "") + self.assertEqual(match[0].source.port, source.port) + + def test_nothing_is_conditional(self): + self.assertTrue(all(not e.conditional for e in self.graph.edges)) + + +class TestNestedDepth(unittest.TestCase): + """A workflow whose child is itself a workflow.""" + + def setUp(self): + inner = library.simple_workflow.flowrep_recipe + self.recipe = workflow_recipe.WorkflowRecipe( + inputs=["p", "q"], + outputs=["r"], + nodes={"inner": inner}, + input_edges={ + edge_models.TargetHandle( + node="inner", port="a" + ): edge_models.InputSource(port="p"), + edge_models.TargetHandle( + node="inner", port="b" + ): edge_models.InputSource(port="q"), + }, + edges={}, + output_edges={ + edge_models.OutputTarget(port="r"): edge_models.SourceHandle( + node="inner", port=inner.outputs[0] + ) + }, + ) + + def test_depth_zero_leaves_the_child_closed(self): + graph = prospective.build(self.recipe, depth=0) + self.assertTrue(_by_path(graph, "inner").is_leaf) + + def test_depth_one_opens_the_child(self): + graph = prospective.build(self.recipe, depth=1) + self.assertFalse(_by_path(graph, "inner").is_leaf) + + def test_grandchild_paths_are_lexical(self): + graph = prospective.build(self.recipe, depth=1) + inner = _by_path(graph, "inner") + for child in inner.children: + with self.subTest(path=child.path): + self.assertTrue(child.path.startswith("inner.")) + + def test_depth_one_grandchildren_are_leaves(self): + graph = prospective.build(self.recipe, depth=1) + self.assertTrue(all(c.is_leaf for c in _by_path(graph, "inner").children)) + + +class TestOutputPassthrough(unittest.TestCase): + """An output_edges entry sourced from the parent's own input, not a child.""" + + def test_passthrough_source_is_the_parent_input(self): + recipe = workflow_recipe.WorkflowRecipe( + inputs=["a"], + outputs=["passthrough"], + nodes={}, + input_edges={}, + edges={}, + output_edges={ + edge_models.OutputTarget(port="passthrough"): edge_models.InputSource( + port="a" + ), + }, + ) + graph = prospective.build(recipe, depth=0) + self.assertEqual(len(graph.edges), 1) + edge = graph.edges[0] + self.assertEqual(edge.source.node_path, "") + self.assertEqual(edge.source.io_type, base_models.IOTypes.INPUTS) + self.assertEqual(edge.source.port, "a") + self.assertEqual(edge.target.node_path, "") + self.assertEqual(edge.target.io_type, base_models.IOTypes.OUTPUTS) + self.assertEqual(edge.target.port, "passthrough") + + +class TestUnrecognizedRecipe(unittest.TestCase): + def test_unrecognized_recipe_subclass_raises(self): + """A NodeRecipe the dispatch has no branch for still falls through.""" + with self.assertRaises(TypeError) as ctx: + prospective.build(_UnrecognizedRecipe(inputs=[], outputs=[])) + self.assertIn("Unrecognized recipe type", str(ctx.exception)) diff --git a/tests/unit/drawing/test_prospective_flow_control.py b/tests/unit/drawing/test_prospective_flow_control.py new file mode 100644 index 00000000..57c6c398 --- /dev/null +++ b/tests/unit/drawing/test_prospective_flow_control.py @@ -0,0 +1,289 @@ +import unittest + +from pyiron_snippets import versions + +from flowrep import edge_models, std +from flowrep.drawing import prospective +from flowrep.prospective import ( + for_recipe, + helper_models, + if_recipe, + try_recipe, + while_recipe, +) + +from flowrep_static import library + + +def _by_path(graph, path): + for node in graph.walk(): + if node.path == path: + return node + raise AssertionError(f"no node at {path!r}; have {[n.path for n in graph.walk()]}") + + +def _make_for() -> for_recipe.ForEachRecipe: + return for_recipe.ForEachRecipe( + inputs=["xs"], + outputs=["ys", "used"], + body_node=helper_models.LabeledRecipe( + label="body", recipe=std.neg.flowrep_recipe + ), + input_edges={ + edge_models.TargetHandle(node="body", port="a"): edge_models.InputSource( + port="xs" + ), + }, + output_edges={ + edge_models.OutputTarget(port="ys"): edge_models.SourceHandle( + node="body", port="negative" + ), + edge_models.OutputTarget(port="used"): edge_models.InputSource(port="xs"), + }, + nested_ports=["a"], + ) + + +def _make_while() -> while_recipe.WhileRecipe: + return while_recipe.WhileRecipe( + inputs=["x"], + outputs=["x"], + case=helper_models.ConditionalCase( + condition=helper_models.LabeledRecipe( + label="cond", recipe=library.is_positive.flowrep_recipe + ), + body=helper_models.LabeledRecipe( + label="body", recipe=library.loop_inc.flowrep_recipe + ), + ), + input_edges={ + edge_models.TargetHandle(node="cond", port="n"): edge_models.InputSource( + port="x" + ), + edge_models.TargetHandle(node="body", port="x"): edge_models.InputSource( + port="x" + ), + }, + output_edges={ + edge_models.OutputTarget(port="x"): edge_models.SourceHandle( + node="body", port="y" + ) + }, + ) + + +def _make_if() -> if_recipe.IfRecipe: + return if_recipe.IfRecipe( + inputs=["n"], + outputs=["out"], + cases=[ + helper_models.ConditionalCase( + condition=helper_models.LabeledRecipe( + label="cond0", recipe=library.is_positive.flowrep_recipe + ), + body=helper_models.LabeledRecipe( + label="body0", recipe=library.increment.flowrep_recipe + ), + ) + ], + else_case=helper_models.LabeledRecipe( + label="otherwise", recipe=library.decrement.flowrep_recipe + ), + input_edges={ + edge_models.TargetHandle(node="cond0", port="n"): edge_models.InputSource( + port="n" + ), + edge_models.TargetHandle(node="body0", port="x"): edge_models.InputSource( + port="n" + ), + edge_models.TargetHandle( + node="otherwise", port="x" + ): edge_models.InputSource(port="n"), + }, + prospective_output_edges={ + edge_models.OutputTarget(port="out"): [ + edge_models.SourceHandle(node="body0", port="output_0"), + edge_models.SourceHandle(node="otherwise", port="output_0"), + ] + }, + ) + + +def _make_try() -> try_recipe.TryRecipe: + return try_recipe.TryRecipe( + inputs=["x", "y"], + outputs=["out"], + try_node=helper_models.LabeledRecipe( + label="attempt", recipe=library.raises_custom.flowrep_recipe + ), + exception_cases=[ + helper_models.ExceptionCase( + exceptions=[versions.VersionInfo.of(library.MyCustomException)], + body=helper_models.LabeledRecipe( + label="handler", recipe=library.combine.flowrep_recipe + ), + ) + ], + input_edges={ + edge_models.TargetHandle(node="attempt", port="x"): edge_models.InputSource( + port="x" + ), + edge_models.TargetHandle(node="attempt", port="y"): edge_models.InputSource( + port="y" + ), + edge_models.TargetHandle(node="handler", port="a"): edge_models.InputSource( + port="x" + ), + edge_models.TargetHandle(node="handler", port="b"): edge_models.InputSource( + port="y" + ), + }, + prospective_output_edges={ + edge_models.OutputTarget(port="out"): [ + edge_models.SourceHandle( + node="attempt", port=library.raises_custom.flowrep_recipe.outputs[0] + ), + edge_models.SourceHandle( + node="handler", port=library.combine.flowrep_recipe.outputs[0] + ), + ] + }, + ) + + +class TestForEach(unittest.TestCase): + def setUp(self): + self.graph = prospective.build(_make_for(), depth=0) + + def test_body_expands_once(self): + self.assertEqual([c.path for c in self.graph.children], ["body"]) + + def test_body_display_label_signals_multiplicity(self): + self.assertEqual(self.graph.children[0].label, "body_n") + + def test_path_keeps_the_real_label(self): + """Identity stays honest even though the display label is decorated.""" + self.assertEqual(self.graph.children[0].path, "body") + + def test_nested_port_badged(self): + badges = {p.label: p.badge for p in self.graph.children[0].inputs} + self.assertEqual(badges["a"], "nested") + + def test_transferred_output_links_input_to_output(self): + matches = [ + e + for e in self.graph.edges + if e.source.node_path == "" + and e.target.node_path == "" + and e.target.port == "used" + ] + self.assertEqual(len(matches), 1) + self.assertEqual(matches[0].source.port, "xs") + + +class TestForEachZipped(unittest.TestCase): + def test_zipped_port_badged(self): + recipe = _make_for().model_copy( + update={"nested_ports": [], "zipped_ports": ["a"]} + ) + graph = prospective.build(recipe, depth=0) + badges = {p.label: p.badge for p in graph.children[0].inputs} + self.assertEqual(badges["a"], "zipped") + + +class TestWhile(unittest.TestCase): + def setUp(self): + self.graph = prospective.build(_make_while(), depth=0) + + def test_condition_and_body_both_present(self): + self.assertEqual(sorted(c.path for c in self.graph.children), ["body", "cond"]) + + def test_iteration_suffix_on_display_labels(self): + self.assertEqual( + sorted(c.label for c in self.graph.children), ["body_i", "cond_i"] + ) + + def test_condition_output_badged(self): + cond = _by_path(self.graph, "cond") + self.assertEqual([p.badge for p in cond.outputs], ["test"]) + + def test_inferred_loop_back_edges_present(self): + back = [ + e + for e in self.graph.edges + if e.source.node_path == "body" and e.target.node_path in ("body", "cond") + ] + self.assertEqual(len(back), 2) + + def test_fallback_edge_is_conditional(self): + fallback = [ + e + for e in self.graph.edges + if e.source.node_path == "" and e.target.node_path == "" and e.conditional + ] + self.assertEqual(len(fallback), 1) + self.assertEqual(fallback[0].source.port, "x") + self.assertEqual(fallback[0].target.port, "x") + + +class TestIf(unittest.TestCase): + def setUp(self): + self.graph = prospective.build(_make_if(), depth=0) + + def test_all_branch_nodes_present(self): + self.assertEqual( + sorted(c.path for c in self.graph.children), + ["body0", "cond0", "otherwise"], + ) + + def test_groups_pair_condition_with_body(self): + groups = {g.label: g.members for g in self.graph.groups} + self.assertEqual(sorted(groups["case 0"]), ["body0", "cond0"]) + + def test_else_group(self): + groups = {g.label: g.members for g in self.graph.groups} + self.assertEqual(groups["else"], ("otherwise",)) + + def test_group_order_follows_declaration(self): + self.assertEqual([g.label for g in self.graph.groups], ["case 0", "else"]) + + def test_candidate_output_edges_are_conditional(self): + to_out = [e for e in self.graph.edges if e.target.node_path == ""] + self.assertEqual(len(to_out), 2) + self.assertTrue(all(e.conditional for e in to_out)) + + def test_condition_output_badged(self): + self.assertEqual( + [p.badge for p in _by_path(self.graph, "cond0").outputs], ["test"] + ) + + +class TestTry(unittest.TestCase): + def setUp(self): + self.graph = prospective.build(_make_try(), depth=0) + + def test_try_and_handler_present(self): + self.assertEqual( + sorted(c.path for c in self.graph.children), ["attempt", "handler"] + ) + + def test_try_group(self): + groups = {g.label: g.members for g in self.graph.groups} + self.assertEqual(groups["try"], ("attempt",)) + + def test_exception_group_names_the_exception(self): + labels = [g.label for g in self.graph.groups] + self.assertTrue( + any(label.startswith("except ") for label in labels), msg=str(labels) + ) + + def test_candidate_output_edges_are_conditional(self): + to_out = [e for e in self.graph.edges if e.target.node_path == ""] + self.assertEqual(len(to_out), 2) + self.assertTrue(all(e.conditional for e in to_out)) + + +class TestFlowControlDepth(unittest.TestCase): + def test_for_body_is_a_leaf_when_atomic(self): + graph = prospective.build(_make_for(), depth=1) + self.assertTrue(_by_path(graph, "body").is_leaf) diff --git a/tests/unit/drawing/test_render.py b/tests/unit/drawing/test_render.py new file mode 100644 index 00000000..b839e8ca --- /dev/null +++ b/tests/unit/drawing/test_render.py @@ -0,0 +1,305 @@ +import shutil +import unittest + +from flowrep import base_models, edge_models, std, wfms +from flowrep.drawing import model, prospective, render, retrospective, style +from flowrep.prospective import ( + constant_recipe, + for_recipe, + helper_models, + if_recipe, + while_recipe, + workflow_recipe, +) + +from flowrep_static import library + +try: + import graphviz # noqa: F401 + + _has_graphviz = True +except ImportError: + _has_graphviz = False + + +def _while_recipe(): + return while_recipe.WhileRecipe( + inputs=["x"], + outputs=["x"], + case=helper_models.ConditionalCase( + condition=helper_models.LabeledRecipe( + label="cond", recipe=library.is_positive.flowrep_recipe + ), + body=helper_models.LabeledRecipe( + label="body", recipe=library.loop_inc.flowrep_recipe + ), + ), + input_edges={ + edge_models.TargetHandle(node="cond", port="n"): edge_models.InputSource( + port="x" + ), + edge_models.TargetHandle(node="body", port="x"): edge_models.InputSource( + port="x" + ), + }, + output_edges={ + edge_models.OutputTarget(port="x"): edge_models.SourceHandle( + node="body", port="y" + ) + }, + ) + + +def _for_recipe(): + return for_recipe.ForEachRecipe( + inputs=["xs"], + outputs=["ys"], + body_node=helper_models.LabeledRecipe( + label="body", recipe=std.neg.flowrep_recipe + ), + input_edges={ + edge_models.TargetHandle(node="body", port="a"): edge_models.InputSource( + port="xs" + ) + }, + output_edges={ + edge_models.OutputTarget(port="ys"): edge_models.SourceHandle( + node="body", port="negative" + ) + }, + nested_ports=["a"], + ) + + +def _if_recipe(): + return if_recipe.IfRecipe( + inputs=["n"], + outputs=["out"], + cases=[ + helper_models.ConditionalCase( + condition=helper_models.LabeledRecipe( + label="cond0", recipe=library.is_positive.flowrep_recipe + ), + body=helper_models.LabeledRecipe( + label="body0", recipe=library.increment.flowrep_recipe + ), + ) + ], + else_case=helper_models.LabeledRecipe( + label="otherwise", recipe=library.decrement.flowrep_recipe + ), + input_edges={ + edge_models.TargetHandle(node="cond0", port="n"): edge_models.InputSource( + port="n" + ), + edge_models.TargetHandle(node="body0", port="x"): edge_models.InputSource( + port="n" + ), + edge_models.TargetHandle( + node="otherwise", port="x" + ): edge_models.InputSource(port="n"), + }, + prospective_output_edges={ + edge_models.OutputTarget(port="out"): [ + edge_models.SourceHandle(node="body0", port="output_0"), + edge_models.SourceHandle(node="otherwise", port="output_0"), + ] + }, + ) + + +@unittest.skipUnless(_has_graphviz, "graphviz not installed") +class TestLeafRendering(unittest.TestCase): + def setUp(self): + self.source = render.render(prospective.build(std.neg.flowrep_recipe)).source + + def test_is_left_to_right(self): + self.assertIn("rankdir=LR", self.source) + + def test_node_label_present(self): + self.assertIn("neg", self.source) + + def test_ports_are_addressable(self): + self.assertIn('PORT="i_a"', self.source) + self.assertIn('PORT="o_negative"', self.source) + + def test_atomic_fill_colour_used(self): + fill, _ = style.NODE_PALETTE[base_models.RecipeElementType.ATOMIC] + self.assertIn(fill, self.source) + + +@unittest.skipUnless(_has_graphviz, "graphviz not installed") +class TestCompositeRendering(unittest.TestCase): + def setUp(self): + graph = prospective.build(library.simple_workflow.flowrep_recipe, depth=0) + self.source = render.render(graph).source + + def test_root_becomes_a_cluster(self): + self.assertIn("subgraph cluster_", self.source) + + def test_child_ids_appear_in_source(self): + """A single-segment lexical path is a valid DOT identifier and Graphviz + does not quote it unnecessarily; quoting is exercised separately for a + genuinely nested (dotted) path.""" + for child in prospective.build( + library.simple_workflow.flowrep_recipe, depth=0 + ).children: + with self.subTest(path=child.path): + self.assertIn(child.path, self.source) + + +@unittest.skipUnless(_has_graphviz, "graphviz not installed") +class TestNestedIdsAreQuoted(unittest.TestCase): + def test_dotted_child_path_is_quoted(self): + """A dotted lexical path is not a bare DOT identifier, so Graphviz quotes it.""" + inner = library.simple_workflow.flowrep_recipe + outer = workflow_recipe.WorkflowRecipe( + inputs=["p", "q"], + outputs=["r"], + nodes={"inner": inner}, + input_edges={ + edge_models.TargetHandle( + node="inner", port="a" + ): edge_models.InputSource(port="p"), + edge_models.TargetHandle( + node="inner", port="b" + ): edge_models.InputSource(port="q"), + }, + edges={}, + output_edges={ + edge_models.OutputTarget(port="r"): edge_models.SourceHandle( + node="inner", port=inner.outputs[0] + ) + }, + ) + source = render.render(prospective.build(outer, depth=1)).source + self.assertIn('"inner.typed_add_0"', source) + + +@unittest.skipUnless(_has_graphviz, "graphviz not installed") +class TestCycleHandling(unittest.TestCase): + def test_back_edges_are_unconstrained(self): + """While-loop feedback would otherwise destroy the rankdir=LR layout.""" + source = render.render(prospective.build(_while_recipe(), depth=0)).source + self.assertIn("constraint=false", source) + + def test_acyclic_composite_has_no_unconstrained_edges(self): + """A plain DAG must not be mistaken for a cycle just because its own + input and its own output share the empty ``node_path`` placeholder.""" + source = render.render( + prospective.build(library.simple_workflow.flowrep_recipe, depth=0) + ).source + self.assertNotIn("constraint=false", source) + + +@unittest.skipUnless(_has_graphviz, "graphviz not installed") +class TestConditionalEdges(unittest.TestCase): + def test_conditional_edges_dashed(self): + source = render.render(prospective.build(_while_recipe(), depth=0)).source + self.assertIn("style=dashed", source) + + +@unittest.skipUnless(_has_graphviz, "graphviz not installed") +class TestGroupOrdering(unittest.TestCase): + def test_groups_emit_in_reverse_declaration_order(self): + """Under rankdir=LR, Graphviz stacks same-rank clusters bottom-up in + declaration order, so the first-declared group ("case 0") must be the + *last* one written to source for it to land on top.""" + source = render.render(prospective.build(_if_recipe(), depth=0)).source + self.assertLess(source.index("else"), source.index("case 0")) + + +@unittest.skipUnless(_has_graphviz, "graphviz not installed") +class TestEscaping(unittest.TestCase): + def test_special_characters_in_subtitle_are_escaped(self): + graph = prospective.build(constant_recipe.ConstantRecipe(constant="")) + source = render.render(graph).source + self.assertNotIn("", source) + self.assertIn("<a & b>", source) + + +@unittest.skipUnless(_has_graphviz, "graphviz not installed") +class TestPortPaddingBranches(unittest.TestCase): + def test_more_inputs_than_outputs_pads_output_column(self): + source = render.render(prospective.build(library.combine.flowrep_recipe)).source + self.assertIn("", source) + + def test_more_outputs_than_inputs_pads_input_column(self): + source = render.render( + prospective.build(constant_recipe.ConstantRecipe(constant=1)) + ).source + self.assertIn("", source) + + +@unittest.skipUnless(_has_graphviz, "graphviz not installed") +class TestEmptyIoComposite(unittest.TestCase): + def test_composite_with_no_own_io_draws_no_io_boxes(self): + recipe = workflow_recipe.WorkflowRecipe( + inputs=[], + outputs=[], + nodes={"c": constant_recipe.ConstantRecipe(constant=1)}, + input_edges={}, + edges={}, + output_edges={}, + ) + source = render.render(prospective.build(recipe, depth=0)).source + self.assertNotIn("inputs.", source) + self.assertNotIn("outputs.", source) + + +@unittest.skipUnless(_has_graphviz, "graphviz not installed") +class TestMultipleOwnOutputsRankSame(unittest.TestCase): + def test_two_own_outputs_are_rank_aligned(self): + source = render.render( + prospective.build(library.autoencoder.flowrep_recipe, depth=0) + ).source + self.assertIn("rank=same", source) + + +@unittest.skipUnless(_has_graphviz, "graphviz not installed") +class TestBadgePreferredOverHint(unittest.TestCase): + def test_badge_wins_when_both_set(self): + node = model.DrawNode( + path="", + label="x", + kind=base_models.RecipeElementType.ATOMIC, + subtitle=None, + inputs=(model.DrawPort(label="a", hint="int", badge="nested"),), + outputs=(), + children=(), + edges=(), + ) + source = render.render(node).source + self.assertIn("nested", source) + self.assertNotIn(">int<", source) + + +@unittest.skipUnless(_has_graphviz, "graphviz not installed") +class TestRetrospectiveNote(unittest.TestCase): + def test_no_recorded_edges_note_appears_in_cluster_label(self): + """The known WfMS limitation surfaces as an italic note on the cluster.""" + data = wfms.run_recipe(_for_recipe(), xs=[1, 2, 3]) + graph = retrospective.build(data, depth=0) + source = render.render(graph).source + self.assertIn("no recorded edges", source) + + +@unittest.skipUnless(_has_graphviz, "graphviz not installed") +class TestActuallyRenders(unittest.TestCase): + @unittest.skipIf(shutil.which("dot") is None, "Graphviz `dot` binary not installed") + def test_dot_accepts_the_source(self): + """The DOT we emit must survive a real Graphviz parse, not just look right.""" + digraph = render.render(prospective.build(_for_recipe(), depth=0)) + self.assertTrue(digraph.pipe(format="svg").startswith(b"