diff --git a/CHANGELOG.md b/CHANGELOG.md index 137d1c9..70bec31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,10 @@ ## [Unreleased][unreleased] -N/A +### Added + +- Support for `rich.pretty` to `autorepr` and `ReprHelperMixin`. +- `RichReprHelper`. ## [2.0.0] - 2023-12-30 diff --git a/doc/conf.py b/doc/conf.py index b2ffe57..7c7ff1b 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -62,6 +62,7 @@ intersphinx_mapping = { "python": ("https://docs.python.org/3", None), "ipython": ("https://ipython.readthedocs.io/en/stable/", None), + "rich": ("https://rich.readthedocs.io/en/stable/", None), } autodoc_member_order = "bysource" diff --git a/pyproject.toml b/pyproject.toml index ba1c623..e850a5a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ Documentation = "https://represent.readthedocs.io" test = [ "ipython", "pytest", + "rich", ] docstest = [ "parver", @@ -54,3 +55,6 @@ source = ["represent", ".tox/*/lib/python*/site-packages/represent"] [tool.coverage.report] precision = 1 exclude_lines = ["pragma: no cover", "pass"] + +[tool.isort] +profile = "black" diff --git a/src/represent/core.py b/src/represent/core.py index af96fea..4b2c995 100644 --- a/src/represent/core.py +++ b/src/represent/core.py @@ -2,26 +2,31 @@ from functools import partial from reprlib import recursive_repr -from .helper import PrettyReprHelper, ReprHelper +from .helper import PrettyReprHelper, ReprHelper, RichReprHelper from .utilities import ReprInfo __all__ = ["ReprHelperMixin", "autorepr"] _DEFAULT_INCLUDE_PRETTY = True +_DEFAULT_INCLUDE_RICH = True def autorepr(*args, **kwargs): """Class decorator to construct :code:`__repr__` **automatically** based on the arguments to ``__init__``. - :code:`_repr_pretty_` for :py:mod:`IPython.lib.pretty` is also constructed, + :code:`_repr_pretty_` for :py:mod:`IPython.lib.pretty` is constructed unless `include_pretty=False`. + :code:`__rich_repr__` for :py:mod:`rich.pretty` is constructed + unless `include_rich=False`. + :param positional: Mark arguments as positional by number, or a list of argument names. :param include_pretty: Add a ``_repr_pretty_`` to the class (defaults to True). + :param include_rich: Add a ``__rich_repr__`` to the class (defaults to True). Example: @@ -51,6 +56,7 @@ def autorepr(*args, **kwargs): """ cls = positional = None include_pretty = _DEFAULT_INCLUDE_PRETTY + include_rich = _DEFAULT_INCLUDE_RICH # We allow using @autorepr or @autorepr(positional=..., ...), so check # how we were called. @@ -68,7 +74,7 @@ def autorepr(*args, **kwargs): ) elif not args and kwargs: - valid_kwargs = {"positional", "include_pretty"} + valid_kwargs = {"positional", "include_pretty", "include_rich"} invalid_kwargs = set(kwargs) - valid_kwargs if invalid_kwargs: @@ -77,6 +83,7 @@ def autorepr(*args, **kwargs): positional = kwargs.get("positional") include_pretty = kwargs.get("include_pretty", include_pretty) + include_rich = kwargs.get("include_rich", include_rich) elif (args and kwargs) or (not args and not kwargs): raise TypeError("Use bare @autorepr or @autorepr(...) with keyword args.") @@ -87,19 +94,25 @@ def autorepr(*args, **kwargs): def __repr__(self): return self.__class__._represent.fstr.format(self=self) - _repr_pretty_ = None + repr_pretty = rich_repr = None if include_pretty: - _repr_pretty_ = _make_repr_pretty() + repr_pretty = _make_repr_pretty() + if include_rich: + rich_repr = _make_rich_repr() if cls is not None: - return _autorepr_decorate(cls, repr=__repr__, repr_pretty=_repr_pretty_) + return _autorepr_decorate( + cls, repr=__repr__, repr_pretty=repr_pretty, rich_repr=rich_repr + ) else: return partial( _autorepr_decorate, repr=__repr__, - repr_pretty=_repr_pretty_, + repr_pretty=repr_pretty, + rich_repr=rich_repr, positional=positional, include_pretty=include_pretty, + include_rich=include_rich, ) @@ -132,6 +145,24 @@ def _repr_pretty_(self, p, cycle): return _repr_pretty_ +def _make_rich_repr(): + def __rich_repr__(self): + """Pretty printer for :mod:`rich.pretty`""" + cls = self.__class__ + clsname = cls.__name__ + + positional_args = cls._represent.args + keyword_args = cls._represent.kw + + for positional in positional_args: + yield getattr(self, positional) + + for keyword in keyword_args: + yield keyword, getattr(self, keyword) + + return __rich_repr__ + + def _getparams(cls): signature = inspect.signature(cls) params = list(signature.parameters) @@ -145,7 +176,13 @@ def _getparams(cls): def _autorepr_decorate( - cls, repr, repr_pretty, positional=None, include_pretty=_DEFAULT_INCLUDE_PRETTY + cls, + repr, + repr_pretty, + rich_repr, + positional=None, + include_pretty=_DEFAULT_INCLUDE_PRETTY, + include_rich=_DEFAULT_INCLUDE_RICH, ): params, kwonly = _getparams(cls) @@ -194,6 +231,8 @@ def _autorepr_decorate( cls.__repr__ = repr if include_pretty: cls._repr_pretty_ = repr_pretty + if include_rich: + cls.__rich_repr__ = rich_repr return cls @@ -228,3 +267,8 @@ def __repr__(self): def _repr_pretty_(self, p, cycle): with PrettyReprHelper(self, p, cycle) as r: self._repr_helper_(r) + + def __rich_repr__(self): + r = RichReprHelper(self) + self._repr_helper_(r) + yield from r diff --git a/src/represent/helper.py b/src/represent/helper.py index 52bcfaa..bbdccb7 100644 --- a/src/represent/helper.py +++ b/src/represent/helper.py @@ -2,7 +2,7 @@ from .utilities import Parantheses, inherit_docstrings -__all__ = ["ReprHelper", "PrettyReprHelper"] +__all__ = ["ReprHelper", "PrettyReprHelper", "RichReprHelper"] class BaseReprHelper(metaclass=ABCMeta): @@ -244,3 +244,54 @@ def close(self): self.p.text("...") clsname = self.other_cls.__name__ self.p.end_group(len(clsname) + 1, self.parantheses.right) + + +class RawReprWrapper: + def __init__(self, repr: str): + if not isinstance(repr, str): + raise TypeError("repr must be a str") + self._repr = repr + + def __repr__(self): + return self._repr + + +class RichReprHelper(BaseReprHelper): + """Help manual construction of :code:`__rich_repr__` for + :py:mod:`rich.pretty`. + + It should be used as follows: + + .. code-block:: python + + def __rich_repr__(self) + r = RichReprHelper(self) + r.keyword_from_attr('name') + yield from r + """ + + def __init__(self, other): + self._tuples = [] + super().__init__(other) + + def positional_from_attr(self, attr_name): + if self.keyword_started: + raise ValueError("positional arguments cannot follow keyword arguments") + self._tuples.append((None, getattr(self.other, attr_name))) + + def positional_with_value(self, value, raw=False): + if self.keyword_started: + raise ValueError("positional arguments cannot follow keyword arguments") + self._tuples.append((None, RawReprWrapper(value) if raw else value)) + + def keyword_from_attr(self, name, attr_name=None): + self.keyword_started = True + attr_name = attr_name or name + self._tuples.append((name, getattr(self.other, attr_name))) + + def keyword_with_value(self, name, value, raw=False): + self.keyword_started = True + return self._tuples.append((name, RawReprWrapper(value) if raw else value)) + + def __iter__(self): + return iter(self._tuples) diff --git a/tests/test_autorepr.py b/tests/test_autorepr.py index 2f59b05..778184e 100644 --- a/tests/test_autorepr.py +++ b/tests/test_autorepr.py @@ -3,18 +3,21 @@ import pytest from IPython.lib.pretty import pretty +from rich.pretty import pretty_repr from represent import autorepr def test_standard(): @mock_repr_pretty + @mock_rich_repr @autorepr class A: def __init__(self): pass @mock_repr_pretty + @mock_rich_repr @autorepr class B: def __init__(self, a, b, c=5): @@ -25,14 +28,19 @@ def __init__(self, a, b, c=5): assert repr(A()) == "A()" assert pretty(A()) == "A()" assert A._repr_pretty_.called + assert pretty_repr(A()) == "A()" + assert A.__rich_repr__.called assert repr(B(1, 2)) == "B(a=1, b=2, c=5)" assert pretty(B(1, 2)) == "B(a=1, b=2, c=5)" assert B._repr_pretty_.called + assert pretty_repr(B(1, 2)) == "B(a=1, b=2, c=5)" + assert B.__rich_repr__.called def test_positional(): @mock_repr_pretty + @mock_rich_repr @autorepr(positional=1) class A: def __init__(self, a, b, c=5): @@ -41,6 +49,7 @@ def __init__(self, a, b, c=5): self.c = c @mock_repr_pretty + @mock_rich_repr @autorepr(positional=2) class B: def __init__(self, a, b, c=5): @@ -49,6 +58,7 @@ def __init__(self, a, b, c=5): self.c = c @mock_repr_pretty + @mock_rich_repr @autorepr(positional="a") class C: def __init__(self, a, b, c=5): @@ -57,6 +67,7 @@ def __init__(self, a, b, c=5): self.c = c @mock_repr_pretty + @mock_rich_repr @autorepr(positional=["a", "b"]) class D: def __init__(self, a, b, c=5): @@ -67,18 +78,26 @@ def __init__(self, a, b, c=5): assert repr(A(1, 2)) == "A(1, b=2, c=5)" assert pretty(A(1, 2)) == "A(1, b=2, c=5)" assert A._repr_pretty_.called + assert pretty_repr(A(1, 2)) == "A(1, b=2, c=5)" + assert A.__rich_repr__.called assert repr(B(1, 2)) == "B(1, 2, c=5)" assert pretty(B(1, 2)) == "B(1, 2, c=5)" assert B._repr_pretty_.called + assert pretty_repr(B(1, 2)) == "B(1, 2, c=5)" + assert B.__rich_repr__.called assert repr(C(1, 2)) == "C(1, b=2, c=5)" assert pretty(C(1, 2)) == "C(1, b=2, c=5)" assert C._repr_pretty_.called + assert pretty_repr(C(1, 2)) == "C(1, b=2, c=5)" + assert C.__rich_repr__.called assert repr(D(1, 2)) == "D(1, 2, c=5)" assert pretty(D(1, 2)) == "D(1, 2, c=5)" assert D._repr_pretty_.called + assert pretty_repr(D(1, 2)) == "D(1, 2, c=5)" + assert D.__rich_repr__.called with pytest.raises(ValueError): @@ -120,6 +139,7 @@ def __init__(self): def test_cycle(): @mock_repr_pretty + @mock_rich_repr @autorepr class A: def __init__(self, a=None): @@ -128,8 +148,11 @@ def __init__(self, a=None): a = A() a.a = a + assert repr(a) == "A(a=...)" assert pretty(a) == "A(a=A(...))" assert A._repr_pretty_.call_count == 2 + assert pretty_repr(a) == "A(a=...)" + assert A.__rich_repr__.call_count == 1 def test_reuse(): @@ -158,6 +181,7 @@ def test_recursive_repr(): """Test that autorepr applies the :func:`reprlib.recursive_repr` decorator.""" @mock_repr_pretty + @mock_rich_repr @autorepr class A: def __init__(self, a=None): @@ -173,6 +197,7 @@ def __init__(self, a=None): @pytest.mark.parametrize("include_pretty", [False, True]) def test_include_pretty(include_pretty): @mock_repr_pretty + @mock_rich_repr @autorepr(include_pretty=include_pretty) class A: def __init__(self, a): @@ -192,6 +217,29 @@ def __init__(self, a): assert not hasattr(A, "_repr_pretty_") +@pytest.mark.parametrize("include_rich", [False, True]) +def test_include_rich(include_rich): + @mock_repr_pretty + @mock_rich_repr + @autorepr(include_rich=include_rich) + class A: + def __init__(self, a): + self.a = a + + a = A(1) + reprstr = "A(a=1)" + assert repr(a) == reprstr + + if include_rich: + assert pretty_repr(a) == reprstr + assert A.__rich_repr__.call_count == 1 + else: + # check rich falls back to __repr__ (to make sure we didn't leave a + # broken _repr_pretty_ on the class) + assert pretty_repr(a) == reprstr + assert not hasattr(A, "__rich_repr__") + + def mock_repr_pretty(cls): """Wrap cls._repr_pretty_ in a mock, if it exists.""" _repr_pretty_ = getattr(cls, "_repr_pretty_", None) @@ -202,3 +250,15 @@ def mock_repr_pretty(cls): cls._repr_pretty_ = Mock(wraps=_repr_pretty_) return cls + + +def mock_rich_repr(cls): + """Wrap cls.__rich_repr__ in a mock, if it exists.""" + __rich_repr__ = getattr(cls, "__rich_repr__", None) + + # Only mock it if it's there, it's up to the tests to check the mock was + # called. + if __rich_repr__ is not None: + cls.__rich_repr__ = Mock(wraps=__rich_repr__) + + return cls diff --git a/tests/test_helper.py b/tests/test_helper.py index 800a0de..e57d8b7 100644 --- a/tests/test_helper.py +++ b/tests/test_helper.py @@ -2,8 +2,9 @@ import pytest from IPython.lib.pretty import pretty +from rich.pretty import pretty_repr -from represent import PrettyReprHelper, ReprHelper, ReprHelperMixin +from represent import PrettyReprHelper, ReprHelper, ReprHelperMixin, RichReprHelper def test_helper_methods(): @@ -31,6 +32,11 @@ def _repr_pretty_(self, p, cycle): with PrettyReprHelper(self, p, cycle) as r: self._repr_helper(r) + def __rich_repr__(self): + r = RichReprHelper(self) + self._repr_helper(r) + yield from r + ce = ContrivedExample("does something", 0.345, "square", "red", 22) assert repr(ce) == ( "ContrivedExample('does something', 0.345, " @@ -43,6 +49,15 @@ def _repr_pretty_(self, p, cycle): color='red', miles=22.0)""" assert pretty(ce) == textwrap.dedent(prettystr).lstrip() + prettystr = """ + ContrivedExample( + 'does something', + 0.345, + shape='square', + color='red', + miles=22.0 + )""" + assert pretty_repr(ce) == textwrap.dedent(prettystr).lstrip() class RecursionChecker: def __init__(self, a, b, c, d, e): @@ -63,6 +78,11 @@ def _repr_pretty_(self, p, cycle): with PrettyReprHelper(self, p, cycle) as r: self._repr_helper(r) + def __rich_repr__(self): + r = RichReprHelper(self) + self._repr_helper(r) + yield from r + rc = RecursionChecker(None, None, None, None, None) rc.a = rc rc.b = rc @@ -76,6 +96,7 @@ def _repr_pretty_(self, p, cycle): d=RecursionChecker(...), e=RecursionChecker(...))""" assert pretty(rc) == textwrap.dedent(prettystr).lstrip() + assert pretty_repr(rc) == "RecursionChecker(..., ..., c=..., d=..., e=...)" def test_helper_exceptions(): @@ -99,6 +120,11 @@ def _repr_pretty_(self, p, cycle): with PrettyReprHelper(self, p, cycle) as r: self._repr_helper(r) + def __rich_repr__(self): + r = RichReprHelper(self) + self._repr_helper(r) + yield from r + a = A(1, 2) with pytest.raises(ValueError): @@ -107,6 +133,9 @@ def _repr_pretty_(self, p, cycle): with pytest.raises(ValueError): pretty(a) + with pytest.raises(ValueError): + pretty_repr(a) + class B: def __init__(self, a, b): self.a = a @@ -127,6 +156,11 @@ def _repr_pretty_(self, p, cycle): with PrettyReprHelper(self, p, cycle) as r: self._repr_helper(r) + def __rich_repr__(self): + r = RichReprHelper(self) + self._repr_helper(r) + yield from r + b = B(1, 2) with pytest.raises(ValueError): @@ -135,6 +169,9 @@ def _repr_pretty_(self, p, cycle): with pytest.raises(ValueError): pretty(b) + with pytest.raises(ValueError): + pretty_repr(b) + def test_helper_raw(): class A(ReprHelperMixin): @@ -149,6 +186,7 @@ def _repr_helper_(self, r): a = A("a", "b") assert repr(a) == "A(a, b=b)" assert pretty(a) == "A(a, b=b)" + assert pretty_repr(a) == "A(a, b=b)" def test_helper_mixin(): @@ -181,6 +219,15 @@ def _repr_helper_(self, r): color='red', miles=22.0)""" assert pretty(ce) == textwrap.dedent(prettystr).lstrip() + prettystr = """ + ContrivedExample( + 'does something', + 0.345, + shape='square', + color='red', + miles=22.0 + )""" + assert pretty_repr(ce) == textwrap.dedent(prettystr).lstrip() class ContrivedExampleKeywords(ContrivedExample): def _repr_helper_(self, r): @@ -202,6 +249,15 @@ def _repr_helper_(self, r): color='red', miles=22.0)""" assert pretty(ce) == textwrap.dedent(prettystr).lstrip() + prettystr = """ + ContrivedExampleKeywords( + 'does something', + 0.345, + shape='square', + color='red', + miles=22.0 + )""" + assert pretty_repr(ce) == textwrap.dedent(prettystr).lstrip() def test_helper_mixin_recursive():