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 support for named tuples to pretty #2031

Merged
merged 9 commits into from Mar 8, 2022
2 changes: 2 additions & 0 deletions CHANGELOG.md
@@ -1,3 +1,4 @@

# Changelog

All notable changes to this project will be documented in this file.
Expand All @@ -14,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added ProgressColumn `MofNCompleteColumn` to display raw `completed/total` column (similar to DownloadColumn,
but displays values as ints, does not convert to floats or add bit/bytes units).
https://github.com/Textualize/rich/pull/1941
- Add support for namedtuples to `Pretty` https://github.com/Textualize/rich/pull/2031

### Fixed

Expand Down
83 changes: 79 additions & 4 deletions rich/pretty.py
@@ -1,4 +1,5 @@
import builtins
import collections
import dataclasses
import inspect
import os
Expand Down Expand Up @@ -30,7 +31,6 @@
except ImportError: # pragma: no cover
_attr_module = None # type: ignore


from . import get_console
from ._loop import loop_last
from ._pick import pick_bool
Expand Down Expand Up @@ -79,6 +79,24 @@ def _is_dataclass_repr(obj: object) -> bool:
return False


_dummy_namedtuple = collections.namedtuple("_dummy_namedtuple", [])


def _has_default_namedtuple_repr(obj: object) -> bool:
"""Check if an instance of namedtuple contains the default repr

Args:
obj (object): A namedtuple

Returns:
bool: True if the default repr is used, False if there's a custom repr.
"""
obj_file = inspect.getfile(obj.__repr__)
darrenburns marked this conversation as resolved.
Show resolved Hide resolved
default_repr_file = inspect.getfile(_dummy_namedtuple.__repr__)
print(obj_file, default_repr_file)
darrenburns marked this conversation as resolved.
Show resolved Hide resolved
return obj_file == default_repr_file


def _ipy_display_hook(
value: Any,
console: Optional["Console"] = None,
Expand Down Expand Up @@ -383,6 +401,7 @@ class Node:
empty: str = ""
last: bool = False
is_tuple: bool = False
is_namedtuple: bool = False
children: Optional[List["Node"]] = None
key_separator = ": "
separator: str = ", "
Expand All @@ -397,7 +416,7 @@ def iter_tokens(self) -> Iterable[str]:
elif self.children is not None:
if self.children:
yield self.open_brace
if self.is_tuple and len(self.children) == 1:
if self.is_tuple and not self.is_namedtuple and len(self.children) == 1:
yield from self.children[0].iter_tokens()
yield ","
else:
Expand Down Expand Up @@ -524,6 +543,28 @@ def __str__(self) -> str:
)


def _is_namedtuple(obj: Any) -> bool:
willmcgugan marked this conversation as resolved.
Show resolved Hide resolved
"""Checks if an object is most likely a namedtuple. It is possible
to craft an object that passes this check and isn't a namedtuple, but
there is only a minuscule chance of this happening unintentionally.

Args:
obj (Any): The object to test

Returns:
bool: True if the object is a namedtuple. False otherwise.
"""
base_classes = getattr(type(obj), "__bases__", [])
if len(base_classes) != 1 or base_classes[0] != tuple:
darrenburns marked this conversation as resolved.
Show resolved Hide resolved
return False

fields = getattr(obj, "_fields", None)
if not fields or not isinstance(fields, tuple):
darrenburns marked this conversation as resolved.
Show resolved Hide resolved
return False

return True


def traverse(
_object: Any,
max_length: Optional[int] = None,
Expand Down Expand Up @@ -731,7 +772,24 @@ def iter_attrs() -> Iterable[
append(child_node)

pop_visited(obj_id)

elif _is_namedtuple(obj) and _has_default_namedtuple_repr(obj):
children = []
append = children.append
if reached_max_depth:
node = Node(value_repr="...")
else:
node = Node(
open_brace=f"{obj.__class__.__name__}(",
close_brace=")",
children=children,
)
for last, (key, value) in loop_last(obj._asdict().items()):
print(last, key, value)
darrenburns marked this conversation as resolved.
Show resolved Hide resolved
child_node = _traverse(value, depth=depth + 1)
child_node.key_repr = key
child_node.last = last
child_node.key_separator = "="
append(child_node)
elif _safe_isinstance(obj, _CONTAINERS):
for container_type in _CONTAINERS:
if _safe_isinstance(obj, container_type):
Expand Down Expand Up @@ -780,14 +838,15 @@ def iter_attrs() -> Iterable[
child_node.last = index == last_item_index
append(child_node)
if max_length is not None and num_items > max_length:
append(Node(value_repr=f"... +{num_items-max_length}", last=True))
append(Node(value_repr=f"... +{num_items - max_length}", last=True))
else:
node = Node(empty=empty, children=[], last=root)

pop_visited(obj_id)
else:
node = Node(value_repr=to_repr(obj), last=root)
node.is_tuple = _safe_isinstance(obj, tuple)
node.is_namedtuple = _is_namedtuple(obj)
return node

node = _traverse(_object, root=True)
Expand Down Expand Up @@ -878,6 +937,15 @@ def __repr__(self) -> str:
1 / 0
return "this will fail"

from typing import NamedTuple

class StockKeepingUnit(NamedTuple):
name: str
description: str
price: float
category: str
reviews: List[str]

d = defaultdict(int)
d["foo"] = 5
data = {
Expand All @@ -904,6 +972,13 @@ def __repr__(self) -> str:
]
),
"atomic": (False, True, None),
"namedtuple": StockKeepingUnit(
"Sparkling British Spring Water",
"Carbonated spring water",
0.9,
"water",
["its amazing!", "its terrible!"],
),
"Broken": BrokenRepr(),
}
data["foo"].append(data) # type: ignore
Expand Down
71 changes: 70 additions & 1 deletion tests/test_pretty.py
@@ -1,9 +1,10 @@
import collections
import io
import sys
from array import array
from collections import UserDict, defaultdict
from dataclasses import dataclass, field
from typing import List
from typing import List, NamedTuple

import attr
import pytest
Expand Down Expand Up @@ -169,6 +170,74 @@ def test_pretty_dataclass():
assert result == "ExampleDataclass(foo=1000, bar=..., baz=['foo', 'bar', 'baz'])"


class StockKeepingUnit(NamedTuple):
name: str
description: str
price: float
category: str
reviews: List[str]


def test_pretty_namedtuple():
console = Console(color_system=None)
console.begin_capture()

example_namedtuple = StockKeepingUnit(
"Sparkling British Spring Water",
"Carbonated spring water",
0.9,
"water",
["its amazing!", "its terrible!"],
)

result = pretty_repr(example_namedtuple)

print(result)
assert (
result
== """StockKeepingUnit(
name='Sparkling British Spring Water',
description='Carbonated spring water',
price=0.9,
category='water',
reviews=['its amazing!', 'its terrible!']
)"""
)


def test_pretty_namedtuple_length_one_no_trailing_comma():
instance = collections.namedtuple("Thing", ["name"])(name="Bob")
assert pretty_repr(instance) == "Thing(name='Bob')"


def test_pretty_namedtuple_empty():
instance = collections.namedtuple("Thing", [])()
assert pretty_repr(instance) == "Thing()"


def test_pretty_namedtuple_custom_repr():
class Thing(NamedTuple):
def __repr__(self):
return "XX"

assert pretty_repr(Thing()) == "XX"


def test_pretty_namedtuple_fields_invalid_type():
class LooksLikeANamedTupleButIsnt(tuple):
_fields = "blah"

instance = LooksLikeANamedTupleButIsnt()
result = pretty_repr(instance)
assert result == "()" # Treated as tuple


def test_pretty_namedtuple_max_depth():
instance = {"unit": StockKeepingUnit("a", "b", 1.0, "c", ["d", "e"])}
result = pretty_repr(instance, max_depth=1)
assert result == "{'unit': ...}"


def test_small_width():
test = ["Hello world! 12345"]
result = pretty_repr(test, max_width=10)
Expand Down