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
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.
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
showcommand or a--verbosetable. - 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
Treewidget 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”).
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.
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.