diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e8938f3..f0320afd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,8 +17,9 @@ Changelog follow https://keepachangelog.com/ format. * Add `contextvars` option: Fields annotated as `edc.ContextVars[T]` are wrapped in `contextvars.ContextVars`. * Fix error when using `_: dataclasses.KW_ONLY` + * `__repr__` now also pretty-print nested dataclasses, list, dict,... * `epy`: - * Better `epy.Lines.block` for custom pretty print classes, list,... + * Better `epy.Lines.make_block` for custom pretty print classes, list,... ## [1.2.0] - 2023-04-03 diff --git a/etils/edc/dataclass_utils.py b/etils/edc/dataclass_utils.py index 94e801f8..179bd6c6 100644 --- a/etils/edc/dataclass_utils.py +++ b/etils/edc/dataclass_utils.py @@ -303,7 +303,7 @@ def __repr__(self) -> str: # pylint: disable=invalid-name return epy.Lines.make_block( header=self.__class__.__name__, content={ - field.name: repr(getattr(self, field.name)) + field.name: getattr(self, field.name) for field in all_fields if field.repr }, diff --git a/etils/edc/dataclass_utils_test.py b/etils/edc/dataclass_utils_test.py index b89305e3..6925993d 100644 --- a/etils/edc/dataclass_utils_test.py +++ b/etils/edc/dataclass_utils_test.py @@ -34,7 +34,7 @@ class KwOnly: assert a.y == 2 with pytest.raises(TypeError, match='contructor is keyword-only.'): - _ = KwOnly(1, 2) + _ = KwOnly(1, 2) # pylint: disable=missing-kwoa @edc.dataclass @@ -104,18 +104,12 @@ class R1Field: def test_repr(): - assert repr(R(123, R11(y='abc'))) == epy.dedent( - """ + assert repr(R(123, R11(y='abc'))) == epy.dedent(""" R( x=123, - y=R11( - x=None, - y='abc', - z=None, - ), - ) - """ + y=R11(x=None, y='abc', z=None), ) + """) # Curstom __repr__ assert repr(R2()) == 'R2 repr' @@ -129,11 +123,4 @@ def test_repr(): x = R() x.x = x assert repr(x) == edc.repr(x) - assert repr(x) == epy.dedent( - """ - R( - x=..., - y=None, - ) - """ - ) + assert repr(x) == 'R(x=..., y=None)' diff --git a/etils/edc/frozen_utils_test.py b/etils/edc/frozen_utils_test.py index 8961de4e..fbe706d5 100644 --- a/etils/edc/frozen_utils_test.py +++ b/etils/edc/frozen_utils_test.py @@ -72,16 +72,11 @@ def test_unfrozen_call_twice(): y = x.y x.x = 123 - assert repr(x) == epy.dedent( - """ + assert repr(x) == epy.dedent(""" _MutableProxy(A( x=123, - y=A( - x=456, - y=None, - ), - ))""" - ) + y=A(x=456, y=None), + ))""") # Attribute still accessible assert x.not_a_dataclass_attr() == 123 diff --git a/etils/epy/text_utils.py b/etils/epy/text_utils.py index bc464488..48ff8a9d 100644 --- a/etils/epy/text_utils.py +++ b/etils/epy/text_utils.py @@ -19,7 +19,7 @@ import contextlib import dataclasses import textwrap -from typing import Iterable, Iterator, Union +from typing import Any, Iterable, Iterator, Union _BRACE_TO_BRACES = { '(': ('(', ')'), @@ -131,11 +131,11 @@ def join(self, *, collapse: bool = False) -> str: def make_block( cls, header: str = '', - content: str | dict[str, str] | list[str] | tuple[str, ...] = (), + content: str | dict[str, Any] | list[Any] | tuple[Any, ...] = (), *, braces: Union[str, tuple[str, str]] = '(', equal: str = '=', - limit: int = 10, + limit: int = 20, ) -> str: """Util function to create a code block. @@ -178,9 +178,9 @@ def make_block( content = [content] if isinstance(content, dict): - parts = [f'{k}{equal}{v}' for k, v in content.items()] + parts = [f'{k}{equal}{_repr_value(v)}' for k, v in content.items()] elif isinstance(content, (list, tuple)): - parts = [f'{v}' for v in content] + parts = [f'{_repr_value(v)}' for v in content] else: raise TypeError(f'Invalid fields {type(content)}') @@ -203,6 +203,38 @@ def make_block( return lines.join(collapse=collapse) + @classmethod + def repr(cls, obj: Any) -> str: + """Pretty print object.""" + return _repr_value(obj) + + +def _repr_value(obj: Any) -> str: + """Object representation, pretty-display for list, dict,...""" + from etils import edc # pylint: disable=g-import-not-at-top + + if isinstance(obj, str): + return repr(obj) + elif type(obj) in (list, tuple): # Skip sub-class as could have custom repr + return Lines.make_block( + content=obj, + braces='[' if isinstance(obj, list) else '(', + ) + elif type(obj) is dict: # pylint: disable=unidiomatic-typecheck + return Lines.make_block( + content={repr(k): v for k, v in obj.items()}, + braces='{', + equal=': ', + ) + elif ( + not isinstance(obj, type) + and dataclasses.is_dataclass(obj) + and edc.dataclass_utils.has_default_repr(type(obj)) + ): + return edc.repr(obj) + else: + return repr(obj) + def dedent(text: str) -> str: r"""Wrapper around `textwrap.dedent` which also `strip()` the content. diff --git a/etils/epy/text_utils_test.py b/etils/epy/text_utils_test.py index 22849116..a19955c5 100644 --- a/etils/epy/text_utils_test.py +++ b/etils/epy/text_utils_test.py @@ -14,6 +14,9 @@ """Tests for text_utils.""" +from __future__ import annotations + +import dataclasses import textwrap from etils import epy @@ -60,49 +63,61 @@ def test_lines(): def test_lines_block(): assert epy.Lines.make_block('A', {}) == 'A()' assert epy.Lines.make_block('A', {}, braces='[') == 'A[]' - assert epy.Lines.make_block('A', {'x': '1'}) == 'A(x=1)' - assert epy.Lines.make_block('A', {'x': '1'}, braces=('<', '>')) == 'A' - assert ( - epy.Lines.make_block('', {'x': '1'}, braces='{', equal=': ') == '{x: 1}' - ) + assert epy.Lines.make_block('A', {'x': 1}) == 'A(x=1)' + assert epy.Lines.make_block('A', {'x': 1}, braces=('<', '>')) == 'A' + assert epy.Lines.make_block('', {'x': 1}, braces='{', equal=': ') == '{x: 1}' assert epy.Lines.make_block( 'A', { - 'x': '111', - 'y': '222', - 'z': '333', + 'x': 111, + 'y': 222, + 'z': 333, }, ) == epy.dedent( """ - A( - x=111, - y=222, - z=333, - ) + A(x=111, y=222, z=333) """ ) - assert epy.Lines.make_block( - 'A', - epy.Lines.make_block( - 'A', - { - 'x': '111', - 'y': '222', - 'z': '333', - }, + + assert epy.Lines.make_block('', ['a', 'b'], braces='[') == "['a', 'b']" + + +def test_lines_std(): + @dataclasses.dataclass + class B: + x: int = 1 + y: int = 2 + + @dataclasses.dataclass + class A: + t: tuple[str, ...] = () + l: list[int] = dataclasses.field(default_factory=list) + d: dict[str, int] = dataclasses.field(default_factory=dict) + dc: B = dataclasses.field(default_factory=B) + s: str = 'aaa' + + a = A( + t=('aaaaaaaaaaaaaaaaaaaa', 'bbbbbbbbbbbbbbbbbbbb'), + l=[1, 2, 3, 4], + d={'aaaaaaaaaaaaaaaaaaaa': 1, 'bbbbbbbbbbbbbbbbbbbb': 1}, + ) + + repr_ = epy.Lines.repr(a) + assert repr_ == epy.dedent(""" + A( + t=( + 'aaaaaaaaaaaaaaaaaaaa', + 'bbbbbbbbbbbbbbbbbbbb', ), - ) == epy.dedent( - """ - A( - A( - x=111, - y=222, - z=333, - ), - ) - """ + l=[1, 2, 3, 4], + d={ + 'aaaaaaaaaaaaaaaaaaaa': 1, + 'bbbbbbbbbbbbbbbbbbbb': 1, + }, + dc=B(x=1, y=2), + s='aaa', ) - assert epy.Lines.make_block('', ['a', 'b'], braces='[') == '[a, b]' + """) def test_lines_nested_indent(): diff --git a/pyproject.toml b/pyproject.toml index 2d6f4240..25266c5d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,7 @@ ecolab = [ "etils[epy]", ] edc = [ - "typing_extensions", + # Do not add anything here. `edc` is an alias for `epy` "etils[epy]", ] enp = [