Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ Add utilities for printing complex JSON objects #1099

Open
wants to merge 24 commits into
base: master
Choose a base branch
from
Open
Changes from 1 commit
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
1464947
Add code to create a RichTable for a complex object
rickwporter Dec 20, 2024
9d585ce
Add .DS_Store to .gitignore for Mac users
rickwporter Dec 20, 2024
62c8569
Add print_rich_object() to print a complex object in json|yaml|text f…
rickwporter Dec 20, 2024
7106ed1
Refactor: add function that allows for providing own console
rickwporter Dec 20, 2024
9efbf73
🎨 [pre-commit.ci] Auto format from pre-commit.com hooks
pre-commit-ci[bot] Dec 20, 2024
944d48b
Add missing dependency
rickwporter Dec 20, 2024
0c0f863
More dependency work
rickwporter Dec 20, 2024
f454bf0
Split/strip output to avoid tests failing because pre-commit removes …
rickwporter Dec 20, 2024
d1d6a30
Use startswith to check rich output
rickwporter Dec 21, 2024
53ebe69
Fix the right test this time... sigh
rickwporter Dec 21, 2024
32dc2b1
Use List/Dict from typing instead of builtins
rickwporter Dec 22, 2024
c411de3
Try to avoid CR issues on Windows
rickwporter Dec 22, 2024
df57719
Another typing fix
rickwporter Dec 22, 2024
29c61e2
Improve debugability
rickwporter Dec 22, 2024
53abdbb
Attempt to avoid differences in non-ASCII terminal outputs
rickwporter Dec 22, 2024
38b114f
Add TableConfig to allow table customization
rickwporter Jan 1, 2025
d6f760c
Allow print_rich_object() to take a TableConfig
rickwporter Jan 1, 2025
a57f8e8
Different approach for testing
rickwporter Jan 1, 2025
08ce680
More test updates -- use local copies and full comparison (for easier…
rickwporter Jan 2, 2025
61162a5
Merge branch 'master' into rich-table
rickwporter Jan 7, 2025
97e4bec
Merge branch 'master' into rich-table
rickwporter Jan 10, 2025
8300017
Allow for printing list of simple properties
rickwporter Feb 23, 2025
2f242d6
Remove a couple unrelated, minor changes
rickwporter Feb 27, 2025
b55e162
Merge branch 'master' into rich-table
rickwporter Feb 27, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
Add code to create a RichTable for a complex object
  • Loading branch information
rickwporter committed Dec 20, 2024
commit 14649475a51b2671629eb9923c388cbb32ac4deb
206 changes: 206 additions & 0 deletions tests/test_rich_table.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
from copy import deepcopy
from itertools import zip_longest

import pytest
from rich.box import HEAVY_HEAD
from typer.rich_table import RichTable, rich_table_factory

SIMPLE_DICT = {
"abc": "def",
"ghi": False,
"jkl": ["mno", "pqr", "stu"],
"vwx": [1, 2, 4],
2: 3,
"yxa": None,
}


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():
with pytest.raises(ValueError) as excinfo:
rich_table_factory([1, 2, 3])

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 = {
"mid-url": "https://typer.tiangolo.com/virtual-environments/#install-packages-directly",
"really looooooooooooooooooonnnng key value": "sna",
"long value": "a" * 75,
"long": "https://typer.tiangolo.com/virtual-environments/#install-packages-directly?1234567890123456890123456",
}

uut = rich_table_factory(data)

assert uut.row_count == 4
col0 = uut.columns[0]
col1 = uut.columns[1]

# 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
== "https://typer.tiangolo.com/virtual-environments/#install-packages-directly?1234567890123456890123..."
)


def test_create_table_inner_list():
data = {
"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,
}

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"]
166 changes: 166 additions & 0 deletions typer/rich_table.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
from gettext import gettext
from typing import Any, Optional

from rich.box import HEAVY_HEAD
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 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]) -> 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) -> bool:
"""Rudimentary check for somethingt starting with URL prefix"""
return any(s.startswith(p) for p in URL_PREFIXES)


def _create_list_table(items: list[dict[Any, Any]], outer: bool) -> 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 = FOUND_ITEMS.format(len(items)) if outer else None
name_key = _get_name_key(items[0])
if not name_key:
# without identifiers just create table with one "Values" column
table = RichTable(VALUES, outer=outer, show_lines=True, caption=caption)
for item in items:
table.add_row(_table_cell_value(item))
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, PROPERTIES]
table = RichTable(*fields, outer=outer, show_lines=True, caption=caption)
for item in items:
# id may be an int, so convert to string before truncating
name = str(item.pop(name_key, UNKNOWN))
body = _table_cell_value(item)
table.add_row(_truncate(name, KEY_MAX_LEN), body)

return table


def _create_object_table(obj: dict[Any, Any], outer: bool) -> RichTable:
"""Creates a table of a dictionary object.

NOTE: nesting is done in the right column as needed.
"""
table = RichTable(*OBJECT_HEADERS, outer=outer, show_lines=False)
for k, v in obj.items():
name = str(k)
table.add_row(_truncate(name, KEY_MAX_LEN), _table_cell_value(v))

return table


def _table_cell_value(obj: Any) -> 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)
elif isinstance(obj, list) and obj:
if isinstance(obj[0], dict):
value = _create_list_table(obj, outer=False)
else:
values = [str(x) for x in obj]
s = str(", ".join(values))
value = _truncate(s, VALUE_MAX_LEN)
else:
s = str(obj)
max_len = URL_MAX_LEN if _is_url(s) else VALUE_MAX_LEN
value = _truncate(s, max_len)

return value


def rich_table_factory(obj: Any) -> RichTable:
"""Create a RichTable (alias for rich.table.Table) from the object."""
if isinstance(obj, dict):
return _create_object_table(obj, outer=True)

if isinstance(obj, list) and obj and isinstance(obj[0], dict):
return _create_list_table(obj, outer=True)

raise ValueError(f"Unable to create table for type {type(obj).__name__}")