diff --git a/requirements-tests.txt b/requirements-tests.txt index 709b15a6c6..48e1e9c82c 100644 --- a/requirements-tests.txt +++ b/requirements-tests.txt @@ -10,3 +10,5 @@ ruff ==0.11.13 # Needed explicitly by typer-slim rich >=10.11.0 shellingham >=1.3.0 +PyYAML >= 6.0 +types-PyYAML >= 6.0 diff --git a/tests/test_rich_table.py b/tests/test_rich_table.py new file mode 100644 index 0000000000..b5fe0d8bfe --- /dev/null +++ b/tests/test_rich_table.py @@ -0,0 +1,426 @@ +from copy import deepcopy +from itertools import zip_longest + +import pytest +from rich.box import HEAVY_HEAD +from typer.rich_table import RichTable, TableConfig, rich_table_factory + +SIMPLE_DICT = { + "abc": "def", + "ghi": False, + "jkl": ["mno", "pqr", "stu"], + "vwx": [1, 2, 4], + 2: 3, + "yxa": None, +} + +LONG_VALUES = { + "mid-url": "https://typer.tiangolo.com/virtual-environments/#install-packages-directly", + "really looooooooooooooooooonnnng key value": "sna", + "long value": "a" * 75, + "long": "ftp://typer.tiangolo.com/virtual-environments/#install-packages-directly?12345678901234568901234567890123", +} + +INNER_LIST = { + "prop 1": "simple", + "prOp B": [ + {"name": "sna", "abc": "def", "ghi": True}, + {"name": "foo", "abc": "def", "ghi": None}, + {"name": "bar", "abc": "def", "ghi": 1.2345}, + {"abc": "def", "ghi": "blah"}, + ], + "Prop III": None, +} + +SIMPLE_LIST = [ + "str value", + "3", + 4, + False, + None, + "foo", +] + +UNSAFE_DICT = { + "[bold]rick[/]": "key has markup", + "value-markup": "This [red]body[/] has markup", + "simple-list": ["[red]abc[/]", "[yellow]def[/]"], + "complex-list": [ + { + "[green]name": "foo", + "body": "[//]contains escape", + }, + ], +} + + +def test_rich_table_defaults_outer(): + columns = ["col 1", "Column B", "III"] + uut = RichTable(*columns, outer=True) + assert len(uut.columns) == len(columns) + assert uut.highlight + assert uut.row_styles == [] + assert uut.caption_justify == "left" + assert uut.border_style is None + assert uut.leading == 0 + + assert uut.show_header + assert uut.show_edge + assert uut.box == HEAVY_HEAD + + for name, column in zip_longest(columns, uut.columns): + assert column.header == name + assert column.overflow == "ignore" + assert column.no_wrap + assert column.justify == "left" + + +def test_rich_table_defaults_inner(): + columns = ["col 1", "Column B", "III"] + uut = RichTable(*columns, outer=False) + assert len(uut.columns) == len(columns) + assert uut.highlight + assert uut.row_styles == [] + assert uut.caption_justify == "left" + assert uut.border_style is None + assert uut.leading == 0 + + assert not uut.show_header + assert not uut.show_edge + assert uut.box is None + + for name, column in zip_longest(columns, uut.columns): + assert column.header == name + assert column.overflow == "ignore" + assert column.no_wrap + assert column.justify == "left" + + +def test_create_table_not_obj(): + class TestData: + def __init__(self, value: int): + self.value = value + + with pytest.raises(ValueError) as excinfo: + data = [TestData(1), TestData(2), TestData(3)] + rich_table_factory(data) + + assert excinfo.match("Unable to create table for type list") + + +def test_create_table_simple_dict(): + uut = rich_table_factory(SIMPLE_DICT) + + # basic outer table stuff for object + assert len(uut.columns) == 2 + assert uut.show_header + assert uut.show_edge + assert not uut.show_lines + + # data-driven info + assert uut.row_count == 6 + + +def test_create_table_list_nameless_dict(): + items = [SIMPLE_DICT, SIMPLE_DICT, {"foo": "bar"}] + uut = rich_table_factory(items) + + # basic outer table stuff for object + assert len(uut.columns) == 1 + assert uut.show_header + assert uut.show_edge + assert uut.show_lines + + # data-driven info + assert uut.row_count == len(items) + + +def test_create_table_list_named_dict(): + names = ["sna", "foo", "bar", "baz"] + items = [] + for name in names: + item = deepcopy(SIMPLE_DICT) + item["name"] = name + items.append(item) + + uut = rich_table_factory(items) + + # basic outer table stuff for object + assert len(uut.columns) == 2 + assert uut.show_header + assert uut.show_edge + assert uut.show_lines + + # data-driven info + assert uut.row_count == len(items) + assert uut.caption == f"Found {len(items)} items" + + col0 = uut.columns[0] + col1 = uut.columns[1] + for left, right, name, item in zip_longest(col0._cells, col1._cells, names, items): + assert left == name + inner_keys = right.columns[0]._cells + item_keys = [str(k) for k in item.keys() if k != "name"] + assert inner_keys == item_keys + + +def test_create_table_truncted(): + data = deepcopy(LONG_VALUES) + + uut = rich_table_factory(data) + + assert uut.row_count == 4 + col0 = uut.columns[0] + col1 = uut.columns[1] + + assert col0.header == "Property" + assert col1.header == "Value" + + # url has longer length than "normal" fields + index = 0 + left = col0._cells[index] + right = col1._cells[index] + assert left == "mid-url" + assert ( + right + == "https://typer.tiangolo.com/virtual-environments/#install-packages-directly" + ) + + # keys get truncated at 35 characters + index = 1 + left = col0._cells[index] + right = col1._cells[index] + assert left == "really looooooooooooooooooonnnng..." + assert right == "sna" + + # non-url values get truncated at 50 characters + index = 2 + left = col0._cells[index] + right = col1._cells[index] + assert left == "long value" + assert right == "a" * 47 + "..." + + # really long urls get truncated at 100 characters + index = 3 + left = col0._cells[index] + right = col1._cells[index] + assert left == "long" + assert ( + right + == "ftp://typer.tiangolo.com/virtual-environments/#install-packages-directly?123456789012345689012345..." + ) + + +def test_create_table_inner_list(): + data = deepcopy(INNER_LIST) + + uut = rich_table_factory(data) + assert uut.row_count == 3 + assert len(uut.columns) == 2 + col0 = uut.columns[0] + col1 = uut.columns[1] + + left = col0._cells[0] + right = col1._cells[0] + assert left == "prop 1" + assert right == "simple" + + left = col0._cells[2] + right = col1._cells[2] + assert left == "Prop III" + assert right == "None" + + left = col0._cells[1] + inner = col1._cells[1] + assert left == "prOp B" + assert len(inner.columns) == 2 + assert inner.row_count == 4 + names = inner.columns[0]._cells + assert names == ["sna", "foo", "bar", "Unknown"] + + +def test_create_table_config_truncated(): + config = TableConfig(url_max_len=16, value_max_len=20, key_max_len=4) + data = deepcopy(LONG_VALUES) + + uut = rich_table_factory(data, config) + + assert uut.row_count == 4 + col0 = uut.columns[0] + col1 = uut.columns[1] + + # keys truncated at 4 characters, and urls at 14 + index = 0 + left = col0._cells[index] + right = col1._cells[index] + assert left == "m..." + assert right == "https://typer..." + + # keys truncated at 4 characters, and short fields are not truncated + index = 1 + left = col0._cells[index] + right = col1._cells[index] + assert left == "r..." + assert right == "sna" + + # non-url values get truncated at 20 characters + index = 2 + left = col0._cells[index] + right = col1._cells[index] + assert left == "l..." + assert right == "a" * 17 + "..." + + # really long urls get truncated at 16 characters + index = 3 + left = col0._cells[index] + right = col1._cells[index] + assert left == "l..." + assert right == "ftp://typer.t..." + + +def test_create_table_config_fields(): + config = TableConfig( + url_max_len=16, + value_max_len=50, + key_max_len=100, + url_prefixes=["https://"], # do NOT recognize ftp as a prefix + property_label="foo", + value_label="bar", + ) + data = deepcopy(LONG_VALUES) + + uut = rich_table_factory(data, config) + + assert uut.row_count == 4 + col0 = uut.columns[0] + col1 = uut.columns[1] + + assert col0.header == "foo" + assert col1.header == "bar" + + # keys truncated at 4 characters, and urls at 16 + index = 0 + left = col0._cells[index] + right = col1._cells[index] + assert left == "mid-url" + assert right == "https://typer..." + + # keys truncated at 4 characters, and short fields are not truncated + index = 1 + left = col0._cells[index] + right = col1._cells[index] + assert left == "really looooooooooooooooooonnnng key value" + assert right == "sna" + + # non-url values get truncated at 20 characters + index = 2 + left = col0._cells[index] + right = col1._cells[index] + assert left == "long value" + assert right == "a" * 47 + "..." + + # ftp is NOT a URL, so it gets truncated at 50 characerts + index = 3 + left = col0._cells[index] + right = col1._cells[index] + assert left == "long" + assert right == "ftp://typer.tiangolo.com/virtual-environments/#..." + + +def test_create_table_config_inner_list(): + data = deepcopy(INNER_LIST) + config = TableConfig( + key_fields=["ghi"], + properties_label="Different", + ) + + uut = rich_table_factory(data, config) + assert uut.row_count == 3 + assert len(uut.columns) == 2 + col0 = uut.columns[0] + col1 = uut.columns[1] + + left = col0._cells[0] + right = col1._cells[0] + assert left == "prop 1" + assert right == "simple" + + left = col0._cells[2] + right = col1._cells[2] + assert left == "Prop III" + assert right == "None" + + left = col0._cells[1] + inner = col1._cells[1] + assert left == "prOp B" + assert len(inner.columns) == 2 + assert inner.columns[0].header == "Ghi" + assert inner.columns[1].header == "Different" + assert inner.row_count == 4 + names = inner.columns[0]._cells + assert names == ["True", "None", "1.2345", "blah"] + + +def test_create_table_simple_list(): + data = deepcopy(SIMPLE_LIST) + config = TableConfig( + items_caption="Got {} simple things", + items_label="Simple stuff", + ) + uut = rich_table_factory(data, config) + assert uut.row_count == 6 + assert len(uut.columns) == 1 + + col0 = uut.columns[0] + assert col0.header == "Simple stuff" + assert uut.caption == "Got 6 simple things" + + # check the values + assert col0._cells[0] == "str value" + assert col0._cells[1] == "3" + assert col0._cells[2] == "4" + assert col0._cells[3] == "False" + assert col0._cells[4] == "None" + assert col0._cells[5] == "foo" + + +def test_unsafe_table(): + data = deepcopy(UNSAFE_DICT) + config = TableConfig(key_fields=["[green]name"]) + uut = rich_table_factory(data, config) + assert uut.row_count == 4 + assert len(uut.columns) == 2 + + col0 = uut.columns[0] + col1 = uut.columns[1] + + # check headers + assert col0.header == "Property" + assert col1.header == "Value" + + # check the values + assert col0._cells[0] == "\\[bold]rick\\[/]" + assert col1._cells[0] == "key has markup" + + assert col0._cells[1] == "value-markup" + assert col1._cells[1] == "This \\[red]body\\[/] has markup" + + assert col0._cells[2] == "simple-list" + assert col1._cells[2] == "\\[red]abc\\[/], \\[yellow]def\\[/]" + + assert col0._cells[3] == "complex-list" + inner = col1._cells[3] + assert inner.row_count == 1 + assert len(inner.columns) == 2 + ic0 = inner.columns[0] + ic1 = inner.columns[1] + + assert ic0._cells[0] == "foo" + deeper = ic1._cells[0] + assert deeper.row_count == 1 + assert len(deeper.columns) == 2 + + dc0 = deeper.columns[0] + dc1 = deeper.columns[1] + assert dc0._cells[0] == "body" + assert dc1._cells[0] == "\\[//]contains escape" diff --git a/tests/test_rich_utils.py b/tests/test_rich_utils.py index c62e3512aa..e47fafc58c 100644 --- a/tests/test_rich_utils.py +++ b/tests/test_rich_utils.py @@ -1,5 +1,11 @@ +import json +from copy import deepcopy + +import pytest import typer import typer.completion +import yaml +from typer.rich_utils import OutputFormat, TableConfig, print_rich_object from typer.testing import CliRunner runner = CliRunner() @@ -50,3 +56,125 @@ def main() -> None: assert result.exit_code == 0 assert "Show this message" in result.stdout + + +DATA = [ + {"name": "sna", "prop1": 1, "prop B": None, "blah": "zay"}, + { + "name": "foo", + "prop2": 2, + "prop B": True, + }, + { + "name": "bar", + 1: "inverse", + }, +] + + +EXPECTED_JSON = json.dumps(DATA, indent=2) +EXPECTED_YAML = yaml.dump(DATA) +EXPECTED_TEXT = """\ +┏━━━━━━┳━━━━━━━━━━━━━━━━┓ +┃ Name ┃ Properties ┃ +┡━━━━━━╇━━━━━━━━━━━━━━━━┩ +│ sna │ prop1 1 │ +│ │ prop B None │ +│ │ blah zay │ +├──────┼────────────────┤ +│ foo │ prop2 2 │ +│ │ prop B True │ +├──────┼────────────────┤ +│ bar │ 1 inverse │ +└──────┴────────────────┘ +Found 3 items""" + +CONFIGED_TEXT = """\ +┏━━━━━━┳━━━━━━━━━━━━━━━━┓ +┃ Name ┃ Inner ┃ +┡━━━━━━╇━━━━━━━━━━━━━━━━┩ +│ sna │ prop1 1 │ +│ │ prop B None │ +│ │ blah zay │ +├──────┼────────────────┤ +│ foo │ prop2 2 │ +│ │ prop B True │ +├──────┼────────────────┤ +│ bar │ 1 inverse │ +└──────┴────────────────┘ +This shows 3 items""" + + +def prepare(s: str) -> str: + """ + Return string with '.' in place of all non-ASCII characters (other than newlines). + + This avoids differences in terminal output for non-ASCII characters like, table borders. The + newline is passed through to let original look "almost" like the modified version. + """ + return "".join( + char if 31 < ord(char) < 127 or char == "\n" else "." for char in s + ).rstrip() + + +@pytest.mark.parametrize( + "output_format, expected", + [ + pytest.param("json", EXPECTED_JSON, id="json"), + pytest.param("yaml", EXPECTED_YAML, id="yaml"), + pytest.param("text", EXPECTED_TEXT, id="text"), + ], +) +def test_rich_object_data(output_format, expected): + app = typer.Typer() + + @app.command() + def print_rich_data(output_format: OutputFormat): + print_rich_object(deepcopy(DATA), out_fmt=output_format) + + result = runner.invoke(app, [output_format]) + assert result.exit_code == 0 + output = prepare(result.stdout) + prepared = prepare(expected) + assert output == prepared + + +@pytest.mark.parametrize( + "output_format, expected", + [ + pytest.param("json", "null\n", id="json"), + pytest.param("yaml", "null\n...\n\n", id="yaml"), + pytest.param("text", "Nothing found\n", id="text"), + ], +) +def test_rich_object_none(output_format, expected): + app = typer.Typer() + + @app.command() + def print_rich_none(output_format: OutputFormat): + print_rich_object(None, out_fmt=output_format) + + result = runner.invoke(app, [output_format]) + assert result.exit_code == 0 + output = prepare(result.stdout) + prepared = prepare(expected) + assert output == prepared + + +def test_rich_object_config(): + app = typer.Typer() + + @app.command() + def print_rich_data(): + config = TableConfig( + row_properties={"justify": "right", "no_wrap": True, "overflow": "ignore"}, + properties_label="Inner", + items_caption="This shows {} items", + ) + print_rich_object(DATA, config=config) + + result = runner.invoke(app, []) + assert result.exit_code == 0 + output = prepare(result.stdout) + prepared = prepare(deepcopy(CONFIGED_TEXT)) + assert output == prepared diff --git a/typer/rich_table.py b/typer/rich_table.py new file mode 100644 index 0000000000..b352c21d84 --- /dev/null +++ b/typer/rich_table.py @@ -0,0 +1,253 @@ +from gettext import gettext +from typing import Any, Dict, List, Optional + +from rich.box import HEAVY_HEAD +from rich.markup import escape +from rich.table import Table + +DEFAULT_ROW_PROPS = { + "justify": "left", + "no_wrap": True, + "overflow": "ignore", +} + +# allow for i18n/l8n +ITEMS = gettext("Items") +PROPERTY = gettext("Property") +PROPERTIES = gettext("Properties") +VALUE = gettext("Value") +VALUES = gettext("Values") +UNKNOWN = gettext("Unknown") +FOUND_ITEMS = gettext("Found {} items") +ELLIPSIS = gettext("...") + +OBJECT_HEADERS = [PROPERTY, VALUE] + +KEY_FIELDS = ["name", "id"] +URL_PREFIXES = ["http://", "https://", "ftp://"] + +KEY_MAX_LEN = 35 +VALUE_MAX_LEN = 50 +URL_MAX_LEN = 100 + + +# NOTE: the key field of dictionaries are expected to be be `str`, `int`, `float`, but use +# `Any` readability. + + +class TableConfig: + """This data class provides a means for customizing the table outputs. + + The defaults provide a standard look and feel, but can be overridden to all customization. + """ + + def __init__( + self, + items_label: str = ITEMS, + property_label: str = PROPERTY, + properties_label: str = PROPERTIES, + value_label: str = VALUE, + values_label: str = VALUES, + unknown_label: str = UNKNOWN, + items_caption: str = FOUND_ITEMS, + url_prefixes: List[str] = URL_PREFIXES, + url_max_len: int = URL_MAX_LEN, + key_fields: List[str] = KEY_FIELDS, + key_max_len: int = KEY_MAX_LEN, + value_max_len: int = VALUE_MAX_LEN, + row_properties: Dict[str, Any] = DEFAULT_ROW_PROPS, + ): + self.items_label = items_label + self.property_label = property_label + self.properties_label = properties_label + self.value_label = value_label + self.values_label = values_label + self.unknown_label = unknown_label + self.items_caption = items_caption + self.url_prefixes = url_prefixes + self.url_max_len = url_max_len + self.key_fields = key_fields + self.key_max_len = key_max_len + self.value_max_len = value_max_len + self.row_properties = row_properties + + +class RichTable(Table): + """ + This is wrapper around the rich.Table to provide some methods for adding complex items. + """ + + def __init__( + self, + *args: Any, + outer: bool = True, + row_props: Dict[str, Any] = DEFAULT_ROW_PROPS, + **kwargs: Any, + ): + super().__init__( + # items with "regular" defaults + highlight=kwargs.pop("highlight", True), + row_styles=kwargs.pop("row_styles", None), + expand=kwargs.pop("expand", False), + caption_justify=kwargs.pop("caption_justify", "left"), + border_style=kwargs.pop("border_style", None), + leading=kwargs.pop( + "leading", 0 + ), # warning: setting to non-zero disables lines + # these items take queues from `outer` + show_header=kwargs.pop("show_header", outer), + show_edge=kwargs.pop("show_edge", outer), + box=HEAVY_HEAD if outer else None, + **kwargs, + ) + for name in args: + self.add_column(name, **row_props) + + +def _truncate(s: str, max_length: int) -> str: + """Truncates the provided string to a maximum of max_length (including elipsis)""" + if len(s) < max_length: + return s + return s[: max_length - 3] + ELLIPSIS + + +def _get_name_key(item: Dict[Any, Any], key_fields: List[str]) -> Optional[str]: + """Attempts to find an identifying value.""" + for k in key_fields: + key = str(k) + if key in item: + return key + + return None + + +def _is_url(s: str, url_prefixes: List[str]) -> bool: + """Rudimentary check for somethingt starting with URL prefix""" + return any(s.startswith(p) for p in url_prefixes) + + +def _safe(v: Any) -> str: + """Converts 'v' to a string that is properly escaped.""" + return escape(str(v)) + + +def _create_list_table( + items: List[Dict[Any, Any]], outer: bool, config: TableConfig +) -> RichTable: + """Creates a table from a list of dictionary items. + + If an identifying "name key" is found (in the first entry), the table will have 2 columns: name, Properties + If no identifying "name key" is found, the table will be a single column table with the properties. + + NOTE: nesting is done as needed + """ + caption = config.items_caption.format(len(items)) if outer else None + name_key = _get_name_key(items[0], config.key_fields) + if not name_key: + # without identifiers just create table with one "Values" column + table = RichTable( + config.values_label, + outer=outer, + show_lines=True, + caption=caption, + row_props=config.row_properties, + ) + for item in items: + table.add_row(_table_cell_value(item, config)) + return table + + # create a table with identifier in left column, and rest of data in right column + name_label = name_key[0].upper() + name_key[1:] + fields = [name_label, config.properties_label] + table = RichTable( + *fields, + outer=outer, + show_lines=True, + caption=caption, + row_props=config.row_properties, + ) + for item in items: + # id may be an int, so convert to string before truncating + name = _safe(item.pop(name_key, config.unknown_label)) + body = _table_cell_value(item, config) + table.add_row(_truncate(name, config.key_max_len), body) + + return table + + +def _create_object_table( + obj: Dict[Any, Any], outer: bool, config: TableConfig +) -> RichTable: + """Creates a table of a dictionary object. + + NOTE: nesting is done in the right column as needed. + """ + headers = [config.property_label, config.value_label] + table = RichTable( + *headers, outer=outer, show_lines=False, row_props=config.row_properties + ) + for k, v in obj.items(): + name = _safe(k) + table.add_row(_truncate(name, config.key_max_len), _table_cell_value(v, config)) + + return table + + +def _table_cell_value(obj: Any, config: TableConfig) -> Any: + """Creates the "inner" value for a table cell. + + Depending on the input value type, the cell may look different. If a dict, or list[dict], + an inner table is created. Otherwise, the object is converted to a printable value. + """ + value: Any = None + if isinstance(obj, dict): + value = _create_object_table(obj, outer=False, config=config) + elif isinstance(obj, list) and obj: + if isinstance(obj[0], dict): + value = _create_list_table(obj, outer=False, config=config) + else: + values = [str(x) for x in obj] + s = _safe(", ".join(values)) + value = _truncate(s, config.value_max_len) + else: + s = _safe(obj) + max_len = ( + config.url_max_len + if _is_url(s, config.url_prefixes) + else config.value_max_len + ) + value = _truncate(s, max_len) + + return value + + +def rich_table_factory(obj: Any, config: Optional[TableConfig] = None) -> RichTable: + """Create a RichTable (alias for rich.table.Table) from the object.""" + config = config or TableConfig() + if isinstance(obj, dict): + return _create_object_table(obj, outer=True, config=config) + + if isinstance(obj, list) and obj and isinstance(obj[0], dict): + return _create_list_table(obj, outer=True, config=config) + + # this is a list of "simple" properties + if ( + isinstance(obj, list) + and obj + and all( + item is None or isinstance(item, (str, float, bool, int)) for item in obj + ) + ): + caption = config.items_caption.format(len(obj)) + table = RichTable( + config.items_label, + outer=True, + show_lines=True, + caption=caption, + row_props=config.row_properties, + ) + for item in obj: + table.add_row(_table_cell_value(item, config)) + return table + + raise ValueError(f"Unable to create table for type {type(obj).__name__}") diff --git a/typer/rich_utils.py b/typer/rich_utils.py index 4b6c5a840f..b68e409db4 100644 --- a/typer/rich_utils.py +++ b/typer/rich_utils.py @@ -4,11 +4,13 @@ import io import sys from collections import defaultdict +from enum import Enum from gettext import gettext as _ from os import getenv from typing import Any, DefaultDict, Dict, Iterable, List, Optional, Union import click +import yaml from rich import box from rich.align import Align from rich.columns import Columns @@ -22,6 +24,8 @@ from rich.text import Text from rich.theme import Theme +from .rich_table import TableConfig, rich_table_factory + if sys.version_info >= (3, 9): from typing import Literal else: @@ -739,3 +743,46 @@ def rich_render_text(text: str) -> str: """Remove rich tags and render a pure text representation""" console = _get_rich_console() return "".join(segment.text for segment in console.render(text)).rstrip("\n") + + +class OutputFormat(str, Enum): + TEXT = "text" + JSON = "json" + YAML = "yaml" + + +def print_rich_object_for_console( + console: Console, + obj: Any, + out_fmt: OutputFormat = OutputFormat.TEXT, + indent: int = 2, + config: TableConfig = TableConfig(), +) -> None: + """Print rich version of the provided object in the specified format using provided `Console`.""" + if out_fmt == OutputFormat.JSON: + console.print_json(data=obj, indent=indent) + return + + if out_fmt == OutputFormat.YAML: + console.print(yaml.dump(obj, indent=indent)) + return + + if not obj: + console.print("Nothing found") + return + + table = rich_table_factory(obj, config) + console.print(table) + + +def print_rich_object( + obj: Any, + out_fmt: OutputFormat = OutputFormat.TEXT, + indent: int = 2, + config: TableConfig = TableConfig(), +) -> None: + """Print rich version of the provided object in the specified format.""" + console = _get_rich_console() + print_rich_object_for_console( + console, obj, out_fmt=out_fmt, indent=indent, config=config + )