Goal
Make plain and Rich tables, plus document-to-record rendering, enforce the same no-loss contract.
Background
render_records() documents deterministic terminal_width and max_cell_width bounds. The plain path calculates and truncates to those widths, but the Rich path receives the original untruncated rows and never receives max_cell_width:
|
def _write_table( |
|
stream: TextIO, |
|
records: Sequence[Mapping[str, Any]], |
|
columns: Sequence[tuple[str, str]], |
|
footer: str | None, |
|
minimum_widths: Sequence[int] | None, |
|
*, |
|
terminal_width: int | None, |
|
max_cell_width: int | None, |
|
rich: bool, |
|
) -> None: |
|
selected_minimums = minimum_widths or () |
|
if len(selected_minimums) > len(columns): |
|
raise ValueError("minimum_widths cannot contain more entries than columns") |
|
|
|
if not records: |
|
if footer: |
|
stream.write(f"{footer}\n") |
|
return |
|
|
|
if not columns: |
|
if footer: |
|
stream.write(f"{footer}\n") |
|
return |
|
|
|
table_rows = [[_table_cell(_cell_value(record.get(key))) for _header, key in columns] for record in records] |
|
headers = [_table_cell(header) for header, _key in columns] |
|
widths = [ |
|
max(_display_width(header), selected_minimums[index] if index < len(selected_minimums) else 0) |
|
for index, header in enumerate(headers) |
|
] |
|
for row in table_rows: |
|
widths = [max(width, _display_width(value)) for width, value in zip(widths, row, strict=False)] |
|
|
|
if max_cell_width is not None: |
|
if max_cell_width < 1: |
|
raise ValueError("max_cell_width must be greater than 0 when set") |
|
widths = [min(width, max_cell_width) for width in widths] |
|
|
|
if terminal_width is not None and terminal_width < 1: |
|
raise ValueError("terminal_width must be greater than 0 when set") |
|
available_width = terminal_width if terminal_width is not None else _terminal_width(stream) |
|
widths = _fit_table_width(widths, available_width) |
|
|
|
if rich and try_render_rich_table( |
|
stream, |
|
headers, |
|
table_rows, |
|
footer, |
|
terminal_width=terminal_width, |
|
): |
|
return |
.
render_document() also silently removes non-mapping elements from a selected list and silently falls back to rendering the entire document when records_key is not a list:
|
def render_document( |
|
document: Mapping[str, Any], |
|
*, |
|
requested_format: str | None, |
|
records_key: str | None = None, |
|
columns: Sequence[tuple[str, str]] | None = None, |
|
stream: TextIO | None = None, |
|
) -> str: |
|
"""Render a structured report or leave terminal text to its existing renderer. |
|
|
|
Structured formats preserve the complete document. Delimited output uses |
|
the selected record list (or the document itself) and never emits report |
|
prose, headers, or footers. A terminal ``text`` request returns ``text`` |
|
without writing so the caller can keep its established human report. |
|
""" |
|
|
|
target = stream if stream is not None else sys.stdout |
|
resolved = resolve_output_format(requested_format, stream=target) |
|
if resolved == "text": |
|
return resolved |
|
if resolved == "json": |
|
target.write(json.dumps(dict(document), indent=2)) |
|
target.write("\n") |
|
return resolved |
|
if resolved == "yaml": |
|
try: |
|
yaml = require_yaml("PyYAML is required for YAML output.") |
|
except RuntimeError as exc: |
|
raise OutputFormatError(str(exc)) from exc |
|
target.write(yaml.safe_dump(dict(document), sort_keys=False, allow_unicode=True)) |
|
return resolved |
|
|
|
if records_key: |
|
candidate = document.get(records_key) |
|
if isinstance(candidate, list): |
|
records = [record for record in candidate if isinstance(record, Mapping)] |
|
else: |
|
records = [document] |
|
else: |
|
records = [document] |
|
selected_columns = columns or _document_columns(records) |
|
render_records( |
. This is a remaining data-loss/fallback variant after
#146.
Scope
- Centralize validated row selection and cell bounding before renderer dispatch.
- Reject malformed selected record collections with actionable errors.
- Keep valid JSON/YAML document preservation and delimited streaming behavior.
Acceptance Criteria
- Rich and plain tables honor identical cell and terminal width limits.
- Every selected list element is rendered or the call fails; no element is silently dropped.
- Missing keys, non-list values, and heterogeneous records have explicit documented behavior.
- Optional Rich failure still falls back deterministically.
- Tests cover long Unicode/control-bearing cells, empty data, invalid records, TTY/non-TTY, and Rich installed/uninstalled.
Validation
Run output, optional-integration, security, and large-generator tests.
Non-Goals
Do not require Rich or change valid JSON/YAML shapes.
Project Fields
- Status: Backlog
- Priority: P1
- Area: CLI
- Initiative: v1.0 Readiness
- Size: M
Ownership
Goal
Make plain and Rich tables, plus document-to-record rendering, enforce the same no-loss contract.
Background
render_records()documents deterministicterminal_widthandmax_cell_widthbounds. The plain path calculates and truncates to those widths, but the Rich path receives the original untruncated rows and never receivesmax_cell_width:base-cli/lib/python/base_cli/output.py
Lines 261 to 312 in 8a93d22
render_document()also silently removes non-mapping elements from a selected list and silently falls back to rendering the entire document whenrecords_keyis not a list:base-cli/lib/python/base_cli/output.py
Lines 177 to 218 in 8a93d22
Scope
Acceptance Criteria
Validation
Run output, optional-integration, security, and large-generator tests.
Non-Goals
Do not require Rich or change valid JSON/YAML shapes.
Project Fields
Ownership