From 36ff82ad4405d32e21b122c23e7bf198ed0757a9 Mon Sep 17 00:00:00 2001 From: Stephen Rosen Date: Tue, 30 Dec 2025 14:20:20 -0600 Subject: [PATCH 01/12] Add an initial FoldedTablePrinter This is an alternative output mode which, when selected, prints tabular data with row separators. Based on terminal width detection, the output can be "folded" to stack up adjacent elements into columns, making rows more horizontally compact. The table folding first tries by halves, then thirds, and finally resorts to folding by N where N is the number of columns. --- src/globus_cli/commands/endpoint/search.py | 1 + src/globus_cli/commands/group/list.py | 1 + src/globus_cli/termio/_display.py | 7 +- src/globus_cli/termio/printers/__init__.py | 2 + .../termio/printers/folded_table_printer.py | 213 ++++++++++++++++++ 5 files changed, 223 insertions(+), 1 deletion(-) create mode 100644 src/globus_cli/termio/printers/folded_table_printer.py diff --git a/src/globus_cli/commands/endpoint/search.py b/src/globus_cli/commands/endpoint/search.py index b0c2d27e3..a8e1ff275 100644 --- a/src/globus_cli/commands/endpoint/search.py +++ b/src/globus_cli/commands/endpoint/search.py @@ -158,6 +158,7 @@ def endpoint_search( search_iterator, fields=ENDPOINT_LIST_FIELDS, json_converter=iterable_response_to_dict, + text_mode=display.FOLDED_TABLE, ) if search_iterator.has_next(): diff --git a/src/globus_cli/commands/group/list.py b/src/globus_cli/commands/group/list.py index 73ff5a3cc..2d0ecd576 100644 --- a/src/globus_cli/commands/group/list.py +++ b/src/globus_cli/commands/group/list.py @@ -22,4 +22,5 @@ def group_list(login_manager: LoginManager) -> None: SESSION_ENFORCEMENT_FIELD, Field("Roles", "my_memberships[].role", formatter=formatters.SortedArray), ], + text_mode=display.FOLDED_TABLE, ) diff --git a/src/globus_cli/termio/_display.py b/src/globus_cli/termio/_display.py index 66131ca11..35d72ff08 100644 --- a/src/globus_cli/termio/_display.py +++ b/src/globus_cli/termio/_display.py @@ -10,6 +10,7 @@ from .field import Field from .printers import ( CustomPrinter, + FoldedTablePrinter, JsonPrinter, Printer, RecordListPrinter, @@ -24,6 +25,7 @@ class TextMode(enum.Enum): silent = enum.auto() json = enum.auto() text_table = enum.auto() + text_folded_table = enum.auto() text_record = enum.auto() text_record_list = enum.auto() text_raw = enum.auto() @@ -40,6 +42,7 @@ class Renderer: """ TABLE = TextMode.text_table + FOLDED_TABLE = TextMode.text_folded_table SILENT = TextMode.silent JSON = TextMode.json RECORD = TextMode.text_record @@ -165,7 +168,7 @@ def _resolve_printer( if not isinstance(text_mode, TextMode): return CustomPrinter(custom_print=text_mode) - if text_mode in (self.TABLE, self.RECORD, self.RECORD_LIST): + if text_mode in (self.FOLDED_TABLE, self.TABLE, self.RECORD, self.RECORD_LIST): fields = _assert_fields(fields) if text_mode == self.RECORD: return RecordPrinter(fields) @@ -173,6 +176,8 @@ def _resolve_printer( _assert_iterable(data) if text_mode == self.TABLE: return TablePrinter(fields) + if text_mode == self.FOLDED_TABLE: + return FoldedTablePrinter(fields) if text_mode == self.RECORD_LIST: return RecordListPrinter(fields) diff --git a/src/globus_cli/termio/printers/__init__.py b/src/globus_cli/termio/printers/__init__.py index cad92e994..87e55a7d1 100644 --- a/src/globus_cli/termio/printers/__init__.py +++ b/src/globus_cli/termio/printers/__init__.py @@ -1,5 +1,6 @@ from .base import Printer from .custom_printer import CustomPrinter +from .folded_table_printer import FoldedTablePrinter from .json_printer import JsonPrinter from .record_printer import RecordListPrinter, RecordPrinter from .table_printer import TablePrinter @@ -11,6 +12,7 @@ "JsonPrinter", "UnixPrinter", "TablePrinter", + "FoldedTablePrinter", "RecordPrinter", "RecordListPrinter", ) diff --git a/src/globus_cli/termio/printers/folded_table_printer.py b/src/globus_cli/termio/printers/folded_table_printer.py new file mode 100644 index 000000000..01d8e49d6 --- /dev/null +++ b/src/globus_cli/termio/printers/folded_table_printer.py @@ -0,0 +1,213 @@ +from __future__ import annotations + +import collections +import functools +import shutil +import typing as t + +import click + +from ..field import Field +from .base import Printer + + +class FoldedTablePrinter(Printer[t.Iterable[t.Any]]): + """ + A printer to render an iterable of objects holding tabular data with cells folded + together and stacked in the format: + + +--------------------------------------------------+ + | | | | + | | | | + +==================================================+ + | | | | + | | | | + +--------------------------------------------------+ + | | | | + | | | | + +--------------------------------------------------+ + + Rows are folded and stacked only if they won't fit in the output width. + + :param fields: a list of Fields with load and render instructions; one per column. + """ + + def __init__(self, fields: t.Iterable[Field]) -> None: + self._fields = tuple(fields) + self._width = _get_terminal_content_width() + + def echo(self, data: t.Iterable[t.Any], stream: t.IO[str] | None = None) -> None: + """ + Print out a rendered table. + + :param data: an iterable of data objects. + :param stream: an optional IO stream to write to. Defaults to stdout. + """ + echo = functools.partial(click.echo, file=stream) + + table = RowTable.from_data(self._fields, data) + + # ----- Main Table Folding Rules ----- + # if the table is too wide + if not table.fits_in_width(self._width): + # try folding the table in half, and see if that fits + folded_table = table.fold_rows(2) + if folded_table.fits_in_width(self._width): + table = folded_table + # if it's still too wide, fold in thirds and check that + else: + folded_table = table.fold_rows(3) + if folded_table.fits_in_width(self._width): + table = folded_table + # if folded by thirds does not fit, fold all the way to a single column + else: + table = table.fold_rows(table.num_columns) + + col_widths = table.calculate_column_widths() + + echo(_separator_line(col_widths)) + echo(table.header_row.serialize(col_widths)) + echo(_separator_line(col_widths, heavy=True)) + + for row in table.cells[1:]: + echo(row.serialize(col_widths)) + echo(_separator_line(col_widths)) + + +@functools.cache +def _separator_line(col_widths: tuple[int, ...], heavy: bool = False) -> str: + fill = "-" + if heavy: + fill = "=" + + # .--- 3 spaces between columns + # .--- total rendered width | .--- one space at each + # v v v end + fill_length = sum(col_widths) + 3 * (len(col_widths) - 1) + 2 + return "+" + (fill_length * fill) + "+" + + +class RowTable: + """ + A data structure to hold tabular data which has not yet been laid out. + + This class only models data cells; other table elements like headers are not + persisted and must be handled separately. + + :param cells: a list of rows with table's cell data. + :raises ValueError: if any rows have different numbers of columns. + """ + + def __init__(self, cells: tuple[Row, ...]) -> None: + self.cells = cells + + for row in cells: + if len(row) != len(cells[0]): + raise ValueError("All rows must have the same number of columns.") + + self.num_columns = cells[0].num_cols + self.num_rows = len(cells) + + @property + def header_row(self) -> Row: + return self.cells[0] + + def fits_in_width(self, width: int) -> bool: + return all(x.min_rendered_width <= width for x in self.cells) + + def fold_rows(self, n: int) -> RowTable: + """Produce a new table with folded rows.""" + return RowTable(tuple(cell.fold(n) for cell in self.cells)) + + def calculate_column_widths(self) -> tuple[int, ...]: + return tuple( + max( + 0, *(self.cells[row].column_widths[col] for row in range(self.num_rows)) + ) + for col in range(self.cells[0].num_cols) + ) + + @classmethod + def from_data(cls, fields: tuple[Field, ...], data: t.Iterable[t.Any]) -> RowTable: + """ + Create a RowTable from a list of fields and iterable of data objects. + + The data objects are serialized and discarded upon creation. + """ + rows = [] + # insert the header row + rows.append(Row((tuple(f.name for f in fields),))) + for data_obj in data: + rows.append(Row.from_source_data(fields, data_obj)) + + return cls(tuple(rows)) + + +class Row: + """A semantic row in the table of output, with a gridded internal layout.""" + + def __init__(self, grid: tuple[tuple[str, ...], ...]) -> None: + self.grid: tuple[tuple[str, ...], ...] = grid + + def __len__(self) -> int: + return sum(len(subrow) for subrow in self.grid) + + def __getitem__(self, coords: tuple[int, int]) -> str: + subrow, col = coords + return self.grid[subrow][col] + + @classmethod + def from_source_data(cls, fields: tuple[Field, ...], source: t.Any) -> Row: + return cls((tuple(field.serialize(source) for field in fields),)) + + def fold(self, n: int) -> Row: + """Fold the internal grid by N, stacking elements. Produces a new Row.""" + if len(self.grid) > 1: + raise ValueError("Rows can only be folded once. Use the original row.") + return Row(tuple(self._split_level(self.grid[0], n))) + + def _split_level( + self, level: tuple[str, ...], modulus: int + ) -> t.Iterator[tuple[str, ...], ...]: + bins = collections.defaultdict(list) + for i, x in enumerate(level): + bins[i % modulus].append(x) + + for i in range(modulus): + yield tuple(bins[i]) + + @functools.cached_property + def min_rendered_width(self) -> int: + return sum(self.column_widths) + (2 * self.num_cols) + 2 + + @functools.cached_property + def num_cols(self) -> int: + return max(0, *(len(subrow) for subrow in self.grid)) + + @functools.cached_property + def column_widths(self) -> tuple[int, ...]: + """The width of all columns in the row (as measured in this row).""" + return tuple(self._calculate_col_width(i) for i in range(self.num_cols)) + + def _calculate_col_width(self, idx: int) -> int: + return max( + 0, *(len(subrow[idx]) if idx < len(subrow) else 0 for subrow in self.grid) + ) + + def serialize(self, use_col_widths: tuple[int, ...]) -> str: + lines: list[str] = [] + for subrow in self.grid: + new_line: list[str] = [] + for idx, width in enumerate(use_col_widths): + new_line.append((subrow[idx] if idx < len(subrow) else "").ljust(width)) + lines.append("| " + " | ".join(new_line) + " |\n") + return "".join(lines).rstrip("\n") + + +def _get_terminal_content_width() -> int: + """Get a content width for text output based on the terminal size. + + Uses the 90% of terminal width, if it can be detected. + """ + cols = shutil.get_terminal_size(fallback=(80, 20)).columns + return cols if cols < 88 else int(0.9 * cols) From 083b1e5dd9c6a0732ddd85fb112cda9d1953883a Mon Sep 17 00:00:00 2001 From: Stephen Rosen Date: Tue, 30 Dec 2025 17:02:36 -0600 Subject: [PATCH 02/12] Refine output style in folded table printer Also make the styling fallback to a line-oriented style when output is not an interactive terminal. --- .../termio/printers/folded_table_printer.py | 186 ++++++++++++------ 1 file changed, 123 insertions(+), 63 deletions(-) diff --git a/src/globus_cli/termio/printers/folded_table_printer.py b/src/globus_cli/termio/printers/folded_table_printer.py index 01d8e49d6..b14681280 100644 --- a/src/globus_cli/termio/printers/folded_table_printer.py +++ b/src/globus_cli/termio/printers/folded_table_printer.py @@ -1,31 +1,41 @@ from __future__ import annotations import collections +import enum import functools import shutil import typing as t import click +from ..context import out_is_terminal, term_is_interactive from ..field import Field from .base import Printer +class OutputStyle(enum.Flag): + none = enum.auto() + decorated = enum.auto() + heavy = enum.auto() + top = enum.auto() + bottom = enum.auto() + + class FoldedTablePrinter(Printer[t.Iterable[t.Any]]): """ A printer to render an iterable of objects holding tabular data with cells folded together and stacked in the format: - +--------------------------------------------------+ + .--------------------------------------------------. | | | | | | | | - +==================================================+ + +================+================+================+ | | | | | | | | - +--------------------------------------------------+ + +----------------+----------------+----------------+ | | | | | | | | - +--------------------------------------------------+ + '----------------+----------------+----------------' Rows are folded and stacked only if they won't fit in the output width. @@ -45,86 +55,114 @@ def echo(self, data: t.Iterable[t.Any], stream: t.IO[str] | None = None) -> None """ echo = functools.partial(click.echo, file=stream) - table = RowTable.from_data(self._fields, data) - - # ----- Main Table Folding Rules ----- - # if the table is too wide - if not table.fits_in_width(self._width): - # try folding the table in half, and see if that fits - folded_table = table.fold_rows(2) - if folded_table.fits_in_width(self._width): - table = folded_table - # if it's still too wide, fold in thirds and check that - else: - folded_table = table.fold_rows(3) - if folded_table.fits_in_width(self._width): - table = folded_table - # if folded by thirds does not fit, fold all the way to a single column - else: - table = table.fold_rows(table.num_columns) - + table = self._fold_table(RowTable.from_data(self._fields, data)) col_widths = table.calculate_column_widths() - echo(_separator_line(col_widths)) - echo(table.header_row.serialize(col_widths)) - echo(_separator_line(col_widths, heavy=True)) + # if folded, print a leading separator line + table_style = OutputStyle.decorated if table.folded else OutputStyle.none + if table.folded: + echo(_separator_line(col_widths, style=table_style | OutputStyle.top)) + # print the header row and a separator (heavy if folded) + echo(table.header_row.serialize(col_widths, style=table_style)) + echo( + _separator_line( + col_widths, + style=( + table_style + | (OutputStyle.heavy if table.folded else OutputStyle.none) + ), + ) + ) - for row in table.cells[1:]: - echo(row.serialize(col_widths)) - echo(_separator_line(col_widths)) + for row in table.rows[1:-1]: + echo(row.serialize(col_widths, style=table_style)) + if table.folded: + echo(_separator_line(col_widths, style=table_style)) + echo(table.rows[-1].serialize(col_widths, style=table_style)) + if table.folded: + echo(_separator_line(col_widths, style=table_style | OutputStyle.bottom)) + + def _fold_table(self, table: RowTable) -> RowTable: + if not _detect_folding_enabled(): + return table + + # if the table is initially narrow enough to fit, do not fold + if table.fits_in_width(self._width): + return table + + # try folding the table in half, and see if that fits + folded_table = table.fold_rows(2) + if folded_table.fits_in_width(self._width): + return folded_table + # if it's still too wide, fold in thirds and check that + else: + folded_table = table.fold_rows(3) + if folded_table.fits_in_width(self._width): + return folded_table + # if folded by thirds does not fit, fold all the way to a single column + else: + return table.fold_rows(table.num_columns) @functools.cache -def _separator_line(col_widths: tuple[int, ...], heavy: bool = False) -> str: - fill = "-" - if heavy: - fill = "=" - - # .--- 3 spaces between columns - # .--- total rendered width | .--- one space at each - # v v v end - fill_length = sum(col_widths) + 3 * (len(col_widths) - 1) + 2 - return "+" + (fill_length * fill) + "+" +def _separator_line( + col_widths: tuple[int, ...], style: OutputStyle = OutputStyle.none +) -> str: + fill = "=" if style & OutputStyle.heavy else "-" + + if style & OutputStyle.decorated: + decorator = "+" + if style & OutputStyle.top: + decorator = "." + elif style & OutputStyle.bottom: + decorator = "'" + + leader = f"{decorator}{fill}" + trailer = f"{fill}{decorator}" + else: + leader = "" + trailer = "" + + line_parts = [leader] + for col in col_widths[:-1]: + line_parts.append(col * fill) + line_parts.append(f"{fill}+{fill}") + line_parts.append(col_widths[-1] * fill) + line_parts.append(trailer) + return "".join(line_parts) class RowTable: """ A data structure to hold tabular data which has not yet been laid out. - This class only models data cells; other table elements like headers are not - persisted and must be handled separately. - - :param cells: a list of rows with table's cell data. + :param rows: a list of rows with table's contents, including the header row + :param folded: whether or not the table has been folded at all :raises ValueError: if any rows have different numbers of columns. """ - def __init__(self, cells: tuple[Row, ...]) -> None: - self.cells = cells - - for row in cells: - if len(row) != len(cells[0]): - raise ValueError("All rows must have the same number of columns.") + def __init__(self, rows: tuple[Row, ...], folded: bool = False) -> None: + self.rows = rows + self.folded = folded - self.num_columns = cells[0].num_cols - self.num_rows = len(cells) + self.num_columns = rows[0].num_cols + self.num_rows = len(rows) @property def header_row(self) -> Row: - return self.cells[0] + return self.rows[0] def fits_in_width(self, width: int) -> bool: - return all(x.min_rendered_width <= width for x in self.cells) + return all(x.min_rendered_width <= width for x in self.rows) def fold_rows(self, n: int) -> RowTable: """Produce a new table with folded rows.""" - return RowTable(tuple(cell.fold(n) for cell in self.cells)) + return RowTable(tuple(cell.fold(n) for cell in self.rows), folded=True) def calculate_column_widths(self) -> tuple[int, ...]: return tuple( - max( - 0, *(self.cells[row].column_widths[col] for row in range(self.num_rows)) - ) - for col in range(self.cells[0].num_cols) + max(0, *(self.rows[row].column_widths[col] for row in range(self.num_rows))) + for col in range(self.rows[0].num_cols) ) @classmethod @@ -162,8 +200,10 @@ def from_source_data(cls, fields: tuple[Field, ...], source: t.Any) -> Row: def fold(self, n: int) -> Row: """Fold the internal grid by N, stacking elements. Produces a new Row.""" - if len(self.grid) > 1: - raise ValueError("Rows can only be folded once. Use the original row.") + if self.is_folded: + raise ValueError( + "Rows can only be folded once. Use the original row to refold." + ) return Row(tuple(self._split_level(self.grid[0], n))) def _split_level( @@ -176,9 +216,16 @@ def _split_level( for i in range(modulus): yield tuple(bins[i]) + @functools.cached_property + def is_folded(self) -> bool: + return len(self.grid) > 1 + @functools.cached_property def min_rendered_width(self) -> int: - return sum(self.column_widths) + (2 * self.num_cols) + 2 + decoration_length = 0 + if self.is_folded: + decoration_length = 4 + return sum(self.column_widths) + (3 * (self.num_cols - 1)) + decoration_length @functools.cached_property def num_cols(self) -> int: @@ -194,14 +241,23 @@ def _calculate_col_width(self, idx: int) -> int: 0, *(len(subrow[idx]) if idx < len(subrow) else 0 for subrow in self.grid) ) - def serialize(self, use_col_widths: tuple[int, ...]) -> str: + def serialize( + self, use_col_widths: tuple[int, ...], style: OutputStyle = OutputStyle.none + ) -> str: lines: list[str] = [] for subrow in self.grid: new_line: list[str] = [] for idx, width in enumerate(use_col_widths): new_line.append((subrow[idx] if idx < len(subrow) else "").ljust(width)) - lines.append("| " + " | ".join(new_line) + " |\n") - return "".join(lines).rstrip("\n") + + if style & OutputStyle.decorated: + leader = "| " + trailer = " |" + else: + leader = "" + trailer = "" + lines.append(leader + " | ".join(new_line) + f"{trailer}") + return "\n".join(lines) def _get_terminal_content_width() -> int: @@ -211,3 +267,7 @@ def _get_terminal_content_width() -> int: """ cols = shutil.get_terminal_size(fallback=(80, 20)).columns return cols if cols < 88 else int(0.9 * cols) + + +def _detect_folding_enabled() -> bool: + return out_is_terminal() and term_is_interactive() From 53fd96197dd26e63013c2b8547b9a5eaf17fb04f Mon Sep 17 00:00:00 2001 From: Stephen Rosen Date: Tue, 30 Dec 2025 18:07:47 -0600 Subject: [PATCH 03/12] Add tests of folded table printer Unit tests of the Row class and some higher level integration tests of the printer itself to ensure it produces proper tabular output both with and without folding enabled. --- src/globus_cli/commands/endpoint/search.py | 1 - src/globus_cli/commands/group/list.py | 1 - .../termio/printers/folded_table_printer.py | 9 +- .../printer/test_folded_table_printer.py | 191 ++++++++++++++++++ 4 files changed, 196 insertions(+), 6 deletions(-) create mode 100644 tests/unit/termio/printer/test_folded_table_printer.py diff --git a/src/globus_cli/commands/endpoint/search.py b/src/globus_cli/commands/endpoint/search.py index a8e1ff275..b0c2d27e3 100644 --- a/src/globus_cli/commands/endpoint/search.py +++ b/src/globus_cli/commands/endpoint/search.py @@ -158,7 +158,6 @@ def endpoint_search( search_iterator, fields=ENDPOINT_LIST_FIELDS, json_converter=iterable_response_to_dict, - text_mode=display.FOLDED_TABLE, ) if search_iterator.has_next(): diff --git a/src/globus_cli/commands/group/list.py b/src/globus_cli/commands/group/list.py index 2d0ecd576..73ff5a3cc 100644 --- a/src/globus_cli/commands/group/list.py +++ b/src/globus_cli/commands/group/list.py @@ -22,5 +22,4 @@ def group_list(login_manager: LoginManager) -> None: SESSION_ENFORCEMENT_FIELD, Field("Roles", "my_memberships[].role", formatter=formatters.SortedArray), ], - text_mode=display.FOLDED_TABLE, ) diff --git a/src/globus_cli/termio/printers/folded_table_printer.py b/src/globus_cli/termio/printers/folded_table_printer.py index b14681280..bf8a27d91 100644 --- a/src/globus_cli/termio/printers/folded_table_printer.py +++ b/src/globus_cli/termio/printers/folded_table_printer.py @@ -42,9 +42,10 @@ class FoldedTablePrinter(Printer[t.Iterable[t.Any]]): :param fields: a list of Fields with load and render instructions; one per column. """ - def __init__(self, fields: t.Iterable[Field]) -> None: + def __init__(self, fields: t.Iterable[Field], width: int | None = None) -> None: self._fields = tuple(fields) - self._width = _get_terminal_content_width() + self._width = width or _get_terminal_content_width() + self._folding_enabled = _detect_folding_enabled() def echo(self, data: t.Iterable[t.Any], stream: t.IO[str] | None = None) -> None: """ @@ -83,7 +84,7 @@ def echo(self, data: t.Iterable[t.Any], stream: t.IO[str] | None = None) -> None echo(_separator_line(col_widths, style=table_style | OutputStyle.bottom)) def _fold_table(self, table: RowTable) -> RowTable: - if not _detect_folding_enabled(): + if not self._folding_enabled: return table # if the table is initially narrow enough to fit, do not fold @@ -208,7 +209,7 @@ def fold(self, n: int) -> Row: def _split_level( self, level: tuple[str, ...], modulus: int - ) -> t.Iterator[tuple[str, ...], ...]: + ) -> t.Iterator[tuple[str, ...]]: bins = collections.defaultdict(list) for i, x in enumerate(level): bins[i % modulus].append(x) diff --git a/tests/unit/termio/printer/test_folded_table_printer.py b/tests/unit/termio/printer/test_folded_table_printer.py new file mode 100644 index 000000000..af0d6eed0 --- /dev/null +++ b/tests/unit/termio/printer/test_folded_table_printer.py @@ -0,0 +1,191 @@ +from io import StringIO + +import pytest + +from globus_cli.termio import Field +from globus_cli.termio.printers import FoldedTablePrinter +from globus_cli.termio.printers.folded_table_printer import Row + + +@pytest.mark.parametrize( + "folding_enabled, width", + ( + (False, 10), + (False, 1000), + (True, 1000), + ), +) +def test_folded_table_printer_can_print_unfolded_output(folding_enabled, width): + fields = ( + Field("Column A", "a"), + Field("Column B", "b"), + Field("Column C", "c"), + ) + data = ( + {"a": 1, "b": 4, "c": 7}, + {"a": 2, "b": 5, "c": 8}, + {"a": 3, "b": 6, "c": 9}, + ) + printer = FoldedTablePrinter(fields=fields, width=width) + # override detection, set by test + printer._folding_enabled = folding_enabled + + with StringIO() as stream: + printer.echo(data, stream) + printed_table = stream.getvalue() + + # fmt: off + assert printed_table == ( + "Column A | Column B | Column C\n" + "---------+----------+---------\n" + "1 | 4 | 7 \n" + "2 | 5 | 8 \n" + "3 | 6 | 9 \n" + ) + # fmt: on + + +def test_folded_table_printer_can_fold_in_half(): + fields = ( + Field("Column A", "a"), + Field("Column B", "b"), + Field("Column C", "c"), + Field("Column D", "d"), + ) + data = ( + {"a": 1, "b": 4, "c": 7, "d": "alpha"}, + {"a": 2, "b": 5, "c": 8, "d": "beta"}, + {"a": 3, "b": 6, "c": 9, "d": "gamma"}, + ) + + printer = FoldedTablePrinter(fields=fields, width=25) + # override detection of an interactive session + printer._folding_enabled = True + + with StringIO() as stream: + printer.echo(data, stream) + printed_table = stream.getvalue() + + # fmt: off + assert printed_table == ( + ".----------+----------.\n" + "| Column A | Column C |\n" + "| Column B | Column D |\n" + "+==========+==========+\n" + "| 1 | 7 |\n" + "| 4 | alpha |\n" + "+----------+----------+\n" + "| 2 | 8 |\n" + "| 5 | beta |\n" + "+----------+----------+\n" + "| 3 | 9 |\n" + "| 6 | gamma |\n" + "'----------+----------'\n" + ) + # fmt: on + + +def test_folded_table_printer_can_fold_in_half_unevenly(): + fields = ( + Field("Column A", "a"), + Field("Column B", "b"), + Field("Column C", "c"), + ) + data = ( + {"a": 1, "b": 4, "c": 7, "d": "alpha"}, + {"a": 2, "b": 5, "c": 8, "d": "beta"}, + {"a": 3, "b": 6, "c": 9, "d": "gamma"}, + ) + + printer = FoldedTablePrinter(fields=fields, width=25) + # override detection of an interactive session + printer._folding_enabled = True + + with StringIO() as stream: + printer.echo(data, stream) + printed_table = stream.getvalue() + + # fmt: off + assert printed_table == ( + ".----------+----------.\n" + "| Column A | Column C |\n" + "| Column B | |\n" + "+==========+==========+\n" + "| 1 | 7 |\n" + "| 4 | |\n" + "+----------+----------+\n" + "| 2 | 8 |\n" + "| 5 | |\n" + "+----------+----------+\n" + "| 3 | 9 |\n" + "| 6 | |\n" + "'----------+----------'\n" + ) + # fmt: on + + +def test_row_folding_no_remainder(): + six_items = Row((("1", "2", "3", "4", "5", "6"),)) + + # fold by 2 or "in half" + fold2 = six_items.fold(2) + assert len(fold2.grid) == 2 + assert fold2.grid == ( + ("1", "3", "5"), # odds + ("2", "4", "6"), # evens + ) + + # fold by 3 or "in thirds" + fold3 = six_items.fold(3) + assert len(fold3.grid) == 3 + assert fold3.grid == ( + ("1", "4"), + ("2", "5"), + ("3", "6"), + ) + + # fold by N where N is the number of columns + fold6 = six_items.fold(6) + assert len(fold6.grid) == 6 + assert fold6.grid == ( + ("1",), + ("2",), + ("3",), + ("4",), + ("5",), + ("6",), + ) + + +def test_row_folding_with_remainder(): + five_items = Row( + ( + ( + "1", + "2", + "3", + "4", + "5", + ), + ) + ) + + # fold by 2 or "in half" + fold2 = five_items.fold(2) + assert len(fold2.grid) == 2 + assert fold2.grid == ( + ("1", "3", "5"), # odds + ( + "2", + "4", + ), # evens + ) + + # fold by 3 or "in thirds" + fold3 = five_items.fold(3) + assert len(fold3.grid) == 3 + assert fold3.grid == ( + ("1", "4"), + ("2", "5"), + ("3",), + ) From 35d13a59e298bc7d97b4f7a5f87d1ab83ce9a4cb Mon Sep 17 00:00:00 2001 From: Stephen Rosen Date: Tue, 30 Dec 2025 19:10:53 -0600 Subject: [PATCH 04/12] Add support for table folding via an env var 'GLOBUS_CLI_FOLD_TABLES=1' enables table folding for all tabular outputs. This can be used to preview the behavior and, in the future, to opt-out of folded table output for the entire CLI. --- src/globus_cli/termio/_display.py | 18 +++++++++++------- src/globus_cli/termio/context.py | 5 +++++ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/globus_cli/termio/_display.py b/src/globus_cli/termio/_display.py index 35d72ff08..61e938a46 100644 --- a/src/globus_cli/termio/_display.py +++ b/src/globus_cli/termio/_display.py @@ -6,7 +6,12 @@ import click import globus_sdk -from .context import outformat_is_json, outformat_is_text, outformat_is_unix +from .context import ( + fold_tables, + outformat_is_json, + outformat_is_text, + outformat_is_unix, +) from .field import Field from .printers import ( CustomPrinter, @@ -25,7 +30,6 @@ class TextMode(enum.Enum): silent = enum.auto() json = enum.auto() text_table = enum.auto() - text_folded_table = enum.auto() text_record = enum.auto() text_record_list = enum.auto() text_raw = enum.auto() @@ -42,7 +46,6 @@ class Renderer: """ TABLE = TextMode.text_table - FOLDED_TABLE = TextMode.text_folded_table SILENT = TextMode.silent JSON = TextMode.json RECORD = TextMode.text_record @@ -168,16 +171,17 @@ def _resolve_printer( if not isinstance(text_mode, TextMode): return CustomPrinter(custom_print=text_mode) - if text_mode in (self.FOLDED_TABLE, self.TABLE, self.RECORD, self.RECORD_LIST): + if text_mode in (self.TABLE, self.RECORD, self.RECORD_LIST): fields = _assert_fields(fields) if text_mode == self.RECORD: return RecordPrinter(fields) _assert_iterable(data) if text_mode == self.TABLE: - return TablePrinter(fields) - if text_mode == self.FOLDED_TABLE: - return FoldedTablePrinter(fields) + if fold_tables(): + return FoldedTablePrinter(fields) + else: + return TablePrinter(fields) if text_mode == self.RECORD_LIST: return RecordListPrinter(fields) diff --git a/src/globus_cli/termio/context.py b/src/globus_cli/termio/context.py index b533c94fb..f80122851 100644 --- a/src/globus_cli/termio/context.py +++ b/src/globus_cli/termio/context.py @@ -108,3 +108,8 @@ def term_is_interactive() -> bool: return True return os.getenv("PS1") is not None + + +def fold_tables() -> bool | None: + val = os.getenv("GLOBUS_CLI_FOLD_TABLES") + return val is not None and utils.str2bool(val) From 5e89019d3282a7bdf8f2a1b298ce62e60567b658 Mon Sep 17 00:00:00 2001 From: Stephen Rosen Date: Fri, 20 Feb 2026 15:57:41 -0600 Subject: [PATCH 05/12] Invert the default for 'GLOBUS_CLI_FOLD_TABLES' Per discussion, switch from opt-in to opt-out. --- src/globus_cli/termio/context.py | 2 +- .../termio/printers/folded_table_printer.py | 12 +++++++----- tests/functional/endpoint/test_endpoint_search.py | 2 +- .../endpoint/test_storage_gateway_commands.py | 2 +- .../endpoint/test_user_credential_commands.py | 2 +- tests/functional/flows/test_list_flows.py | 10 +++++----- tests/functional/flows/test_list_runs.py | 2 +- tests/functional/flows/test_validate_flow.py | 4 ++-- 8 files changed, 19 insertions(+), 17 deletions(-) diff --git a/src/globus_cli/termio/context.py b/src/globus_cli/termio/context.py index f80122851..ad429d677 100644 --- a/src/globus_cli/termio/context.py +++ b/src/globus_cli/termio/context.py @@ -112,4 +112,4 @@ def term_is_interactive() -> bool: def fold_tables() -> bool | None: val = os.getenv("GLOBUS_CLI_FOLD_TABLES") - return val is not None and utils.str2bool(val) + return val is None or utils.str2bool(val) diff --git a/src/globus_cli/termio/printers/folded_table_printer.py b/src/globus_cli/termio/printers/folded_table_printer.py index bf8a27d91..204183471 100644 --- a/src/globus_cli/termio/printers/folded_table_printer.py +++ b/src/globus_cli/termio/printers/folded_table_printer.py @@ -75,11 +75,13 @@ def echo(self, data: t.Iterable[t.Any], stream: t.IO[str] | None = None) -> None ) ) - for row in table.rows[1:-1]: - echo(row.serialize(col_widths, style=table_style)) - if table.folded: - echo(_separator_line(col_widths, style=table_style)) - echo(table.rows[-1].serialize(col_widths, style=table_style)) + # if the table is empty, print nothing, but normally there is more than one row + if len(table.rows) > 1: + for row in table.rows[1:-1]: + echo(row.serialize(col_widths, style=table_style)) + if table.folded: + echo(_separator_line(col_widths, style=table_style)) + echo(table.rows[-1].serialize(col_widths, style=table_style)) if table.folded: echo(_separator_line(col_widths, style=table_style | OutputStyle.bottom)) diff --git a/tests/functional/endpoint/test_endpoint_search.py b/tests/functional/endpoint/test_endpoint_search.py index 96f8b7da0..72083a5f4 100644 --- a/tests/functional/endpoint/test_endpoint_search.py +++ b/tests/functional/endpoint/test_endpoint_search.py @@ -165,7 +165,7 @@ def test_search_shows_collection_id(run_line, singular_search_response): header_row = re.split(r"\s+\|\s+", header_line) assert header_row == ["ID", "Owner", "Display Name"] # the separator line is a series of dashes - separator_row = re.split(r"\s+\|\s+", separator_line) + separator_row = separator_line.split("-+-") assert len(separator_row) == 3 for separator in separator_row: assert set(separator) == {"-"} # exactly one character is used diff --git a/tests/functional/endpoint/test_storage_gateway_commands.py b/tests/functional/endpoint/test_storage_gateway_commands.py index 864a83a78..63666c8a6 100644 --- a/tests/functional/endpoint/test_storage_gateway_commands.py +++ b/tests/functional/endpoint/test_storage_gateway_commands.py @@ -17,7 +17,7 @@ def test_storage_gateway_list(add_gcs_login, run_line): expected = ( "ID | Display Name | High Assurance | Allowed Domains\n" # noqa: E501 - "------------------------------------ | ----------------- | -------------- | ---------------\n" # noqa: E501 + "-------------------------------------+-------------------+----------------+----------------\n" # noqa: E501 "a0cbde58-0183-11ea-92bd-9cb6d0d9fd63 | example gateway 1 | False | example.edu \n" # noqa: E501 "6840c8ba-eb98-11e9-b89c-9cb6d0d9fd63 | example gateway 2 | False | example.edu \n" # noqa: E501 ) diff --git a/tests/functional/endpoint/test_user_credential_commands.py b/tests/functional/endpoint/test_user_credential_commands.py index 283a4d51e..e6c6e6d13 100644 --- a/tests/functional/endpoint/test_user_credential_commands.py +++ b/tests/functional/endpoint/test_user_credential_commands.py @@ -19,7 +19,7 @@ def test_user_credential_list(add_gcs_login, run_line): expected = ( "ID | Display Name | Globus Identity | Local Username | Invalid\n" # noqa: E501 - "------------------------------------ | ---------------- | ------------------------------------ | -------------- | -------\n" # noqa: E501 + "-------------------------------------+------------------+--------------------------------------+----------------+--------\n" # noqa: E501 "af43d884-64a1-4414-897a-680c32374439 | posix_credential | 948847d4-ffcc-4ae0-ba3a-a4c88d480159 | testuser | False \n" # noqa: E501 "c96b8f70-1448-46db-89af-292623c93ee4 | s3_credential | 948847d4-ffcc-4ae0-ba3a-a4c88d480159 | testuser | False \n" # noqa: E501 ) diff --git a/tests/functional/flows/test_list_flows.py b/tests/functional/flows/test_list_flows.py index 606a61cfd..f1bbc1e32 100644 --- a/tests/functional/flows/test_list_flows.py +++ b/tests/functional/flows/test_list_flows.py @@ -10,7 +10,7 @@ def test_list_flows(run_line): expected = ( "Flow ID | Title | Owner | Created At | Updated At \n" # noqa: E501 - "------- | --------------- | ---------------- | ------------------- | -------------------\n" # noqa: E501 + "--------+-----------------+------------------+---------------------+--------------------\n" # noqa: E501 "id-b | Fairytale Index | shrek@globus.org | 2007-05-18 00:00:00 | 2007-05-18 00:00:00\n" # noqa: E501 "id-a | Swamp Transfer | shrek@globus.org | 2001-04-01 00:00:00 | 2004-05-19 00:00:00\n" # noqa: E501 ) @@ -41,7 +41,7 @@ def test_list_flows_filter_role_single(run_line): expected = ( "Flow ID | Title | Owner | Created At | Updated At \n" # noqa: E501 - "------- | --------------- | ------------------------ | ------------------- | -------------------\n" # noqa: E501 + "--------+-----------------+--------------------------+---------------------+--------------------\n" # noqa: E501 "id-bee | Recover Honey | barrybbenson@thehive.com | 2007-10-25 00:00:00 | 2007-10-25 00:00:00\n" # noqa: E501 "id-b | Fairytale Index | shrek@globus.org | 2007-05-18 00:00:00 | 2007-05-18 00:00:00\n" # noqa: E501 "id-a | Swamp Transfer | shrek@globus.org | 2001-04-01 00:00:00 | 2004-05-19 00:00:00\n" # noqa: E501 @@ -56,7 +56,7 @@ def test_list_flows_filter_role_multiple(run_line): expected = ( "Flow ID | Title | Owner | Created At | Updated At \n" # noqa: E501 - "------- | --------------- | ---------------- | ------------------- | -------------------\n" # noqa: E501 + "--------+-----------------+------------------+---------------------+--------------------\n" # noqa: E501 "id-b | Fairytale Index | shrek@globus.org | 2007-05-18 00:00:00 | 2007-05-18 00:00:00\n" # noqa: E501 "id-a | Swamp Transfer | shrek@globus.org | 2001-04-01 00:00:00 | 2004-05-19 00:00:00\n" # noqa: E501 ) @@ -81,7 +81,7 @@ def test_list_flows_filter_fulltext(run_line): expected = ( "Flow ID | Title | Owner | Created At | Updated At \n" # noqa: E501 - "------- | --------------- | ---------------- | ------------------- | -------------------\n" # noqa: E501 + "--------+-----------------+------------------+---------------------+--------------------\n" # noqa: E501 "id-b | Fairytale Index | shrek@globus.org | 2007-05-18 00:00:00 | 2007-05-18 00:00:00\n" # noqa: E501 ) @@ -135,7 +135,7 @@ def test_list_flows_empty_list(run_line): expected = ( "Flow ID | Title | Owner | Created At | Updated At\n" - "------- | ----- | ----- | ---------- | ----------\n" + "--------+-------+-------+------------+-----------\n" # noqa: E501 ) result = run_line("globus flows list") diff --git a/tests/functional/flows/test_list_runs.py b/tests/functional/flows/test_list_runs.py index ddf0309aa..6c60141a5 100644 --- a/tests/functional/flows/test_list_runs.py +++ b/tests/functional/flows/test_list_runs.py @@ -97,7 +97,7 @@ def test_list_runs_filter_role(run_line): expected = ( "Run ID | Flow Title | Run Label | Status \n" # noqa: E501 - "------------------------------------ | ------------ | ----------- | ---------\n" # noqa: E501 + "-------------------------------------+--------------+-------------+----------\n" # noqa: E501 f"{first_run_id} | My Cool Flow | My Cool Run | SUCCEEDED\n" # noqa: E501 ) assert result.output == expected diff --git a/tests/functional/flows/test_validate_flow.py b/tests/functional/flows/test_validate_flow.py index aa457110d..e1c24666e 100644 --- a/tests/functional/flows/test_validate_flow.py +++ b/tests/functional/flows/test_validate_flow.py @@ -229,7 +229,7 @@ def _parse_table_content(output): Parse the output of a command, searching for tables in the output and returning a list of headers and a list of rows (which are lists of cell values). - Expects a table with divider lines of the form `--- | --- | ---` and rows of the + Expects a table with divider lines of the form `----+-----+----` and rows of the form `value | value | value`. Returns a list of tuples where each tuple represents a parsed table and is @@ -240,7 +240,7 @@ def _parse_table_content(output): # Find the table divider lines = output.splitlines() divider_indices = [ - i for i, line in enumerate(lines) if re.fullmatch(r"-+ \| [-| ]*", line) + i for i, line in enumerate(lines) if re.fullmatch(r"\-+\+[\-\+]*", line) ] if not divider_indices: From d2fae5b5bb35c4a13234ca7317585b82028ca712 Mon Sep 17 00:00:00 2001 From: Stephen Rosen Date: Fri, 20 Feb 2026 17:56:26 -0600 Subject: [PATCH 06/12] Convert folded tables to box drawing chars Instrument the selection of various box drawing characters instead of plain ASCII characters for folded table output. This enables some clearer rendering without noisy/confusing lines that make it hard to see an element of the table as singular. --- .../termio/printers/folded_table_printer.py | 109 +++++++++++++----- .../printer/test_folded_table_printer.py | 60 +++++----- 2 files changed, 116 insertions(+), 53 deletions(-) diff --git a/src/globus_cli/termio/printers/folded_table_printer.py b/src/globus_cli/termio/printers/folded_table_printer.py index 204183471..899dc6211 100644 --- a/src/globus_cli/termio/printers/folded_table_printer.py +++ b/src/globus_cli/termio/printers/folded_table_printer.py @@ -16,7 +16,8 @@ class OutputStyle(enum.Flag): none = enum.auto() decorated = enum.auto() - heavy = enum.auto() + double = enum.auto() + double_transition = enum.auto() top = enum.auto() bottom = enum.auto() @@ -26,16 +27,19 @@ class FoldedTablePrinter(Printer[t.Iterable[t.Any]]): A printer to render an iterable of objects holding tabular data with cells folded together and stacked in the format: - .--------------------------------------------------. - | | | | - | | | | - +================+================+================+ - | | | | - | | | | - +----------------+----------------+----------------+ - | | | | - | | | | - '----------------+----------------+----------------' + ╒════════════════╤════════════════╤════════════════╕ + │ │ + ├╴─ ─ ─ ─ ─ ─ ─ ╶┼╴─ ─ ─ ─ ─ ─ ─ ╶┼╴─ ─ ─ ─ ─ ─ ─ ╶┤ + │ ╎ │ + ╞════════════════╪════════════════╪════════════════╡ + │ │ + ├╴─ ─ ─ ─ ─ ─ ─ ╶┼╴─ ─ ─ ─ ─ ─ ─ ╶┼╴─ ─ ─ ─ ─ ─ ─ ╶┤ + │ ╎ │ + ├────────────────┼────────────────┼────────────────┤ + │ │ + ├╴─ ─ ─ ─ ─ ─ ─ ╶┼╴─ ─ ─ ─ ─ ─ ─ ╶┼╴─ ─ ─ ─ ─ ─ ─ ╶┤ + │ ╎ │ + └────────────────┴────────────────┴────────────────┘ Rows are folded and stacked only if they won't fit in the output width. @@ -61,16 +65,31 @@ def echo(self, data: t.Iterable[t.Any], stream: t.IO[str] | None = None) -> None # if folded, print a leading separator line table_style = OutputStyle.decorated if table.folded else OutputStyle.none + if table.folded: - echo(_separator_line(col_widths, style=table_style | OutputStyle.top)) - # print the header row and a separator (heavy if folded) - echo(table.header_row.serialize(col_widths, style=table_style)) + echo( + _separator_line( + col_widths, style=table_style | OutputStyle.double | OutputStyle.top + ) + ) + # print the header row and a separator (double if folded) + echo( + table.header_row.serialize( + col_widths, + style=table_style + | (OutputStyle.double if table.folded else OutputStyle.none), + ) + ) echo( _separator_line( col_widths, style=( table_style - | (OutputStyle.heavy if table.folded else OutputStyle.none) + | ( + OutputStyle.double_transition + if table.folded + else OutputStyle.none + ) ), ) ) @@ -111,25 +130,39 @@ def _fold_table(self, table: RowTable) -> RowTable: def _separator_line( col_widths: tuple[int, ...], style: OutputStyle = OutputStyle.none ) -> str: - fill = "=" if style & OutputStyle.heavy else "-" + fill = "=" if style & (OutputStyle.double | OutputStyle.double_transition) else "-" if style & OutputStyle.decorated: - decorator = "+" + # remap fill to a box drawing char + fill = {"=": "═", "-": "─"}[fill] + + before_decorator = "├" + after_decorator = "┤" + middle_decorator = "┼" if style & OutputStyle.top: - decorator = "." + before_decorator = "╒" + after_decorator = "╕" + middle_decorator = "╤" elif style & OutputStyle.bottom: - decorator = "'" - - leader = f"{decorator}{fill}" - trailer = f"{fill}{decorator}" + before_decorator = "└" + after_decorator = "┘" + middle_decorator = "┴" + elif style & OutputStyle.double_transition: + before_decorator = "╞" + after_decorator = "╡" + middle_decorator = "╪" + + leader = f"{before_decorator}{fill}" + trailer = f"{fill}{after_decorator}" else: leader = "" trailer = "" + middle_decorator = "+" line_parts = [leader] for col in col_widths[:-1]: line_parts.append(col * fill) - line_parts.append(f"{fill}+{fill}") + line_parts.append(f"{fill}{middle_decorator}{fill}") line_parts.append(col_widths[-1] * fill) line_parts.append(trailer) return "".join(line_parts) @@ -248,21 +281,43 @@ def serialize( self, use_col_widths: tuple[int, ...], style: OutputStyle = OutputStyle.none ) -> str: lines: list[str] = [] - for subrow in self.grid: + + separator_line: list[str] = [] + if len(self.grid) > 1: + for width in use_col_widths: + separator_line.append(_make_row_separator(width)) + + for i, subrow in enumerate(self.grid): new_line: list[str] = [] for idx, width in enumerate(use_col_widths): new_line.append((subrow[idx] if idx < len(subrow) else "").ljust(width)) if style & OutputStyle.decorated: - leader = "| " - trailer = " |" + separator = "╎" + leader = "│ " + trailer = " │" + + if i > 0 and separator_line: + lines.append("├╴" + "╶┼╴".join(separator_line) + "╶┤") else: leader = "" trailer = "" - lines.append(leader + " | ".join(new_line) + f"{trailer}") + separator = "|" + lines.append(leader + f" {separator} ".join(new_line) + trailer) return "\n".join(lines) +_ROW_SEPARATOR_CHAR = "─" + + +@functools.cache +def _make_row_separator(width: int) -> str: + # repeat with whitespace + sep = (_ROW_SEPARATOR_CHAR + " ") * width + sep = sep[:width] # trim to length + return sep + + def _get_terminal_content_width() -> int: """Get a content width for text output based on the terminal size. diff --git a/tests/unit/termio/printer/test_folded_table_printer.py b/tests/unit/termio/printer/test_folded_table_printer.py index af0d6eed0..aad08c2ff 100644 --- a/tests/unit/termio/printer/test_folded_table_printer.py +++ b/tests/unit/termio/printer/test_folded_table_printer.py @@ -68,19 +68,23 @@ def test_folded_table_printer_can_fold_in_half(): # fmt: off assert printed_table == ( - ".----------+----------.\n" - "| Column A | Column C |\n" - "| Column B | Column D |\n" - "+==========+==========+\n" - "| 1 | 7 |\n" - "| 4 | alpha |\n" - "+----------+----------+\n" - "| 2 | 8 |\n" - "| 5 | beta |\n" - "+----------+----------+\n" - "| 3 | 9 |\n" - "| 6 | gamma |\n" - "'----------+----------'\n" + "╒══════════╤══════════╕\n" + "│ Column A ╎ Column C │\n" + "├╴─ ─ ─ ─ ╶┼╴─ ─ ─ ─ ╶┤\n" + "│ Column B ╎ Column D │\n" + "╞══════════╪══════════╡\n" + "│ 1 ╎ 7 │\n" + "├╴─ ─ ─ ─ ╶┼╴─ ─ ─ ─ ╶┤\n" + "│ 4 ╎ alpha │\n" + "├──────────┼──────────┤\n" + "│ 2 ╎ 8 │\n" + "├╴─ ─ ─ ─ ╶┼╴─ ─ ─ ─ ╶┤\n" + "│ 5 ╎ beta │\n" + "├──────────┼──────────┤\n" + "│ 3 ╎ 9 │\n" + "├╴─ ─ ─ ─ ╶┼╴─ ─ ─ ─ ╶┤\n" + "│ 6 ╎ gamma │\n" + "└──────────┴──────────┘\n" ) # fmt: on @@ -107,19 +111,23 @@ def test_folded_table_printer_can_fold_in_half_unevenly(): # fmt: off assert printed_table == ( - ".----------+----------.\n" - "| Column A | Column C |\n" - "| Column B | |\n" - "+==========+==========+\n" - "| 1 | 7 |\n" - "| 4 | |\n" - "+----------+----------+\n" - "| 2 | 8 |\n" - "| 5 | |\n" - "+----------+----------+\n" - "| 3 | 9 |\n" - "| 6 | |\n" - "'----------+----------'\n" + "╒══════════╤══════════╕\n" + "│ Column A ╎ Column C │\n" + "├╴─ ─ ─ ─ ╶┼╴─ ─ ─ ─ ╶┤\n" + "│ Column B ╎ │\n" + "╞══════════╪══════════╡\n" + "│ 1 ╎ 7 │\n" + "├╴─ ─ ─ ─ ╶┼╴─ ─ ─ ─ ╶┤\n" + "│ 4 ╎ │\n" + "├──────────┼──────────┤\n" + "│ 2 ╎ 8 │\n" + "├╴─ ─ ─ ─ ╶┼╴─ ─ ─ ─ ╶┤\n" + "│ 5 ╎ │\n" + "├──────────┼──────────┤\n" + "│ 3 ╎ 9 │\n" + "├╴─ ─ ─ ─ ╶┼╴─ ─ ─ ─ ╶┤\n" + "│ 6 ╎ │\n" + "└──────────┴──────────┘\n" ) # fmt: on From 92f416b4741c39945569611656e254f6e20b4e7b Mon Sep 17 00:00:00 2001 From: Stephen Rosen Date: Fri, 20 Feb 2026 18:13:02 -0600 Subject: [PATCH 07/12] Add changelog for folded table output --- changelog.d/20260220_181030_sirosen_folded_table.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 changelog.d/20260220_181030_sirosen_folded_table.md diff --git a/changelog.d/20260220_181030_sirosen_folded_table.md b/changelog.d/20260220_181030_sirosen_folded_table.md new file mode 100644 index 000000000..f1d328dea --- /dev/null +++ b/changelog.d/20260220_181030_sirosen_folded_table.md @@ -0,0 +1,6 @@ +### Enhancements + +* When using table output on narrow terminals, the Globus CLI will now stack + table elements in a new "folded table" layout. This behavior is only used + when the output device is a TTY. To disable the new output altogether, users + can set `GLOBUS_CLI_FOLD_TABLES=0`. From e9c4e66ec26cc7d127ae8f506048e1ecc6fd3f02 Mon Sep 17 00:00:00 2001 From: Stephen Rosen Date: Mon, 23 Feb 2026 10:29:37 -0600 Subject: [PATCH 08/12] Switch folded table away from half-dash character Using the half-dash char looks good in many fonts, but in some renderings it appears to be out of the horizontal line with the rest of the dashed line. In order to reduce the potential for this to be an oddity we see with some users' fonts in the future, simply avoid using the half-dash chars. --- .../termio/printers/folded_table_printer.py | 12 +++++++----- .../termio/printer/test_folded_table_printer.py | 16 ++++++++-------- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/src/globus_cli/termio/printers/folded_table_printer.py b/src/globus_cli/termio/printers/folded_table_printer.py index 899dc6211..8357589ae 100644 --- a/src/globus_cli/termio/printers/folded_table_printer.py +++ b/src/globus_cli/termio/printers/folded_table_printer.py @@ -29,15 +29,15 @@ class FoldedTablePrinter(Printer[t.Iterable[t.Any]]): ╒════════════════╤════════════════╤════════════════╕ │ │ - ├╴─ ─ ─ ─ ─ ─ ─ ╶┼╴─ ─ ─ ─ ─ ─ ─ ╶┼╴─ ─ ─ ─ ─ ─ ─ ╶┤ + ├─ ─ ─ ─ ─ ─ ─ ─┼─ ─ ─ ─ ─ ─ ─ ─┼─ ─ ─ ─ ─ ─ ─ ─┤ │ ╎ │ ╞════════════════╪════════════════╪════════════════╡ │ │ - ├╴─ ─ ─ ─ ─ ─ ─ ╶┼╴─ ─ ─ ─ ─ ─ ─ ╶┼╴─ ─ ─ ─ ─ ─ ─ ╶┤ + ├─ ─ ─ ─ ─ ─ ─ ─┼─ ─ ─ ─ ─ ─ ─ ─┼─ ─ ─ ─ ─ ─ ─ ─┤ │ ╎ │ ├────────────────┼────────────────┼────────────────┤ │ │ - ├╴─ ─ ─ ─ ─ ─ ─ ╶┼╴─ ─ ─ ─ ─ ─ ─ ╶┼╴─ ─ ─ ─ ─ ─ ─ ╶┤ + ├─ ─ ─ ─ ─ ─ ─ ─┼─ ─ ─ ─ ─ ─ ─ ─┼─ ─ ─ ─ ─ ─ ─ ─┤ │ ╎ │ └────────────────┴────────────────┴────────────────┘ @@ -298,7 +298,7 @@ def serialize( trailer = " │" if i > 0 and separator_line: - lines.append("├╴" + "╶┼╴".join(separator_line) + "╶┤") + lines.append("├─" + "─┼─".join(separator_line) + "─┤") else: leader = "" trailer = "" @@ -313,8 +313,10 @@ def serialize( @functools.cache def _make_row_separator(width: int) -> str: # repeat with whitespace - sep = (_ROW_SEPARATOR_CHAR + " ") * width + sep = (" " + _ROW_SEPARATOR_CHAR) * width sep = sep[:width] # trim to length + if sep[-1] == _ROW_SEPARATOR_CHAR: # ensure it ends in whitespace + sep = sep[:-1] + " " return sep diff --git a/tests/unit/termio/printer/test_folded_table_printer.py b/tests/unit/termio/printer/test_folded_table_printer.py index aad08c2ff..e9a48d3f7 100644 --- a/tests/unit/termio/printer/test_folded_table_printer.py +++ b/tests/unit/termio/printer/test_folded_table_printer.py @@ -70,19 +70,19 @@ def test_folded_table_printer_can_fold_in_half(): assert printed_table == ( "╒══════════╤══════════╕\n" "│ Column A ╎ Column C │\n" - "├╴─ ─ ─ ─ ╶┼╴─ ─ ─ ─ ╶┤\n" + "├─ ─ ─ ─ ─┼─ ─ ─ ─ ─┤\n" "│ Column B ╎ Column D │\n" "╞══════════╪══════════╡\n" "│ 1 ╎ 7 │\n" - "├╴─ ─ ─ ─ ╶┼╴─ ─ ─ ─ ╶┤\n" + "├─ ─ ─ ─ ─┼─ ─ ─ ─ ─┤\n" "│ 4 ╎ alpha │\n" "├──────────┼──────────┤\n" "│ 2 ╎ 8 │\n" - "├╴─ ─ ─ ─ ╶┼╴─ ─ ─ ─ ╶┤\n" + "├─ ─ ─ ─ ─┼─ ─ ─ ─ ─┤\n" "│ 5 ╎ beta │\n" "├──────────┼──────────┤\n" "│ 3 ╎ 9 │\n" - "├╴─ ─ ─ ─ ╶┼╴─ ─ ─ ─ ╶┤\n" + "├─ ─ ─ ─ ─┼─ ─ ─ ─ ─┤\n" "│ 6 ╎ gamma │\n" "└──────────┴──────────┘\n" ) @@ -113,19 +113,19 @@ def test_folded_table_printer_can_fold_in_half_unevenly(): assert printed_table == ( "╒══════════╤══════════╕\n" "│ Column A ╎ Column C │\n" - "├╴─ ─ ─ ─ ╶┼╴─ ─ ─ ─ ╶┤\n" + "├─ ─ ─ ─ ─┼─ ─ ─ ─ ─┤\n" "│ Column B ╎ │\n" "╞══════════╪══════════╡\n" "│ 1 ╎ 7 │\n" - "├╴─ ─ ─ ─ ╶┼╴─ ─ ─ ─ ╶┤\n" + "├─ ─ ─ ─ ─┼─ ─ ─ ─ ─┤\n" "│ 4 ╎ │\n" "├──────────┼──────────┤\n" "│ 2 ╎ 8 │\n" - "├╴─ ─ ─ ─ ╶┼╴─ ─ ─ ─ ╶┤\n" + "├─ ─ ─ ─ ─┼─ ─ ─ ─ ─┤\n" "│ 5 ╎ │\n" "├──────────┼──────────┤\n" "│ 3 ╎ 9 │\n" - "├╴─ ─ ─ ─ ╶┼╴─ ─ ─ ─ ╶┤\n" + "├─ ─ ─ ─ ─┼─ ─ ─ ─ ─┤\n" "│ 6 ╎ │\n" "└──────────┴──────────┘\n" ) From 16859191e8e469f4a8dfebd3037ec1927793a81d Mon Sep 17 00:00:00 2001 From: Stephen Rosen Date: Mon, 23 Feb 2026 18:05:50 -0600 Subject: [PATCH 09/12] Convert folded table row printing to use row types Define an enum of separator row types, including various "box drawing" row types and one (and only one) ascii row type. `_separator_line` now takes one of these row types, instead of a "style" parameter, and uses it to determine how printing should be done. This also now supports "box_intra_row_separator" as a type, and therefore is used to simplify row serialization. --- .../termio/printers/folded_table_printer.py | 195 ++++++++++-------- 1 file changed, 112 insertions(+), 83 deletions(-) diff --git a/src/globus_cli/termio/printers/folded_table_printer.py b/src/globus_cli/termio/printers/folded_table_printer.py index 8357589ae..dfe71505a 100644 --- a/src/globus_cli/termio/printers/folded_table_printer.py +++ b/src/globus_cli/termio/printers/folded_table_printer.py @@ -13,13 +13,26 @@ from .base import Printer +# the separator rows, including the top and bottom +class SeparatorRowType(enum.Enum): + # top of a table + box_top = enum.auto() + # between the header and the rest of the table (box drawing chars) + box_header_separator = enum.auto() + # the same, but for ASCII tables + ascii_header_separator = enum.auto() + # between rows of the table + box_row_separator = enum.auto() + # between element lines inside of a row of a table + box_intra_row_separator = enum.auto() + # bottom of a table + box_bottom = enum.auto() + + class OutputStyle(enum.Flag): none = enum.auto() decorated = enum.auto() double = enum.auto() - double_transition = enum.auto() - top = enum.auto() - bottom = enum.auto() class FoldedTablePrinter(Printer[t.Iterable[t.Any]]): @@ -67,11 +80,7 @@ def echo(self, data: t.Iterable[t.Any], stream: t.IO[str] | None = None) -> None table_style = OutputStyle.decorated if table.folded else OutputStyle.none if table.folded: - echo( - _separator_line( - col_widths, style=table_style | OutputStyle.double | OutputStyle.top - ) - ) + echo(_separator_line(col_widths, row_type=SeparatorRowType.box_top)) # print the header row and a separator (double if folded) echo( table.header_row.serialize( @@ -83,13 +92,10 @@ def echo(self, data: t.Iterable[t.Any], stream: t.IO[str] | None = None) -> None echo( _separator_line( col_widths, - style=( - table_style - | ( - OutputStyle.double_transition - if table.folded - else OutputStyle.none - ) + row_type=( + SeparatorRowType.box_header_separator + if table.folded + else SeparatorRowType.ascii_header_separator ), ) ) @@ -99,10 +105,15 @@ def echo(self, data: t.Iterable[t.Any], stream: t.IO[str] | None = None) -> None for row in table.rows[1:-1]: echo(row.serialize(col_widths, style=table_style)) if table.folded: - echo(_separator_line(col_widths, style=table_style)) + echo( + _separator_line( + col_widths, + row_type=SeparatorRowType.box_row_separator, + ) + ) echo(table.rows[-1].serialize(col_widths, style=table_style)) if table.folded: - echo(_separator_line(col_widths, style=table_style | OutputStyle.bottom)) + echo(_separator_line(col_widths, row_type=SeparatorRowType.box_bottom)) def _fold_table(self, table: RowTable) -> RowTable: if not self._folding_enabled: @@ -126,48 +137,6 @@ def _fold_table(self, table: RowTable) -> RowTable: return table.fold_rows(table.num_columns) -@functools.cache -def _separator_line( - col_widths: tuple[int, ...], style: OutputStyle = OutputStyle.none -) -> str: - fill = "=" if style & (OutputStyle.double | OutputStyle.double_transition) else "-" - - if style & OutputStyle.decorated: - # remap fill to a box drawing char - fill = {"=": "═", "-": "─"}[fill] - - before_decorator = "├" - after_decorator = "┤" - middle_decorator = "┼" - if style & OutputStyle.top: - before_decorator = "╒" - after_decorator = "╕" - middle_decorator = "╤" - elif style & OutputStyle.bottom: - before_decorator = "└" - after_decorator = "┘" - middle_decorator = "┴" - elif style & OutputStyle.double_transition: - before_decorator = "╞" - after_decorator = "╡" - middle_decorator = "╪" - - leader = f"{before_decorator}{fill}" - trailer = f"{fill}{after_decorator}" - else: - leader = "" - trailer = "" - middle_decorator = "+" - - line_parts = [leader] - for col in col_widths[:-1]: - line_parts.append(col * fill) - line_parts.append(f"{fill}{middle_decorator}{fill}") - line_parts.append(col_widths[-1] * fill) - line_parts.append(trailer) - return "".join(line_parts) - - class RowTable: """ A data structure to hold tabular data which has not yet been laid out. @@ -280,42 +249,102 @@ def _calculate_col_width(self, idx: int) -> int: def serialize( self, use_col_widths: tuple[int, ...], style: OutputStyle = OutputStyle.none ) -> str: - lines: list[str] = [] + if style & OutputStyle.decorated: + separator = "╎" + leader = "│ " + trailer = " │" + else: + leader = "" + trailer = "" + separator = "|" - separator_line: list[str] = [] - if len(self.grid) > 1: - for width in use_col_widths: - separator_line.append(_make_row_separator(width)) + if len(self.grid) < 1: + raise ValueError("Invalid state. Cannot serialize an empty row.") - for i, subrow in enumerate(self.grid): - new_line: list[str] = [] - for idx, width in enumerate(use_col_widths): - new_line.append((subrow[idx] if idx < len(subrow) else "").ljust(width)) + if len(self.grid) == 1: + return _format_subrow( + self.grid[0], use_col_widths, separator, leader, trailer + ) - if style & OutputStyle.decorated: - separator = "╎" - leader = "│ " - trailer = " │" + lines: list[str] = [] - if i > 0 and separator_line: - lines.append("├─" + "─┼─".join(separator_line) + "─┤") - else: - leader = "" - trailer = "" - separator = "|" - lines.append(leader + f" {separator} ".join(new_line) + trailer) + row_separator: str = _separator_line( + use_col_widths, SeparatorRowType.box_intra_row_separator + ) + for i, subrow in enumerate(self.grid): + if i > 0: + lines.append(row_separator) + lines.append( + _format_subrow(subrow, use_col_widths, separator, leader, trailer) + ) return "\n".join(lines) -_ROW_SEPARATOR_CHAR = "─" +def _format_subrow( + subrow: tuple[str, ...], + use_col_widths: tuple[int, ...], + separator: str, + leader: str, + trailer: str, +) -> str: + line: list[str] = [] + for idx, width in enumerate(use_col_widths): + line.append((subrow[idx] if idx < len(subrow) else "").ljust(width)) + return leader + f" {separator} ".join(line) + trailer + + +@functools.cache +def _separator_line(col_widths: tuple[int, ...], row_type: SeparatorRowType) -> str: + if row_type is SeparatorRowType.ascii_header_separator: + fill = "-" + leader = "" + trailer = "" + middle_decorator = "+" + elif row_type is SeparatorRowType.box_top: + fill = "═" + leader = "╒═" + trailer = "═╕" + middle_decorator = "╤" + elif row_type is SeparatorRowType.box_header_separator: + fill = "═" + leader = "╞═" + trailer = "═╡" + middle_decorator = "╪" + elif row_type is SeparatorRowType.box_bottom: + fill = "─" + leader = "└─" + trailer = "─┘" + middle_decorator = "┴" + else: + fill = "─" + leader = "├─" + trailer = "─┤" + middle_decorator = "┼" + + # in intra-row separator lines, they are drawn as dashed box char lines + if row_type is SeparatorRowType.box_intra_row_separator: + fill_column: t.Callable[[int], str] = _draw_dashed_box_line + # for all other cases, they're just a "flood fill" with the fill char + else: + + def fill_column(width: int) -> str: + return width * fill + + line_parts = [leader] + for col in col_widths[:-1]: + line_parts.append(fill_column(col)) + line_parts.append(f"{fill}{middle_decorator}{fill}") + line_parts.append(fill_column(col_widths[-1])) + line_parts.append(trailer) + return "".join(line_parts) @functools.cache -def _make_row_separator(width: int) -> str: +def _draw_dashed_box_line(width: int) -> str: # repeat with whitespace - sep = (" " + _ROW_SEPARATOR_CHAR) * width + sep = " ─" * width sep = sep[:width] # trim to length - if sep[-1] == _ROW_SEPARATOR_CHAR: # ensure it ends in whitespace + if sep[-1] == "─": # ensure it ends in whitespace sep = sep[:-1] + " " return sep From 088bac0789878930f615463a320fd8cfb915820b Mon Sep 17 00:00:00 2001 From: Stephen Rosen Date: Mon, 23 Feb 2026 18:12:46 -0600 Subject: [PATCH 10/12] Remove 'OutputStyle' from folded table printer The information passed via OutputStyle is always available in one of three places, depending on context: - the table is marked "folded" - the row has multiple subrows - the separator row type It turns out there is no need for a replacement for this enum. --- .../termio/printers/folded_table_printer.py | 45 ++++--------------- 1 file changed, 9 insertions(+), 36 deletions(-) diff --git a/src/globus_cli/termio/printers/folded_table_printer.py b/src/globus_cli/termio/printers/folded_table_printer.py index dfe71505a..48cb67752 100644 --- a/src/globus_cli/termio/printers/folded_table_printer.py +++ b/src/globus_cli/termio/printers/folded_table_printer.py @@ -29,12 +29,6 @@ class SeparatorRowType(enum.Enum): box_bottom = enum.auto() -class OutputStyle(enum.Flag): - none = enum.auto() - decorated = enum.auto() - double = enum.auto() - - class FoldedTablePrinter(Printer[t.Iterable[t.Any]]): """ A printer to render an iterable of objects holding tabular data with cells folded @@ -77,18 +71,10 @@ def echo(self, data: t.Iterable[t.Any], stream: t.IO[str] | None = None) -> None col_widths = table.calculate_column_widths() # if folded, print a leading separator line - table_style = OutputStyle.decorated if table.folded else OutputStyle.none - if table.folded: echo(_separator_line(col_widths, row_type=SeparatorRowType.box_top)) - # print the header row and a separator (double if folded) - echo( - table.header_row.serialize( - col_widths, - style=table_style - | (OutputStyle.double if table.folded else OutputStyle.none), - ) - ) + # print the header row and a separator + echo(table.header_row.serialize(col_widths)) echo( _separator_line( col_widths, @@ -103,7 +89,7 @@ def echo(self, data: t.Iterable[t.Any], stream: t.IO[str] | None = None) -> None # if the table is empty, print nothing, but normally there is more than one row if len(table.rows) > 1: for row in table.rows[1:-1]: - echo(row.serialize(col_widths, style=table_style)) + echo(row.serialize(col_widths)) if table.folded: echo( _separator_line( @@ -111,7 +97,7 @@ def echo(self, data: t.Iterable[t.Any], stream: t.IO[str] | None = None) -> None row_type=SeparatorRowType.box_row_separator, ) ) - echo(table.rows[-1].serialize(col_widths, style=table_style)) + echo(table.rows[-1].serialize(col_widths)) if table.folded: echo(_separator_line(col_widths, row_type=SeparatorRowType.box_bottom)) @@ -246,25 +232,13 @@ def _calculate_col_width(self, idx: int) -> int: 0, *(len(subrow[idx]) if idx < len(subrow) else 0 for subrow in self.grid) ) - def serialize( - self, use_col_widths: tuple[int, ...], style: OutputStyle = OutputStyle.none - ) -> str: - if style & OutputStyle.decorated: - separator = "╎" - leader = "│ " - trailer = " │" - else: - leader = "" - trailer = "" - separator = "|" - + def serialize(self, use_col_widths: tuple[int, ...]) -> str: if len(self.grid) < 1: raise ValueError("Invalid state. Cannot serialize an empty row.") if len(self.grid) == 1: - return _format_subrow( - self.grid[0], use_col_widths, separator, leader, trailer - ) + # format using ASCII characters (not folded) + return _format_subrow(self.grid[0], use_col_widths, "|", "", "") lines: list[str] = [] @@ -274,9 +248,8 @@ def serialize( for i, subrow in enumerate(self.grid): if i > 0: lines.append(row_separator) - lines.append( - _format_subrow(subrow, use_col_widths, separator, leader, trailer) - ) + # format using box drawing characters (part of folded output) + lines.append(_format_subrow(subrow, use_col_widths, "╎", "│ ", " │")) return "\n".join(lines) From a62eb7d24f9ac8b8e4e4dee3dfdfe8217b4936eb Mon Sep 17 00:00:00 2001 From: Stephen Rosen Date: Mon, 23 Feb 2026 18:21:41 -0600 Subject: [PATCH 11/12] Add 'content_rows' to simplify RowTable usage Co-authored-by: derek-globus <113056046+derek-globus@users.noreply.github.com> --- .../termio/printers/folded_table_printer.py | 27 +++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/src/globus_cli/termio/printers/folded_table_printer.py b/src/globus_cli/termio/printers/folded_table_printer.py index 48cb67752..12fd9c588 100644 --- a/src/globus_cli/termio/printers/folded_table_printer.py +++ b/src/globus_cli/termio/printers/folded_table_printer.py @@ -86,18 +86,19 @@ def echo(self, data: t.Iterable[t.Any], stream: t.IO[str] | None = None) -> None ) ) - # if the table is empty, print nothing, but normally there is more than one row - if len(table.rows) > 1: - for row in table.rows[1:-1]: - echo(row.serialize(col_widths)) - if table.folded: - echo( - _separator_line( - col_widths, - row_type=SeparatorRowType.box_row_separator, - ) + for row in table.content_rows[:-1]: + echo(row.serialize(col_widths)) + if table.folded: + echo( + _separator_line( + col_widths, + row_type=SeparatorRowType.box_row_separator, ) - echo(table.rows[-1].serialize(col_widths)) + ) + # it is possible for a table to be empty, so check before attempting to + # serialize the last row + if table.content_rows: + echo(table.content_rows[-1].serialize(col_widths)) if table.folded: echo(_separator_line(col_widths, row_type=SeparatorRowType.box_bottom)) @@ -143,6 +144,10 @@ def __init__(self, rows: tuple[Row, ...], folded: bool = False) -> None: def header_row(self) -> Row: return self.rows[0] + @property + def content_rows(self) -> tuple[Row, ...]: + return self.rows[1:] + def fits_in_width(self, width: int) -> bool: return all(x.min_rendered_width <= width for x in self.rows) From 19806f3b16cf6cc79b6b3596ef00ed89d6667fea Mon Sep 17 00:00:00 2001 From: Stephen Rosen Date: Mon, 23 Feb 2026 18:41:07 -0600 Subject: [PATCH 12/12] Abstract out handling of folded table row styles Define a small dataclass, pop it into a mapping of row types -> styles, and use it where style information is interpreted. Co-authored-by: derek-globus <113056046+derek-globus@users.noreply.github.com> --- .../termio/printers/folded_table_printer.py | 65 ++++++++++--------- 1 file changed, 36 insertions(+), 29 deletions(-) diff --git a/src/globus_cli/termio/printers/folded_table_printer.py b/src/globus_cli/termio/printers/folded_table_printer.py index 12fd9c588..cf77a0e53 100644 --- a/src/globus_cli/termio/printers/folded_table_printer.py +++ b/src/globus_cli/termio/printers/folded_table_printer.py @@ -1,6 +1,7 @@ from __future__ import annotations import collections +import dataclasses import enum import functools import shutil @@ -29,6 +30,36 @@ class SeparatorRowType(enum.Enum): box_bottom = enum.auto() +@dataclasses.dataclass +class SeparatorRowStyle: + fill: str + leader: str + trailer: str + middle: str + + +SEPARATOR_ROW_STYLE_CHART: dict[SeparatorRowType, SeparatorRowStyle] = { + SeparatorRowType.ascii_header_separator: SeparatorRowStyle( + fill="-", leader="", trailer="", middle="+" + ), + SeparatorRowType.box_top: SeparatorRowStyle( + fill="═", leader="╒═", trailer="═╕", middle="╤" + ), + SeparatorRowType.box_header_separator: SeparatorRowStyle( + fill="═", leader="╞═", trailer="═╡", middle="╪" + ), + SeparatorRowType.box_row_separator: SeparatorRowStyle( + fill="─", leader="├─", trailer="─┤", middle="┼" + ), + SeparatorRowType.box_intra_row_separator: SeparatorRowStyle( + fill="─", leader="├─", trailer="─┤", middle="┼" + ), + SeparatorRowType.box_bottom: SeparatorRowStyle( + fill="─", leader="└─", trailer="─┘", middle="┴" + ), +} + + class FoldedTablePrinter(Printer[t.Iterable[t.Any]]): """ A printer to render an iterable of objects holding tabular data with cells folded @@ -273,31 +304,7 @@ def _format_subrow( @functools.cache def _separator_line(col_widths: tuple[int, ...], row_type: SeparatorRowType) -> str: - if row_type is SeparatorRowType.ascii_header_separator: - fill = "-" - leader = "" - trailer = "" - middle_decorator = "+" - elif row_type is SeparatorRowType.box_top: - fill = "═" - leader = "╒═" - trailer = "═╕" - middle_decorator = "╤" - elif row_type is SeparatorRowType.box_header_separator: - fill = "═" - leader = "╞═" - trailer = "═╡" - middle_decorator = "╪" - elif row_type is SeparatorRowType.box_bottom: - fill = "─" - leader = "└─" - trailer = "─┘" - middle_decorator = "┴" - else: - fill = "─" - leader = "├─" - trailer = "─┤" - middle_decorator = "┼" + style = SEPARATOR_ROW_STYLE_CHART[row_type] # in intra-row separator lines, they are drawn as dashed box char lines if row_type is SeparatorRowType.box_intra_row_separator: @@ -306,14 +313,14 @@ def _separator_line(col_widths: tuple[int, ...], row_type: SeparatorRowType) -> else: def fill_column(width: int) -> str: - return width * fill + return width * style.fill - line_parts = [leader] + line_parts = [style.leader] for col in col_widths[:-1]: line_parts.append(fill_column(col)) - line_parts.append(f"{fill}{middle_decorator}{fill}") + line_parts.append(f"{style.fill}{style.middle}{style.fill}") line_parts.append(fill_column(col_widths[-1])) - line_parts.append(trailer) + line_parts.append(style.trailer) return "".join(line_parts)