Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,74 @@ Print the task dependency graph:

```console
$ python pipeline.py dag
gather_sources
prepare_shared <- gather_sources
download_source [array] <- gather_sources
convert_source [array] <- download_source, prepare_shared
gather_temp_levels <- convert_source
finalize [array] <- gather_temp_levels
```

Array tasks are marked `[array]`; the `<-` arrow lists each task's
dependencies.

Use `--format` to choose a different rendering. The `text`, `mermaid`,
and `dot` formats have no extra dependencies:

```console
$ python pipeline.py dag --format mermaid
flowchart TD
gather_sources[gather_sources]
download_source[[download_source]]
convert_source[[convert_source]]
gather_sources --> download_source
download_source --> convert_source
prepare_shared --> convert_source
```

`mermaid` output renders directly in GitHub and in mkdocs-material, so it
is handy for embedding a workflow diagram in documentation. `dot` emits
Graphviz source you can pipe to the `dot` binary:

```console
$ python pipeline.py dag --format dot | dot -Tpng -o dag.png
```

In both `mermaid` and `dot`, array tasks are drawn with a distinct shape
(a subroutine box `[[...]]` in Mermaid, a doubled border in Graphviz) so
they stand out from singleton tasks.

The `phart` format draws a pretty Unicode diagram directly in the
terminal, with the multi-parent fan-in rendered without duplicating
shared nodes:

```console
$ python pipeline.py dag --format phart
[gather_sources]
<<download_source>>───────+───────────→[prepare_shared]
↓ ↓
+────────→<<convert_source>>──────────+
→[gather_temp_levels]
<<finalize>>
```

Array tasks appear as `<<name>>` and singletons as `[name]`. This format
needs an optional dependency:

```console
$ pip install 'reflow-hpc[pretty]'
```

If the extra is not installed, `--format phart` prints the plain `text`
diagram to stdout and a one-line install hint to stderr, then exits
successfully — so redirecting the output (for example
`dag --format phart > graph.txt`) still produces a clean diagram. Pass
`--ascii` to force 7-bit ASCII instead of Unicode box characters, which
is useful for terminals or logs that do not handle Unicode well.

### `describe`

Print the full workflow manifest as JSON:
Expand Down Expand Up @@ -165,6 +231,17 @@ Only applies to `runs`.

Skip the confirmation prompt when cancelling multiple runs.

### `--format`

Output format for `dag`: `text` (default), `mermaid`, `dot`, or
`phart`. Only applies to `dag`. See the [`dag`](#dag) command above for
details and examples.

### `--ascii`

For `dag --format phart`, force 7-bit ASCII output instead of Unicode
box-drawing characters. Only applies to `dag`.

### `--version`

Print the reflow version.
7 changes: 7 additions & 0 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ Build a complete workflow from scratch in five minutes.
pip install reflow-hpc
```

For a prettier terminal diagram from the [`dag`](cli-reference.md#dag)
command, install the optional `pretty` extra:

```console
pip install 'reflow-hpc[pretty]'
```

## Step 1 — create a workflow

```python
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ dev = [
"ruff",
"mypy",
]
pretty = ["rich-argparse"]
pretty = ["rich-argparse", "phart", "networkx"]
docs = [
"mkdocs>=1.5",
"mkdocs-material>=9.5",
Expand Down
134 changes: 134 additions & 0 deletions src/reflow/_dag_render.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
"""DAG rendering for the ``dag`` CLI command.

Renders a workflow's task graph in one of several formats:

- ``text`` : indented adjacency list (default, zero dependencies)
- ``mermaid`` : Mermaid ``flowchart`` source (zero dependencies)
- ``dot`` : Graphviz DOT source (zero dependencies)
- ``phart`` : pretty ASCII/Unicode rendered in the terminal, requires the
optional ``reflow[pretty]`` extra (phart + networkx)

The text, mermaid, and dot renderers are pure string emission. The phart
renderer imports lazily and the caller is responsible for falling back to
text when the import is unavailable.

Array tasks are marked consistently across formats: a ``[array]`` suffix in
text, a distinct node shape in mermaid/dot, and angle-bracket decorators in
phart.
"""

from __future__ import annotations

from typing import TYPE_CHECKING

if TYPE_CHECKING:
from .workflow import Workflow

FORMATS = ("text", "mermaid", "dot", "phart")


def _edges(wf: Workflow) -> list[tuple[str, str]]:
"""Return (dependency, task) edges in topological order."""
edges: list[tuple[str, str]] = []
for tname in wf._topological_order():
spec = wf.tasks[tname]
for dep in wf._effective_dependencies(spec):
edges.append((dep, tname))
return edges


def _array_tasks(wf: Workflow) -> set[str]:
return {name for name, spec in wf.tasks.items() if spec.config.array}


def render_text(wf: Workflow) -> str:
"""Indented adjacency list (the original format)."""
lines: list[str] = []
for tname in wf._topological_order():
spec = wf.tasks[tname]
deps = wf._effective_dependencies(spec)
dep_str = f" <- {', '.join(deps)}" if deps else ""
tag = " [array]" if spec.config.array else ""
lines.append(f" {tname}{tag}{dep_str}")
return "\n".join(lines)


def render_mermaid(wf: Workflow) -> str:
"""Mermaid ``flowchart TD`` source.

Array tasks use the subroutine shape ``[[name]]``; singletons use the
default box ``[name]``. Renders natively in mkdocs-material and GitHub.
"""
array = _array_tasks(wf)
lines = ["flowchart TD"]
# Declare nodes first so isolated tasks (no edges) still appear.
for tname in wf._topological_order():
if tname in array:
lines.append(f" {tname}[[{tname}]]")
else:
lines.append(f" {tname}[{tname}]")
for dep, tname in _edges(wf):
lines.append(f" {dep} --> {tname}")
return "\n".join(lines)


def render_dot(wf: Workflow) -> str:
"""Graphviz DOT source.

Array tasks are drawn as boxes with doubled borders (``peripheries=2``)
to distinguish them from singleton tasks.
"""
array = _array_tasks(wf)
lines = ["digraph reflow {", " rankdir=TB;", " node [shape=box];"]
for tname in wf._topological_order():
if tname in array:
lines.append(f' "{tname}" [peripheries=2];')
else:
lines.append(f' "{tname}";')
for dep, tname in _edges(wf):
lines.append(f' "{dep}" -> "{tname}";')
lines.append("}")
return "\n".join(lines)


def render_phart(wf: Workflow, *, use_ascii: bool = False) -> str:
"""Pretty ASCII/Unicode DAG via the optional phart + networkx extra.

Array tasks are decorated with angle brackets ``<<name>>``; singletons
with square brackets ``[name]``. Raises ImportError if the extra is
not installed; the caller should catch this and fall back to text.
"""
import networkx as nx # noqa: PLC0415 - optional dependency
from phart import ASCIIRenderer, NodeStyle # noqa: PLC0415

array = _array_tasks(wf)
g = nx.DiGraph()
# Add all nodes so isolated tasks still render.
for tname in wf._topological_order():
g.add_node(tname)
g.add_edges_from(_edges(wf))

decorators = {
name: (("<<", ">>") if name in array else ("[", "]")) for name in g.nodes
}
renderer = ASCIIRenderer(
g,
node_style=NodeStyle.CUSTOM,
custom_decorators=decorators,
use_ascii=use_ascii,
)
result: str = renderer.render()
return result.rstrip("\n")


def render(wf: Workflow, fmt: str, *, use_ascii: bool = False) -> str:
"""Render the DAG in *fmt*. phart import errors propagate to the caller."""
if fmt == "text":
return render_text(wf)
if fmt == "mermaid":
return render_mermaid(wf)
if fmt == "dot":
return render_dot(wf)
if fmt == "phart":
return render_phart(wf, use_ascii=use_ascii)
raise ValueError(f"Unknown DAG format: {fmt!r}")
39 changes: 32 additions & 7 deletions src/reflow/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,10 +217,27 @@ def _add_runs_parser(sp: Any) -> None:


def _add_dag_parser(sp: Any) -> None:
from ._dag_render import FORMATS

p = sp.add_parser(
"dag",
help="Print the task DAG.",
)
p.add_argument(
"--format",
choices=FORMATS,
default="text",
help=(
"Output format. 'text' is the default adjacency list; 'mermaid' "
"and 'dot' emit graph source for external renderers; 'phart' "
"draws a pretty terminal diagram (needs the 'pretty' extra)."
),
)
p.add_argument(
"--ascii",
action="store_true",
help="For --format phart, force 7-bit ASCII instead of Unicode.",
)
p.set_defaults(_command="dag")


Expand Down Expand Up @@ -552,13 +569,21 @@ def _cmd_runs(wf: Any, args: argparse.Namespace) -> int:


def _cmd_dag(wf: Any, args: argparse.Namespace) -> int:
order = wf._topological_order()
for tname in order:
spec = wf.tasks[tname]
deps = wf._effective_dependencies(spec)
dep_str = f" <- {', '.join(deps)}" if deps else ""
tag = " [array]" if spec.config.array else ""
print(f" {tname}{tag}{dep_str}")
from . import _dag_render

fmt = getattr(args, "format", "text")
if fmt == "phart":
try:
print(_dag_render.render_phart(wf, use_ascii=getattr(args, "ascii", False)))
return 0
except ImportError:
print(
"phart not installed; showing plain text. "
"For a prettier diagram: pip install 'reflow[pretty]'",
file=sys.stderr,
)
fmt = "text"
print(_dag_render.render(wf, fmt, use_ascii=getattr(args, "ascii", False)))
return 0


Expand Down
Loading
Loading