From ddddce088952cf62906bb2c83662ac961a266a59 Mon Sep 17 00:00:00 2001 From: Cristian Pufu Date: Sun, 15 Feb 2026 14:14:24 +0200 Subject: [PATCH] feat: add call-graph sample and depth/breadth truncation Add configurable max_depth and max_breadth options to prune large graphs before rendering. Truncated portions are replaced with "..." placeholder nodes. Includes a new call-graph sample (pizza ordering system) and corresponding screenshot. Co-Authored-By: Claude Opus 4.6 --- pyproject.toml | 2 +- samples/call-graph/graph.json | 78 +++++++ screenshots/call-graph.png | Bin 0 -> 65361 bytes scripts/generate_screenshots.py | 5 + src/graphtty/__init__.py | 2 + src/graphtty/__main__.py | 16 ++ src/graphtty/renderer.py | 11 +- src/graphtty/truncate.py | 169 +++++++++++++++ tests/test_cli.py | 1 + tests/test_truncate.py | 371 ++++++++++++++++++++++++++++++++ uv.lock | 2 +- 11 files changed, 654 insertions(+), 3 deletions(-) create mode 100644 samples/call-graph/graph.json create mode 100644 screenshots/call-graph.png create mode 100644 src/graphtty/truncate.py create mode 100644 tests/test_truncate.py diff --git a/pyproject.toml b/pyproject.toml index d3df10b..420693e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "graphtty" -version = "0.1.6" +version = "0.1.7" description = "Turn any directed graph into colored ASCII art for your terminal" readme = "README.md" license = "MIT" diff --git a/samples/call-graph/graph.json b/samples/call-graph/graph.json new file mode 100644 index 0000000..db17403 --- /dev/null +++ b/samples/call-graph/graph.json @@ -0,0 +1,78 @@ +{ + "nodes": [ + { + "id": "main", + "name": "main", + "type": "entrypoint", + "description": "CLI entry point" + }, + { + "id": "order_pizza", + "name": "order_pizza", + "type": "function", + "description": "Orchestrates the full order flow" + }, + { + "id": "select_size", + "name": "select_size", + "type": "function", + "description": "Small, Medium, Large" + }, + { + "id": "select_toppings", + "name": "select_toppings", + "type": "function", + "description": "Pepperoni, Mushrooms, Olives" + }, + { + "id": "validate_order", + "name": "validate_order", + "type": "function", + "description": "Checks item availability" + }, + { + "id": "calculate_price", + "name": "calculate_price", + "type": "function", + "description": "Applies discounts and tax" + }, + { + "id": "process_payment", + "name": "process_payment", + "type": "function", + "description": "Stripe API integration" + }, + { + "id": "send_confirmation", + "name": "send_confirmation", + "type": "function", + "description": "Email via SendGrid" + }, + { + "id": "log_order", + "name": "log_order", + "type": "function", + "description": "Writes to order database" + }, + { + "id": "notify_kitchen", + "name": "notify_kitchen", + "type": "function", + "description": "WebSocket push to kitchen display" + } + ], + "edges": [ + { "source": "main", "target": "order_pizza" }, + { "source": "order_pizza", "target": "select_size" }, + { "source": "order_pizza", "target": "select_toppings" }, + { "source": "select_size", "target": "validate_order" }, + { "source": "select_toppings", "target": "validate_order" }, + { "source": "validate_order", "target": "calculate_price" }, + { "source": "calculate_price", "target": "process_payment" }, + { "source": "process_payment", "target": "send_confirmation" }, + { "source": "process_payment", "target": "log_order" }, + { "source": "process_payment", "target": "notify_kitchen" }, + { "source": "log_order", "target": "send_confirmation" }, + { "source": "notify_kitchen", "target": "send_confirmation" } + ] +} diff --git a/screenshots/call-graph.png b/screenshots/call-graph.png new file mode 100644 index 0000000000000000000000000000000000000000..628e60c46689df0d0a093873eb379bf9180b7ef6 GIT binary patch literal 65361 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 literal 0 HcmV?d00001 diff --git a/scripts/generate_screenshots.py b/scripts/generate_screenshots.py index f162788..bfae5fe 100644 --- a/scripts/generate_screenshots.py +++ b/scripts/generate_screenshots.py @@ -213,6 +213,11 @@ def main(): "dracula", "Orchestrator Agent (dracula)", ), + ( + "samples/call-graph/graph.json", + "monokai", + "Call Graph (monokai)", + ), ] images: list[tuple[str, Image.Image]] = [] diff --git a/src/graphtty/__init__.py b/src/graphtty/__init__.py index ad4ad66..3ee11b3 100644 --- a/src/graphtty/__init__.py +++ b/src/graphtty/__init__.py @@ -2,6 +2,7 @@ from .renderer import RenderOptions, render from .themes import Theme, get_theme, list_themes +from .truncate import truncate_graph from .types import AsciiEdge, AsciiGraph, AsciiNode __all__ = [ @@ -13,4 +14,5 @@ "get_theme", "list_themes", "render", + "truncate_graph", ] diff --git a/src/graphtty/__main__.py b/src/graphtty/__main__.py index 0bab5d3..1218aad 100644 --- a/src/graphtty/__main__.py +++ b/src/graphtty/__main__.py @@ -92,6 +92,20 @@ def main(argv: list[str] | None = None) -> None: default=None, help="Max output width in columns (0 = no limit, default = terminal width)", ) + parser.add_argument( + "-d", + "--max-depth", + type=int, + default=None, + help="Max graph depth (layers from root)", + ) + parser.add_argument( + "-b", + "--max-breadth", + type=int, + default=None, + help="Max nodes per layer", + ) args = parser.parse_args(argv) @@ -135,6 +149,8 @@ def main(argv: list[str] | None = None) -> None: show_types=not args.no_types, theme=theme, max_width=max_width, + max_depth=args.max_depth, + max_breadth=args.max_breadth, ) print(render(graph, options)) diff --git a/src/graphtty/renderer.py b/src/graphtty/renderer.py index 0210133..40c3636 100644 --- a/src/graphtty/renderer.py +++ b/src/graphtty/renderer.py @@ -37,6 +37,8 @@ class RenderOptions: padding: int = 2 theme: Theme = field(default_factory=lambda: DEFAULT_THEME) max_width: int | None = None + max_depth: int | None = None + max_breadth: int | None = None def render( @@ -59,6 +61,13 @@ def render( if not graph.nodes: return "" + if options.max_depth is not None or options.max_breadth is not None: + from .truncate import truncate_graph + + graph = truncate_graph( + graph, max_depth=options.max_depth, max_breadth=options.max_breadth + ) + use_color = options.theme is not DEFAULT_THEME canvas = _render_canvas(graph, options) return canvas.to_string(use_color=use_color) @@ -268,7 +277,7 @@ def _do_render_canvas( # --------------------------------------------------------------------------- # Node types that are structural markers — no border label needed. -_HIDDEN_TYPE_LABELS = {"__start__", "__end__"} +_HIDDEN_TYPE_LABELS = {"__start__", "__end__", "__truncated__"} def _type_label(node_type: str, show_types: bool) -> str | None: diff --git a/src/graphtty/truncate.py b/src/graphtty/truncate.py new file mode 100644 index 0000000..faaed11 --- /dev/null +++ b/src/graphtty/truncate.py @@ -0,0 +1,169 @@ +"""Graph truncation — prune large graphs by depth and breadth.""" + +from __future__ import annotations + +from collections import deque + +from .types import AsciiEdge, AsciiGraph, AsciiNode + +_DEPTH_PLACEHOLDER_ID = "__truncated_depth__" + + +def truncate_graph( + graph: AsciiGraph, + *, + max_depth: int | None = None, + max_breadth: int | None = None, +) -> AsciiGraph: + """Return a truncated copy of *graph*. + + * **max_depth** — keep only nodes within this many layers from the roots + (in-degree-0 nodes). Nodes beyond the limit are replaced by a single + ``...`` placeholder node. + * **max_breadth** — keep at most this many nodes per layer. Excess nodes + in a layer are collapsed into a per-layer ``...`` placeholder. + + Returns a new :class:`AsciiGraph`; the original is not modified. + """ + if max_depth is None and max_breadth is None: + return graph + + node_ids = {n.id for n in graph.nodes} + + # Build forward adjacency & in-degree + forward: dict[str, list[str]] = {nid: [] for nid in node_ids} + in_degree: dict[str, int] = {nid: 0 for nid in node_ids} + for e in graph.edges: + if e.source in node_ids and e.target in node_ids and e.source != e.target: + forward[e.source].append(e.target) + in_degree[e.target] += 1 + + # Longest-path layer assignment via topological order (Kahn's algorithm). + # Processing in topo order guarantees all incoming edges are resolved + # before a node is expanded — correct for longest-path in a DAG. + roots = [nid for nid in node_ids if in_degree[nid] == 0] + if not roots: + # Pure cycle — treat all nodes as layer 0 + layers: dict[str, int] = {nid: 0 for nid in node_ids} + else: + layers = {nid: 0 for nid in node_ids} + remaining = dict(in_degree) + topo: deque[str] = deque(roots) + while topo: + nid = topo.popleft() + for child in forward[nid]: + new_layer = layers[nid] + 1 + if new_layer > layers[child]: + layers[child] = new_layer + remaining[child] -= 1 + if remaining[child] == 0: + topo.append(child) + + # --- Depth truncation --- + keep_ids: set[str] = set() + depth_trunc_parents: set[str] = set() # kept nodes with children beyond limit + + if max_depth is not None: + for nid, layer in layers.items(): + if layer <= max_depth: + keep_ids.add(nid) + # Find kept nodes that have children beyond the depth limit + for nid in list(keep_ids): + for child in forward[nid]: + if child not in keep_ids: + depth_trunc_parents.add(nid) + else: + keep_ids = set(node_ids) + + # --- Breadth truncation --- + breadth_replacements: dict[str, str] = {} # removed_id -> placeholder_id + + if max_breadth is not None and max_breadth >= 1: + # Group nodes by layer (preserving original graph order) + max_layer = max(layers.values()) if layers else 0 + nodes_by_layer: list[list[str]] = [[] for _ in range(max_layer + 1)] + for n in graph.nodes: + nodes_by_layer[layers[n.id]].append(n.id) + + for layer_idx, layer_nodes in enumerate(nodes_by_layer): + # Only consider nodes that survived depth truncation + surviving = [nid for nid in layer_nodes if nid in keep_ids] + if len(surviving) <= max_breadth: + continue + # Keep first (max_breadth - 1) nodes, replace rest with placeholder + removed = surviving[max_breadth - 1 :] + placeholder_id = f"__truncated_breadth_{layer_idx}__" + for rid in removed: + keep_ids.discard(rid) + breadth_replacements[rid] = placeholder_id + # If this node was a depth-truncation parent, remove it + depth_trunc_parents.discard(rid) + + # --- Build result nodes --- + result_nodes: list[AsciiNode] = [] + added_placeholders: set[str] = set() + + for n in graph.nodes: + if n.id in keep_ids: + # Recurse into subgraphs + if n.subgraph and n.subgraph.nodes: + sub = truncate_graph( + n.subgraph, max_depth=max_depth, max_breadth=max_breadth + ) + result_nodes.append( + AsciiNode( + id=n.id, + name=n.name, + type=n.type, + description=n.description, + subgraph=sub, + ) + ) + else: + result_nodes.append(n) + elif n.id in breadth_replacements: + pid = breadth_replacements[n.id] + if pid not in added_placeholders: + added_placeholders.add(pid) + result_nodes.append(AsciiNode(id=pid, name="...", type="__truncated__")) + + if depth_trunc_parents: + result_nodes.append( + AsciiNode(id=_DEPTH_PLACEHOLDER_ID, name="...", type="__truncated__") + ) + + # --- Build result edges --- + result_node_ids = {n.id for n in result_nodes} + seen_edges: set[tuple[str, str]] = set() + result_edges: list[AsciiEdge] = [] + + for e in graph.edges: + src = e.source + tgt = e.target + + # Remap breadth-truncated nodes + if src in breadth_replacements: + src = breadth_replacements[src] + if tgt in breadth_replacements: + tgt = breadth_replacements[tgt] + + # Depth-truncated target: redirect to depth placeholder + if src in result_node_ids and tgt not in result_node_ids: + if src in depth_trunc_parents: + tgt = _DEPTH_PLACEHOLDER_ID + + if src not in result_node_ids or tgt not in result_node_ids: + continue + if src == tgt: + continue + + pair = (src, tgt) + if pair in seen_edges: + continue + seen_edges.add(pair) + + # Preserve label only if both endpoints are original (not placeholders) + label = e.label if src == e.source and tgt == e.target else None + result_edges.append(AsciiEdge(source=src, target=tgt, label=label)) + + return AsciiGraph(nodes=result_nodes, edges=result_edges) diff --git a/tests/test_cli.py b/tests/test_cli.py index b9ec401..93c2130 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -153,6 +153,7 @@ class TestCLISamples: "samples/deep-agent/graph.json", "samples/supervisor-agent/graph.json", "samples/world-map/graph.json", + "samples/call-graph/graph.json", ] ) def sample_path(self, request): diff --git a/tests/test_truncate.py b/tests/test_truncate.py new file mode 100644 index 0000000..2d0beca --- /dev/null +++ b/tests/test_truncate.py @@ -0,0 +1,371 @@ +"""Tests for graph truncation (depth/breadth pruning).""" + +from graphtty import AsciiEdge, AsciiGraph, AsciiNode, RenderOptions, render +from graphtty.truncate import truncate_graph + + +def _ids(graph: AsciiGraph) -> set[str]: + return {n.id for n in graph.nodes} + + +def _edge_pairs(graph: AsciiGraph) -> set[tuple[str, str]]: + return {(e.source, e.target) for e in graph.edges} + + +# --------------------------------------------------------------------------- +# Depth truncation +# --------------------------------------------------------------------------- + + +class TestDepthTruncation: + def test_chain_depth_1(self): + """A→B→C→D with max_depth=1 keeps A, B and adds '...' placeholder.""" + g = AsciiGraph( + nodes=[ + AsciiNode(id="A", name="A"), + AsciiNode(id="B", name="B"), + AsciiNode(id="C", name="C"), + AsciiNode(id="D", name="D"), + ], + edges=[ + AsciiEdge(source="A", target="B"), + AsciiEdge(source="B", target="C"), + AsciiEdge(source="C", target="D"), + ], + ) + result = truncate_graph(g, max_depth=1) + ids = _ids(result) + assert "A" in ids + assert "B" in ids + assert "C" not in ids + assert "D" not in ids + assert "__truncated_depth__" in ids + # B should connect to the placeholder + assert ("B", "__truncated_depth__") in _edge_pairs(result) + + def test_longest_path_reconverging(self): + """Longest-path must follow the longest route through re-converging paths. + + A→D→E (short) and A→B→C→D→E (long). D should be at layer 3, E at 4. + With max_depth=2 only A, B, C are kept (layers 0, 1, 2). + """ + g = AsciiGraph( + nodes=[ + AsciiNode(id="A", name="A"), + AsciiNode(id="B", name="B"), + AsciiNode(id="C", name="C"), + AsciiNode(id="D", name="D"), + AsciiNode(id="E", name="E"), + ], + edges=[ + AsciiEdge(source="A", target="D"), + AsciiEdge(source="A", target="B"), + AsciiEdge(source="B", target="C"), + AsciiEdge(source="C", target="D"), + AsciiEdge(source="D", target="E"), + ], + ) + result = truncate_graph(g, max_depth=2) + ids = _ids(result) + assert {"A", "B", "C"} <= ids + # D is at layer 3 (longest path A→B→C→D), so it's truncated + assert "D" not in ids + assert "E" not in ids + assert "__truncated_depth__" in ids + + def test_fan_out_depth_0(self): + """max_depth=0 keeps only root(s) + placeholder.""" + g = AsciiGraph( + nodes=[ + AsciiNode(id="root", name="root"), + AsciiNode(id="a", name="a"), + AsciiNode(id="b", name="b"), + ], + edges=[ + AsciiEdge(source="root", target="a"), + AsciiEdge(source="root", target="b"), + ], + ) + result = truncate_graph(g, max_depth=0) + ids = _ids(result) + assert ids == {"root", "__truncated_depth__"} + assert ("root", "__truncated_depth__") in _edge_pairs(result) + + def test_depth_no_truncation_needed(self): + """Graph fits within max_depth — no placeholder added.""" + g = AsciiGraph( + nodes=[ + AsciiNode(id="A", name="A"), + AsciiNode(id="B", name="B"), + ], + edges=[AsciiEdge(source="A", target="B")], + ) + result = truncate_graph(g, max_depth=5) + assert _ids(result) == {"A", "B"} + assert "__truncated_depth__" not in _ids(result) + + def test_diamond_depth(self): + """Diamond: A→B, A→C, B→D, C→D — longest-path layers: A=0,B=1,C=1,D=2.""" + g = AsciiGraph( + nodes=[ + AsciiNode(id="A", name="A"), + AsciiNode(id="B", name="B"), + AsciiNode(id="C", name="C"), + AsciiNode(id="D", name="D"), + ], + edges=[ + AsciiEdge(source="A", target="B"), + AsciiEdge(source="A", target="C"), + AsciiEdge(source="B", target="D"), + AsciiEdge(source="C", target="D"), + ], + ) + result = truncate_graph(g, max_depth=1) + ids = _ids(result) + assert {"A", "B", "C"} <= ids + assert "D" not in ids + assert "__truncated_depth__" in ids + + +# --------------------------------------------------------------------------- +# Breadth truncation +# --------------------------------------------------------------------------- + + +class TestBreadthTruncation: + def test_wide_layer(self): + """5-node layer with max_breadth=3 → 2 nodes + placeholder.""" + nodes = [AsciiNode(id="root", name="root")] + edges = [] + for i in range(5): + nid = f"n{i}" + nodes.append(AsciiNode(id=nid, name=nid)) + edges.append(AsciiEdge(source="root", target=nid)) + g = AsciiGraph(nodes=nodes, edges=edges) + + result = truncate_graph(g, max_breadth=3) + ids = _ids(result) + assert "root" in ids + # Should have 2 original nodes + 1 placeholder = 3 at layer 1 + layer1_ids = ids - {"root"} + assert len(layer1_ids) == 3 + assert any("__truncated_breadth_" in nid for nid in layer1_ids) + + def test_breadth_no_truncation_needed(self): + """Layers within limit — no placeholder.""" + g = AsciiGraph( + nodes=[ + AsciiNode(id="A", name="A"), + AsciiNode(id="B", name="B"), + AsciiNode(id="C", name="C"), + ], + edges=[ + AsciiEdge(source="A", target="B"), + AsciiEdge(source="A", target="C"), + ], + ) + result = truncate_graph(g, max_breadth=5) + assert _ids(result) == {"A", "B", "C"} + + +# --------------------------------------------------------------------------- +# Combined +# --------------------------------------------------------------------------- + + +class TestCombinedTruncation: + def test_depth_and_breadth(self): + """Both limits applied together.""" + # root → a, b, c, d (layer 1, 4 nodes) + # a → leaf (layer 2) + nodes = [ + AsciiNode(id="root", name="root"), + AsciiNode(id="a", name="a"), + AsciiNode(id="b", name="b"), + AsciiNode(id="c", name="c"), + AsciiNode(id="d", name="d"), + AsciiNode(id="leaf", name="leaf"), + ] + edges = [ + AsciiEdge(source="root", target="a"), + AsciiEdge(source="root", target="b"), + AsciiEdge(source="root", target="c"), + AsciiEdge(source="root", target="d"), + AsciiEdge(source="a", target="leaf"), + ] + g = AsciiGraph(nodes=nodes, edges=edges) + + result = truncate_graph(g, max_depth=1, max_breadth=3) + ids = _ids(result) + # root kept (layer 0) + assert "root" in ids + # layer 1 breadth-truncated to 2 nodes + placeholder + # leaf removed by depth truncation + assert "leaf" not in ids + + +# --------------------------------------------------------------------------- +# Edge cases +# --------------------------------------------------------------------------- + + +class TestEdgeCases: + def test_noop_no_limits(self): + """No limits → return original graph.""" + g = AsciiGraph( + nodes=[AsciiNode(id="A", name="A")], + edges=[], + ) + result = truncate_graph(g, max_depth=None, max_breadth=None) + assert result is g + + def test_single_node(self): + """Single node graph with depth=0.""" + g = AsciiGraph( + nodes=[AsciiNode(id="only", name="only")], + edges=[], + ) + result = truncate_graph(g, max_depth=0) + assert _ids(result) == {"only"} + + def test_empty_graph(self): + """Empty graph stays empty.""" + g = AsciiGraph(nodes=[], edges=[]) + result = truncate_graph(g, max_depth=1, max_breadth=2) + assert len(result.nodes) == 0 + + def test_edge_deduplication(self): + """Multiple parents → same placeholder should produce one edge each.""" + g = AsciiGraph( + nodes=[ + AsciiNode(id="p1", name="p1"), + AsciiNode(id="p2", name="p2"), + AsciiNode(id="c1", name="c1"), + AsciiNode(id="c2", name="c2"), + ], + edges=[ + AsciiEdge(source="p1", target="c1"), + AsciiEdge(source="p1", target="c2"), + AsciiEdge(source="p2", target="c1"), + AsciiEdge(source="p2", target="c2"), + ], + ) + # p1, p2 are layer 0; c1, c2 are layer 1 + result = truncate_graph(g, max_depth=0) + edges = _edge_pairs(result) + # Each parent should have exactly one edge to the placeholder + p1_edges = [(s, t) for s, t in edges if s == "p1"] + p2_edges = [(s, t) for s, t in edges if s == "p2"] + assert len(p1_edges) == 1 + assert len(p2_edges) == 1 + + def test_self_loop_ignored(self): + """Self-loops should not create placeholder edges to self.""" + g = AsciiGraph( + nodes=[ + AsciiNode(id="A", name="A"), + AsciiNode(id="B", name="B"), + ], + edges=[ + AsciiEdge(source="A", target="B"), + AsciiEdge(source="A", target="A"), # self-loop + ], + ) + result = truncate_graph(g, max_depth=1) + # Self-loop should not cause issues + assert "A" in _ids(result) + + def test_placeholder_node_name_is_ellipsis(self): + """Placeholder nodes have name='...'.""" + g = AsciiGraph( + nodes=[ + AsciiNode(id="A", name="A"), + AsciiNode(id="B", name="B"), + AsciiNode(id="C", name="C"), + ], + edges=[ + AsciiEdge(source="A", target="B"), + AsciiEdge(source="B", target="C"), + ], + ) + result = truncate_graph(g, max_depth=1) + placeholders = [n for n in result.nodes if n.type == "__truncated__"] + assert len(placeholders) == 1 + assert placeholders[0].name == "..." + + +# --------------------------------------------------------------------------- +# Subgraph recursion +# --------------------------------------------------------------------------- + + +class TestSubgraphRecursion: + def test_subgraph_truncated(self): + """Truncation should recurse into subgraphs.""" + inner = AsciiGraph( + nodes=[ + AsciiNode(id="s1", name="s1"), + AsciiNode(id="s2", name="s2"), + AsciiNode(id="s3", name="s3"), + ], + edges=[ + AsciiEdge(source="s1", target="s2"), + AsciiEdge(source="s2", target="s3"), + ], + ) + g = AsciiGraph( + nodes=[ + AsciiNode(id="outer", name="outer", subgraph=inner), + ], + edges=[], + ) + result = truncate_graph(g, max_depth=1) + sub = result.nodes[0].subgraph + assert sub is not None + sub_ids = _ids(sub) + assert "s1" in sub_ids + assert "s2" in sub_ids + assert "s3" not in sub_ids + + +# --------------------------------------------------------------------------- +# Render integration +# --------------------------------------------------------------------------- + + +class TestRenderIntegration: + def test_render_with_depth(self): + """render() with max_depth produces output containing '...'.""" + g = AsciiGraph( + nodes=[ + AsciiNode(id="A", name="A"), + AsciiNode(id="B", name="B"), + AsciiNode(id="C", name="C"), + ], + edges=[ + AsciiEdge(source="A", target="B"), + AsciiEdge(source="B", target="C"), + ], + ) + opts = RenderOptions(max_depth=1) + out = render(g, opts) + assert "..." in out + assert "A" in out + assert "B" in out + # C should be replaced by "..." + assert "C" not in out + + def test_render_with_breadth(self): + """render() with max_breadth produces output containing '...'.""" + nodes = [AsciiNode(id="root", name="root")] + edges = [] + for i in range(5): + nid = f"child{i}" + nodes.append(AsciiNode(id=nid, name=nid)) + edges.append(AsciiEdge(source="root", target=nid)) + g = AsciiGraph(nodes=nodes, edges=edges) + + opts = RenderOptions(max_breadth=3) + out = render(g, opts) + assert "..." in out + assert "root" in out diff --git a/uv.lock b/uv.lock index c4cf558..87b0583 100644 --- a/uv.lock +++ b/uv.lock @@ -105,7 +105,7 @@ toml = [ [[package]] name = "graphtty" -version = "0.1.6" +version = "0.1.7" source = { editable = "." } [package.dev-dependencies]