From 51f35aef1ef5e73b9ce8f68456a83daf1dc33e6c Mon Sep 17 00:00:00 2001 From: Nathan Gilbert Date: Sat, 27 Jun 2026 09:59:06 -0600 Subject: [PATCH 1/3] Draft of new text-based exports --- .mdl_style.rb | 2 +- README.md | 4 +- src/graphworks/__init__.py | 13 +- src/graphworks/export/__init__.py | 3 + src/graphworks/export/ascii_utils.py | 336 +++++++++++++++++++++++++++ src/graphworks/export/text_utils.py | 231 ++++++++++++++++++ src/graphworks/graph.py | 26 +++ tests/export/test_ascii_utils.py | 176 ++++++++++++++ tests/export/test_text_utils.py | 123 ++++++++++ 9 files changed, 911 insertions(+), 3 deletions(-) create mode 100644 src/graphworks/export/ascii_utils.py create mode 100644 src/graphworks/export/text_utils.py create mode 100644 tests/export/test_ascii_utils.py create mode 100644 tests/export/test_text_utils.py diff --git a/.mdl_style.rb b/.mdl_style.rb index 5dc3eae..5f9dc01 100644 --- a/.mdl_style.rb +++ b/.mdl_style.rb @@ -3,5 +3,5 @@ all exclude_rule 'MD024' -rule 'MD013', line_length: 100, ignore_code_blocks: true, tables: false +rule 'MD013', line_length: 120, ignore_code_blocks: true, tables: false rule 'MD029', style: 'ordered' diff --git a/README.md b/README.md index 5b8d3a4..f57ac18 100755 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Graphworks -[![Python package](https://github.com/nathan-gilbert/graphworks/actions/workflows/python-package-ci.yml/badge.svg)](https://github.com/nathan-gilbert/graphworks/actions/workflows/python-package-ci.yml) +[![Python package](https://github.com/nathan-gilbert/graphworks/actions/workflows/python-package-ci.yml/badge.svg)][1] ## A Python module for efficient graph theoretic programming @@ -128,3 +128,5 @@ Version is managed automatically via git tags using `hatchling-vcs`. - [ ] Thread safety (immutable graph views or locking around mutations) - [ ] Input validation hardening - [ ] Performance benchmarks + +[1]: https://github.com/nathan-gilbert/graphworks/actions/workflows/python-package-ci.yml diff --git a/src/graphworks/__init__.py b/src/graphworks/__init__.py index bea1916..732449e 100644 --- a/src/graphworks/__init__.py +++ b/src/graphworks/__init__.py @@ -23,11 +23,22 @@ from ._version import __version__, __version_tuple__ from .edge import Edge +from .export.ascii_utils import to_ascii +from .export.text_utils import to_text from .graph import Graph from .types import AdjacencyMatrix from .vertex import Vertex -__all__ = ["AdjacencyMatrix", "Edge", "Graph", "Vertex", "__version__", "__version_tuple__"] +__all__ = [ + "AdjacencyMatrix", + "Edge", + "Graph", + "Vertex", + "__version__", + "__version_tuple__", + "to_ascii", + "to_text", +] # ---- Package metadata (PEP 566 / importlib.metadata style) ---- diff --git a/src/graphworks/export/__init__.py b/src/graphworks/export/__init__.py index 0543517..d81d153 100644 --- a/src/graphworks/export/__init__.py +++ b/src/graphworks/export/__init__.py @@ -5,4 +5,7 @@ - :mod:`~graphworks.export.json_utils` — JSON file export - :mod:`~graphworks.export.graphviz_utils` — Graphviz DOT export +- :mod:`~graphworks.export.text_utils` — zero-dependency ASCII/Unicode tree rendering +- :mod:`~graphworks.export.ascii_utils` — zero-dependency 2-D ASCII art rendering (Phase 2, + experimental) """ diff --git a/src/graphworks/export/ascii_utils.py b/src/graphworks/export/ascii_utils.py new file mode 100644 index 0000000..aad1f80 --- /dev/null +++ b/src/graphworks/export/ascii_utils.py @@ -0,0 +1,336 @@ +r"""Two-dimensional ASCII art rendering of graphworks graphs. + +Where ``to_text`` draws a graph as an indented tree, :func:`to_ascii` lays vertices out in 2-D +space and draws edges as lines on a character grid — the "boxes and lines" view. + +Layout strategy +--------------- +* **DAGs** use a layered (Sugiyama-style) layout: each vertex is placed in a row equal to its + longest path from a source, reusing :func:`~graphworks.algorithms.sort.topological`. +* **Cyclic or undirected** graphs fall back to a deterministic circular layout. + +.. note:: + + This is a deliberately simple **first cut**. Edges are routed as naive straight lines via + Bresenham's algorithm, so dense graphs will show crossings and overlap. Planned refinements: + crossing minimization (barycenter ordering within layers), orthogonal/edge-routed connectors, + self-loop rendering, and an optional high-resolution braille canvas. The public surface + (:func:`to_ascii`) is stable; the rendered output is expected to improve. + +Example:: + + >>> from graphworks import Graph + >>> g = Graph("flow", directed=True) + >>> g.add_edges([("A", "B"), ("A", "C"), ("B", "D"), ("C", "D")]) + Graph('flow', order=4, size=4) + >>> print(to_ascii(g)) # doctest: +SKIP + flow + A + |\\ + | C + B | + \\| + D +""" + +from __future__ import annotations + +import math +from typing import TYPE_CHECKING + +from ..algorithms.directed import is_dag +from ..algorithms.sort import topological + +if TYPE_CHECKING: + from ..graph import Graph + +# Pixel spacing applied when mapping logical layout cells onto the canvas. +_X_GAP_PAD = 3 # horizontal gap added to the widest label +_Y_GAP = 2 # blank row between layout rows, leaving space for edges + + +class _Canvas: + """A fixed-size grid of characters supporting text and line drawing. + + Coordinates are ``(x, y)`` with the origin at the top-left; out-of-bounds writes are + silently ignored so callers need not clip. + """ + + def __init__(self, width: int, height: int) -> None: + """Create a blank canvas at least ``1 x 1`` in size. + + :param width: Desired width in characters. + :type width: int + :param height: Desired height in characters. + :type height: int + """ + self._width = max(width, 1) + self._height = max(height, 1) + self._grid: list[list[str]] = [[" "] * self._width for _ in range(self._height)] + + def put(self, x: int, y: int, char: str) -> None: + """Write a single character at ``(x, y)`` if it lies on the canvas. + + :param x: Column index. + :type x: int + :param y: Row index. + :type y: int + :param char: The character to write. + :type char: str + :return: Nothing. + :rtype: None + """ + if 0 <= x < self._width and 0 <= y < self._height: + self._grid[y][x] = char + + def text(self, x: int, y: int, label: str) -> None: + """Write *label* left-to-right starting at ``(x, y)``. + + :param x: Starting column index. + :type x: int + :param y: Row index. + :type y: int + :param label: The string to stamp. + :type label: str + :return: Nothing. + :rtype: None + """ + for offset, char in enumerate(label): + self.put(x + offset, y, char) + + def line(self, x0: int, y0: int, x1: int, y1: int) -> None: + """Draw a straight line between two points using a slope-appropriate glyph. + + :param x0: Start column. + :type x0: int + :param y0: Start row. + :type y0: int + :param x1: End column. + :type x1: int + :param y1: End row. + :type y1: int + :return: Nothing. + :rtype: None + """ + glyph = _line_glyph(x1 - x0, y1 - y0) + for x, y in _bresenham(x0, y0, x1, y1): + self.put(x, y, glyph) + + def render(self) -> str: + """Return the canvas as a string, trailing whitespace trimmed per row. + + :return: The rendered grid. + :rtype: str + """ + return "\n".join("".join(row).rstrip() for row in self._grid) + + +def to_ascii(graph: Graph) -> str: + """Render *graph* as 2-D ASCII art with vertices placed in a plane and edges drawn as lines. + + DAGs are laid out top-down by layer; cyclic and undirected graphs use a circular layout. + The first line is the graph label (or ``"graph"``). See the module docstring for the current + limitations of edge routing. + + :param graph: The graph to render. + :type graph: Graph + :return: A multi-line string suitable for printing to a terminal. + :rtype: str + """ + root_token = graph.label or "graph" + if graph.order == 0: + return f"{root_token}\n(empty graph)" + + cells = _layered_cells(graph) if is_dag(graph) else _circular_cells(graph) + canvas = _draw(graph, cells) + return f"{root_token}\n{canvas.render()}" + + +def _layered_cells(graph: Graph) -> dict[str, tuple[int, int]]: + """Assign each vertex an integer ``(col, row)`` cell using a longest-path layering. + + Row is the longest path from any source; vertices sharing a row are packed left-to-right in + insertion order. + + :param graph: A directed acyclic graph. + :type graph: Graph + :return: Mapping of vertex name to ``(col, row)`` layout cell. + :rtype: dict[str, tuple[int, int]] + """ + layer = dict.fromkeys(graph.vertices(), 0) + for u in topological(graph): + base = layer[u] + 1 + for w in graph.neighbors(u): + layer[w] = max(layer[w], base) + + width_used: dict[int, int] = {} + cells: dict[str, tuple[int, int]] = {} + for v in graph.vertices(): + row = layer[v] + col = width_used.get(row, 0) + width_used[row] = col + 1 + cells[v] = (col, row) + return cells + + +def _circular_cells(graph: Graph) -> dict[str, tuple[int, int]]: + """Place vertices evenly on a circle and quantize to integer cells. + + The radius grows with vertex count to keep neighbours from colliding. Rows are squashed + relative to columns to compensate for terminal characters being taller than they are wide. + + :param graph: The graph to lay out. + :type graph: Graph + :return: Mapping of vertex name to ``(col, row)`` layout cell. + :rtype: dict[str, tuple[int, int]] + """ + verts = graph.vertices() + n = len(verts) + radius = max(2, (n + 1) // 2) + cells: dict[str, tuple[int, int]] = {} + for i, v in enumerate(verts): + angle = 2 * math.pi * i / n + col = round(radius + radius * math.cos(angle)) + row = round(radius / 2 + (radius / 2) * math.sin(angle)) + cells[v] = (col, row) + return cells + + +def _draw(graph: Graph, cells: dict[str, tuple[int, int]]) -> _Canvas: + """Build and populate a canvas from layout *cells*: edges first, labels on top. + + :param graph: The graph being rendered. + :type graph: Graph + :param cells: Mapping of vertex name to ``(col, row)`` layout cell. + :type cells: dict[str, tuple[int, int]] + :return: The populated canvas. + :rtype: _Canvas + """ + label_width = max(len(v) for v in graph.vertices()) + x_gap = label_width + _X_GAP_PAD + min_col = min(c for c, _ in cells.values()) + min_row = min(r for _, r in cells.values()) + + anchors = { + v: ((col - min_col) * x_gap, (row - min_row) * _Y_GAP) for v, (col, row) in cells.items() + } + width = max(px for px, _ in anchors.values()) + label_width + 1 + height = max(py for _, py in anchors.values()) + 1 + + canvas = _Canvas(width, height) + _draw_edges(canvas, graph, anchors) + _draw_labels(canvas, anchors) + return canvas + + +def _draw_edges( + canvas: _Canvas, + graph: Graph, + anchors: dict[str, tuple[int, int]], +) -> None: + """Draw every edge once as a straight line between vertex-label centres. + + Self-loops are skipped (not yet supported); undirected edges are drawn a single time. + + :param canvas: The canvas to draw on. + :type canvas: _Canvas + :param graph: The graph being rendered. + :type graph: Graph + :param anchors: Mapping of vertex name to its top-left pixel anchor. + :type anchors: dict[str, tuple[int, int]] + :return: Nothing. + :rtype: None + """ + drawn: set[tuple[str, str]] = set() + for u in graph.vertices(): + for w in graph.neighbors(u): + if u == w: + continue + key = (u, w) if graph.directed else (min(u, w), max(u, w)) + if key in drawn: + continue + drawn.add(key) + ux, uy = _centre(u, anchors) + wx, wy = _centre(w, anchors) + canvas.line(ux, uy, wx, wy) + + +def _draw_labels(canvas: _Canvas, anchors: dict[str, tuple[int, int]]) -> None: + """Stamp each vertex name at its anchor, over any edge glyphs already drawn. + + :param canvas: The canvas to draw on. + :type canvas: _Canvas + :param anchors: Mapping of vertex name to its top-left pixel anchor. + :type anchors: dict[str, tuple[int, int]] + :return: Nothing. + :rtype: None + """ + for name, (px, py) in anchors.items(): + canvas.text(px, py, name) + + +def _centre(name: str, anchors: dict[str, tuple[int, int]]) -> tuple[int, int]: + """Return the approximate centre pixel of *name*'s label. + + :param name: Vertex name. + :type name: str + :param anchors: Mapping of vertex name to its top-left pixel anchor. + :type anchors: dict[str, tuple[int, int]] + :return: The ``(x, y)`` centre of the label. + :rtype: tuple[int, int] + """ + px, py = anchors[name] + return px + len(name) // 2, py + + +def _line_glyph(dx: int, dy: int) -> str: + r"""Pick a line character matching the dominant direction of travel. + + :param dx: Horizontal delta (end minus start). + :type dx: int + :param dy: Vertical delta (end minus start). + :type dy: int + :return: One of ``-``, ``|``, ``\\`` or ``/``. + :rtype: str + """ + if dx == 0: + return "|" + if dy == 0: + return "-" + if (dx > 0) == (dy > 0): + return "\\" + return "/" + + +def _bresenham(x0: int, y0: int, x1: int, y1: int) -> list[tuple[int, int]]: + """Return the integer points on the line from ``(x0, y0)`` to ``(x1, y1)``. + + :param x0: Start column. + :type x0: int + :param y0: Start row. + :type y0: int + :param x1: End column. + :type x1: int + :param y1: End row. + :type y1: int + :return: Ordered list of ``(x, y)`` points, endpoints included. + :rtype: list[tuple[int, int]] + """ + points: list[tuple[int, int]] = [] + dx = abs(x1 - x0) + dy = -abs(y1 - y0) + step_x = 1 if x0 < x1 else -1 + step_y = 1 if y0 < y1 else -1 + error = dx + dy + x, y = x0, y0 + while True: + points.append((x, y)) + if x == x1 and y == y1: + return points + doubled = 2 * error + if doubled >= dy: + error += dy + x += step_x + if doubled <= dx: + error += dx + y += step_y diff --git a/src/graphworks/export/text_utils.py b/src/graphworks/export/text_utils.py new file mode 100644 index 0000000..571f323 --- /dev/null +++ b/src/graphworks/export/text_utils.py @@ -0,0 +1,231 @@ +"""Plain-text (ASCII/Unicode) rendering of graphworks graphs. + +Provides :func:`to_text`, a zero-dependency forest renderer that draws a graph as an +indented tree using box-drawing connectors (``├──``, ``└──``), in the spirit of the Unix +``tree`` command. It works on any graph — directed or undirected, cyclic or acyclic — by +laying out a depth-first spanning forest and marking edges that close a cycle or point back +to an already-shown vertex with a trailing ``...``. + +This is the lightweight, deterministic counterpart to the 2-D grid renderer planned for +:mod:`graphworks.export.ascii_utils`; it is ideal for trees and DAGs and for snapshot tests. + +Example:: + + >>> from graphworks import Graph + >>> g = Graph("demo") + >>> g.add_edge("A", "B").add_edge("A", "C").add_edge("B", "D") + Graph('demo', order=4, size=3) + >>> print(to_text(g)) + demo + ├── A + │ ├── B + │ │ └── D + │ └── C + ├── D ... + ... +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from ..graph import Graph + +# Box-drawing connectors. +_TEE = "├── " +_ELBOW = "└── " +_PIPE = "│ " +_SPACE = " " + +# Trailing marker for a vertex whose subtree was already drawn elsewhere +# (a cycle, a shared child, or a self-loop). +_SEEN = " ..." + + +def to_text(graph: Graph) -> str: + """Render *graph* as an indented tree using box-drawing connectors. + + The graph is drawn as a depth-first spanning forest under a single root line (the graph + label, or ``"graph"`` if unlabeled). For directed graphs, vertices with no incoming edges + are used as roots first; remaining vertices (including those trapped in cycles) seed + additional roots in insertion order. For undirected graphs, the first vertex of each + connected component becomes that component's root and the edge back to a vertex's parent is + suppressed for readability. + + A vertex reached a second time is shown as a leaf with a trailing ``...`` and its subtree is + not repeated, so cycles and shared substructure render in finite space. When the graph is + weighted, edge weights are annotated as ``(w=…)``. + + :param graph: The graph to render. + :type graph: Graph + :return: A multi-line string suitable for printing to a terminal. + :rtype: str + """ + root_token = graph.label or "graph" + if graph.order == 0: + return f"{root_token}\n(empty graph)" + + candidates = _root_candidates(graph) + roots = _select_roots(graph, candidates) + + lines: list[str] = [root_token] + visited: set[str] = set() + last_root = len(roots) - 1 + for idx, root in enumerate(roots): + _render(graph, root, None, "", idx == last_root, visited, lines) + return "\n".join(lines) + + +def _root_candidates(graph: Graph) -> list[str]: + """Return vertices in the order they should be considered as forest roots. + + Directed sources (in-degree 0) come first so DAGs render top-down; all vertices follow in + insertion order so that cyclic and undirected components are still covered. + + :param graph: The graph being rendered. + :type graph: Graph + :return: Candidate root vertex names, best roots first. + :rtype: list[str] + """ + verts = graph.vertices() + if not graph.directed: + return verts + + indegree = _indegrees(graph) + sources = [v for v in verts if indegree[v] == 0] + rest = [v for v in verts if indegree[v] != 0] + return sources + rest + + +def _indegrees(graph: Graph) -> dict[str, int]: + """Return a mapping of each vertex to its number of incoming edges. + + :param graph: The graph being rendered. + :type graph: Graph + :return: Vertex name to in-degree count. + :rtype: dict[str, int] + """ + indegree = dict.fromkeys(graph.vertices(), 0) + for src in graph.vertices(): + for tgt in graph.neighbors(src): + indegree[tgt] += 1 + return indegree + + +def _select_roots(graph: Graph, candidates: list[str]) -> list[str]: + """Reduce *candidates* to the actual forest roots via a reachability pass. + + A candidate becomes a root only if it was not already reachable from an earlier root, + following the same out-neighbor traversal that :func:`_render` uses. This guarantees every + vertex is drawn exactly once as a real node and no chosen root is pre-visited. + + :param graph: The graph being rendered. + :type graph: Graph + :param candidates: Vertices to consider, in priority order. + :type candidates: list[str] + :return: The vertices that seed each tree in the forest. + :rtype: list[str] + """ + roots: list[str] = [] + reached: set[str] = set() + for candidate in candidates: + if candidate in reached: + continue + roots.append(candidate) + stack = [candidate] + while stack: + node = stack.pop() + if node in reached: + continue + reached.add(node) + stack.extend(graph.neighbors(node)) + return roots + + +def _render( # noqa: PLR0913 + graph: Graph, + node: str, + parent: str | None, + prefix: str, + is_last: bool, + visited: set[str], + lines: list[str], +) -> None: + """Append the lines for *node* and its subtree to *lines* (depth-first). + + :param graph: The graph being rendered. + :type graph: Graph + :param node: The vertex to draw. + :type node: str + :param parent: The vertex *node* was reached from, or ``None`` for a root. Used to suppress + the trivial back-edge in undirected graphs. + :type parent: str | None + :param prefix: The accumulated indentation string for this depth. + :type prefix: str + :param is_last: Whether *node* is the last child of its parent (selects the connector glyph). + :type is_last: bool + :param visited: Vertices already drawn; mutated as the forest is built. + :type visited: set[str] + :param lines: Output accumulator, mutated in place. + :type lines: list[str] + :return: Nothing. + :rtype: None + """ + connector = _ELBOW if is_last else _TEE + weight = _weight_annotation(graph, parent, node) + + if node in visited: + lines.append(f"{prefix}{connector}{node}{weight}{_SEEN}") + return + + lines.append(f"{prefix}{connector}{node}{weight}") + visited.add(node) + + children = _children(graph, node, parent) + child_prefix = prefix + (_SPACE if is_last else _PIPE) + last_child = len(children) - 1 + for idx, child in enumerate(children): + _render(graph, child, node, child_prefix, idx == last_child, visited, lines) + + +def _children(graph: Graph, node: str, parent: str | None) -> list[str]: + """Return the out-neighbors of *node* to recurse into. + + In undirected graphs the edge back to *parent* is dropped so the trivial back-reference is + not redrawn; genuine cycles (which reach a non-parent ancestor) are preserved. + + :param graph: The graph being rendered. + :type graph: Graph + :param node: The vertex whose children are requested. + :type node: str + :param parent: The vertex *node* was reached from, or ``None`` for a root. + :type parent: str | None + :return: The child vertex names, in adjacency order. + :rtype: list[str] + """ + if graph.directed or parent is None: + return graph.neighbors(node) + return [tgt for tgt in graph.neighbors(node) if tgt != parent] + + +def _weight_annotation(graph: Graph, source: str | None, target: str) -> str: + """Return a ``" (w=…)"`` suffix for the *source* → *target* edge, or ``""``. + + Empty unless the graph is weighted and the edge carries a non-``None`` weight. + + :param graph: The graph being rendered. + :type graph: Graph + :param source: The edge's source vertex, or ``None`` for a root (no incoming edge). + :type source: str | None + :param target: The edge's target vertex. + :type target: str + :return: The annotation string. + :rtype: str + """ + if not graph.weighted or source is None: + return "" + edge = graph.edge(source, target) + if edge is None or edge.weight is None: + return "" + return f" (w={edge.weight})" diff --git a/src/graphworks/graph.py b/src/graphworks/graph.py index 6045893..a88f3db 100644 --- a/src/graphworks/graph.py +++ b/src/graphworks/graph.py @@ -48,6 +48,7 @@ from typing import TYPE_CHECKING from .edge import Edge +from .export.text_utils import to_text as _to_text from .vertex import Vertex if TYPE_CHECKING: @@ -638,6 +639,31 @@ def index_to_vertex(self, index: int) -> str: """ return self.vertices()[index] + # ------------------------------------------------------------------ + # Rendering + # ------------------------------------------------------------------ + + def to_text(self) -> str: + """Render this graph as an indented ASCII/Unicode tree. + + Convenience wrapper around :func:`graphworks.export.text_utils.to_text`. Draws the graph + as a depth-first spanning forest using box-drawing connectors (``├──``, ``└──``), marking + edges that close a cycle or revisit a vertex with a trailing ``...``:: + + >>> g = Graph("demo") + >>> g.add_edge("A", "B").add_edge("A", "C") + Graph('demo', order=3, size=2) + >>> print(g.to_text()) + demo + └── A + ├── B + └── C + + :return: A multi-line string suitable for printing to a terminal. + :rtype: str + """ + return _to_text(self) + # ------------------------------------------------------------------ # Protected helpers # ------------------------------------------------------------------ diff --git a/tests/export/test_ascii_utils.py b/tests/export/test_ascii_utils.py new file mode 100644 index 0000000..ebcd096 --- /dev/null +++ b/tests/export/test_ascii_utils.py @@ -0,0 +1,176 @@ +"""Unit tests for :mod:`graphworks.export.ascii_utils`. + +These cover the Phase 2 scaffold: the character canvas primitives and the layout/render pipeline. +Assertions check structure (labels present, determinism, layout shape) rather than exact art, so +the renderer can be refined without churning the suite. +""" + +from __future__ import annotations + +import json + +import pytest + +from graphworks.export.ascii_utils import ( + _bresenham, + _Canvas, + _line_glyph, + to_ascii, +) +from graphworks.graph import Graph + + +class TestCanvas: + """Primitive drawing operations on the character grid.""" + + def test_put_and_render(self) -> None: + """A single character lands at its coordinate; the rest stays blank.""" + canvas = _Canvas(3, 2) + canvas.put(1, 0, "x") + assert canvas.render() == " x\n" + + def test_out_of_bounds_is_ignored(self) -> None: + """Writes outside the grid are silently dropped.""" + canvas = _Canvas(2, 2) + canvas.put(-1, 0, "x") + canvas.put(5, 5, "y") + assert canvas.render() == "\n" + + def test_text_runs_left_to_right(self) -> None: + """text() stamps a label one character per column.""" + canvas = _Canvas(5, 1) + canvas.text(1, 0, "hi") + assert canvas.render() == " hi" + + def test_minimum_size(self) -> None: + """A degenerate request still yields a usable 1x1 canvas.""" + assert _Canvas(0, 0).render() == "" + + def test_horizontal_line(self) -> None: + """A flat line uses the dash glyph.""" + canvas = _Canvas(4, 1) + canvas.line(0, 0, 3, 0) + assert canvas.render() == "----" + + def test_vertical_line(self) -> None: + """A straight drop uses the pipe glyph.""" + canvas = _Canvas(1, 3) + canvas.line(0, 0, 0, 2) + assert canvas.render() == "|\n|\n|" + + def test_diagonal_glyphs(self) -> None: + """Down-right uses backslash; down-left uses forward slash.""" + down_right = _Canvas(3, 3) + down_right.line(0, 0, 2, 2) + assert down_right.render().splitlines()[0][0] == "\\" + + down_left = _Canvas(3, 3) + down_left.line(2, 0, 0, 2) + assert down_left.render().splitlines()[0][2] == "/" + + +class TestLineGlyph: + """Direction-to-glyph mapping.""" + + @pytest.mark.parametrize( + ("dx", "dy", "expected"), + [ + (0, 5, "|"), + (5, 0, "-"), + (3, 4, "\\"), + (-3, -4, "\\"), + (3, -4, "/"), + (-3, 4, "/"), + ], + ) + def test_glyph(self, dx: int, dy: int, expected: str) -> None: + """Each direction maps to the expected line character.""" + assert _line_glyph(dx, dy) == expected + + +class TestBresenham: + """The integer line rasteriser.""" + + def test_endpoints_included(self) -> None: + """The returned path starts and ends on the requested points.""" + points = _bresenham(0, 0, 3, 1) + assert points[0] == (0, 0) + assert points[-1] == (3, 1) + + def test_single_point(self) -> None: + """A zero-length line is just its single point.""" + assert _bresenham(2, 2, 2, 2) == [(2, 2)] + + +class TestToAscii: + """End-to-end rendering.""" + + def test_empty_graph(self) -> None: + """An empty graph renders the root token and a marker.""" + assert to_ascii(Graph("empty")) == "empty\n(empty graph)" + + def test_label_is_first_line(self) -> None: + """The label heads the rendering; unlabeled graphs use 'graph'.""" + g = Graph(directed=True) + g.add_edge("A", "B") + assert to_ascii(g).splitlines()[0] == "graph" + + def test_dag_contains_every_vertex(self, directed_dag: Graph) -> None: + """A layered DAG render mentions every vertex.""" + rendered = to_ascii(directed_dag) + for vertex in directed_dag.vertices(): + assert vertex in rendered + + def test_linear_dag_stacks_vertically(self) -> None: + """A path A->B->C lays out one vertex per layer, top to bottom.""" + g = Graph("line", directed=True) + g.add_edges([("A", "B"), ("B", "C")]) + rows = [line.strip() for line in to_ascii(g).splitlines()[1:] if line.strip()] + assert rows == ["A", "|", "B", "|", "C"] + + def test_cyclic_directed_falls_back_to_circular(self) -> None: + """A directed cycle (not a DAG) still renders all vertices.""" + g = Graph("cyc", directed=True) + g.add_edges([("A", "B"), ("B", "C"), ("C", "A")]) + rendered = to_ascii(g) + for vertex in ("A", "B", "C"): + assert vertex in rendered + + def test_undirected_contains_every_vertex(self, triangle_graph: Graph) -> None: + """An undirected graph renders every vertex via the circular layout.""" + rendered = to_ascii(triangle_graph) + for vertex in triangle_graph.vertices(): + assert vertex in rendered + + def test_self_loop_does_not_crash(self) -> None: + """A self-loop is skipped rather than raising during edge routing.""" + g = Graph("loop", directed=True) + g.add_edge("A", "A") + assert "A" in to_ascii(g) + + @pytest.mark.parametrize( + "raw", + [ + {"label": "D", "directed": True, "graph": {"A": ["B", "C"], "B": ["C"], "C": []}}, + {"label": "U", "graph": {"a": ["b", "c"], "b": ["a"], "c": ["a"]}}, + ], + ) + def test_render_is_deterministic(self, raw: dict) -> None: + """Rendering the same graph twice yields identical art.""" + g = Graph(input_graph=json.dumps(raw)) + assert to_ascii(g) == to_ascii(g) + + +class TestPublicSurface: + """to_ascii is exposed at the package root (but not on Graph).""" + + def test_top_level_export(self, directed_dag: Graph) -> None: + """to_ascii is importable directly from the package root.""" + import graphworks + + assert "to_ascii" in graphworks.__all__ + assert graphworks.to_ascii(directed_dag) == to_ascii(directed_dag) + + def test_not_a_graph_method(self) -> None: + """to_ascii is intentionally not (yet) a Graph method.""" + assert not hasattr(Graph, "to_ascii") diff --git a/tests/export/test_text_utils.py b/tests/export/test_text_utils.py new file mode 100644 index 0000000..2d24c81 --- /dev/null +++ b/tests/export/test_text_utils.py @@ -0,0 +1,123 @@ +"""Unit tests for :mod:`graphworks.export.text_utils`.""" + +from __future__ import annotations + +import json + +import pytest + +from graphworks.export.text_utils import to_text +from graphworks.graph import Graph + + +class TestToText: + """Tests for to_text.""" + + def test_empty_graph(self) -> None: + """An empty graph renders the root token and an explicit marker.""" + assert to_text(Graph("empty")) == "empty\n(empty graph)" + + def test_unlabeled_graph_uses_placeholder_root(self) -> None: + """An unlabeled graph falls back to the literal 'graph' root token.""" + g = Graph() + g.add_edge("A", "B") + assert to_text(g).splitlines()[0] == "graph" + + def test_label_is_root_line(self, simple_edge_graph: Graph) -> None: + """The graph label is the first (root) line of the output.""" + assert to_text(simple_edge_graph).splitlines()[0] == "my graph" + + def test_simple_tree(self) -> None: + """A small tree renders with the expected connectors.""" + g = Graph("demo") + g.add_edge("A", "B").add_edge("A", "C").add_edge("B", "D") + assert to_text(g) == ("demo\n" "└── A\n" " ├── B\n" " │ └── D\n" " └── C") + + def test_every_vertex_appears(self, connected_graph: Graph) -> None: + """Each vertex name appears at least once in the rendering.""" + rendered = to_text(connected_graph) + for vertex in connected_graph.vertices(): + assert vertex in rendered + + def test_isolated_vertices_are_roots(self, isolated_graph: Graph) -> None: + """Vertices with no edges render as sibling leaves under the root.""" + assert to_text(isolated_graph) == "graph\n├── a\n├── b\n└── c" + + def test_directed_sources_render_first(self, directed_dag: Graph) -> None: + """In a DAG, in-degree-zero vertices seed the forest before others.""" + body = to_text(directed_dag).splitlines()[1:] + first_roots = [line for line in body if line[0] in "├└"] + # E and F are the only sources in directed_dag_json. + assert first_roots[0].endswith(("E", "F")) + + def test_cycle_terminates_with_marker(self, directed_cycle_graph: Graph) -> None: + """A directed cycle is broken with a trailing '...' rather than recursing forever.""" + rendered = to_text(directed_cycle_graph) + assert "..." in rendered + # The back-edge A -> ... -> A closes onto the already-shown A. + assert "A ..." in rendered + + def test_undirected_parent_edge_suppressed(self) -> None: + """A child does not redundantly list the parent it was reached from.""" + g = Graph("pair") + g.add_edge("a", "b").add_edge("b", "a") # both directions declared + # 'a' is root -> 'b'; 'b' must not re-list 'a' as a child. + assert to_text(g) == "pair\n└── a\n └── b" + + def test_seen_marker_for_shared_child(self) -> None: + """A vertex reachable two ways is fully drawn once and marked on reuse.""" + g = Graph("diamond", directed=True) + g.add_edges([("A", "B"), ("A", "C"), ("B", "D"), ("C", "D")]) + rendered = to_text(g) + assert rendered.count("└── D") + rendered.count("├── D") >= 1 + assert "D ..." in rendered + + def test_weighted_edges_annotated(self) -> None: + """Edge weights are shown when the graph is weighted.""" + g = Graph("weighted", weighted=True) + g.add_edge("A", "B", weight=3).add_edge("B", "C", weight=1.5) + assert to_text(g) == "weighted\n└── A\n └── B (w=3)\n └── C (w=1.5)" + + def test_unweighted_graph_has_no_annotation(self, simple_edge_graph: Graph) -> None: + """Unweighted graphs carry no '(w=' annotations.""" + assert "(w=" not in to_text(simple_edge_graph) + + def test_weighted_graph_edge_without_weight_unannotated(self) -> None: + """A weighted graph still omits the annotation for edges that carry no weight.""" + g = Graph("weighted", weighted=True) + g.add_edge("A", "B") # no weight supplied + assert to_text(g) == "weighted\n└── A\n └── B" + + def test_self_loop_marked(self) -> None: + """A self-loop renders the vertex as a seen leaf beneath itself.""" + g = Graph("loop", directed=True) + g.add_edge("A", "A") + assert to_text(g) == "loop\n└── A\n └── A ..." + + @pytest.mark.parametrize( + "raw", + [ + {"label": "L", "graph": {"a": ["b"], "b": ["c"], "c": ["d"], "d": []}}, + {"label": "C", "directed": True, "graph": {"A": ["B"], "B": ["A"]}}, + {"label": "K", "graph": {"a": ["b", "c"], "b": ["a", "c"], "c": ["a", "b"]}}, + ], + ) + def test_output_is_deterministic(self, raw: dict) -> None: + """Rendering the same graph twice yields identical output.""" + g = Graph(input_graph=json.dumps(raw)) + assert to_text(g) == to_text(g) + + +class TestPublicSurface: + """The convenience wrappers exposed on Graph and the package root.""" + + def test_graph_method_matches_function(self, connected_graph: Graph) -> None: + """Graph.to_text() delegates to the export function.""" + assert connected_graph.to_text() == to_text(connected_graph) + + def test_top_level_export(self, simple_edge_graph: Graph) -> None: + """to_text is importable directly from the package root.""" + import graphworks + + assert "to_text" in graphworks.__all__ + assert graphworks.to_text(simple_edge_graph) == to_text(simple_edge_graph) From 6356bffe3c4a53290a2690f585a2ac743c0159ff Mon Sep 17 00:00:00 2001 From: Nathan Gilbert Date: Sat, 27 Jun 2026 10:22:17 -0600 Subject: [PATCH 2/3] Halfway there --- src/graphworks/export/_labels.py | 94 +++++ src/graphworks/export/ascii_utils.py | 531 ++++++++++++++++++++++----- src/graphworks/export/text_utils.py | 68 ++-- src/graphworks/graph.py | 22 +- tests/export/test_ascii_utils.py | 83 +++++ tests/export/test_labels.py | 79 ++++ tests/export/test_text_utils.py | 54 ++- 7 files changed, 799 insertions(+), 132 deletions(-) create mode 100644 src/graphworks/export/_labels.py create mode 100644 tests/export/test_labels.py diff --git a/src/graphworks/export/_labels.py b/src/graphworks/export/_labels.py new file mode 100644 index 0000000..c22ea9e --- /dev/null +++ b/src/graphworks/export/_labels.py @@ -0,0 +1,94 @@ +"""Shared label and weight formatting for the graph renderers. + +Both :mod:`graphworks.export.text_utils` and :mod:`graphworks.export.ascii_utils` resolve what +text to show for a vertex and what annotation to show for an edge through the helpers here, so the +two styles can never drift. :class:`RenderStyle` bundles the three display toggles common to +``to_text`` and ``to_ascii``. + +The toggles default to "show when present", which is backward-compatible: a graph with no custom +vertex/edge labels and ``weighted=False`` renders exactly as it did before these options existed. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from ..graph import Graph + + +@dataclass(frozen=True, slots=True) +class RenderStyle: + """Display toggles shared by the text and ASCII renderers. + + :param vertex_labels: Show a vertex's :attr:`~graphworks.vertex.Vertex.label` when set, + falling back to its name. + :type vertex_labels: bool + :param edge_labels: Show an edge's :attr:`~graphworks.edge.Edge.label` when set. + :type edge_labels: bool + :param weights: Show edge weights. ``None`` (the default) follows the graph's + :attr:`~graphworks.graph.Graph.weighted` flag; ``True``/``False`` force the choice. + :type weights: bool | None + """ + + vertex_labels: bool = True + edge_labels: bool = True + weights: bool | None = None + + +def vertex_text(graph: Graph, name: str, *, use_label: bool = True) -> str: + """Return the display text for vertex *name*. + + :param graph: The graph being rendered. + :type graph: Graph + :param name: The vertex name (its identity key). + :type name: str + :param use_label: When ``True``, prefer the vertex's label if one is set. + :type use_label: bool + :return: The vertex label if requested and present, otherwise the name. + :rtype: str + """ + vertex = graph.vertex(name) + if use_label and vertex is not None and vertex.label: + return vertex.label + return name + + +def edge_annotation( + graph: Graph, + source: str | None, + target: str, + *, + style: RenderStyle, +) -> str: + """Return the bare annotation for the *source* → *target* edge (no surrounding punctuation). + + Combines the edge label and/or weight into a comma-separated string such as ``"retry, w=3"``. + Returns ``""`` when there is no edge, no source, or nothing to show. Callers wrap the result + as they see fit — ``to_text`` brackets it, ``to_ascii`` stamps it at the edge midpoint. + + :param graph: The graph being rendered. + :type graph: Graph + :param source: The edge's source vertex, or ``None`` for a root (no incoming edge). + :type source: str | None + :param target: The edge's target vertex. + :type target: str + :param style: The active display toggles. + :type style: RenderStyle + :return: The annotation content, or ``""``. + :rtype: str + """ + if source is None: + return "" + edge = graph.edge(source, target) + if edge is None: + return "" + + parts: list[str] = [] + if style.edge_labels and edge.label: + parts.append(edge.label) + show_weights = graph.weighted if style.weights is None else style.weights + if show_weights and edge.weight is not None: + parts.append(f"w={edge.weight}") + return ", ".join(parts) diff --git a/src/graphworks/export/ascii_utils.py b/src/graphworks/export/ascii_utils.py index aad1f80..26d9003 100644 --- a/src/graphworks/export/ascii_utils.py +++ b/src/graphworks/export/ascii_utils.py @@ -5,17 +5,25 @@ Layout strategy --------------- -* **DAGs** use a layered (Sugiyama-style) layout: each vertex is placed in a row equal to its - longest path from a source, reusing :func:`~graphworks.algorithms.sort.topological`. +* **DAGs** use a layered (Sugiyama-style) layout: + + 1. *Layer assignment* — each vertex's row is its longest path from a source, reusing + :func:`~graphworks.algorithms.sort.topological`. + 2. *Dummy nodes* — an edge spanning multiple rows is split into unit-length segments through + virtual nodes in the intervening rows, so it routes as a chain of short lines instead of one + long diagonal slashing across the layout. + 3. *Ordering* — the left-to-right order within each row is refined by the **barycenter + heuristic** (iterative up/down sweeps, keeping the ordering with the fewest edge crossings). + * **Cyclic or undirected** graphs fall back to a deterministic circular layout. .. note:: - This is a deliberately simple **first cut**. Edges are routed as naive straight lines via - Bresenham's algorithm, so dense graphs will show crossings and overlap. Planned refinements: - crossing minimization (barycenter ordering within layers), orthogonal/edge-routed connectors, - self-loop rendering, and an optional high-resolution braille canvas. The public surface - (:func:`to_ascii`) is stable; the rendered output is expected to improve. + Edge segments are still drawn as straight Bresenham lines (with slope-appropriate glyphs) + rather than orthogonal box-drawing connectors. Remaining refinements: orthogonal/corner + routing, horizontal-coordinate assignment (Brandes–Köpf) for straighter chains, a force- + directed layout for the cyclic/undirected case, self-loop rendering, and a braille canvas. + The public surface (:func:`to_ascii`) is stable; the rendered output is expected to improve. Example:: @@ -26,20 +34,22 @@ >>> print(to_ascii(g)) # doctest: +SKIP flow A - |\\ - | C - B | - \\| - D + |\ + B C + | / + D """ from __future__ import annotations import math +from collections import defaultdict +from dataclasses import dataclass from typing import TYPE_CHECKING from ..algorithms.directed import is_dag from ..algorithms.sort import topological +from ._labels import RenderStyle, edge_annotation, vertex_text if TYPE_CHECKING: from ..graph import Graph @@ -48,6 +58,12 @@ _X_GAP_PAD = 3 # horizontal gap added to the widest label _Y_GAP = 2 # blank row between layout rows, leaving space for edges +# Number of barycenter down+up sweeps when ordering layered graphs. +_SWEEPS = 4 + +# Prefix marking a routing-only dummy node (never a real vertex name). +_DUMMY = "\x00d" + class _Canvas: """A fixed-size grid of characters supporting text and line drawing. @@ -125,15 +141,50 @@ def render(self) -> str: return "\n".join("".join(row).rstrip() for row in self._grid) -def to_ascii(graph: Graph) -> str: +@dataclass(frozen=True, slots=True) +class _Plot: + """A fully positioned layout ready to rasterise onto a :class:`_Canvas`. + + :param ports: Each node id mapped to the pixel ``(x, y)`` it connects through. + :type ports: dict[str, tuple[int, int]] + :param labels: Each node id mapped to its display text (``""`` for routing dummies). + :type labels: dict[str, str] + :param links: Segment endpoints ``(a, b)`` to draw as lines. + :type links: list[tuple[str, str]] + :param notes: Edge annotations to stamp, as ``(x, y, text)``. + :type notes: list[tuple[int, int, str]] + """ + + ports: dict[str, tuple[int, int]] + labels: dict[str, str] + links: list[tuple[str, str]] + notes: list[tuple[int, int, str]] + + +def to_ascii( + graph: Graph, + *, + vertex_labels: bool = True, + edge_labels: bool = True, + weights: bool | None = None, +) -> str: """Render *graph* as 2-D ASCII art with vertices placed in a plane and edges drawn as lines. - DAGs are laid out top-down by layer; cyclic and undirected graphs use a circular layout. - The first line is the graph label (or ``"graph"``). See the module docstring for the current - limitations of edge routing. + DAGs are laid out top-down by layer with barycenter ordering; cyclic and undirected graphs use + a circular layout. The first line is the graph label (or ``"graph"``). + + Vertex labels are shown inside the node text; edge labels and weights are stamped at the edge + midpoint as ``label, w=…``. All three are on by default and backward-compatible. :param graph: The graph to render. :type graph: Graph + :param vertex_labels: Show each vertex's label when set, falling back to its name. + :type vertex_labels: bool + :param edge_labels: Show each edge's label when set. + :type edge_labels: bool + :param weights: Show edge weights. ``None`` follows the graph's ``weighted`` flag; + ``True``/``False`` force the choice. + :type weights: bool | None :return: A multi-line string suitable for printing to a terminal. :rtype: str """ @@ -141,47 +192,292 @@ def to_ascii(graph: Graph) -> str: if graph.order == 0: return f"{root_token}\n(empty graph)" - cells = _layered_cells(graph) if is_dag(graph) else _circular_cells(graph) - canvas = _draw(graph, cells) - return f"{root_token}\n{canvas.render()}" + style = RenderStyle( + vertex_labels=vertex_labels, + edge_labels=edge_labels, + weights=weights, + ) + plot = _layered_layout(graph, style) if is_dag(graph) else _circular_layout(graph, style) + return f"{root_token}\n{_render(plot)}" + +# --------------------------------------------------------------------------- +# Layered (Sugiyama) layout for DAGs +# --------------------------------------------------------------------------- -def _layered_cells(graph: Graph) -> dict[str, tuple[int, int]]: - """Assign each vertex an integer ``(col, row)`` cell using a longest-path layering. - Row is the longest path from any source; vertices sharing a row are packed left-to-right in - insertion order. +def _layered_layout(graph: Graph, style: RenderStyle) -> _Plot: + """Build a layered, barycenter-ordered layout for a directed acyclic *graph*. :param graph: A directed acyclic graph. :type graph: Graph - :return: Mapping of vertex name to ``(col, row)`` layout cell. - :rtype: dict[str, tuple[int, int]] + :param style: The active display toggles. + :type style: RenderStyle + :return: The positioned layout. + :rtype: _Plot + """ + layer_of = _assign_layers(graph) + layers, segments, chains = _expand_with_dummies(graph, layer_of) + _order_layers(layers, segments) + + labels = {nid: _node_label(graph, nid, style) for layer in layers for nid in layer} + x_gap = _x_gap(labels) + ports = _build_ports(layers, x_gap) + notes = _chain_notes(graph, chains, ports, style) + return _Plot(ports=ports, labels=labels, links=segments, notes=notes) + + +def _assign_layers(graph: Graph) -> dict[str, int]: + """Return each vertex's row as its longest path from any source. + + :param graph: A directed acyclic graph. + :type graph: Graph + :return: Mapping of vertex name to row index. + :rtype: dict[str, int] """ layer = dict.fromkeys(graph.vertices(), 0) for u in topological(graph): base = layer[u] + 1 for w in graph.neighbors(u): layer[w] = max(layer[w], base) + return layer - width_used: dict[int, int] = {} - cells: dict[str, tuple[int, int]] = {} + +def _expand_with_dummies( + graph: Graph, + layer_of: dict[str, int], +) -> tuple[list[list[str]], list[tuple[str, str]], list[tuple[str, str, list[str]]]]: + """Place vertices into rows and split multi-row edges through dummy nodes. + + :param graph: The graph being rendered. + :type graph: Graph + :param layer_of: Each vertex's row index. + :type layer_of: dict[str, int] + :return: The ordered ``layers``; the unit-length ``segments`` between adjacent rows; and the + ``chains`` (one ``(source, target, node_ids)`` per original edge) used for annotation. + :rtype: tuple[list[list[str]], list[tuple[str, str]], list[tuple[str, str, list[str]]]] + """ + layers: list[list[str]] = [[] for _ in range(max(layer_of.values()) + 1)] for v in graph.vertices(): - row = layer[v] - col = width_used.get(row, 0) - width_used[row] = col + 1 - cells[v] = (col, row) - return cells + layers[layer_of[v]].append(v) + + segments: list[tuple[str, str]] = [] + chains: list[tuple[str, str, list[str]]] = [] + counter = 0 + for u in graph.vertices(): + for w in graph.neighbors(u): + if u == w: + continue + chain = [u] + for row in range(layer_of[u] + 1, layer_of[w]): + dummy = f"{_DUMMY}{counter}" + counter += 1 + layers[row].append(dummy) + chain.append(dummy) + chain.append(w) + chains.append((u, w, chain)) + segments.extend(zip(chain, chain[1:], strict=False)) + return layers, segments, chains + + +def _order_layers(layers: list[list[str]], segments: list[tuple[str, str]]) -> None: + """Reorder nodes within each row to reduce edge crossings (barycenter heuristic). + + Runs alternating down/up sweeps and keeps the ordering with the fewest crossings. *layers* is + mutated in place. + + :param layers: The rows of node ids, reordered in place. + :type layers: list[list[str]] + :param segments: Unit-length connections between adjacent rows. + :type segments: list[tuple[str, str]] + :return: Nothing. + :rtype: None + """ + if len(layers) < 2: # noqa: PLR2004 + return + + upward, downward = _adjacency(segments) + best = [list(layer) for layer in layers] + best_score = _count_crossings(layers, segments) + for _ in range(_SWEEPS): + for row in range(1, len(layers)): + _sort_layer(layers[row], upward, layers[row - 1]) + for row in range(len(layers) - 2, -1, -1): + _sort_layer(layers[row], downward, layers[row + 1]) + score = _count_crossings(layers, segments) + if score < best_score: + best_score = score + best = [list(layer) for layer in layers] + + for row, ordered in enumerate(best): + layers[row][:] = ordered + + +def _adjacency( + segments: list[tuple[str, str]], +) -> tuple[dict[str, list[str]], dict[str, list[str]]]: + """Return ``(upward, downward)`` neighbor maps from the segment list. + + ``upward[b]`` holds the upper-row endpoints feeding into *b*; ``downward[a]`` holds the + lower-row endpoints fed from *a*. + + :param segments: Unit-length connections between adjacent rows. + :type segments: list[tuple[str, str]] + :return: The upward and downward adjacency maps. + :rtype: tuple[dict[str, list[str]], dict[str, list[str]]] + """ + upward: dict[str, list[str]] = defaultdict(list) + downward: dict[str, list[str]] = defaultdict(list) + for a, b in segments: + downward[a].append(b) + upward[b].append(a) + return upward, downward + + +def _sort_layer(layer: list[str], neighbors: dict[str, list[str]], reference: list[str]) -> None: + """Sort *layer* in place by each node's barycenter over its *reference*-row neighbors. + + Nodes with no neighbor in *reference* keep their current position. + + :param layer: The row to reorder (mutated in place). + :type layer: list[str] + :param neighbors: Adjacency map from a node to its *reference*-row neighbors. + :type neighbors: dict[str, list[str]] + :param reference: The already-ordered adjacent row supplying positions. + :type reference: list[str] + :return: Nothing. + :rtype: None + """ + position = {nid: index for index, nid in enumerate(reference)} + keyed: list[tuple[float, int, str]] = [] + for index, nid in enumerate(layer): + seen = [position[n] for n in neighbors.get(nid, ()) if n in position] + key = sum(seen) / len(seen) if seen else float(index) + keyed.append((key, index, nid)) + keyed.sort() + layer[:] = [nid for _, _, nid in keyed] + + +def _count_crossings(layers: list[list[str]], segments: list[tuple[str, str]]) -> int: + """Return the total number of edge crossings across all adjacent-row boundaries. + + :param layers: The current row orderings. + :type layers: list[list[str]] + :param segments: Unit-length connections between adjacent rows. + :type segments: list[tuple[str, str]] + :return: The crossing count. + :rtype: int + """ + position = {nid: index for layer in layers for index, nid in enumerate(layer)} + level = {nid: row for row, layer in enumerate(layers) for nid in layer} + by_boundary: dict[int, list[tuple[int, int]]] = defaultdict(list) + for a, b in segments: + by_boundary[level[a]].append((position[a], position[b])) + return sum(_inversions(pairs) for pairs in by_boundary.values()) + + +def _inversions(pairs: list[tuple[int, int]]) -> int: + """Count pairs of segments that cross, given their ``(upper_pos, lower_pos)`` endpoints. + + :param pairs: One ``(upper_pos, lower_pos)`` per segment on a single boundary. + :type pairs: list[tuple[int, int]] + :return: The number of crossing pairs. + :rtype: int + """ + count = 0 + for i in range(len(pairs)): + a_top, a_bottom = pairs[i] + for j in range(i + 1, len(pairs)): + b_top, b_bottom = pairs[j] + if (a_top - b_top) * (a_bottom - b_bottom) < 0: + count += 1 + return count + + +def _build_ports(layers: list[list[str]], x_gap: int) -> dict[str, tuple[int, int]]: + """Map each node id to its connection pixel from its ``(column, row)`` cell. + + :param layers: The ordered rows of node ids. + :type layers: list[list[str]] + :param x_gap: Horizontal pixels per column. + :type x_gap: int + :return: Mapping of node id to ``(x, y)`` port pixel. + :rtype: dict[str, tuple[int, int]] + """ + ports: dict[str, tuple[int, int]] = {} + for row, layer in enumerate(layers): + for column, nid in enumerate(layer): + ports[nid] = (column * x_gap + x_gap // 2, row * _Y_GAP) + return ports + + +def _chain_notes( + graph: Graph, + chains: list[tuple[str, str, list[str]]], + ports: dict[str, tuple[int, int]], + style: RenderStyle, +) -> list[tuple[int, int, str]]: + """Build the annotation placements for each edge, at the midpoint of its routed chain. + + :param graph: The graph being rendered. + :type graph: Graph + :param chains: One ``(source, target, node_ids)`` per original edge. + :type chains: list[tuple[str, str, list[str]]] + :param ports: Node id to port pixel. + :type ports: dict[str, tuple[int, int]] + :param style: The active display toggles. + :type style: RenderStyle + :return: Annotations as ``(x, y, text)``. + :rtype: list[tuple[int, int, str]] + """ + notes: list[tuple[int, int, str]] = [] + for source, target, chain in chains: + text = edge_annotation(graph, source, target, style=style) + if not text: + continue + ax, ay = ports[chain[0]] + bx, by = ports[chain[-1]] + notes.append(((ax + bx) // 2 + 1, (ay + by) // 2, text)) + return notes + + +# --------------------------------------------------------------------------- +# Circular layout for cyclic / undirected graphs +# --------------------------------------------------------------------------- + + +def _circular_layout(graph: Graph, style: RenderStyle) -> _Plot: + """Build a deterministic circular layout for a cyclic or undirected *graph*. + + :param graph: The graph to lay out. + :type graph: Graph + :param style: The active display toggles. + :type style: RenderStyle + :return: The positioned layout. + :rtype: _Plot + """ + cells = _circular_cells(graph) + labels = {v: vertex_text(graph, v, use_label=style.vertex_labels) for v in graph.vertices()} + x_gap = _x_gap(labels) + min_col = min(c for c, _ in cells.values()) + min_row = min(r for _, r in cells.values()) + ports = { + v: ((col - min_col) * x_gap + x_gap // 2, (row - min_row) * _Y_GAP) + for v, (col, row) in cells.items() + } + links, notes = _edge_links_and_notes(graph, ports, style) + return _Plot(ports=ports, labels=labels, links=links, notes=notes) def _circular_cells(graph: Graph) -> dict[str, tuple[int, int]]: - """Place vertices evenly on a circle and quantize to integer cells. + """Place vertices evenly on a circle and quantize to integer ``(column, row)`` cells. The radius grows with vertex count to keep neighbours from colliding. Rows are squashed relative to columns to compensate for terminal characters being taller than they are wide. :param graph: The graph to lay out. :type graph: Graph - :return: Mapping of vertex name to ``(col, row)`` layout cell. + :return: Mapping of vertex name to ``(column, row)`` cell. :rtype: dict[str, tuple[int, int]] """ verts = graph.vertices() @@ -196,51 +492,26 @@ def _circular_cells(graph: Graph) -> dict[str, tuple[int, int]]: return cells -def _draw(graph: Graph, cells: dict[str, tuple[int, int]]) -> _Canvas: - """Build and populate a canvas from layout *cells*: edges first, labels on top. - - :param graph: The graph being rendered. - :type graph: Graph - :param cells: Mapping of vertex name to ``(col, row)`` layout cell. - :type cells: dict[str, tuple[int, int]] - :return: The populated canvas. - :rtype: _Canvas - """ - label_width = max(len(v) for v in graph.vertices()) - x_gap = label_width + _X_GAP_PAD - min_col = min(c for c, _ in cells.values()) - min_row = min(r for _, r in cells.values()) - - anchors = { - v: ((col - min_col) * x_gap, (row - min_row) * _Y_GAP) for v, (col, row) in cells.items() - } - width = max(px for px, _ in anchors.values()) + label_width + 1 - height = max(py for _, py in anchors.values()) + 1 - - canvas = _Canvas(width, height) - _draw_edges(canvas, graph, anchors) - _draw_labels(canvas, anchors) - return canvas - - -def _draw_edges( - canvas: _Canvas, +def _edge_links_and_notes( graph: Graph, - anchors: dict[str, tuple[int, int]], -) -> None: - """Draw every edge once as a straight line between vertex-label centres. + ports: dict[str, tuple[int, int]], + style: RenderStyle, +) -> tuple[list[tuple[str, str]], list[tuple[int, int, str]]]: + """Return each edge once as a direct link plus its midpoint annotation. - Self-loops are skipped (not yet supported); undirected edges are drawn a single time. + Self-loops are skipped; undirected edges are emitted a single time. - :param canvas: The canvas to draw on. - :type canvas: _Canvas :param graph: The graph being rendered. :type graph: Graph - :param anchors: Mapping of vertex name to its top-left pixel anchor. - :type anchors: dict[str, tuple[int, int]] - :return: Nothing. - :rtype: None + :param ports: Vertex name to port pixel. + :type ports: dict[str, tuple[int, int]] + :param style: The active display toggles. + :type style: RenderStyle + :return: The ``links`` to draw and the ``notes`` to stamp. + :rtype: tuple[list[tuple[str, str]], list[tuple[int, int, str]]] """ + links: list[tuple[str, str]] = [] + notes: list[tuple[int, int, str]] = [] drawn: set[tuple[str, str]] = set() for u in graph.vertices(): for w in graph.neighbors(u): @@ -250,37 +521,105 @@ def _draw_edges( if key in drawn: continue drawn.add(key) - ux, uy = _centre(u, anchors) - wx, wy = _centre(w, anchors) - canvas.line(ux, uy, wx, wy) + links.append((u, w)) + text = edge_annotation(graph, u, w, style=style) + if text: + ax, ay = ports[u] + bx, by = ports[w] + notes.append(((ax + bx) // 2 + 1, (ay + by) // 2, text)) + return links, notes -def _draw_labels(canvas: _Canvas, anchors: dict[str, tuple[int, int]]) -> None: - """Stamp each vertex name at its anchor, over any edge glyphs already drawn. +# --------------------------------------------------------------------------- +# Shared helpers and rasterisation +# --------------------------------------------------------------------------- - :param canvas: The canvas to draw on. - :type canvas: _Canvas - :param anchors: Mapping of vertex name to its top-left pixel anchor. - :type anchors: dict[str, tuple[int, int]] - :return: Nothing. - :rtype: None + +def _node_label(graph: Graph, nid: str, style: RenderStyle) -> str: + """Return the display text for node *nid* (``""`` for routing dummies). + + :param graph: The graph being rendered. + :type graph: Graph + :param nid: A node id (a real vertex name or a dummy id). + :type nid: str + :param style: The active display toggles. + :type style: RenderStyle + :return: The label text, or ``""`` for a dummy node. + :rtype: str + """ + if nid not in graph: + return "" + return vertex_text(graph, nid, use_label=style.vertex_labels) + + +def _x_gap(labels: dict[str, str]) -> int: + """Return the horizontal pixels-per-column: the widest label plus padding. + + :param labels: Node id to display text. + :type labels: dict[str, str] + :return: The column width in pixels. + :rtype: int + """ + widths = [len(text) for text in labels.values() if text] + return (max(widths) if widths else 1) + _X_GAP_PAD + + +def _label_x(port_x: int, text: str) -> int: + """Return the leftmost column at which *text* is centred on *port_x* (clamped to 0). + + :param port_x: The node's port column. + :type port_x: int + :param text: The label text. + :type text: str + :return: The starting column for the label. + :rtype: int """ - for name, (px, py) in anchors.items(): - canvas.text(px, py, name) + return max(0, port_x - len(text) // 2) -def _centre(name: str, anchors: dict[str, tuple[int, int]]) -> tuple[int, int]: - """Return the approximate centre pixel of *name*'s label. +def _render(plot: _Plot) -> str: + """Rasterise *plot* onto a canvas: links first, then annotations, then labels on top. - :param name: Vertex name. - :type name: str - :param anchors: Mapping of vertex name to its top-left pixel anchor. - :type anchors: dict[str, tuple[int, int]] - :return: The ``(x, y)`` centre of the label. + :param plot: The positioned layout. + :type plot: _Plot + :return: The rendered grid. + :rtype: str + """ + width, height = _plot_size(plot) + canvas = _Canvas(width, height) + for a, b in plot.links: + ax, ay = plot.ports[a] + bx, by = plot.ports[b] + canvas.line(ax, ay, bx, by) + for x, y, text in plot.notes: + canvas.text(x, y, text) + for nid, text in plot.labels.items(): + if text: + px, py = plot.ports[nid] + canvas.text(_label_x(px, text), py, text) + return canvas.render() + + +def _plot_size(plot: _Plot) -> tuple[int, int]: + """Compute the canvas dimensions needed to fit every port, label, and annotation. + + :param plot: The positioned layout. + :type plot: _Plot + :return: The ``(width, height)`` of the canvas. :rtype: tuple[int, int] """ - px, py = anchors[name] - return px + len(name) // 2, py + width = 1 + height = 1 + for nid, (px, py) in plot.ports.items(): + width = max(width, px + 1) + height = max(height, py + 1) + text = plot.labels.get(nid, "") + if text: + width = max(width, _label_x(px, text) + len(text) + 1) + for x, y, text in plot.notes: + width = max(width, x + len(text) + 1) + height = max(height, y + 1) + return width, height def _line_glyph(dx: int, dy: int) -> str: @@ -290,7 +629,7 @@ def _line_glyph(dx: int, dy: int) -> str: :type dx: int :param dy: Vertical delta (end minus start). :type dy: int - :return: One of ``-``, ``|``, ``\\`` or ``/``. + :return: One of ``-``, ``|``, ``\`` or ``/``. :rtype: str """ if dx == 0: diff --git a/src/graphworks/export/text_utils.py b/src/graphworks/export/text_utils.py index 571f323..c40c1d5 100644 --- a/src/graphworks/export/text_utils.py +++ b/src/graphworks/export/text_utils.py @@ -29,6 +29,8 @@ from typing import TYPE_CHECKING +from ._labels import RenderStyle, edge_annotation, vertex_text + if TYPE_CHECKING: from ..graph import Graph @@ -43,7 +45,13 @@ _SEEN = " ..." -def to_text(graph: Graph) -> str: +def to_text( + graph: Graph, + *, + vertex_labels: bool = True, + edge_labels: bool = True, + weights: bool | None = None, +) -> str: """Render *graph* as an indented tree using box-drawing connectors. The graph is drawn as a depth-first spanning forest under a single root line (the graph @@ -54,11 +62,21 @@ def to_text(graph: Graph) -> str: suppressed for readability. A vertex reached a second time is shown as a leaf with a trailing ``...`` and its subtree is - not repeated, so cycles and shared substructure render in finite space. When the graph is - weighted, edge weights are annotated as ``(w=…)``. + not repeated, so cycles and shared substructure render in finite space. + + Vertex and edge labels and edge weights are shown when present; an edge carrying either is + annotated as ``[label, w=…]``. All three are on by default and are backward-compatible — a + graph with no custom labels and ``weighted=False`` renders identically to the bare structure. :param graph: The graph to render. :type graph: Graph + :param vertex_labels: Show each vertex's label when set, falling back to its name. + :type vertex_labels: bool + :param edge_labels: Show each edge's label when set. + :type edge_labels: bool + :param weights: Show edge weights. ``None`` follows the graph's ``weighted`` flag; + ``True``/``False`` force the choice. + :type weights: bool | None :return: A multi-line string suitable for printing to a terminal. :rtype: str """ @@ -66,6 +84,11 @@ def to_text(graph: Graph) -> str: if graph.order == 0: return f"{root_token}\n(empty graph)" + style = RenderStyle( + vertex_labels=vertex_labels, + edge_labels=edge_labels, + weights=weights, + ) candidates = _root_candidates(graph) roots = _select_roots(graph, candidates) @@ -73,7 +96,7 @@ def to_text(graph: Graph) -> str: visited: set[str] = set() last_root = len(roots) - 1 for idx, root in enumerate(roots): - _render(graph, root, None, "", idx == last_root, visited, lines) + _render(graph, root, None, "", idx == last_root, visited, lines, style) return "\n".join(lines) @@ -151,6 +174,7 @@ def _render( # noqa: PLR0913 is_last: bool, visited: set[str], lines: list[str], + style: RenderStyle, ) -> None: """Append the lines for *node* and its subtree to *lines* (depth-first). @@ -159,7 +183,7 @@ def _render( # noqa: PLR0913 :param node: The vertex to draw. :type node: str :param parent: The vertex *node* was reached from, or ``None`` for a root. Used to suppress - the trivial back-edge in undirected graphs. + the trivial back-edge in undirected graphs and to resolve the incoming edge's annotation. :type parent: str | None :param prefix: The accumulated indentation string for this depth. :type prefix: str @@ -169,24 +193,28 @@ def _render( # noqa: PLR0913 :type visited: set[str] :param lines: Output accumulator, mutated in place. :type lines: list[str] + :param style: The active display toggles. + :type style: RenderStyle :return: Nothing. :rtype: None """ connector = _ELBOW if is_last else _TEE - weight = _weight_annotation(graph, parent, node) + display = vertex_text(graph, node, use_label=style.vertex_labels) + content = edge_annotation(graph, parent, node, style=style) + annotation = f" [{content}]" if content else "" if node in visited: - lines.append(f"{prefix}{connector}{node}{weight}{_SEEN}") + lines.append(f"{prefix}{connector}{display}{annotation}{_SEEN}") return - lines.append(f"{prefix}{connector}{node}{weight}") + lines.append(f"{prefix}{connector}{display}{annotation}") visited.add(node) children = _children(graph, node, parent) child_prefix = prefix + (_SPACE if is_last else _PIPE) last_child = len(children) - 1 for idx, child in enumerate(children): - _render(graph, child, node, child_prefix, idx == last_child, visited, lines) + _render(graph, child, node, child_prefix, idx == last_child, visited, lines, style) def _children(graph: Graph, node: str, parent: str | None) -> list[str]: @@ -207,25 +235,3 @@ def _children(graph: Graph, node: str, parent: str | None) -> list[str]: if graph.directed or parent is None: return graph.neighbors(node) return [tgt for tgt in graph.neighbors(node) if tgt != parent] - - -def _weight_annotation(graph: Graph, source: str | None, target: str) -> str: - """Return a ``" (w=…)"`` suffix for the *source* → *target* edge, or ``""``. - - Empty unless the graph is weighted and the edge carries a non-``None`` weight. - - :param graph: The graph being rendered. - :type graph: Graph - :param source: The edge's source vertex, or ``None`` for a root (no incoming edge). - :type source: str | None - :param target: The edge's target vertex. - :type target: str - :return: The annotation string. - :rtype: str - """ - if not graph.weighted or source is None: - return "" - edge = graph.edge(source, target) - if edge is None or edge.weight is None: - return "" - return f" (w={edge.weight})" diff --git a/src/graphworks/graph.py b/src/graphworks/graph.py index a88f3db..095a908 100644 --- a/src/graphworks/graph.py +++ b/src/graphworks/graph.py @@ -643,7 +643,13 @@ def index_to_vertex(self, index: int) -> str: # Rendering # ------------------------------------------------------------------ - def to_text(self) -> str: + def to_text( + self, + *, + vertex_labels: bool = True, + edge_labels: bool = True, + weights: bool | None = None, + ) -> str: """Render this graph as an indented ASCII/Unicode tree. Convenience wrapper around :func:`graphworks.export.text_utils.to_text`. Draws the graph @@ -659,10 +665,22 @@ def to_text(self) -> str: ├── B └── C + :param vertex_labels: Show each vertex's label when set, falling back to its name. + :type vertex_labels: bool + :param edge_labels: Show each edge's label when set. + :type edge_labels: bool + :param weights: Show edge weights. ``None`` follows this graph's :attr:`weighted` flag; + ``True``/``False`` force the choice. + :type weights: bool | None :return: A multi-line string suitable for printing to a terminal. :rtype: str """ - return _to_text(self) + return _to_text( + self, + vertex_labels=vertex_labels, + edge_labels=edge_labels, + weights=weights, + ) # ------------------------------------------------------------------ # Protected helpers diff --git a/tests/export/test_ascii_utils.py b/tests/export/test_ascii_utils.py index ebcd096..e24fc95 100644 --- a/tests/export/test_ascii_utils.py +++ b/tests/export/test_ascii_utils.py @@ -161,6 +161,89 @@ def test_render_is_deterministic(self, raw: dict) -> None: assert to_ascii(g) == to_ascii(g) +class TestLabelsAndWeights: + """Label and weight display in the 2-D renderer.""" + + def test_vertex_label_appears(self) -> None: + """A vertex label is rendered in place of its name.""" + g = Graph("g", directed=True) + g.add_vertex("A", label="Start") + g.add_edge("A", "B") + rendered = to_ascii(g) + assert "Start" in rendered + assert "A" not in rendered # the label replaced the bare name + + def test_vertex_labels_can_be_disabled(self) -> None: + """vertex_labels=False keeps the bare name.""" + g = Graph("g", directed=True) + g.add_vertex("A", label="Start") + g.add_edge("A", "B") + assert "Start" not in to_ascii(g, vertex_labels=False) + + def test_edge_weight_appears_at_midpoint(self) -> None: + """A weighted edge stamps its weight onto the canvas.""" + g = Graph("g", directed=True, weighted=True) + g.add_edge("A", "B", weight=3) + assert "w=3" in to_ascii(g) + + def test_edge_label_appears(self) -> None: + """An edge label is stamped onto the canvas.""" + g = Graph("g", directed=True) + g.add_edge("A", "B", label="retry") + assert "retry" in to_ascii(g) + + def test_weights_can_be_disabled(self) -> None: + """weights=False suppresses the mid-edge weight.""" + g = Graph("g", directed=True, weighted=True) + g.add_edge("A", "B", weight=3) + assert "w=3" not in to_ascii(g, weights=False) + + def test_weight_on_cyclic_graph(self) -> None: + """The circular layout (cyclic graph) also stamps edge weights.""" + g = Graph("cyc", directed=True, weighted=True) + g.add_edges([("A", "B"), ("B", "C"), ("C", "A")]) + g.add_edge("A", "B", weight=7) + assert "w=7" in to_ascii(g) + + +class TestLayeredLayout: + """Barycenter ordering and dummy-node routing for DAGs.""" + + def test_long_span_edge_renders(self) -> None: + """An edge spanning multiple layers routes through dummies without dropping vertices.""" + g = Graph("span", directed=True) + g.add_edges([("A", "B"), ("B", "C"), ("C", "D"), ("A", "D")]) + rendered = to_ascii(g) + for vertex in ("A", "B", "C", "D"): + assert vertex in rendered + # A -> D spans three rows, so the drawing is at least that tall. + assert len(rendered.splitlines()) >= 5 + + def test_long_span_edge_is_deterministic(self) -> None: + """Dummy-routed layouts render identically across calls.""" + g = Graph("span", directed=True) + g.add_edges([("A", "B"), ("B", "C"), ("C", "D"), ("A", "D")]) + assert to_ascii(g) == to_ascii(g) + + def test_single_layer_dag(self) -> None: + """A DAG with no edges (one layer) renders its vertices and skips ordering.""" + g = Graph("flat", directed=True) + g.add_vertex("A") + g.add_vertex("B") + rendered = to_ascii(g) + assert "A" in rendered + assert "B" in rendered + + def test_crossing_reduction_keeps_all_edges(self) -> None: + """A layout whose initial order crosses still renders every vertex after reordering.""" + g = Graph("cross", directed=True) + # Declaring targets in an order that initially crosses exercises the barycenter sweeps. + g.add_edges([("A", "Y"), ("B", "X"), ("X", "Z"), ("Y", "Z")]) + rendered = to_ascii(g) + for vertex in ("A", "B", "X", "Y", "Z"): + assert vertex in rendered + + class TestPublicSurface: """to_ascii is exposed at the package root (but not on Graph).""" diff --git a/tests/export/test_labels.py b/tests/export/test_labels.py new file mode 100644 index 0000000..dca7cdd --- /dev/null +++ b/tests/export/test_labels.py @@ -0,0 +1,79 @@ +"""Unit tests for :mod:`graphworks.export._labels`.""" + +from __future__ import annotations + +from graphworks.export._labels import RenderStyle, edge_annotation, vertex_text +from graphworks.graph import Graph + + +class TestVertexText: + """Resolving a vertex's display text.""" + + def test_label_preferred_when_set(self) -> None: + """A set label wins over the name.""" + g = Graph(directed=True) + g.add_vertex("A", label="Start") + assert vertex_text(g, "A") == "Start" + + def test_falls_back_to_name(self) -> None: + """Without a label, the name is used.""" + g = Graph(directed=True) + g.add_vertex("A") + assert vertex_text(g, "A") == "A" + + def test_use_label_false_forces_name(self) -> None: + """use_label=False returns the name even when a label exists.""" + g = Graph(directed=True) + g.add_vertex("A", label="Start") + assert vertex_text(g, "A", use_label=False) == "A" + + def test_missing_vertex_returns_name(self) -> None: + """An unknown vertex name is returned verbatim.""" + assert vertex_text(Graph(), "ghost") == "ghost" + + +class TestEdgeAnnotation: + """Resolving an edge's annotation content.""" + + def test_no_source_is_empty(self) -> None: + """A root (no source) has no annotation.""" + assert edge_annotation(Graph(), None, "A", style=RenderStyle()) == "" + + def test_missing_edge_is_empty(self) -> None: + """A non-existent edge has no annotation.""" + g = Graph(directed=True) + g.add_vertex("A") + g.add_vertex("B") + assert edge_annotation(g, "A", "B", style=RenderStyle()) == "" + + def test_label_only(self) -> None: + """An unweighted edge shows just its label.""" + g = Graph(directed=True) + g.add_edge("A", "B", label="retry") + assert edge_annotation(g, "A", "B", style=RenderStyle()) == "retry" + + def test_label_and_weight(self) -> None: + """A weighted, labeled edge combines both, comma-separated.""" + g = Graph(directed=True, weighted=True) + g.add_edge("A", "B", label="retry", weight=3) + assert edge_annotation(g, "A", "B", style=RenderStyle()) == "retry, w=3" + + def test_weights_none_follows_graph_flag(self) -> None: + """weights=None hides the weight on a graph not declared weighted.""" + g = Graph(directed=True) + g.add_edge("A", "B", weight=3) + assert edge_annotation(g, "A", "B", style=RenderStyle()) == "" + + def test_weights_forced_true(self) -> None: + """weights=True shows the weight regardless of the graph flag.""" + g = Graph(directed=True) + g.add_edge("A", "B", weight=3) + style = RenderStyle(weights=True) + assert edge_annotation(g, "A", "B", style=style) == "w=3" + + def test_edge_labels_disabled(self) -> None: + """edge_labels=False drops the label but keeps the weight.""" + g = Graph(directed=True, weighted=True) + g.add_edge("A", "B", label="retry", weight=3) + style = RenderStyle(edge_labels=False) + assert edge_annotation(g, "A", "B", style=style) == "w=3" diff --git a/tests/export/test_text_utils.py b/tests/export/test_text_utils.py index 2d24c81..a7829de 100644 --- a/tests/export/test_text_utils.py +++ b/tests/export/test_text_utils.py @@ -76,11 +76,11 @@ def test_weighted_edges_annotated(self) -> None: """Edge weights are shown when the graph is weighted.""" g = Graph("weighted", weighted=True) g.add_edge("A", "B", weight=3).add_edge("B", "C", weight=1.5) - assert to_text(g) == "weighted\n└── A\n └── B (w=3)\n └── C (w=1.5)" + assert to_text(g) == "weighted\n└── A\n └── B [w=3]\n └── C [w=1.5]" def test_unweighted_graph_has_no_annotation(self, simple_edge_graph: Graph) -> None: - """Unweighted graphs carry no '(w=' annotations.""" - assert "(w=" not in to_text(simple_edge_graph) + """Unweighted graphs carry no weight annotation.""" + assert "w=" not in to_text(simple_edge_graph) def test_weighted_graph_edge_without_weight_unannotated(self) -> None: """A weighted graph still omits the annotation for edges that carry no weight.""" @@ -88,6 +88,44 @@ def test_weighted_graph_edge_without_weight_unannotated(self) -> None: g.add_edge("A", "B") # no weight supplied assert to_text(g) == "weighted\n└── A\n └── B" + def test_vertex_label_shown_by_default(self) -> None: + """A vertex's label replaces its name in the output when set.""" + g = Graph("g", directed=True) + g.add_vertex("A", label="Start") + g.add_edge("A", "B") + assert to_text(g) == "g\n└── Start\n └── B" + + def test_vertex_labels_can_be_disabled(self) -> None: + """vertex_labels=False renders the name even when a label is set.""" + g = Graph("g", directed=True) + g.add_vertex("A", label="Start") + g.add_edge("A", "B") + assert to_text(g, vertex_labels=False) == "g\n└── A\n └── B" + + def test_edge_label_shown_by_default(self) -> None: + """An edge's label is annotated in brackets.""" + g = Graph("g", directed=True) + g.add_edge("A", "B", label="retry") + assert to_text(g) == "g\n└── A\n └── B [retry]" + + def test_edge_label_and_weight_combined(self) -> None: + """Label and weight share a single bracketed annotation.""" + g = Graph("g", directed=True, weighted=True) + g.add_edge("A", "B", label="retry", weight=3) + assert to_text(g) == "g\n└── A\n └── B [retry, w=3]" + + def test_edge_labels_can_be_disabled(self) -> None: + """edge_labels=False hides labels but leaves weights intact.""" + g = Graph("g", directed=True, weighted=True) + g.add_edge("A", "B", label="retry", weight=3) + assert to_text(g, edge_labels=False) == "g\n└── A\n └── B [w=3]" + + def test_weights_forced_on_unweighted_graph(self) -> None: + """weights=True shows weights even when the graph is not declared weighted.""" + g = Graph("g", directed=True) + g.add_edge("A", "B", weight=3) + assert to_text(g, weights=True) == "g\n└── A\n └── B [w=3]" + def test_self_loop_marked(self) -> None: """A self-loop renders the vertex as a seen leaf beneath itself.""" g = Graph("loop", directed=True) @@ -115,6 +153,16 @@ def test_graph_method_matches_function(self, connected_graph: Graph) -> None: """Graph.to_text() delegates to the export function.""" assert connected_graph.to_text() == to_text(connected_graph) + def test_graph_method_forwards_options(self) -> None: + """Graph.to_text() forwards its display options to the export function.""" + g = Graph("g", directed=True, weighted=True) + g.add_vertex("A", label="Start") + g.add_edge("A", "B", weight=3) + assert g.to_text(vertex_labels=False, weights=False) == to_text( + g, vertex_labels=False, weights=False + ) + assert g.to_text(vertex_labels=False, weights=False) == "g\n└── A\n └── B" + def test_top_level_export(self, simple_edge_graph: Graph) -> None: """to_text is importable directly from the package root.""" import graphworks From 898bc10f0801c66348c31e620c3f17d0a5024e28 Mon Sep 17 00:00:00 2001 From: Nathan Gilbert Date: Sat, 27 Jun 2026 10:25:58 -0600 Subject: [PATCH 3/3] Add ascii changes --- src/graphworks/export/ascii_utils.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/graphworks/export/ascii_utils.py b/src/graphworks/export/ascii_utils.py index 26d9003..f5de66d 100644 --- a/src/graphworks/export/ascii_utils.py +++ b/src/graphworks/export/ascii_utils.py @@ -264,10 +264,9 @@ def _expand_with_dummies( segments: list[tuple[str, str]] = [] chains: list[tuple[str, str, list[str]]] = [] counter = 0 + # A DAG has no self-loops (they are back-edges), so every edge runs strictly downward. for u in graph.vertices(): for w in graph.neighbors(u): - if u == w: - continue chain = [u] for row in range(layer_of[u] + 1, layer_of[w]): dummy = f"{_DUMMY}{counter}"