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.

Sharp edges and limits#

The presentation layer is barely tested. tests/test_cli.py runs every subcommand’s main() in-process and does assert on some literal text captured through capsys: TestAnalyze.test_summary_output checks for "Tables: 2" and "Entity types:"; TestDeploy.test_constraint_check_summary_printed checks for "Constraint check:" and "checks passed"; test_summary_no_emoji and test_no_emoji_in_console_output check that specific emoji characters never appear. That is real coverage of specific strings, but nothing in the test suite asserts on a Table’s column count, a Panel’s title or border color, whether Progress ever actually starts or removes its task, or how many times Live.update() fires. The two claims this chapter makes with confidence, that “Discovered Tables” has four columns and that deploy.py’s Live table only updates twice, are true because the source code says so, not because a test would fail if either changed.

TTY detection is entirely automatic and entirely unverified by this project’s own tests. None of the five CLI files call isatty(), set force_terminal, read NO_COLOR, or pass markup=False anywhere; every Console() is constructed with its defaults, so whether ANSI codes get emitted at all is delegated completely to Rich’s own detection (see The idea underneath). That works, and pytest’s capsys fixture is itself a non-terminal file object, so the whole test suite already runs the CLI through the “not a terminal” branch of that detection on every run. Nothing in test_cli.py, though, exercises the actual terminal branch, or checks what a redirected tdw deploy > deploy.log looks like once Live’s cursor-control codes are stripped.

JSON output is not escaped against Rich’s own markup, despite being documented as machine-readable. docs/source/reference/cli.rst describes the --format choice for analyze as: “json is machine-readable.” Two call sites produce that output. analyze.py’s display_json_format, when no --output file is given, calls console.print(json.dumps(result, indent=2)); validate.py’s --format json branch, which has no file-output option at all, always calls console.print(json.dumps(output, indent=2)). Neither call passes markup=False, and neither string is passed through rich.markup.escape(). Rich’s own markup documentation warns that constructing markup dynamically without escaping “may be possible to inject tags where you don’t want them,” and that bracketed text matching its tag syntax is parsed out of the printed output rather than shown literally. Schema-derived string values, a table comment, a business rule description, a column name, that happen to contain a bracket pair could therefore be silently altered before they reach whatever is consuming this supposedly machine-readable output. test_json_output_to_file only exercises the safe, file-based path for analyze (plain json.dump to a file handle, no console involved). TestValidate.test_dangerous_pattern_flagged does call validate --format json, exercising the vulnerable console.print branch, but it only asserts exit_code == 1; it never captures or inspects the printed text, and no fixture anywhere in the test suite contains a bracket pair in a string value, so nothing would currently catch this if it happened.

Color choices lean on the terminal’s own named palette, which is not a complete answer to contrast. Every style used across the five files (cyan, magenta, green, yellow, red, blue, bold) is one of Rich’s standard named colors, which map to whatever the sixteen ANSI colors mean in the user’s own terminal theme, not to a fixed hex value the way a web palette would. That makes the output more theme-adaptive than a fixed-color GUI, but it is not a guarantee of legibility: yellow specifically carries meaningful content, the “Estimated Rows” column in analyze.py, the "running" status in deploy.py’s phase table, and several "[yellow]...[/yellow]" warning lines, and plain yellow text is a well-known low-contrast combination against a light or white terminal background. Nothing in this codebase adapts to the terminal’s actual background color; each piece of content just picks a fixed color name.

Panel border color is not always paired with a text label, and in one case it is the only signal. deploy.py’s three border_style panels ("green", "yellow", "red") each pair that color with a title and body that separately state the outcome in words (“Deployment complete” with “within” or “over” spelled out, “Deployment failed”, “Failure”), so the border color there reinforces text that already carries the meaning. demo.py’s closing panel is different: its border_style="green" if success else "yellow" is the only thing that changes between a successful and a failed tdw demo run. The panel’s title is the fixed string "Quickstart complete" regardless of outcome, and its body (the database path and generated-artifacts path) never mentions success or failure. A reader who cannot distinguish green from yellow, or who is looking at a plain-text capture where color did not survive, cannot tell from that panel alone whether the run succeeded; only the process’s exit code (return 0 if success else 1) reliably carries that information.

No non-interactive path for the default ``tdw team`` flow. Prompt.ask and Confirm.ask read from real stdin and block waiting for a line. setup_team_development has no flag to skip the interactive loop. Running it from a script or a non-interactive CI step, without separately piping matching input, will hang or fail; --member/--skill and --git-hooks are the only scriptable entry points into team.py.

The dispatcher’s own cancellation message bypasses Rich entirely. cli/main.py does not import Rich at all. On KeyboardInterrupt, main() calls the bare builtin print("\nCancelled by user.", file=sys.stderr) rather than going through any module’s console. Every other message a user sees from this CLI goes through Rich; the one that fires when they press Ctrl-C does not.

Learn more resources#

Official documentation#

  • Introduction: Rich’s own overview of what it renders and how to install it.

  • Console API: the Console object every CLI module in this project instantiates, including the documented automatic terminal detection, force_terminal, and is_terminal behavior referenced in The idea underneath and Sharp edges and limits.

  • Console Markup: the [tag]...[/tag] syntax used throughout this project’s console.print and Panel calls, including the escaping guidance behind the JSON output finding above.

  • Tables: the Table/add_column/add_row API behind every table this project builds.

  • Panel: the Panel class and its title/border_style options.

  • Progress display: Progress, SpinnerColumn, TextColumn, and indeterminate-task behavior, behind analyze.py’s spinner.

  • Live Display: the Live class behind deploy.py’s phase table.

  • Prompt: Prompt.ask and Confirm.ask, as used in team.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#