From 5d3cc7ea8cbab1c631fae4cc4a50a2b24075d5fb Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Mon, 7 Mar 2022 11:38:51 +0000 Subject: [PATCH 1/9] Add support for named tuples to pretty --- rich/pretty.py | 53 ++++++++++++++++++++++++++++++++++++++++++++ tests/test_pretty.py | 35 +++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/rich/pretty.py b/rich/pretty.py index 216075af..ffa3404e 100644 --- a/rich/pretty.py +++ b/rich/pretty.py @@ -524,6 +524,18 @@ class _Line: ) +def _is_namedtuple(obj: Any) -> bool: + base_classes = getattr(type(obj), "__bases__", []) + if len(base_classes) != 1 or base_classes[0] != tuple: + return False + + fields = getattr(obj, "_fields", None) + if not fields or not isinstance(fields, tuple): + return False + + return all(type(field) == str for field in fields) + + def traverse( _object: Any, max_length: Optional[int] = None, @@ -731,7 +743,32 @@ def traverse( append(child_node) pop_visited(obj_id) + elif _is_namedtuple(obj): + obj_id = id(obj) + if obj_id in visited_ids: + # Recursion detected + return Node(value_repr="...") + push_visited(obj_id) + 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()): + child_node = _traverse(value, depth=depth + 1) + child_node.key_repr = key + child_node.last = last + child_node.key_separator = "=" + append(child_node) + + pop_visited(obj_id) elif _safe_isinstance(obj, _CONTAINERS): for container_type in _CONTAINERS: if _safe_isinstance(obj, container_type): @@ -878,6 +915,15 @@ if __name__ == "__main__": # pragma: no cover 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 = { @@ -904,6 +950,13 @@ if __name__ == "__main__": # pragma: no cover ] ), "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 diff --git a/tests/test_pretty.py b/tests/test_pretty.py index 02488f46..c2b698a4 100644 --- a/tests/test_pretty.py +++ b/tests/test_pretty.py @@ -169,6 +169,41 @@ def test_pretty_dataclass(): assert result == "ExampleDataclass(foo=1000, bar=..., baz=['foo', 'bar', 'baz'])" +def test_pretty_namedtuple(): + console = Console(color_system=None) + console.begin_capture() + from typing import NamedTuple + + class StockKeepingUnit(NamedTuple): + name: str + description: str + price: float + category: str + reviews: List[str] + + 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_small_width(): test = ["Hello world! 12345"] result = pretty_repr(test, max_width=10) From a95c1f1c48e0c50131b672f32f9c978daf8eca0e Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Mon, 7 Mar 2022 11:42:03 +0000 Subject: [PATCH 2/9] Update changelog r.e. support for namedtuple in pretty --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a0e3e51..588be1e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,4 @@ + # Changelog All notable changes to this project will be documented in this file. @@ -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 From 934c6ef7209b4891ef4c9627db7eb825ff5cd06a Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Mon, 7 Mar 2022 13:31:56 +0000 Subject: [PATCH 3/9] Testing pretty.py - objects similar to namedtuples, namedtuple max_depth --- rich/pretty.py | 9 --------- tests/test_pretty.py | 33 ++++++++++++++++++++++++--------- 2 files changed, 24 insertions(+), 18 deletions(-) diff --git a/rich/pretty.py b/rich/pretty.py index ffa3404e..a9e1a56a 100644 --- a/rich/pretty.py +++ b/rich/pretty.py @@ -744,12 +744,6 @@ def traverse( pop_visited(obj_id) elif _is_namedtuple(obj): - obj_id = id(obj) - if obj_id in visited_ids: - # Recursion detected - return Node(value_repr="...") - push_visited(obj_id) - children = [] append = children.append if reached_max_depth: @@ -760,15 +754,12 @@ def traverse( close_brace=")", children=children, ) - 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) - - pop_visited(obj_id) elif _safe_isinstance(obj, _CONTAINERS): for container_type in _CONTAINERS: if _safe_isinstance(obj, container_type): diff --git a/tests/test_pretty.py b/tests/test_pretty.py index c2b698a4..d9539efe 100644 --- a/tests/test_pretty.py +++ b/tests/test_pretty.py @@ -3,7 +3,7 @@ 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 @@ -169,17 +169,17 @@ 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() - from typing import NamedTuple - - class StockKeepingUnit(NamedTuple): - name: str - description: str - price: float - category: str - reviews: List[str] example_namedtuple = StockKeepingUnit( "Sparkling British Spring Water", @@ -204,6 +204,21 @@ def test_pretty_namedtuple(): ) +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) From aa3bc79a4f990839b41ce397ca17b2ce45517e90 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Mon, 7 Mar 2022 13:38:42 +0000 Subject: [PATCH 4/9] Add a docstring to pretty._is_namedtuple --- rich/pretty.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/rich/pretty.py b/rich/pretty.py index a9e1a56a..0eb29e73 100644 --- a/rich/pretty.py +++ b/rich/pretty.py @@ -525,6 +525,16 @@ class _Line: 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 miniscule 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: return False From 06a3edb6b4fa2c811eca9323c78876768ad7a5c4 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Mon, 7 Mar 2022 13:41:25 +0000 Subject: [PATCH 5/9] "Fix" spelling in docstring --- rich/pretty.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rich/pretty.py b/rich/pretty.py index 0eb29e73..d16c55dc 100644 --- a/rich/pretty.py +++ b/rich/pretty.py @@ -527,7 +527,7 @@ class _Line: 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 miniscule chance of this happening unintentionally. + there is only a minuscule chance of this happening unintentionally. Args: obj (Any): The object to test From 26910c309d75b835a89348e4dffb8a588d72f652 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 8 Mar 2022 11:38:52 +0000 Subject: [PATCH 6/9] In Pretty, when namedtuples have custom reprs, use them instead of our own --- rich/pretty.py | 31 ++++++++++++++++++++++++++----- tests/test_pretty.py | 19 +++++++++++++++++++ 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/rich/pretty.py b/rich/pretty.py index d16c55dc..a7c68922 100644 --- a/rich/pretty.py +++ b/rich/pretty.py @@ -1,4 +1,5 @@ import builtins +import collections import dataclasses import inspect import os @@ -30,7 +31,6 @@ try: except ImportError: # pragma: no cover _attr_module = None # type: ignore - from . import get_console from ._loop import loop_last from ._pick import pick_bool @@ -79,6 +79,24 @@ def _is_dataclass_repr(obj: object) -> bool: return False +_dummy_namedtuple = collections.namedtuple("dummy", []) + + +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__) + default_repr_file = inspect.getfile(_dummy_namedtuple.__repr__) + print(obj_file, default_repr_file) + return obj_file == default_repr_file + + def _ipy_display_hook( value: Any, console: Optional["Console"] = None, @@ -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 = ", " @@ -397,7 +416,7 @@ class Node: 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: @@ -543,7 +562,7 @@ def _is_namedtuple(obj: Any) -> bool: if not fields or not isinstance(fields, tuple): return False - return all(type(field) == str for field in fields) + return True def traverse( @@ -753,7 +772,7 @@ def traverse( append(child_node) pop_visited(obj_id) - elif _is_namedtuple(obj): + elif _is_namedtuple(obj) and _has_default_namedtuple_repr(obj): children = [] append = children.append if reached_max_depth: @@ -765,6 +784,7 @@ def traverse( children=children, ) for last, (key, value) in loop_last(obj._asdict().items()): + print(last, key, value) child_node = _traverse(value, depth=depth + 1) child_node.key_repr = key child_node.last = last @@ -818,7 +838,7 @@ def traverse( 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) @@ -826,6 +846,7 @@ def traverse( 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) diff --git a/tests/test_pretty.py b/tests/test_pretty.py index d9539efe..d45b0c22 100644 --- a/tests/test_pretty.py +++ b/tests/test_pretty.py @@ -1,3 +1,4 @@ +import collections import io import sys from array import array @@ -204,6 +205,24 @@ def test_pretty_namedtuple(): ) +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" From 709b6d39bffb099dec24662ca3d523087cef456d Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 8 Mar 2022 11:41:48 +0000 Subject: [PATCH 7/9] Fix namedtuple typing error --- rich/pretty.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rich/pretty.py b/rich/pretty.py index a7c68922..4ad5f722 100644 --- a/rich/pretty.py +++ b/rich/pretty.py @@ -79,7 +79,7 @@ def _is_dataclass_repr(obj: object) -> bool: return False -_dummy_namedtuple = collections.namedtuple("dummy", []) +_dummy_namedtuple = collections.namedtuple("_dummy_namedtuple", []) def _has_default_namedtuple_repr(obj: object) -> bool: From 4febd6e4aa7ae9d4d27f53d136acabfa5c1755cb Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 8 Mar 2022 13:38:54 +0000 Subject: [PATCH 8/9] More defensive introspection in pretty.py for namedtuples --- rich/pretty.py | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/rich/pretty.py b/rich/pretty.py index 4ad5f722..a3d1f747 100644 --- a/rich/pretty.py +++ b/rich/pretty.py @@ -91,9 +91,18 @@ def _has_default_namedtuple_repr(obj: object) -> bool: Returns: bool: True if the default repr is used, False if there's a custom repr. """ - obj_file = inspect.getfile(obj.__repr__) - default_repr_file = inspect.getfile(_dummy_namedtuple.__repr__) - print(obj_file, default_repr_file) + obj_file = None + default_repr_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 + try: + default_repr_file = inspect.getfile(_dummy_namedtuple.__repr__) + except (OSError, TypeError): + pass return obj_file == default_repr_file @@ -554,15 +563,8 @@ def _is_namedtuple(obj: Any) -> bool: 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: - return False - fields = getattr(obj, "_fields", None) - if not fields or not isinstance(fields, tuple): - return False - - return True + return isinstance(obj, tuple) and isinstance(fields, tuple) def traverse( @@ -784,7 +786,6 @@ def traverse( children=children, ) for last, (key, value) in loop_last(obj._asdict().items()): - print(last, key, value) child_node = _traverse(value, depth=depth + 1) child_node.key_repr = key child_node.last = last From 5d64431242cfb9529ca36302c91302dd9f22b9b4 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 8 Mar 2022 14:06:20 +0000 Subject: [PATCH 9/9] Reworking pretty namedtuples support, more defensive --- rich/pretty.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/rich/pretty.py b/rich/pretty.py index a3d1f747..57f3e62e 100644 --- a/rich/pretty.py +++ b/rich/pretty.py @@ -92,17 +92,13 @@ def _has_default_namedtuple_repr(obj: object) -> bool: bool: True if the default repr is used, False if there's a custom repr. """ obj_file = None - default_repr_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 - try: - default_repr_file = inspect.getfile(_dummy_namedtuple.__repr__) - except (OSError, TypeError): - pass + default_repr_file = inspect.getfile(_dummy_namedtuple.__repr__) return obj_file == default_repr_file @@ -563,7 +559,11 @@ def _is_namedtuple(obj: Any) -> bool: Returns: bool: True if the object is a namedtuple. False otherwise. """ - fields = getattr(obj, "_fields", None) + 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) @@ -775,16 +775,18 @@ def traverse( 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: + children = [] + class_name = obj.__class__.__name__ node = Node( - open_brace=f"{obj.__class__.__name__}(", + 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