Rich#
Rich is the presentation layer for the tdw command line tool. It does not decide
what to analyze, generate, or validate; it takes the plain Python values the CLI
already computed and turns them into readable terminal output: tables, panels,
a spinner, a live-updating status display, and two interactive prompts.
Why Rich#
The tdw command is this project’s primary interface. pyproject.toml registers
it as the package’s only console script, tdw = "test_data_workbench.cli.main:main"
under [project.scripts], and this documentation site’s own technology overview
describes the alternative, FastAPI, as “the optional REST interface, with automatic
OpenAPI docs” (FastAPI). Every one of the five subcommands cli/main.py
registers (analyze, deploy, validate, team, demo) prints something
back to the terminal before it does anything else useful: a discovered table list, a
deployment’s phase-by-phase status, a validation verdict, a team roster. For most
users of this tool, that printed output is the whole product experience, not a log
trailing behind it.
pyproject.toml pins rich>=13.7.0 as an unconditional runtime dependency, not
an optional extra, and that placement matches how the code uses it: Console,
Table, Panel, Progress, and Live objects are constructed unconditionally
inside analyze.py, deploy.py, validate.py, and team.py. Without Rich
installed, those modules do not merely look worse; they fail to import.
The idea underneath#
Terminal output that looks like a table, a colored panel, or a spinner is not a
special display mode. It is ordinary text interleaved with ANSI escape sequences:
short byte sequences, standardized in ECMA-48 and summarized on Wikipedia’s “ANSI
escape code” page, that tell the terminal emulator to move the cursor, change a
color, or clear part of the screen instead of printing a visible character. A
hand-written print("\033[32mgreen\033[0m") and Rich’s
console.print("[green]green[/green]") both end up writing bytes to the same file
descriptor; the difference is entirely in how those bytes get produced.
Producing them by hand means tracking, yourself, how wide “green” is once its color
codes are stripped back out (needed for column alignment), how a string wraps at a
given terminal width, and how two overlapping styles combine into one escape
sequence instead of clobbering each other. Rich’s internal model exists to do that
measuring and composing once. The Textualize engineering blog describes the
underlying representation as a “Segment”, a run of text paired with the Style
that applies to it, and states that Segments are converted into ANSI escape codes
only “at the very end of the process.” A Console and the renderables it prints
(Table, Panel, Progress) are that measuring layer: they know the
terminal’s width and each cell’s text width, and they decide layout before a single
escape code is emitted.
The other half of the same idea is knowing when not to emit escape codes at all. A
well-behaved terminal tool checks whether its output is actually going to a
terminal, typically via isatty() on the underlying file object, before deciding
to color anything. Rich’s own Console does this automatically: per its
documentation, “if Rich detects that it is not writing to a terminal it will strip
control codes from the output,” falling back to plain text; a caller who wants
control codes written into a regular file anyway has to explicitly pass
force_terminal=True. Piping a well-behaved CLI’s output into a file, into
less, or into a CI log should read as clean text, not a screen full of literal
\x1b[1m sequences, and that has to happen automatically rather than by asking
every caller to remember a --no-color flag.
None of this is decoration on top of a “real” text-only tool. A command line program’s stdout is its interface in the same sense a window full of widgets is a GUI’s interface: a table that misaligns because a character was measured wrong, or a spinner that leaves broken escape sequences in a saved log, is a design defect the same way a broken button is. Rich exists because that interface carries the same obligations a GUI does, met with the constraints of a terminal instead of a window system.
How it fits this project#
Rich is imported in exactly five files, all under src/test_data_workbench/cli/:
analyze.py, deploy.py, team.py, validate.py, and demo.py. It does
not appear anywhere in core/, adaptation/, or api/. Schema analysis, code
generation, and validation logic never import Rich; they return plain Python objects
(SchemaInfo, ValidationResult, AdaptationResult) that the CLI layer alone
turns into terminal output. The FastAPI layer (FastAPI) formats the same kind
of underlying data as JSON instead, with no Rich involvement at all.
cli/main.py’s build_parser wires the five subcommands into one argparse
parser and dispatches each to its module’s _run function via
parser.set_defaults(func=_run). From there, each subcommand module is entirely
responsible for its own console output; nothing about presentation is shared through
main.py itself.
Console lifecycle: one instance per module#
Each of the five CLI modules creates its own module-level console = Console()
once, at import time, and every function in that module writes through that same
object rather than constructing a new Console per call: analyze.py line 18,
deploy.py line 19, team.py line 15, validate.py line 18, and demo.py
line 20 all follow the identical pattern.
This is a module-level singleton, not a single object shared across the whole
tdw process. tdw demo makes that visible: demo.py builds its own
console for its two Panel calls, then calls analyze_schema() (imported
from analyze.py) and run_deploy() (imported from deploy.py), each of
which prints through its own module’s console, not demo.py’s. A single
tdw demo invocation therefore writes through three separate Console()
instances in sequence. That still behaves correctly, because every Console()
independently detects and writes to the same real terminal, but it is worth naming
precisely: within a module, Rich’s “reuse one Console” guidance is followed; across
modules, it is one instance per module, not one instance overall.
The pattern earns its keep inside deploy.py: the except Exception branch
calls console.print(Panel(...)) while a Live(..., console=console) context is
still open (the except sits inside the same with Live(...) as live: block as
the try). Passing that same console object into Live(...) is what makes
printing through it, while the live region is active, land above the live display
instead of corrupting it.
Tables for structured output#
rich.table.Table is the shape used for anything list-like: discovered tables and
their entity types (analyze.py), the deploy phase-status table (deploy.py),
team member rosters (team.py), and validation findings (validate.py).
analyze.py’s main table is representative:
table = Table(title="Discovered Tables")
table.add_column("Table Name", style="cyan")
table.add_column("Entity Type", style="magenta")
table.add_column("Columns", justify="right", style="green")
table.add_column("Estimated Rows", justify="right", style="yellow")
for table_info in schema_info.tables:
table.add_row(
table_info.name,
table_info.entity_type.value,
str(len(table_info.columns)),
str(row_count) if row_count is not None and row_count > 0 else "Unknown",
)
Column style= is a declarative mechanism distinct from the inline markup covered
below: it styles every cell in that column automatically, so add_row only ever
passes plain strings. analyze.py builds up to three of these tables in one
analyze call: the one above, a “Potential PII columns” table gated on
schema_info.pii_columns, and a “Detected Relationships” table gated on
schema_info.relationships. validate.py’s display_validation_results uses
one table with “Type” and “Message” columns to list every error, warning, and
suggestion together, each row’s “Type” cell colored by kind ("[red]Error[/red]",
"[yellow]Warning[/yellow]", "[blue]Suggestion[/blue]"). team.py’s
setup_team_development builds a “Team Summary” table (Name, Skill Level, Focus
Area) once registration finishes, and deploy.py rebuilds a phase-status table
repeatedly, covered under Progress and Live: the two long-running commands.
The "Unknown" fallback for a missing row count, in the snippet above, is not a
Rich feature by itself, but it is the kind of detail Rich’s declarative cell
rendering makes easy to get right: a --metadata-only analysis
(Privacy and Data Access) leaves row_count as None, and the code turns
that into an explicit word instead of a blank cell or a stray "None" string.
Panels for status and next steps#
rich.panel.Panel wraps every summary or outcome message across all five modules:
analysis complete, deployment succeeded or failed, validation verdict, workspace
ready, demo quickstart complete. Every Panel pairs a title with body text
that uses Rich’s inline markup for emphasis, [bold green]...[/bold green] being
the most common pattern. deploy.py additionally uses border_style to color
the panel’s frame as its own signal:
console.print(Panel(
f"[bold green]Deployment finished {status_word} target[/bold green]\n"
f"Completed in {total_time:.1f} seconds\n"
f"Generators: {len(result.generated_code)}\n"
f"Templates: {len(result.templates)}\n"
f"Configs: {len(result.config_files)}",
title="Deployment complete",
border_style="green" if status_word == "within" else "yellow",
))
A recurring project-level convention, not a Rich feature by itself but one Panel
makes visually distinct from the data printed above it, is a “next steps” panel or
line block on every successful command: analyze.py’s “Recommendations” panel
suggests running tdw deploy, then tdw team, then tdw validate;
deploy.py prints a “Next steps” list suggesting tdw validate and
tdw team; validate.py’s “Good to go” panel and team.py’s “Ready to go”
panel each close out their own command the same way. Through these panels, the five
subcommands read like stops on one guided pipeline (analyze to deploy to
validate, with team alongside), even though nothing at the code level
enforces that order.
Progress and Live: the two long-running commands#
analyze and deploy are the only two commands that do real, potentially slow
work (analyze_production_schema, analyze_and_adapt), and each reaches for a
different Rich primitive suited to what it actually knows about its own progress.
analyze.py has no notion of how far along it is, only that it is working, so it
uses an indeterminate spinner:
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
console=console,
) as progress:
task = progress.add_task("Connecting to database...", total=None)
try:
analyzer = SchemaAnalyzer()
progress.update(task, description=description)
schema_info = await analyzer.analyze_production_schema(
connection_string, metadata_only=metadata_only
)
progress.remove_task(task)
except Exception as e:
progress.remove_task(task)
console.print(f"[bold red]Analysis failed:[/bold red] {e}")
raise
total=None is what makes the task indeterminate rather than a percentage bar.
"[progress.description]{task.description}" uses progress.description, one of
Rich’s own predefined style names for progress output, not a hand-picked color. The
task is removed with progress.remove_task(task) on both the success path and,
inside the except block, before the caught exception is re-raised; there is no
“completed” state shown, only “in progress” followed by disappearance. Note that the
failure message is printed through console while the with Progress(...)
block is technically still open, the same pattern noted for Live above.
deploy.py knows something analyze.py does not: there are exactly three named
phases (“Schema Discovery”, “Generator Creation”, “Template Generation”), each with
a target time, so it uses rich.live.Live to keep re-rendering a phase-status
table in place, rather than a spinner with no structure:
with Live(render_phase_table(), refresh_per_second=4, console=console) as live:
try:
adapter = RapidAdapter()
phases["Schema Discovery"]["status"] = "running"
live.update(render_phase_table(time.time() - start_time))
result: AdaptationResult = await adapter.analyze_and_adapt(
connection_string, output_dir, metadata_only=metadata_only
)
phase_times = getattr(adapter, "phase_times", {})
for phase_name, phase_key in [
("Schema Discovery", "schema_discovery"),
("Generator Creation", "generator_creation"),
("Template Generation", "template_generation"),
]:
if phase_key in phase_times:
phases[phase_name]["time"] = phase_times[phase_key]
phases[phase_name]["status"] = (
"done" if phase_times[phase_key] <= phases[phase_name]["target"] else "over"
)
total_time = time.time() - start_time
live.update(render_phase_table(total_time))
Despite refresh_per_second=4 and three tracked phases, live.update() is only
called twice in this path: once right after “Schema Discovery” is marked
"running", and once at the very end after phase_times comes back from the
single, already-completed await adapter.analyze_and_adapt(...) call. “Generator
Creation” and “Template Generation” never pass through a visible "running"
state; the table jumps from "pending" straight to a final "done" or
"over" in that last update, because nothing inside analyze_and_adapt reports
back to live.update() while it runs. refresh_per_second=4 governs how often
Rich would redraw if the content changed, not how often the content actually
changes here.
Prompt and Confirm: the one interactive command#
team.py’s default setup_team_development (the code path that runs when
tdw team is invoked with neither --member nor --git-hooks) is the only
place in the CLI that asks the terminal a question rather than only reporting to it,
using rich.prompt.Prompt and rich.prompt.Confirm:
name = Prompt.ask("Team member name (or 'done' to finish)")
...
skill_level = Prompt.ask(
"Skill level",
choices=["beginner", "intermediate", "advanced"],
default="beginner",
)
...
if Confirm.ask("\nSet up Git safety hooks?", default=True):
setup_git_hooks(project_dir)
Prompt.ask with a choices list re-prompts on an answer outside that list
rather than accepting anything typed; Confirm.ask("...", default=True) accepts
an empty line as “yes”. Both read from the process’s real stdin, not through
console: Console handles the write side of the terminal, Prompt and
Confirm handle the read side, layered on top of it.
Inline markup: how style gets into the text#
Two distinct mechanisms put color and emphasis on screen in this project, and they
appear side by side in the same functions. One is declarative: Table.add_column(
..., style="cyan") and Panel(..., border_style="green"), keyword arguments
that style a whole column or a whole border without any markup syntax in the text
itself. The other is inline: literal [tag]...[/tag] markup embedded directly in
the string passed to console.print() or built into a Panel’s body, for
example f"[bold red]{e}[/bold red]" in deploy.py’s failure panel, or
f"[green]Results saved to {output_file}[/green]" in analyze.py. Every module
in the CLI uses the inline form somewhere. Console’s default markup=True
behavior parses those brackets out of a string and turns them into styled spans
before anything reaches the terminal, which matters for the JSON output finding
below.
Learn more resources#
Official documentation#
Introduction: Rich’s own overview of what it renders and how to install it.
Console API: the
Consoleobject every CLI module in this project instantiates, including the documented automatic terminal detection,force_terminal, andis_terminalbehavior referenced in The idea underneath and Sharp edges and limits.Console Markup: the
[tag]...[/tag]syntax used throughout this project’sconsole.printandPanelcalls, including the escaping guidance behind the JSON output finding above.Tables: the
Table/add_column/add_rowAPI behind every table this project builds.Panel: the
Panelclass and itstitle/border_styleoptions.Progress display:
Progress,SpinnerColumn,TextColumn, and indeterminate-task behavior, behindanalyze.py’s spinner.Live Display: the
Liveclass behinddeploy.py’s phase table.Prompt:
Prompt.askandConfirm.ask, as used inteam.py.
Tutorials and blogs#
Algorithms for high performance terminal apps (Textualize): the source for this chapter’s description of Segments and styles being converted to ANSI escape codes only at the very end of rendering, the mechanism The idea underneath describes in general terms.
ANSI escape code (Wikipedia): background on the escape sequences themselves, for a reader who wants the mechanism Rich abstracts away, independent of Rich.
Videos#
Get rich quick with Python (package by Will McGugan) (Rodrigo): a focused walkthrough of the Rich package itself, verified via YouTube’s oEmbed API.
Terminal magic with Rich and Textual - Talk Python to Me Ep.336 (Talk Python): a longer conversation with Rich’s creator, Will McGugan, about its design goals, verified via YouTube’s oEmbed API.