Use add_column() with explicit width, max_width, and overflow constraints and delegate CRS parsing to pyproj.CRS.from_user_input(). Without those column constraints a Rich table collapses or overflows when EPSG names, WKT strings, or four-decimal bounding-box values hit a narrow CI terminal. This task is a focused extension of the broader Rich Console Output & Progress Bars pattern, which sits within the CLI Architecture & Design Patterns guide.
Prerequisites
pip install "rich>=13.0.0" "pyproj>=3.4"
Python 3.9 or later is required for the list[str] type hint syntax used below. No other geospatial stack dependencies are needed for the table renderer itself; pyproj alone handles CRS resolution.
Architecture: How the Renderer Fits Together
The diagram below shows how input strings flow through pyproj, get formatted into row data, and are consumed by the Rich table renderer before reaching the terminal.
Complete Working Implementation
The function below is self-contained: copy it into any CLI module, pass a list of CRS strings (EPSG codes, PROJ strings, or WKT), and print the returned Table object.
import sys
from typing import Optional
from rich.console import Console
from rich.table import Table
from pyproj import CRS, Transformer
from pyproj.exceptions import CRSError
def build_crs_table(
crs_inputs: list[str],
console: Optional[Console] = None,
) -> Table:
"""Return a Rich Table summarising each CRS in crs_inputs."""
console = console or Console()
table = Table(
title="Coordinate System Registry",
caption="Batch validation | PROJ >= 8.0",
show_header=True,
header_style="bold cyan",
border_style="dim",
padding=(0, 2),
show_lines=True,
)
# --- Column definitions -------------------------------------------------
# width/max_width prevents expansion; overflow controls truncation style.
table.add_column("ID", justify="right", style="dim", width=4)
table.add_column("EPSG", justify="center", style="bold yellow", width=8)
table.add_column("Name", style="white", overflow="fold", max_width=28)
table.add_column("Type", justify="center", width=14)
table.add_column("Bounds (W,S,E,N)", style="blue", overflow="ellipsis", max_width=32)
table.add_column("Valid", justify="center", width=7)
for idx, raw in enumerate(crs_inputs, start=1):
try:
crs = CRS.from_user_input(raw) # 1
epsg = str(crs.to_epsg() or "CUSTOM") # 2
bounds_str = "N/A"
if crs.area_of_use and crs.area_of_use.bounds:
w, s, e, n = crs.area_of_use.bounds
bounds_str = f"{w:.2f}, {s:.2f}, {e:.2f}, {n:.2f}"
crs_type = crs.type_name # 3
status = "[green]YES[/]" if crs.is_valid else "[red]NO[/]"
# Conditional markup: geographic CRS gets cyan, projected gets yellow
if "Geographic" in crs_type:
type_markup = f"[cyan]{crs_type}[/]"
elif "Projected" in crs_type:
type_markup = f"[yellow]{crs_type}[/]"
else:
type_markup = crs_type
table.add_row(str(idx), epsg, crs.name, type_markup, bounds_str, status)
except CRSError:
truncated = raw[:24] + ("…" if len(raw) > 24 else "")
table.add_row(str(idx), "—", truncated, "[red]ERROR[/]", "—", "[red]NO[/]")
return table
if __name__ == "__main__":
samples = ["EPSG:4326", "EPSG:3857", "EPSG:32633", "EPSG:27700", "INVALID"]
con = Console()
con.print(build_crs_table(samples, con))
Step Annotations
-
CRS.from_user_input(raw)accepts EPSG authority strings ("EPSG:4326"), PROJ4 strings, WKT, and integer authority codes interchangeably. Passing raw user input directly through this method avoids manual string splitting and handles deprecated EPSG codes by resolving them to their canonical replacement. -
crs.to_epsg() or "CUSTOM"returnsNonefor CRS objects that have no registered EPSG mapping (compound CRS, custom projections). Theor "CUSTOM"guard prevents aNonevalue from breaking the column layout. If your pipeline uses authority codes beyond EPSG, replace"CUSTOM"with a call tocrs.to_authority()which returns(authority, code)for any registered database entry. -
crs.type_namereturns a human-readable string such as"Geographic 2D CRS"or"Projected CRS". The conditional markup block applies consistent colour coding: cyan for geographic, yellow for projected, plain text for compound or vertical CRS types. This visual distinction matters when validating mixed input batches where a single projected-CRS slip can corrupt a downstream transformation. -
overflow="fold"on the Name column wraps long CRS names (e.g."WGS 84 / UTM zone 33N") across multiple lines within the cell rather than truncating them. Preserve this for names because truncation silently hides disambiguation information between similarly-named projections. Useoverflow="ellipsis"only on the Bounds column where exact decimal precision is not needed at a glance. -
width=vsmax_width=serve different purposes.widthfixes the column at an exact character count; Rich will not expand or shrink it.max_widthsets a ceiling but lets the column compress below it when the terminal is narrow. Usewidthfor short fixed-format columns (ID, EPSG, Valid) andmax_widthfor variable-length prose columns.
Precomputing Coordinate Transformations Before Rendering
When the table needs to include transformed coordinates alongside metadata, precompute with pyproj.Transformer before building the table. Never run heavy math inside the add_row loop: Rich renders the table synchronously and any blocking computation delays the entire frame.
from pyproj import Transformer
# Precompute: transform lon/lat pairs to Web Mercator before rendering
transformer = Transformer.from_crs("EPSG:4326", "EPSG:3857", always_xy=True)
lon_lat_pairs = [(-122.4194, 37.7749), (-74.0060, 40.7128), (2.3522, 48.8566)]
# Build formatted strings outside the table loop
easting_northing = [
f"{x:,.0f}, {y:,.0f}"
for x, y in (transformer.transform(lon, lat) for lon, lat in lon_lat_pairs)
]
# easting_northing is now a plain list[str]; pass elements to add_row() directly
always_xy=True enforces longitude-first, latitude-second axis order regardless of what the source CRS declares. Without it, EPSG:4326 gives latitude-first results that silently corrupt displayed coordinates.
Named Gotcha: None Return from to_epsg() Breaks Column Alignment
CRS.to_epsg() returns None — not "None" — for any CRS without a registered EPSG code. Passing None directly to table.add_row() raises TypeError inside Rich’s markup renderer. The fix shown above uses str(crs.to_epsg() or "CUSTOM"), converting the value to a string before it reaches the table. If you later sort or filter rows by EPSG code, keep the None check in your business logic separately so the rendering path always receives a str.
Verification Snippet
After running the script, confirm the table structure matches expectations with a Console(record=True) capture:
from rich.console import Console
from rich.table import Table # noqa: F401 — imported via build_crs_table
con = Console(record=True, force_terminal=True, width=120)
tbl = build_crs_table(["EPSG:4326", "EPSG:3857", "INVALID"], con)
con.print(tbl)
captured = con.export_text()
# Structural assertions
assert "EPSG:4326" in captured or "4326" in captured, "WGS 84 row missing"
assert "3857" in captured, "Web Mercator row missing"
assert "ERROR" in captured, "Invalid CRS row missing"
row_count = sum(1 for line in captured.splitlines() if "│" in line and "EPSG" not in line)
assert row_count == 3, f"Expected 3 data rows, got {row_count}"
print("Verification passed.")
Run it with python -c "exec(open('verify_table.py').read())" or include it in your test suite. The force_terminal=True flag ensures Rich renders full ANSI output even when the process has no attached TTY, which is the normal state in CI runners. The export_text() call strips ANSI codes, leaving plain text that string assertions can operate on reliably.
Why does Rich ignore my max_width setting?
Rich only respects max_width if the column also has no_wrap=False (the default). If you explicitly set no_wrap=True, Rich ignores max_width and renders the full string on a single line, which can overflow. Remove no_wrap=True and rely on overflow="ellipsis" or overflow="fold" instead.
How do I render the table to a plain-text log file without ANSI codes?
Instantiate Console(record=True, force_terminal=True), print the table, then call console.export_text(clear=True). Write the returned string to your log file. The clear=True argument resets the internal buffer so subsequent calls to export_text() do not re-include earlier output.
Can I add a footer row with totals or summary counts?
Rich’s Table does not have a built-in footer row concept. The closest approach is to call table.add_section() before the last row to draw a horizontal separator, then add a final row with summary cells formatted in a distinct style (e.g. "[bold]3 valid / 1 error[/]"). Alternatively, use the caption parameter on the table constructor for a plain-text footer below the border.
Related
- Rich Console Output & Progress Bars — the parent guide covering
Consoleinitialization, progress bar integration, and theme configuration for geospatial batch workflows. - CLI Architecture & Design Patterns — the top-level reference for structuring Python GIS command-line tools with subcommands, config layering, and structured logging.
- Argument Parsing with Typer — covers type-safe CLI option definitions that feed directly into the CRS input validation pattern shown above.