From 10dd580985b406566afdcf287dba54b479606fd8 Mon Sep 17 00:00:00 2001 From: Frazer McLean Date: Tue, 23 Jan 2024 16:25:26 +0100 Subject: [PATCH] Add rich.pretty support --- CHANGELOG.md | 5 +- doc/conf.py | 1 + doc/usage/automatic.rst | 18 +++++- doc/usage/helper.rst | 12 ++-- pyproject.toml | 4 ++ src/represent/core.py | 59 +++++++++++++++--- src/represent/helper.py | 55 ++++++++++++++++- tests/test_autorepr.py | 134 ++++++++++++++++++++++++++++++---------- tests/test_helper.py | 74 +++++++++++++++++++++- 9 files changed, 309 insertions(+), 53 deletions(-) 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/doc/usage/automatic.rst b/doc/usage/automatic.rst index 8a68f6c..4d56951 100644 --- a/doc/usage/automatic.rst +++ b/doc/usage/automatic.rst @@ -35,7 +35,8 @@ Pretty Printer -------------- :func:`~represent.core.autorepr` also provides a -:code:`_repr_pretty_` method for :mod:`IPython.lib.pretty`. +:code:`_repr_pretty_` method for :mod:`IPython.lib.pretty` and a +:code:`__rich_repr__` method for :mod:`rich.pretty`. Therefore, with the simple example above, we can pretty print: @@ -53,6 +54,21 @@ Therefore, with the simple example above, we can pretty print: width=15, height=4.5) +.. code:: python + + from rich.pretty import pprint + + pprint(rect) + +.. code-block:: none + + Rectangle( + name='Something really long to force pretty printing line break', + color='red', + width=15, + height=4.5 + ) + Positional Arguments -------------------- diff --git a/doc/usage/helper.rst b/doc/usage/helper.rst index 3663a1b..fc67af7 100644 --- a/doc/usage/helper.rst +++ b/doc/usage/helper.rst @@ -9,9 +9,10 @@ Helper Mixin If you cannot use, or prefer not to use :class:`~represent.core.ReprMixin`, there is an alternative declarative syntax. -:class:`~represent.core.ReprHelperMixin` provides ``__repr__`` and -``_repr_pretty_`` (for :mod:`IPython.lib.pretty`), both of which look for a -user defined function called ``_repr_helper_``. +:class:`~represent.core.ReprHelperMixin` provides ``__repr__``, +``_repr_pretty_`` (for :mod:`IPython.lib.pretty`), and ``__rich_repr__`` (for +:mod:`rich.pretty`), all of which use a user defined function called +``_repr_helper_``. All possible method calls on the passed object `r` are shown here: @@ -80,5 +81,6 @@ Manual Helpers To use the declarative style without using :class:`~represent.core.ReprHelperMixin`, refer to the documentation for -:class:`~represent.helper.ReprHelper` and -:class:`~represent.helper.PrettyReprHelper`. +:class:`~represent.helper.ReprHelper`, +:class:`~represent.helper.PrettyReprHelper`, and +:class:`~represent.helper.RichReprHelper`. 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..0ab60f1 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,23 @@ 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__ + + 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 +175,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 +230,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 +266,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..5764421 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,56 @@ def close(self): self.p.text("...") clsname = self.other_cls.__name__ self.p.end_group(len(clsname) + 1, self.parantheses.right) + + +class RawReprWrapper: + """rich.pretty calls repr for us, so to support raw=True we need a wrapper + object which returns str() when repr() is called. + """ + + def __init__(self, o: object): + self._object = o + + def __repr__(self): + return str(self._object) + + +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..e60bc4e 100644 --- a/tests/test_autorepr.py +++ b/tests/test_autorepr.py @@ -1,20 +1,39 @@ +from contextlib import contextmanager +from functools import partial from textwrap import dedent -from unittest.mock import Mock +from types import MethodType +from unittest.mock import Mock, patch import pytest from IPython.lib.pretty import pretty +from rich.pretty import pretty_repr from represent import autorepr +class WrappedMethod: + def __init__(self, method): + self.mock = Mock(method, wraps=method) + + def __get__(self, instance, owner): + if instance is None: + return self.mock + return partial(self.mock, instance) + + +@contextmanager +def spy_on_method(target, attribute): + wrapped = WrappedMethod(getattr(target, attribute)) + with patch.object(target, attribute, wrapped): + yield wrapped.mock + + def test_standard(): - @mock_repr_pretty @autorepr class A: def __init__(self): pass - @mock_repr_pretty @autorepr class B: def __init__(self, a, b, c=5): @@ -23,16 +42,27 @@ def __init__(self, a, b, c=5): self.c = c assert repr(A()) == "A()" - assert pretty(A()) == "A()" - assert A._repr_pretty_.called + + with spy_on_method(A, "_repr_pretty_"): + assert pretty(A()) == "A()" + assert A._repr_pretty_.called + + with spy_on_method(A, "__rich_repr__"): + 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 + + with spy_on_method(B, "_repr_pretty_"): + assert pretty(B(1, 2)) == "B(a=1, b=2, c=5)" + assert B._repr_pretty_.called + + with spy_on_method(B, "__rich_repr__"): + assert pretty_repr(B(1, 2)) == "B(a=1, b=2, c=5)" + assert B.__rich_repr__.called def test_positional(): - @mock_repr_pretty @autorepr(positional=1) class A: def __init__(self, a, b, c=5): @@ -40,7 +70,6 @@ def __init__(self, a, b, c=5): self.b = b self.c = c - @mock_repr_pretty @autorepr(positional=2) class B: def __init__(self, a, b, c=5): @@ -48,7 +77,6 @@ def __init__(self, a, b, c=5): self.b = b self.c = c - @mock_repr_pretty @autorepr(positional="a") class C: def __init__(self, a, b, c=5): @@ -56,7 +84,6 @@ def __init__(self, a, b, c=5): self.b = b self.c = c - @mock_repr_pretty @autorepr(positional=["a", "b"]) class D: def __init__(self, a, b, c=5): @@ -65,20 +92,44 @@ def __init__(self, a, b, c=5): self.c = c 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 + + with spy_on_method(A, "_repr_pretty_"): + assert pretty(A(1, 2)) == "A(1, b=2, c=5)" + assert A._repr_pretty_.called + + with spy_on_method(A, "__rich_repr__"): + 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 + + with spy_on_method(B, "_repr_pretty_"): + assert pretty(B(1, 2)) == "B(1, 2, c=5)" + assert B._repr_pretty_.called + + with spy_on_method(B, "__rich_repr__"): + 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 + + with spy_on_method(C, "_repr_pretty_"): + assert pretty(C(1, 2)) == "C(1, b=2, c=5)" + assert C._repr_pretty_.called + + with spy_on_method(C, "__rich_repr__"): + 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 + + with spy_on_method(D, "_repr_pretty_"): + assert pretty(D(1, 2)) == "D(1, 2, c=5)" + assert D._repr_pretty_.called + + with spy_on_method(D, "__rich_repr__"): + assert pretty_repr(D(1, 2)) == "D(1, 2, c=5)" + assert D.__rich_repr__.called with pytest.raises(ValueError): @@ -119,7 +170,6 @@ def __init__(self): def test_cycle(): - @mock_repr_pretty @autorepr class A: def __init__(self, a=None): @@ -128,8 +178,15 @@ def __init__(self, a=None): a = A() a.a = a - assert pretty(a) == "A(a=A(...))" - assert A._repr_pretty_.call_count == 2 + assert repr(a) == "A(a=...)" + + with spy_on_method(A, "_repr_pretty_"): + assert pretty(a) == "A(a=A(...))" + assert A._repr_pretty_.call_count == 2 + + with spy_on_method(A, "__rich_repr__"): + assert pretty_repr(a) == "A(a=...)" + assert A.__rich_repr__.call_count == 1 def test_reuse(): @@ -157,7 +214,6 @@ def __init__(self, a, b): def test_recursive_repr(): """Test that autorepr applies the :func:`reprlib.recursive_repr` decorator.""" - @mock_repr_pretty @autorepr class A: def __init__(self, a=None): @@ -172,7 +228,6 @@ def __init__(self, a=None): @pytest.mark.parametrize("include_pretty", [False, True]) def test_include_pretty(include_pretty): - @mock_repr_pretty @autorepr(include_pretty=include_pretty) class A: def __init__(self, a): @@ -183,8 +238,9 @@ def __init__(self, a): assert repr(a) == reprstr if include_pretty: - assert pretty(a) == reprstr - assert A._repr_pretty_.call_count == 1 + with spy_on_method(A, "_repr_pretty_"): + assert pretty(a) == reprstr + assert A._repr_pretty_.call_count == 1 else: # check pretty falls back to __repr__ (to make sure we didn't leave a # broken _repr_pretty_ on the class) @@ -192,13 +248,23 @@ def __init__(self, a): assert not hasattr(A, "_repr_pretty_") -def mock_repr_pretty(cls): - """Wrap cls._repr_pretty_ in a mock, if it exists.""" - _repr_pretty_ = getattr(cls, "_repr_pretty_", None) +@pytest.mark.parametrize("include_rich", [False, True]) +def test_include_rich(include_rich): + @autorepr(include_rich=include_rich) + class A: + def __init__(self, a): + self.a = a - # Only mock it if it's there, it's up to the tests to check the mock was - # called. - if _repr_pretty_ is not None: - cls._repr_pretty_ = Mock(wraps=_repr_pretty_) + a = A(1) + reprstr = "A(a=1)" + assert repr(a) == reprstr - return cls + if include_rich: + with spy_on_method(A, "__rich_repr__"): + 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__") diff --git a/tests/test_helper.py b/tests/test_helper.py index 800a0de..2763331 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(): @@ -93,12 +114,23 @@ def _repr_helper(self, r): def __repr__(self): r = ReprHelper(self) self._repr_helper(r) - return str(r) + raise RuntimeError("unreachable") # pragma: no cover def _repr_pretty_(self, p, cycle): with PrettyReprHelper(self, p, cycle) as r: self._repr_helper(r) + def __rich_repr__(self): + """Important that this is a generator, because rich catches errors + that happen when the function is called. As a generator, we can + postpone them until next() is called and then rich can't swallow + the error. + """ + r = RichReprHelper(self) + self._repr_helper(r) + yield from r # pragma: no cover + raise RuntimeError("unreachable") # pragma: no cover + a = A(1, 2) with pytest.raises(ValueError): @@ -107,6 +139,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 @@ -121,12 +156,23 @@ def _repr_helper(self, r): def __repr__(self): r = ReprHelper(self) self._repr_helper(r) - return str(r) + raise RuntimeError("unreachable") # pragma: no cover def _repr_pretty_(self, p, cycle): with PrettyReprHelper(self, p, cycle) as r: self._repr_helper(r) + def __rich_repr__(self): + """Important that this is a generator, because rich catches errors + that happen when the function is called. As a generator, we can + postpone them until next() is called and then rich can't swallow + the error. + """ + r = RichReprHelper(self) + self._repr_helper(r) + yield from r # pragma: no cover + raise RuntimeError("unreachable") # pragma: no cover + b = B(1, 2) with pytest.raises(ValueError): @@ -135,6 +181,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 +198,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 +231,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 +261,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():