Skip to content

Commit 9b0170d

Browse files
authored
Merge pull request #177 from jmchilton/mermaid_docs
Mermaid docs
2 parents 0b7bf1c + e09e11a commit 9b0170d

File tree

7 files changed

+280
-0
lines changed

7 files changed

+280
-0
lines changed

docs/_ext/examples_catalog.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
from gxformat2.cytoscape import cytoscape_elements
1313
from gxformat2.examples import EXAMPLES_DIR, load_catalog
14+
from gxformat2.mermaid import workflow_to_mermaid
1415

1516
GITHUB_BASE = "https://github.com/galaxyproject/gxformat2/blob/main"
1617

@@ -197,6 +198,11 @@ def _section(self, title, entries):
197198
if viz_node is not None:
198199
entry_section += viz_node
199200

201+
# Mermaid diagram
202+
mermaid_node = self._build_mermaid(entry)
203+
if mermaid_node is not None:
204+
entry_section += mermaid_node
205+
200206
# Workflow source (collapsible)
201207
contents = entry.load_contents()
202208
if entry.format == "format2":
@@ -239,6 +245,23 @@ def _build_viz(self, entry):
239245
)
240246
return nodes.raw("", iframe_html, format="html")
241247

248+
def _build_mermaid(self, entry):
249+
"""Generate a mermaid diagram node, or None on failure."""
250+
try:
251+
diagram = workflow_to_mermaid(entry.path, comments=True)
252+
except Exception:
253+
return None
254+
255+
from sphinxcontrib.mermaid import mermaid as mermaid_node
256+
257+
container = nodes.container(classes=["toggle"])
258+
container += nodes.caption(text="Mermaid diagram")
259+
node = mermaid_node()
260+
node["code"] = diagram
261+
node["options"] = {}
262+
container += node
263+
return container
264+
242265
def _field(self, name, value):
243266
field = nodes.field()
244267
field += nodes.field_name(text=name)

docs/conf.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"sphinx.ext.viewcode",
1919
"sphinxarg.ext",
2020
"sphinx_design",
21+
"sphinxcontrib.mermaid",
2122
"examples_catalog",
2223
]
2324

gxformat2/mermaid/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
"""Mermaid flowchart visualization for Galaxy workflows."""
2+
3+
from ._builder import workflow_to_mermaid
4+
from ._cli import main, to_mermaid
5+
6+
__all__ = (
7+
"main",
8+
"to_mermaid",
9+
"workflow_to_mermaid",
10+
)

gxformat2/mermaid/_builder.py

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
"""Build Mermaid flowchart diagrams from Galaxy workflows."""
2+
3+
from __future__ import annotations
4+
5+
from pathlib import Path
6+
from typing import Any
7+
8+
from gxformat2.normalized import ensure_format2, NormalizedFormat2
9+
from gxformat2.schema.gxformat2 import FrameComment, GalaxyWorkflow, WorkflowInputParameter
10+
11+
# Standard Mermaid shape wrappers: (open, close) bracket pairs.
12+
# >label] = asymmetric / flag (inputs)
13+
# [[label]] = subroutine (subworkflows)
14+
# [label] = rectangle (tool steps, default)
15+
SHAPE_INPUT = (">", "]")
16+
SHAPE_PARAM = ("{{", "}}")
17+
SHAPE_TOOL = ("[", "]")
18+
SHAPE_SUBWORKFLOW = ("[[", "]]")
19+
20+
STEP_TYPE_SHAPES = {
21+
"data": SHAPE_INPUT,
22+
"collection": SHAPE_INPUT,
23+
"integer": SHAPE_PARAM,
24+
"float": SHAPE_PARAM,
25+
"text": SHAPE_PARAM,
26+
"boolean": SHAPE_PARAM,
27+
"color": SHAPE_PARAM,
28+
"input": SHAPE_INPUT,
29+
"tool": SHAPE_TOOL,
30+
"subworkflow": SHAPE_SUBWORKFLOW,
31+
}
32+
33+
MAIN_TS_PREFIX = "toolshed.g2.bx.psu.edu/repos/"
34+
35+
36+
def _sanitize_label(label: str) -> str:
37+
"""Escape characters that have special meaning in Mermaid labels."""
38+
label = label.replace('"', "#quot;")
39+
for ch in "()[]{}<>":
40+
label = label.replace(ch, f"#{ord(ch)};")
41+
return label
42+
43+
44+
def _input_type_str(inp: WorkflowInputParameter) -> str:
45+
if inp.type_ is None:
46+
return "input"
47+
if isinstance(inp.type_, list):
48+
if inp.type_:
49+
return inp.type_[0].value
50+
return "input"
51+
return inp.type_.value
52+
53+
54+
def _node_line(node_id: str, label: str, shape: tuple[str, str]) -> str:
55+
open_br, close_br = shape
56+
return f'{node_id}{open_br}"{label}"{close_br}'
57+
58+
59+
def workflow_to_mermaid(
60+
workflow: dict[str, Any] | str | Path | GalaxyWorkflow | NormalizedFormat2,
61+
*,
62+
comments: bool = False,
63+
) -> str:
64+
"""Convert a Galaxy workflow to a Mermaid flowchart string.
65+
66+
Accepts anything ``ensure_format2()`` supports, plus an already
67+
normalized ``NormalizedFormat2`` instance.
68+
69+
When *comments* is True, FrameComment objects are rendered as
70+
Mermaid subgraphs that group their contained steps.
71+
"""
72+
if isinstance(workflow, NormalizedFormat2):
73+
nf2 = workflow
74+
else:
75+
nf2 = ensure_format2(workflow)
76+
77+
lines = ["graph LR"]
78+
79+
# Build node ID mappings and collect node declaration lines
80+
input_ids: dict[str, str] = {}
81+
input_lines: dict[str, str] = {}
82+
for i, inp in enumerate(nf2.inputs):
83+
node_id = f"input_{i}"
84+
inp_label = inp.id or str(i)
85+
input_ids[inp_label] = node_id
86+
label = _sanitize_label(inp_label)
87+
type_str = _input_type_str(inp)
88+
input_lines[inp_label] = _node_line(
89+
node_id, f"{label}<br/><i>{type_str}</i>", STEP_TYPE_SHAPES.get(type_str, SHAPE_INPUT)
90+
)
91+
92+
step_ids: dict[str, str] = {}
93+
step_lines: dict[str, str] = {}
94+
for i, step in enumerate(nf2.steps):
95+
node_id = f"step_{i}"
96+
step_label = step.label or step.id
97+
step_ids[step_label] = node_id
98+
99+
tool_id = step.tool_id
100+
if tool_id and tool_id.startswith(MAIN_TS_PREFIX):
101+
tool_id = tool_id[len(MAIN_TS_PREFIX) :]
102+
103+
label = _sanitize_label(step.label or step.id or (f"tool:{tool_id}" if tool_id else str(i)))
104+
step_type = step.type_.value if step.type_ else "tool"
105+
step_lines[step_label] = _node_line(node_id, label, STEP_TYPE_SHAPES.get(step_type, SHAPE_TOOL))
106+
107+
# Collect frame comments and which labels they claim
108+
framed: set[str] = set()
109+
frames: list[FrameComment] = []
110+
if comments:
111+
for comment in nf2.comments:
112+
if isinstance(comment, FrameComment) and comment.contains_steps:
113+
frames.append(comment)
114+
for ref in comment.contains_steps:
115+
framed.add(str(ref))
116+
117+
# Emit nodes — framed ones go inside subgraph blocks, others at top level
118+
for inp_label, line in input_lines.items():
119+
if inp_label not in framed:
120+
lines.append(f" {line}")
121+
122+
for step_label, line in step_lines.items():
123+
if step_label not in framed:
124+
lines.append(f" {line}")
125+
126+
for i, frame in enumerate(frames):
127+
title = _sanitize_label(frame.title or f"Group {i}")
128+
lines.append(f' subgraph sub_{i} ["{title}"]')
129+
for ref in frame.contains_steps or []:
130+
ref_str = str(ref)
131+
if ref_str in input_lines:
132+
lines.append(f" {input_lines[ref_str]}")
133+
elif ref_str in step_lines:
134+
lines.append(f" {step_lines[ref_str]}")
135+
lines.append(" end")
136+
137+
# Build edges (deduplicate identical connections)
138+
seen_edges: set[tuple[str, str]] = set()
139+
for i, step in enumerate(nf2.steps):
140+
node_id = f"step_{i}"
141+
for step_input in step.in_:
142+
if step_input.source is None:
143+
continue
144+
sources = step_input.source if isinstance(step_input.source, list) else [step_input.source]
145+
for source in sources:
146+
source_ref = nf2.resolve_source(source)
147+
source_id = input_ids.get(source_ref.step_label) or step_ids.get(source_ref.step_label)
148+
if source_id:
149+
edge_key = (source_id, node_id)
150+
if edge_key not in seen_edges:
151+
seen_edges.add(edge_key)
152+
lines.append(f" {source_id} --> {node_id}")
153+
154+
return "\n".join(lines)

gxformat2/mermaid/_cli.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
"""Command-line interface for Mermaid workflow diagram generation."""
2+
3+
import sys
4+
5+
from ._builder import workflow_to_mermaid
6+
7+
SCRIPT_DESCRIPTION = """
8+
Convert a Galaxy workflow (Format 2 or native .ga) into a Mermaid flowchart
9+
diagram.
10+
11+
Outputs Mermaid markdown to stdout by default, or to a file if an output
12+
path is provided. If the output path ends with .md, the diagram is wrapped
13+
in a fenced code block.
14+
"""
15+
16+
17+
def to_mermaid(workflow_path: str, output_path=None, *, comments: bool = False):
18+
"""Produce mermaid output for the supplied workflow path."""
19+
diagram = workflow_to_mermaid(workflow_path, comments=comments)
20+
21+
if output_path is None:
22+
print(diagram)
23+
return
24+
25+
if output_path.endswith(".md"):
26+
content = f"```mermaid\n{diagram}\n```\n"
27+
else:
28+
content = diagram + "\n"
29+
30+
with open(output_path, "w") as f:
31+
f.write(content)
32+
33+
34+
def main(argv=None):
35+
"""Entry point for generating Mermaid diagrams of Galaxy workflows."""
36+
if argv is None:
37+
argv = sys.argv[1:]
38+
39+
args = _parser().parse_args(argv)
40+
to_mermaid(args.input_path, args.output_path, comments=args.comments)
41+
42+
43+
def _parser():
44+
import argparse
45+
46+
parser = argparse.ArgumentParser(description=SCRIPT_DESCRIPTION)
47+
parser.add_argument("input_path", metavar="INPUT", type=str, help="input workflow path (.ga/gxwf.yml)")
48+
parser.add_argument("output_path", metavar="OUTPUT", type=str, nargs="?", help="output path (.mmd/.md)")
49+
parser.add_argument("--comments", action="store_true", default=False, help="render frame comments as subgraphs")
50+
return parser

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ gxwf-to-format2 = "gxformat2.export:main"
4040
gxwf-lint = "gxformat2.lint:main"
4141
gxwf-viz = "gxformat2.cytoscape:main"
4242
gxwf-abstract-export = "gxformat2.abstract:main"
43+
gxwf-mermaid = "gxformat2.mermaid:main"
4344

4445
[project.urls]
4546
Homepage = "https://github.com/galaxyproject/gxformat2"
@@ -72,6 +73,7 @@ docs = [
7273
"sphinx-rtd-theme",
7374
"myst-parser",
7475
"sphinx-argparse",
76+
"sphinxcontrib-mermaid",
7577
]
7678

7779
[tool.ruff]

uv.lock

Lines changed: 40 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)