Skip to content
1 change: 1 addition & 0 deletions .binder/environment.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions .ci_support/environment-optional.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ dependencies:
- bagofholding =0.1.12
- ipython
- ipytree =0.2.2
- python-graphviz =0.21
- python-workflow-definition =0.1.5
1 change: 1 addition & 0 deletions docs/environment.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,5 @@ dependencies:
- bagofholding =0.1.12
- ipython
- ipytree =0.2.2
- python-graphviz =0.21
- python-workflow-definition =0.1.5
696 changes: 436 additions & 260 deletions notebooks/user-guide.ipynb

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ storage-widget = [
dataviewer = [
"ipython",
]
drawing = [
"graphviz==0.21",
]

[tool.hatch.build]
include = [
Expand Down
1 change: 1 addition & 0 deletions src/flowrep/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/flowrep/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions src/flowrep/api/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 27 additions & 1 deletion src/flowrep/base_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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(
Expand Down
3 changes: 3 additions & 0 deletions src/flowrep/drawing/__init__.py
Original file line number Diff line number Diff line change
@@ -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
55 changes: 55 additions & 0 deletions src/flowrep/drawing/interface.py
Original file line number Diff line number Diff line change
@@ -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}"
)
82 changes: 82 additions & 0 deletions src/flowrep/drawing/model.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading