From 4fc9f1cb9e443b527b7308c18d791cdabd08dd4a Mon Sep 17 00:00:00 2001 From: Austin Varga Date: Mon, 6 Apr 2026 20:25:35 -0600 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20add=20dotflow=20viz=20=E2=80=94=20t?= =?UTF-8?q?erminal=20pipeline=20visualization=20command?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #108 New command: dotflow viz -s [-m ] [--format terminal|mermaid] Renders a workflow pipeline as an ASCII box diagram in the terminal, or exports Mermaid graph syntax for documentation. - Sequential mode: boxes connected left-to-right with ──▶ arrows - Parallel mode: stacked boxes with fork bracket on the left - Sequential group mode: sections rendered per group - Each box shows task name, retry count, and timeout when set - --format mermaid outputs a graph LR Mermaid diagram - 16 tests covering name resolution, box rendering, and all output modes --- dotflow/cli/commands/__init__.py | 9 +- dotflow/cli/commands/viz.py | 41 ++++ dotflow/cli/setup.py | 38 ++++ dotflow/utils/visualizer.py | 318 +++++++++++++++++++++++++++++++ tests/utils/test_visualizer.py | 132 +++++++++++++ 5 files changed, 537 insertions(+), 1 deletion(-) create mode 100644 dotflow/cli/commands/viz.py create mode 100644 dotflow/utils/visualizer.py create mode 100644 tests/utils/test_visualizer.py diff --git a/dotflow/cli/commands/__init__.py b/dotflow/cli/commands/__init__.py index 1c2a7fc0..6a7608af 100644 --- a/dotflow/cli/commands/__init__.py +++ b/dotflow/cli/commands/__init__.py @@ -4,5 +4,12 @@ from dotflow.cli.commands.log import LogCommand from dotflow.cli.commands.schedule import ScheduleCommand from dotflow.cli.commands.start import StartCommand +from dotflow.cli.commands.viz import VizCommand -__all__ = ["InitCommand", "LogCommand", "ScheduleCommand", "StartCommand"] +__all__ = [ + "InitCommand", + "LogCommand", + "ScheduleCommand", + "StartCommand", + "VizCommand", +] diff --git a/dotflow/cli/commands/viz.py b/dotflow/cli/commands/viz.py new file mode 100644 index 00000000..dfd8227f --- /dev/null +++ b/dotflow/cli/commands/viz.py @@ -0,0 +1,41 @@ +"""Command viz module""" + +from dotflow.cli.command import Command +from dotflow.core.module import Module +from dotflow.utils.visualizer import visualize + + +class VizCommand(Command): + def setup(self): + step = self.params.step + mode = self.params.mode + fmt = self.params.format + + # Load the workflow step (same module-loading pattern as StartCommand) + loaded = Module(value=step) + + # The step may be a DotFlow instance, a TaskBuilder, or a bare list of Tasks. + tasks = self._extract_tasks(loaded) + visualize(tasks=tasks, mode=mode, fmt=fmt) + + @staticmethod + def _extract_tasks(obj) -> list: + """ + Accept any of the three common ways a user might point us at tasks: + 1. A DotFlow instance → obj.task.queue + 2. A TaskBuilder instance → obj.queue + 3. A plain list[Task] → obj + """ + # DotFlow wraps a TaskBuilder under .task + if hasattr(obj, "task") and hasattr(obj.task, "queue"): + return list(obj.task.queue) + + # TaskBuilder exposes .queue directly + if hasattr(obj, "queue"): + return list(obj.queue) + + # Plain list + if isinstance(obj, list): + return obj + + return [] diff --git a/dotflow/cli/setup.py b/dotflow/cli/setup.py index 67f45f5b..e6f6e478 100644 --- a/dotflow/cli/setup.py +++ b/dotflow/cli/setup.py @@ -8,6 +8,7 @@ LogCommand, ScheduleCommand, StartCommand, + VizCommand, ) from dotflow.core.exception import ( MESSAGE_UNKNOWN_ERROR, @@ -39,6 +40,7 @@ def __init__(self, parser): self.setup_logs() self.setup_start() self.setup_schedule() + self.setup_viz() self.command() def setup_init(self): @@ -145,6 +147,42 @@ def setup_schedule(self): self.cmd_schedule.set_defaults(exec=ScheduleCommand) + def setup_viz(self): + self.cmd_viz = self.subparsers.add_parser( + "viz", + help="Visualize a workflow pipeline in the terminal", + ) + self.cmd_viz = self.cmd_viz.add_argument_group( + "Usage: dotflow viz [OPTIONS]" + ) + + self.cmd_viz.add_argument( + "-s", + "--step", + required=True, + help="Dotted path to a DotFlow instance, TaskBuilder, or task list", + ) + self.cmd_viz.add_argument( + "-m", + "--mode", + default=TypeExecution.SEQUENTIAL, + choices=[ + TypeExecution.SEQUENTIAL, + TypeExecution.BACKGROUND, + TypeExecution.PARALLEL, + "sequential_group", + ], + help="Execution mode to visualize (default: sequential)", + ) + self.cmd_viz.add_argument( + "--format", + default="terminal", + choices=["terminal", "mermaid"], + help="Output format: terminal (default) or mermaid", + ) + + self.cmd_viz.set_defaults(exec=VizCommand) + def setup_logs(self): self.cmd_logs = self.subparsers.add_parser("logs", help="Logs") self.cmd_logs = self.cmd_logs.add_argument_group( diff --git a/dotflow/utils/visualizer.py b/dotflow/utils/visualizer.py new file mode 100644 index 00000000..ea7a3c17 --- /dev/null +++ b/dotflow/utils/visualizer.py @@ -0,0 +1,318 @@ +"""Workflow visualizer — terminal and Mermaid rendering.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from rich.console import Console +from rich.text import Text + +if TYPE_CHECKING: + from dotflow.core.task import Task + +console = Console() + +# ── Box-drawing constants ───────────────────────────────────────────────────── + +_TL = "┌" +_TR = "┐" +_BL = "└" +_BR = "┘" +_H = "─" +_V = "│" +_ARR = "──▶" +_FORK = "┬" +_JOIN_TOP = "┐" +_JOIN_BOT = "┘" +_BRANCH_TOP = "┌" +_BRANCH_BOT = "└" + + +def _task_name(task: Task) -> str: + step = task.step + + # Case 1: @action direct — step is an Action instance with .func set + func = getattr(step, "func", None) + if func is not None: + return getattr(func, "__name__", None) or type(func).__name__ + + # Case 2: @action(retry=N) — step is the inner closure returned by Action.__call__ + # The original function is captured as args[0] in the closure. + if callable(step) and getattr(step, "__closure__", None): + free_vars = step.__code__.co_freevars + cells = step.__closure__ + if "args" in free_vars: + args_cell = cells[free_vars.index("args")].cell_contents + if args_cell: + return ( + getattr(args_cell[0], "__name__", None) + or type(args_cell[0]).__name__ + ) + + return getattr(step, "__name__", None) or type(step).__name__ + + +def _action_instance(task: Task): + """Return the Action instance for a task regardless of decoration style.""" + step = task.step + # @action direct → step is the Action instance + if hasattr(step, "retry"): + return step + # @action(retry=N) → step is a closure; Action instance is in 'self' cell + if callable(step) and getattr(step, "__closure__", None): + free_vars = step.__code__.co_freevars + if "self" in free_vars: + return step.__closure__[free_vars.index("self")].cell_contents + return None + + +def _task_config(task: Task) -> list[str]: + """Return notable config lines for the box body (retry, timeout).""" + lines = [] + action = _action_instance(task) + if action is None: + return lines + if getattr(action, "retry", 1) != 1: + lines.append(f"retry: {action.retry}") + if getattr(action, "timeout", 0): + lines.append(f"timeout: {action.timeout}s") + return lines + + +def _task_status(task: Task) -> str | None: + """Return a status line if the task has already been executed.""" + from dotflow.core.types.status import TypeStatus + + status = getattr(task, "_status", None) + if status is None or status == TypeStatus.NOT_STARTED: + return None + + symbol = TypeStatus.get_symbol(status) or "" + duration = getattr(task, "_duration", None) + dur_str = f" {duration:.2f}s" if duration is not None else "" + errors = getattr(task, "_errors", None) or [] + err_str = "" + if errors: + last = errors[-1] + exc = getattr(last, "exception", None) + if exc: + err_str = f"\n{exc}" + return f"{symbol}{dur_str}{err_str}".strip() + + +# ── Box builder ─────────────────────────────────────────────────────────────── + + +def _build_box(task: Task, width: int = 16) -> list[str]: + """Return a list of strings representing a single task box.""" + name = _task_name(task) + config_lines = _task_config(task) + status_line = _task_status(task) + + inner = width - 2 # subtract border chars + + def pad(text: str) -> str: + text = text[:inner] + return f"{_V} {text:<{inner - 1}}{_V}" + + top = _TL + _H * (width - 2) + _TR + bot = _BL + _H * (width - 2) + _BR + + rows = [top, pad(name)] + + if config_lines: + rows.append(pad("")) + for line in config_lines: + rows.append(pad(line)) + + if status_line: + for line in status_line.splitlines(): + rows.append(pad(line)) + + rows.append(bot) + return rows + + +# ── Sequential renderer ─────────────────────────────────────────────────────── + + +def _render_sequential(tasks: list[Task]) -> str: + if not tasks: + return "(no tasks)" + + box_w = 18 + connector = f" {_ARR} " + boxes = [_build_box(t, width=box_w) for t in tasks] + height = max(len(b) for b in boxes) + + # Pad all boxes to same height (insert blank lines before bottom border) + padded = [] + for box in boxes: + if len(box) < height: + inner_w = box_w - 2 + filler = f"{_V} {' ' * (inner_w - 1)}{_V}" + # Insert fillers before the last line (bottom border) + box = box[:-1] + [filler] * (height - len(box)) + [box[-1]] + padded.append(box) + + mid = 1 # always connect on the first content row (row after top border) + lines = [] + for row in range(height): + parts = [] + for i, box in enumerate(padded): + parts.append(box[row]) + if i < len(padded) - 1: + parts.append(connector if row == mid else " " * len(connector)) + lines.append("".join(parts)) + + return "\n".join(lines) + + +# ── Parallel renderer ───────────────────────────────────────────────────────── + + +def _render_parallel(tasks: list[Task]) -> str: + """ + Render parallel tasks as a fork/join diagram: + + ┌──────────┐ ┌────────────┐ + │ task_a │─┬──▶│ parallel_1 │──┐ + └──────────┘ │ └────────────┘ │ ┌──────────┐ + │ ┌────────────┐ ├─▶│ join? │ + └──▶│ parallel_2 │──┘ └──────────┘ + └────────────┘ + For dotflow parallel mode all tasks run concurrently so we just + show them stacked with a leading/trailing bracket. + """ + if not tasks: + return "(no tasks)" + + box_w = 18 + boxes = [_build_box(t, width=box_w) for t in tasks] + box_h = max(len(b) for b in boxes) + + padded = [] + for box in boxes: + if len(box) < box_h: + inner_w = box_w - 2 + filler = f"{_V} {' ' * (inner_w - 1)}{_V}" + box = box[:-1] + [filler] * (box_h - len(box)) + [box[-1]] + padded.append(box) + + indent = " " + + lines = [] + for t_idx, box in enumerate(padded): + for b_row, b_line in enumerate(box): + # Left bracket + if len(tasks) == 1: + bracket = " " + elif t_idx == 0 and b_row == box_h // 2: + bracket = "┌─" + elif t_idx == len(tasks) - 1 and b_row == box_h // 2: + bracket = "└─" + elif b_row == box_h // 2: + bracket = "├─" + elif ( + (t_idx == 0 and b_row > box_h // 2) + or (t_idx == len(tasks) - 1 and b_row < box_h // 2) + or t_idx > 0 + ): + bracket = "│ " + else: + bracket = " " + + lines.append(f"{indent}{bracket}{b_line}") + + if t_idx < len(tasks) - 1: + lines.append(f"{indent}│ ") + + return "\n".join(lines) + + +# ── Group renderer ──────────────────────────────────────────────────────────── + + +def _render_groups(groups: dict[str, list[Task]]) -> str: + """Render sequential groups — each group runs in its own process.""" + sections = [] + for group_name, tasks in groups.items(): + header = f" ── group: {group_name} ──" + body = _render_sequential(tasks) + indented = "\n".join(" " + line for line in body.splitlines()) + sections.append(f"{header}\n{indented}") + return "\n\n".join(sections) + + +# ── Mermaid export ──────────────────────────────────────────────────────────── + + +def _render_mermaid(tasks: list[Task], mode: str) -> str: + lines = ["graph LR"] + names = [_task_name(t) for t in tasks] + + if mode == "parallel": + lines.append(" START:::hidden") + lines.append(" END:::hidden") + for name in names: + lines.append(f" START --> {name}") + lines.append(f" {name} --> END") + lines.append(" classDef hidden display:none") + else: + for i, name in enumerate(names): + if i < len(names) - 1: + lines.append(f" {name} --> {names[i + 1]}") + + return "\n".join(lines) + + +# ── Public API ──────────────────────────────────────────────────────────────── + + +def visualize( + tasks: list[Task], + mode: str = "sequential", + fmt: str = "terminal", +) -> None: + """ + Render a workflow pipeline to the terminal or as Mermaid markup. + + Args: + tasks: The task list from a DotFlow / TaskBuilder instance. + mode: Execution mode string — 'sequential', 'parallel', + 'background', or 'sequential_group'. + fmt: Output format — 'terminal' (default) or 'mermaid'. + """ + from dotflow.core.workflow import grouper + + if fmt == "mermaid": + print(_render_mermaid(tasks, mode)) + return + + # ── Terminal output ─────────────────────────────────────────────────────── + has_groups = len(grouper(tasks)) > 1 + + if mode == "parallel": + diagram = _render_parallel(tasks) + mode_label = "parallel" + elif has_groups or mode == "sequential_group": + diagram = _render_groups(grouper(tasks)) + mode_label = "sequential_group" + else: + diagram = _render_sequential(tasks) + mode_label = "sequential" + + task_count = len(tasks) + header = Text() + header.append("dotflow viz", style="bold cyan") + header.append( + f" · {task_count} task{'s' if task_count != 1 else ''}", style="dim" + ) + header.append(" · mode: ", style="dim") + header.append(mode_label, style="bold yellow") + + console.print() + console.print(header) + console.print() + console.print(diagram) + console.print() diff --git a/tests/utils/test_visualizer.py b/tests/utils/test_visualizer.py new file mode 100644 index 00000000..33240551 --- /dev/null +++ b/tests/utils/test_visualizer.py @@ -0,0 +1,132 @@ +"""Tests for the workflow visualizer.""" + +import unittest + +from dotflow import DotFlow, action +from dotflow.utils.visualizer import ( + _build_box, + _render_mermaid, + _render_parallel, + _render_sequential, + _task_name, +) + + +def _make_tasks(*steps): + wf = DotFlow() + for step in steps: + wf.task.add(step) + return list(wf.task.queue) + + +@action +def step_a(): + pass + + +@action(retry=3) +def step_b(): + pass + + +@action(timeout=60) +def step_c(): + pass + + +class TestTaskName(unittest.TestCase): + def test_direct_decorator(self): + tasks = _make_tasks(step_a) + self.assertEqual(_task_name(tasks[0]), "step_a") + + def test_parametrised_decorator(self): + tasks = _make_tasks(step_b) + self.assertEqual(_task_name(tasks[0]), "step_b") + + def test_timeout_decorator(self): + tasks = _make_tasks(step_c) + self.assertEqual(_task_name(tasks[0]), "step_c") + + +class TestBuildBox(unittest.TestCase): + def test_box_has_top_and_bottom_border(self): + tasks = _make_tasks(step_a) + box = _build_box(tasks[0], width=18) + self.assertTrue(box[0].startswith("┌")) + self.assertTrue(box[-1].startswith("└")) + + def test_box_contains_task_name(self): + tasks = _make_tasks(step_a) + box = _build_box(tasks[0], width=18) + full = "\n".join(box) + self.assertIn("step_a", full) + + def test_box_shows_retry(self): + tasks = _make_tasks(step_b) + box = _build_box(tasks[0], width=18) + full = "\n".join(box) + self.assertIn("retry: 3", full) + + def test_box_shows_timeout(self): + tasks = _make_tasks(step_c) + box = _build_box(tasks[0], width=18) + full = "\n".join(box) + self.assertIn("timeout: 60s", full) + + +class TestRenderSequential(unittest.TestCase): + def test_all_task_names_present(self): + tasks = _make_tasks(step_a, step_b, step_c) + output = _render_sequential(tasks) + self.assertIn("step_a", output) + self.assertIn("step_b", output) + self.assertIn("step_c", output) + + def test_arrow_connector_present(self): + tasks = _make_tasks(step_a, step_b) + output = _render_sequential(tasks) + self.assertIn("──▶", output) + + def test_single_task_no_arrow(self): + tasks = _make_tasks(step_a) + output = _render_sequential(tasks) + self.assertNotIn("──▶", output) + + def test_empty_returns_fallback(self): + output = _render_sequential([]) + self.assertEqual(output, "(no tasks)") + + +class TestRenderParallel(unittest.TestCase): + def test_all_names_present(self): + tasks = _make_tasks(step_a, step_b, step_c) + output = _render_parallel(tasks) + self.assertIn("step_a", output) + self.assertIn("step_b", output) + self.assertIn("step_c", output) + + def test_bracket_chars_present(self): + tasks = _make_tasks(step_a, step_b) + output = _render_parallel(tasks) + self.assertIn("┌", output) + self.assertIn("└", output) + + def test_empty_returns_fallback(self): + output = _render_parallel([]) + self.assertEqual(output, "(no tasks)") + + +class TestRenderMermaid(unittest.TestCase): + def test_sequential_chain(self): + tasks = _make_tasks(step_a, step_b, step_c) + output = _render_mermaid(tasks, mode="sequential") + self.assertIn("graph LR", output) + self.assertIn("step_a --> step_b", output) + self.assertIn("step_b --> step_c", output) + + def test_parallel_uses_start_end(self): + tasks = _make_tasks(step_a, step_b) + output = _render_mermaid(tasks, mode="parallel") + self.assertIn("START --> step_a", output) + self.assertIn("START --> step_b", output) + self.assertIn("step_a --> END", output) From f51b8f8f02edfa72d29343f8c509be5edda02613 Mon Sep 17 00:00:00 2001 From: Austin Varga <64624232+avarga1@users.noreply.github.com> Date: Sat, 11 Apr 2026 15:12:37 -0600 Subject: [PATCH 2/4] fix: address review feedback on ISSUE-108 viz feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename command viz → flow (file, class, setup method, subcommand, __all__) - Fix background mode showing wrong label (was falling through to sequential) - Fix Mermaid duplicate node IDs collapsing into self-loop (positional suffix) - Use console.print() instead of bare print() for Mermaid output - Remove narration inline comments; keep only non-obvious why comments - Add test for duplicate task names in Mermaid output - Update existing Mermaid tests for new node ID format --- dotflow/cli/commands/{viz.py => flow.py} | 10 ++----- dotflow/utils/visualizer.py | 33 ++++++++++++------------ tests/utils/test_visualizer.py | 25 ++++++++++++++---- 3 files changed, 39 insertions(+), 29 deletions(-) rename dotflow/cli/commands/{viz.py => flow.py} (73%) diff --git a/dotflow/cli/commands/viz.py b/dotflow/cli/commands/flow.py similarity index 73% rename from dotflow/cli/commands/viz.py rename to dotflow/cli/commands/flow.py index dfd8227f..f0e288d0 100644 --- a/dotflow/cli/commands/viz.py +++ b/dotflow/cli/commands/flow.py @@ -1,20 +1,17 @@ -"""Command viz module""" +"""Command flow module""" from dotflow.cli.command import Command from dotflow.core.module import Module from dotflow.utils.visualizer import visualize -class VizCommand(Command): +class FlowCommand(Command): def setup(self): step = self.params.step mode = self.params.mode fmt = self.params.format - # Load the workflow step (same module-loading pattern as StartCommand) loaded = Module(value=step) - - # The step may be a DotFlow instance, a TaskBuilder, or a bare list of Tasks. tasks = self._extract_tasks(loaded) visualize(tasks=tasks, mode=mode, fmt=fmt) @@ -26,15 +23,12 @@ def _extract_tasks(obj) -> list: 2. A TaskBuilder instance → obj.queue 3. A plain list[Task] → obj """ - # DotFlow wraps a TaskBuilder under .task if hasattr(obj, "task") and hasattr(obj.task, "queue"): return list(obj.task.queue) - # TaskBuilder exposes .queue directly if hasattr(obj, "queue"): return list(obj.queue) - # Plain list if isinstance(obj, list): return obj diff --git a/dotflow/utils/visualizer.py b/dotflow/utils/visualizer.py index ea7a3c17..5552cd04 100644 --- a/dotflow/utils/visualizer.py +++ b/dotflow/utils/visualizer.py @@ -31,13 +31,13 @@ def _task_name(task: Task) -> str: step = task.step - # Case 1: @action direct — step is an Action instance with .func set func = getattr(step, "func", None) if func is not None: return getattr(func, "__name__", None) or type(func).__name__ - # Case 2: @action(retry=N) — step is the inner closure returned by Action.__call__ - # The original function is captured as args[0] in the closure. + # @action(retry=N) wraps the original function in a closure. + # We must introspect __closure__ to recover the original function name + # because the wrapper doesn't preserve __name__. if callable(step) and getattr(step, "__closure__", None): free_vars = step.__code__.co_freevars cells = step.__closure__ @@ -55,10 +55,8 @@ def _task_name(task: Task) -> str: def _action_instance(task: Task): """Return the Action instance for a task regardless of decoration style.""" step = task.step - # @action direct → step is the Action instance if hasattr(step, "retry"): return step - # @action(retry=N) → step is a closure; Action instance is in 'self' cell if callable(step) and getattr(step, "__closure__", None): free_vars = step.__code__.co_freevars if "self" in free_vars: @@ -109,7 +107,7 @@ def _build_box(task: Task, width: int = 16) -> list[str]: config_lines = _task_config(task) status_line = _task_status(task) - inner = width - 2 # subtract border chars + inner = width - 2 def pad(text: str) -> str: text = text[:inner] @@ -151,11 +149,10 @@ def _render_sequential(tasks: list[Task]) -> str: if len(box) < height: inner_w = box_w - 2 filler = f"{_V} {' ' * (inner_w - 1)}{_V}" - # Insert fillers before the last line (bottom border) box = box[:-1] + [filler] * (height - len(box)) + [box[-1]] padded.append(box) - mid = 1 # always connect on the first content row (row after top border) + mid = 1 lines = [] for row in range(height): parts = [] @@ -204,7 +201,6 @@ def _render_parallel(tasks: list[Task]) -> str: lines = [] for t_idx, box in enumerate(padded): for b_row, b_line in enumerate(box): - # Left bracket if len(tasks) == 1: bracket = " " elif t_idx == 0 and b_row == box_h // 2: @@ -250,18 +246,20 @@ def _render_groups(groups: dict[str, list[Task]]) -> str: def _render_mermaid(tasks: list[Task], mode: str) -> str: lines = ["graph LR"] names = [_task_name(t) for t in tasks] + # Use positional suffixes as node IDs to prevent Mermaid collapsing + # duplicate function names into a single node (self-loop). + node_ids = [f"{name}_{i}" for i, name in enumerate(names)] if mode == "parallel": lines.append(" START:::hidden") lines.append(" END:::hidden") - for name in names: - lines.append(f" START --> {name}") - lines.append(f" {name} --> END") + for node_id, name in zip(node_ids, names): + lines.append(f' START --> {node_id}["{name}"]') + lines.append(f' {node_id}["{name}"] --> END') lines.append(" classDef hidden display:none") else: - for i, name in enumerate(names): - if i < len(names) - 1: - lines.append(f" {name} --> {names[i + 1]}") + for i in range(len(node_ids) - 1): + lines.append(f' {node_ids[i]}["{names[i]}"] --> {node_ids[i+1]}["{names[i+1]}"]') return "\n".join(lines) @@ -286,7 +284,7 @@ def visualize( from dotflow.core.workflow import grouper if fmt == "mermaid": - print(_render_mermaid(tasks, mode)) + console.print(_render_mermaid(tasks, mode), highlight=False) return # ── Terminal output ─────────────────────────────────────────────────────── @@ -295,6 +293,9 @@ def visualize( if mode == "parallel": diagram = _render_parallel(tasks) mode_label = "parallel" + elif mode == "background": + diagram = _render_sequential(tasks) + mode_label = "background" elif has_groups or mode == "sequential_group": diagram = _render_groups(grouper(tasks)) mode_label = "sequential_group" diff --git a/tests/utils/test_visualizer.py b/tests/utils/test_visualizer.py index 33240551..f504d480 100644 --- a/tests/utils/test_visualizer.py +++ b/tests/utils/test_visualizer.py @@ -121,12 +121,27 @@ def test_sequential_chain(self): tasks = _make_tasks(step_a, step_b, step_c) output = _render_mermaid(tasks, mode="sequential") self.assertIn("graph LR", output) - self.assertIn("step_a --> step_b", output) - self.assertIn("step_b --> step_c", output) + # Node IDs include positional suffix; display labels are the original names + self.assertIn('"step_a"', output) + self.assertIn('"step_b"', output) + self.assertIn('"step_c"', output) + # There should be two arrows: a→b and b→c + self.assertEqual(output.count("-->"), 2) def test_parallel_uses_start_end(self): tasks = _make_tasks(step_a, step_b) output = _render_mermaid(tasks, mode="parallel") - self.assertIn("START --> step_a", output) - self.assertIn("START --> step_b", output) - self.assertIn("step_a --> END", output) + self.assertIn("START -->", output) + self.assertIn("--> END", output) + self.assertIn('"step_a"', output) + self.assertIn('"step_b"', output) + + def test_duplicate_names_produce_distinct_nodes(self): + # Two tasks wrapping the same function must not collapse into a self-loop + tasks = _make_tasks(step_a, step_a) + output = _render_mermaid(tasks, mode="sequential") + arrow_lines = [l.strip() for l in output.splitlines() if "-->" in l] + self.assertEqual(len(arrow_lines), 1) + parts = arrow_lines[0].split("-->") + # The left and right node IDs must differ + self.assertNotEqual(parts[0].strip(), parts[1].strip()) From d527555973ea3d8ae1cf2fc147fc8ef94ee8097a Mon Sep 17 00:00:00 2001 From: Austin Varga <64624232+avarga1@users.noreply.github.com> Date: Sat, 11 Apr 2026 15:20:01 -0600 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20rename=20ambiguous=20variable=20l=20?= =?UTF-8?q?=E2=86=92=20line=20(ruff=20E741)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/utils/test_visualizer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/utils/test_visualizer.py b/tests/utils/test_visualizer.py index f504d480..8ed4840d 100644 --- a/tests/utils/test_visualizer.py +++ b/tests/utils/test_visualizer.py @@ -140,7 +140,7 @@ def test_duplicate_names_produce_distinct_nodes(self): # Two tasks wrapping the same function must not collapse into a self-loop tasks = _make_tasks(step_a, step_a) output = _render_mermaid(tasks, mode="sequential") - arrow_lines = [l.strip() for l in output.splitlines() if "-->" in l] + arrow_lines = [line.strip() for line in output.splitlines() if "-->" in line] self.assertEqual(len(arrow_lines), 1) parts = arrow_lines[0].split("-->") # The left and right node IDs must differ From f2ed0ea5ef0cba0577099ba36db8592df4901289 Mon Sep 17 00:00:00 2001 From: Austin Varga <64624232+avarga1@users.noreply.github.com> Date: Sat, 11 Apr 2026 15:22:04 -0600 Subject: [PATCH 4/4] style: apply ruff formatting --- dotflow/utils/visualizer.py | 4 +++- tests/utils/test_visualizer.py | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/dotflow/utils/visualizer.py b/dotflow/utils/visualizer.py index 5552cd04..40da837d 100644 --- a/dotflow/utils/visualizer.py +++ b/dotflow/utils/visualizer.py @@ -259,7 +259,9 @@ def _render_mermaid(tasks: list[Task], mode: str) -> str: lines.append(" classDef hidden display:none") else: for i in range(len(node_ids) - 1): - lines.append(f' {node_ids[i]}["{names[i]}"] --> {node_ids[i+1]}["{names[i+1]}"]') + lines.append( + f' {node_ids[i]}["{names[i]}"] --> {node_ids[i + 1]}["{names[i + 1]}"]' + ) return "\n".join(lines) diff --git a/tests/utils/test_visualizer.py b/tests/utils/test_visualizer.py index 8ed4840d..af478fa5 100644 --- a/tests/utils/test_visualizer.py +++ b/tests/utils/test_visualizer.py @@ -140,7 +140,9 @@ def test_duplicate_names_produce_distinct_nodes(self): # Two tasks wrapping the same function must not collapse into a self-loop tasks = _make_tasks(step_a, step_a) output = _render_mermaid(tasks, mode="sequential") - arrow_lines = [line.strip() for line in output.splitlines() if "-->" in line] + arrow_lines = [ + line.strip() for line in output.splitlines() if "-->" in line + ] self.assertEqual(len(arrow_lines), 1) parts = arrow_lines[0].split("-->") # The left and right node IDs must differ