Skip to content

Commit e0b8214

Browse files
authored
feat: add support for outputting a mermaid flowchart of the kind graph (#834)
Especially in repositories with large graphs, it can be useful to visualize the relationship between the various kinds. I looked at directly outputting svg files of the graphs here, but decided against it because rendering mermaid diagrams requires a browser or puppeteer AFAICT; and I don't want to add that dependency. I also looked at adding support for visualizing entire graphs, but those quickly get very unwieldy and I decided against it. (This might be useful in conjunction with a new `--target-task` option, but that's more than I'm willing to take on at this time.)
1 parent 8d41f7b commit e0b8214

File tree

4 files changed

+262
-1
lines changed

4 files changed

+262
-1
lines changed

src/taskgraph/generator.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,15 @@ def graph_config(self):
250250
"""
251251
return self._run_until("graph_config")
252252

253+
@property
254+
def kind_graph(self):
255+
"""
256+
The dependency graph of kinds.
257+
258+
@type: Graph
259+
"""
260+
return self._run_until("kind_graph")
261+
253262
def _load_kinds(self, graph_config, target_kinds=None):
254263
if target_kinds:
255264
# docker-image is an implicit dependency that never appears in
@@ -422,6 +431,8 @@ def _run(self):
422431
set(target_kinds) | {"docker-image"}
423432
)
424433

434+
yield "kind_graph", kind_graph
435+
425436
logger.info("Generating full task set")
426437
# Current parallel generation relies on multiprocessing, and forking.
427438
# This causes problems on Windows and macOS due to how new processes

src/taskgraph/main.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,31 @@ def format_taskgraph_yaml(taskgraph):
6464
return yaml.safe_dump(taskgraph.to_json(), default_flow_style=False)
6565

6666

67+
def format_kind_graph_mermaid(kind_graph):
68+
"""
69+
Convert a kind dependency graph to Mermaid flowchart format.
70+
71+
@param kind_graph: Graph object containing kind nodes and dependencies
72+
@return: String representation of the graph in Mermaid format
73+
"""
74+
lines = ["flowchart TD"]
75+
76+
# Add nodes (kinds)
77+
for node in sorted(kind_graph.nodes):
78+
# Sanitize node names for Mermaid (replace hyphens with underscores for IDs)
79+
node_id = node.replace("-", "_")
80+
lines.append(f" {node_id}[{node}]")
81+
82+
# Add edges (dependencies)
83+
# Reverse the edge direction: if left depends on right, show right --> left
84+
for left, right, _ in sorted(kind_graph.edges):
85+
left_id = left.replace("-", "_")
86+
right_id = right.replace("-", "_")
87+
lines.append(f" {right_id} --> {left_id}")
88+
89+
return "\n".join(lines)
90+
91+
6792
def get_filtered_taskgraph(taskgraph, tasksregex, exclude_keys):
6893
"""
6994
Filter all the tasks on basis of a regular expression
@@ -225,6 +250,69 @@ def logfile(spec):
225250
return returncode
226251

227252

253+
@command(
254+
"kind-graph",
255+
help="Generate a Mermaid flowchart diagram source file for the kind dependency graph. To render as a graph, run the output of this command through the Mermaid CLI or an online renderer.",
256+
)
257+
@argument("--root", "-r", help="root of the taskgraph definition relative to topsrcdir")
258+
@argument("--quiet", "-q", action="store_true", help="suppress all logging output")
259+
@argument(
260+
"--verbose", "-v", action="store_true", help="include debug-level logging output"
261+
)
262+
@argument(
263+
"--parameters",
264+
"-p",
265+
default=None,
266+
help="Parameters to use for the generation. Can be a path to file (.yml or "
267+
".json; see `taskcluster/docs/parameters.rst`), a url, of the form "
268+
"`project=mozilla-central` to download latest parameters file for the specified "
269+
"project from CI, or of the form `task-id=<decision task id>` to download "
270+
"parameters from the specified decision task.",
271+
)
272+
@argument(
273+
"-o",
274+
"--output-file",
275+
default=None,
276+
help="file path to store generated output.",
277+
)
278+
@argument(
279+
"-k",
280+
"--target-kind",
281+
dest="target_kinds",
282+
action="append",
283+
default=[],
284+
help="only return kinds and their dependencies.",
285+
)
286+
def show_kind_graph(options):
287+
from taskgraph.parameters import parameters_loader # noqa: PLC0415
288+
289+
if options.pop("verbose", False):
290+
logging.root.setLevel(logging.DEBUG)
291+
292+
setup_logging()
293+
294+
target_kinds = options.get("target_kinds", [])
295+
parameters = parameters_loader(
296+
options.get("parameters"),
297+
strict=False,
298+
overrides={"target-kinds": target_kinds},
299+
)
300+
301+
tgg = get_taskgraph_generator(options.get("root"), parameters)
302+
kind_graph = tgg.kind_graph
303+
304+
output = format_kind_graph_mermaid(kind_graph)
305+
306+
if output_file := options.get("output_file"):
307+
with open(output_file, "w") as fh:
308+
print(output, file=fh)
309+
print(f"Kind graph written to {output_file}", file=sys.stderr)
310+
else:
311+
print(output)
312+
313+
return 0
314+
315+
228316
@command(
229317
"tasks",
230318
help="Show the full task set in the task graph. The full task set includes all tasks defined by any kind, without edges (dependencies) between them.",

test/test_generator.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,3 +338,44 @@ def test_kind_load_tasks(monkeypatch, graph_config, parameters, datadir, kind_co
338338
)
339339
tasks = kind.load_tasks(parameters, {}, False)
340340
assert tasks
341+
342+
343+
def test_kind_graph(maketgg):
344+
"The kind_graph property has all kinds and their dependencies"
345+
tgg = maketgg(
346+
kinds=[
347+
("_fake3", {"kind-dependencies": ["_fake2", "_fake1"]}),
348+
("_fake2", {"kind-dependencies": ["_fake1"]}),
349+
("_fake1", {"kind-dependencies": []}),
350+
]
351+
)
352+
kind_graph = tgg.kind_graph
353+
assert isinstance(kind_graph, graph.Graph)
354+
assert kind_graph.nodes == {"_fake1", "_fake2", "_fake3"}
355+
assert kind_graph.edges == {
356+
("_fake3", "_fake2", "kind-dependency"),
357+
("_fake3", "_fake1", "kind-dependency"),
358+
("_fake2", "_fake1", "kind-dependency"),
359+
}
360+
361+
362+
def test_kind_graph_with_target_kinds(maketgg):
363+
"The kind_graph property respects target_kinds parameter"
364+
tgg = maketgg(
365+
kinds=[
366+
("_fake3", {"kind-dependencies": ["_fake2"]}),
367+
("_fake2", {"kind-dependencies": ["_fake1"]}),
368+
("_fake1", {"kind-dependencies": []}),
369+
("_other", {"kind-dependencies": []}),
370+
("docker-image", {"kind-dependencies": []}), # Add docker-image
371+
],
372+
params={"target-kinds": ["_fake2"]},
373+
)
374+
kind_graph = tgg.kind_graph
375+
# Should only include _fake2, _fake1, and docker-image (implicit dependency)
376+
assert "_fake2" in kind_graph.nodes
377+
assert "_fake1" in kind_graph.nodes
378+
assert "docker-image" in kind_graph.nodes
379+
# _fake3 and _other should not be included
380+
assert "_fake3" not in kind_graph.nodes
381+
assert "_other" not in kind_graph.nodes

test/test_main.py

Lines changed: 122 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
import taskgraph
1313
from taskgraph.actions import registry
1414
from taskgraph.graph import Graph
15-
from taskgraph.main import get_filtered_taskgraph
15+
from taskgraph.main import format_kind_graph_mermaid, get_filtered_taskgraph
1616
from taskgraph.main import main as taskgraph_main
1717
from taskgraph.task import Task
1818
from taskgraph.taskgraph import TaskGraph
@@ -521,3 +521,124 @@ def test_load_task_command_with_task_id(run_load_task):
521521
user=None,
522522
custom_image=None,
523523
)
524+
525+
526+
def test_format_kind_graph_mermaid():
527+
"""Test conversion of kind graph to Mermaid format"""
528+
# Test with simple graph
529+
kinds = frozenset(["docker-image"])
530+
edges = frozenset()
531+
kind_graph = Graph(kinds, edges)
532+
533+
output = format_kind_graph_mermaid(kind_graph)
534+
assert "flowchart TD" in output
535+
assert "docker_image[docker-image]" in output
536+
537+
# Test with complex graph with dependencies
538+
kinds = frozenset(["docker-image", "build", "test", "lint"])
539+
edges = frozenset(
540+
[
541+
("build", "docker-image", "kind-dependency"),
542+
("test", "build", "kind-dependency"),
543+
("lint", "docker-image", "kind-dependency"),
544+
]
545+
)
546+
kind_graph = Graph(kinds, edges)
547+
548+
output = format_kind_graph_mermaid(kind_graph)
549+
lines = output.split("\n")
550+
551+
assert lines[0] == "flowchart TD"
552+
# Check all nodes are present
553+
assert any("build[build]" in line for line in lines)
554+
assert any("docker_image[docker-image]" in line for line in lines)
555+
assert any("lint[lint]" in line for line in lines)
556+
assert any("test[test]" in line for line in lines)
557+
# Check edges are reversed (dependencies point to dependents)
558+
assert any("docker_image --> build" in line for line in lines)
559+
assert any("build --> test" in line for line in lines)
560+
assert any("docker_image --> lint" in line for line in lines)
561+
562+
563+
def test_show_kinds_command(run_taskgraph, capsys):
564+
"""Test the kinds command outputs Mermaid format"""
565+
res = run_taskgraph(
566+
["kind-graph"],
567+
kinds=[
568+
("_fake", {"kind-dependencies": []}),
569+
],
570+
)
571+
assert res == 0
572+
573+
out, _ = capsys.readouterr()
574+
assert "flowchart TD" in out
575+
assert "_fake[_fake]" in out
576+
577+
578+
def test_show_kinds_with_dependencies(run_taskgraph, capsys):
579+
"""Test the kinds command with kind dependencies"""
580+
res = run_taskgraph(
581+
["kind-graph"],
582+
kinds=[
583+
("_fake3", {"kind-dependencies": ["_fake2"]}),
584+
("_fake2", {"kind-dependencies": ["_fake1"]}),
585+
("_fake1", {"kind-dependencies": []}),
586+
],
587+
)
588+
assert res == 0
589+
590+
out, _ = capsys.readouterr()
591+
assert "flowchart TD" in out
592+
# Check all kinds are present
593+
assert "_fake1[_fake1]" in out
594+
assert "_fake2[_fake2]" in out
595+
assert "_fake3[_fake3]" in out
596+
# Check edges are present and reversed
597+
assert "_fake1 --> _fake2" in out
598+
assert "_fake2 --> _fake3" in out
599+
600+
601+
def test_show_kinds_output_file(run_taskgraph, tmpdir):
602+
"""Test the kinds command writes to file"""
603+
output_file = tmpdir.join("kinds.mmd")
604+
assert not output_file.check()
605+
606+
res = run_taskgraph(
607+
["kind-graph", f"--output-file={output_file.strpath}"],
608+
kinds=[
609+
("_fake", {"kind-dependencies": []}),
610+
],
611+
)
612+
assert res == 0
613+
assert output_file.check()
614+
615+
content = output_file.read_text("utf-8")
616+
assert "flowchart TD" in content
617+
assert "_fake[_fake]" in content
618+
619+
620+
def test_show_kinds_with_target_kinds(run_taskgraph, capsys):
621+
"""Test the kinds command with --target-kind filter"""
622+
res = run_taskgraph(
623+
["kind-graph", "-k", "_fake2"],
624+
kinds=[
625+
("_fake3", {"kind-dependencies": ["_fake2"]}),
626+
("_fake2", {"kind-dependencies": ["_fake1"]}),
627+
("_fake1", {"kind-dependencies": []}),
628+
("_other", {"kind-dependencies": []}),
629+
("docker-image", {"kind-dependencies": []}),
630+
],
631+
params={"target-kinds": ["_fake2"]},
632+
)
633+
assert res == 0
634+
635+
out, _ = capsys.readouterr()
636+
assert "flowchart TD" in out
637+
# Should include _fake2 and its dependencies
638+
assert "_fake2[_fake2]" in out
639+
assert "_fake1[_fake1]" in out
640+
# Should include docker-image (implicit dependency for target_kinds)
641+
assert "docker_image[docker-image]" in out
642+
# Should not include _fake3 or _other
643+
assert "_fake3" not in out
644+
assert "_other" not in out

0 commit comments

Comments
 (0)