Input & UX

Building Tree Views with Rich in a Python CLI

Show hierarchical data as a Rich tree: colour-coded guides, depth limits, a problems-only view, greppable path lines in pipes, nested JSON, and tests.

Updated

A lot of what CLIs show is hierarchical: environments containing services containing instances, a dependency graph, a directory, nested configuration, a test suite of modules, classes and tests. Flattened into a table, the structure disappears and the user has to rebuild it from repeated prefixes. Drawn as a tree — the way tree, pstree, cargo tree and pipdeptree do — the structure is the first thing the eye sees. Rich’s Tree draws the guide lines, handles indentation and wrapping, and accepts any Rich renderable as a label. This guide builds a status command that shows a fleet of services as a tree, colours guides by the worst status below them, limits depth and hides healthy branches on request, and — because trees are terrible input for scripts — emits one path per line in pipes and nested JSON on demand. It belongs to the interactive terminal UI with Rich topic.

Prerequisites

  • uv add rich typer (examples checked with Rich 15 and Typer 0.27).

Anatomy of a Rich tree

Anatomy of a Rich tree A Rich Tree with a root label, branches returned by add, and guide lines whose style follows the worst status beneath each branch. Anatomy of a Rich tree Tree(root label) guide_style="dim" add("production") returns a subtree branch.add(child) recursive walk guide_style worst status below label str, Text or any renderable labels show status only when it is not ok Follow the coloured guide from the root to the problem.

A Tree is created with a label for the root; tree.add(label) returns a new subtree, so building a tree is a recursive walk over your data that adds a branch per child. Each subtree can have its own style for the label and guide_style for the lines that connect it, which is how colour can carry meaning: a red guide leads to a failure, a dim one to healthy nodes. Labels can be plain strings with markup, Text objects, or any renderable — even a small table.

The recipe

# src/mytool/tree_view.py
from __future__ import annotations

from dataclasses import dataclass, field

from rich.text import Text
from rich.tree import Tree


@dataclass
class Node:
    name: str
    status: str = "ok"
    children: list[Node] = field(default_factory=list)


STATUS_STYLE = {"ok": "green", "degraded": "yellow", "down": "bold red"}
GUIDE_STYLE = {"ok": "dim", "degraded": "yellow", "down": "red"}     # bold would draw heavy guides


def _label(node: Node) -> Text:
    label = Text(node.name)
    if node.status != "ok":
        label.append(f"  {node.status}", style=STATUS_STYLE.get(node.status, ""))
    return label


def _worst(node: Node) -> str:
    order = list(STATUS_STYLE)
    statuses = [node.status, *(_worst(c) for c in node.children)]
    return max(statuses, key=lambda s: order.index(s) if s in order else 0)


def build_tree(root: Node, *, max_depth: int | None = None, only_problems: bool = False) -> Tree:
    tree = Tree(_label(root), guide_style="dim")

    def add(parent: Tree, node: Node, depth: int) -> None:
        children = [c for c in node.children if not only_problems or _worst(c) != "ok"]
        if max_depth is not None and depth >= max_depth:
            if children:
                parent.add(Text(f"… {len(children)} more", style="dim"))
            return
        for child in children:
            branch = parent.add(_label(child), guide_style=GUIDE_STYLE.get(_worst(child), "dim"))
            add(branch, child, depth + 1)

    add(tree, root, 0)
    return tree


def to_lines(node: Node, prefix: str = "") -> list[str]:
    """Plain, greppable form for pipes: one full path per line."""
    path = f"{prefix}/{node.name}" if prefix else node.name
    return [f"{path}\t{node.status}"] + [line for c in node.children for line in to_lines(c, path)]


def to_dict(node: Node) -> dict:
    return {"name": node.name, "status": node.status, "children": [to_dict(c) for c in node.children]}
# src/mytool/cli.py
import json
from typing import Annotated, Optional

import typer
from rich.console import Console

from mytool.tree_view import Node, build_tree, to_dict, to_lines

app = typer.Typer()

FLEET = Node("acme", children=[
    Node("production", children=[
        Node("api", children=[Node("api-1"), Node("api-2", "degraded")]),
        Node("worker", children=[Node("worker-1")]),
    ]),
    Node("staging", children=[
        Node("api", children=[Node("api-1", "down")]),
    ]),
])


@app.callback()
def main() -> None:
    """Fleet tool."""


@app.command()
def status(
    depth: Annotated[Optional[int], typer.Option(help="Limit how deep the tree goes.")] = None,
    problems: Annotated[bool, typer.Option("--problems", help="Hide healthy branches.")] = False,
    as_json: Annotated[bool, typer.Option("--json", help="Nested JSON for scripts.")] = False,
) -> None:
    """Show the fleet as a tree."""
    console = Console()
    if as_json:
        print(json.dumps(to_dict(FLEET), indent=2))
    elif console.is_terminal:
        console.print(build_tree(FLEET, max_depth=depth, only_problems=problems))
    else:
        print("\n".join(to_lines(FLEET)))

The data model is deliberately independent of Rich: a Node with a name, a status and children. Your real data might be API responses, importlib.metadata requirements or Path.iterdir(); converting it to a small tree type first keeps rendering, filtering and serialisation separate and testable.

Colour that carries information

Labels show the status only when it is not ok, so a healthy tree is quiet and problems stand out. Guides go further: each branch’s guide is coloured by the worst status anywhere beneath it, computed by _worst. On a large fleet the eye can follow a red line from the root down to the failing instance without reading every label. One detail matters here: Rich draws heavier guide characters (┣━━, ┗━━) when the guide style is bold, so guide colours come from a separate, non-bold mapping to keep the lines consistent.

acme
├── production
│   ├── api
│   │   ├── api-1
│   │   └── api-2  degraded
│   └── worker
│       └── worker-1
└── staging
    └── api
        └── api-1  down

Depth limits and problems-only views

Real hierarchies get big. --depth N stops the walk at a level and replaces what is hidden with a dim “… 3 more” leaf, which tells the user there is more without drawing it. --problems hides every branch whose worst status is ok, leaving only the paths that lead to something wrong — usually the view people actually want during an incident. Both are filters on the walk, not on the data, so they combine freely.

Filtering the tree Terminal session showing a fleet status tree limited to one level, then only branches that lead to problems. Filtering the tree bash $ mytool status --depth 1 acme ├── production │ └── … 2 more └── staging $ mytool status --problems └── staging / api / api-1 down During an incident, only the paths to failures matter.

Output for scripts

Box-drawing characters and indentation are hard to parse. In a pipe, the command prints one line per node with the full path and status separated by a tab:

acme/production/api/api-2   degraded
acme/staging/api/api-1  down

That form works with grep, awk -F'\t' and sort, and each line stands on its own, which a tree line does not (└── api-1 is meaningless without its ancestors). --json emits the nested structure for programs that want the hierarchy itself. This is the same split described in detecting a TTY and adapting output and emitting JSON output for scripting.

UX considerations

  • Sort children predictably. Alphabetical, or by status with problems first; random API order makes two runs hard to compare.
  • Keep labels short. Long labels wrap under the guides and break the visual structure; move detail to a show command or a --verbose table.
  • Mind the characters. Guides use Unicode box drawing. On legacy Windows consoles or with TERM=dumb, Rich falls back to ASCII guides when the encoding cannot display them; see supporting dumb terminals and screen readers for the accessibility side — screen readers read guide characters aloud, which is another reason to offer the path-per-line form.
  • Expand on demand. For trees too big to print, a Textual Tree widget offers collapsible nodes; see building terminal UIs with Textual.
  • Do not hide problems behind a depth limit. If a hidden branch contains a failure, say so in the summary leaf (“… 3 more, 1 down”).
One hierarchy, three forms Comparison of tree, path-per-line and JSON output for hierarchical command line tool data. One hierarchy, three forms Form When Good for Rich tree terminal seeing structure path<TAB>status pipe grep, awk, sort --json on request programs needing nesting Every line of the path form stands on its own; a tree line does not.

Testing the behaviour

Render trees into a StringIO console with colour disabled and a fixed width, then assert on the drawn structure. The CLI runner covers the piped and JSON forms:

# tests/test_tree_view.py
import io
import json

from rich.console import Console
from typer.testing import CliRunner

from mytool.cli import FLEET, app
from mytool.tree_view import build_tree, to_lines


def render(tree) -> str:
    console = Console(file=io.StringIO(), width=60, color_system=None)
    console.print(tree)
    return console.file.getvalue()


def test_tree_shows_hierarchy_with_guides():
    out = render(build_tree(FLEET))
    assert out.splitlines()[0] == "acme"
    assert "├── production" in out and "└── staging" in out
    assert "api-2  degraded" in out


def test_depth_limit_summarises_hidden_children():
    out = render(build_tree(FLEET, max_depth=1))
    assert "production" in out and "api-1" not in out
    assert "… 2 more" in out


def test_problems_only_keeps_paths_to_failures():
    out = render(build_tree(FLEET, only_problems=True))
    assert "api-2" in out and "api-1  down" in out
    assert "worker" not in out


def test_piped_output_is_one_path_per_line():
    result = CliRunner().invoke(app, ["status"])
    assert "acme/staging/api/api-1\tdown" in result.stdout.splitlines()
    assert result.stdout.splitlines() == to_lines(FLEET)


def test_json_output_is_nested():
    data = json.loads(CliRunner().invoke(app, ["status", "--json"]).stdout)
    assert data["children"][0]["children"][0]["name"] == "api"

Asserting on guide fragments such as ├── production and └── staging checks ordering and nesting without depending on the exact width of every line. The piped test compares against to_lines directly, so the two output forms can never drift apart.

Conclusion

Use Rich’s Tree whenever data is hierarchical: build it with a recursive walk over a small, Rich-independent node type, show status only when it is interesting, colour each guide by the worst status beneath it (without bold, to keep guide characters consistent), offer --depth and --problems filters, print one full path per line in pipes and nested JSON on request, and test the rendered structure with a plain, fixed-width console.

Frequently asked questions

How do I show a directory tree?

Walk it with Path.iterdir(), sort directories first, and add a branch per entry; skip ignored files using the approach in walking directory trees with ignore rules. Add sizes as dim text in the label rather than a separate column.

Can a tree node contain a table?

Yes. Any renderable works as a label, so a node can hold a small Table or a Panel. Use it sparingly — a tree of tables quickly becomes harder to read than either alone.

What about graphs that are not trees, like dependencies with shared children?

Draw each shared node in full the first time and as a dim reference (“requests (see above)”) afterwards, the way pipdeptree and cargo tree do. Track visited nodes to avoid infinite loops on cycles.

How large can a Rich tree get before it is too slow?

Rendering thousands of nodes is fast; reading them is not. Past a screenful, depth limits, filters or a pager serve users better than raw speed.

Should the tree go to stdout or stderr?

To stdout — it is the command’s result. Only diagnostics, such as “fetched 3 environments in 1.2s”, belong on stderr.