Skip to content

Commit

Permalink
Merge pull request #2031 from Textualize/pretty-named-tuples
Browse files Browse the repository at this point in the history
Add support for named tuples to pretty
  • Loading branch information
willmcgugan committed Mar 8, 2022
2 parents 8e649fe + 5d64431 commit 9b3ca31
Show file tree
Hide file tree
Showing 3 changed files with 154 additions and 5 deletions.
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
86 changes: 82 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,29 @@ 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 = None
try:
obj_file = inspect.getfile(obj.__repr__)
except (OSError, TypeError):
# OSError handles case where object is defined in __main__ scope, e.g. REPL - no filename available.
# TypeError trapped defensively, in case of object without filename slips through.
pass
default_repr_file = inspect.getfile(_dummy_namedtuple.__repr__)
return obj_file == default_repr_file


def _ipy_display_hook(
value: Any,
console: Optional["Console"] = None,
Expand Down Expand Up @@ -383,6 +406,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 +421,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 +548,25 @@ def __str__(self) -> str:
)


def _is_namedtuple(obj: Any) -> bool:
"""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.
"""
try:
fields = getattr(obj, "_fields", None)
except Exception:
# Being very defensive - if we cannot get the attr then its not a namedtuple
return False
return isinstance(obj, tuple) and isinstance(fields, tuple)


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

pop_visited(obj_id)

elif _is_namedtuple(obj) and _has_default_namedtuple_repr(obj):
if reached_max_depth:
node = Node(value_repr="...")
else:
children = []
class_name = obj.__class__.__name__
node = Node(
open_brace=f"{class_name}(",
close_brace=")",
children=children,
empty=f"{class_name}()",
)
append = children.append
for last, (key, value) in loop_last(obj._asdict().items()):
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 +841,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 +940,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 +975,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

0 comments on commit 9b3ca31

Please sign in to comment.