├── tests ├── __init__.py ├── test_autorepr.py └── test_helper.py ├── dev-requirements.txt ├── src └── represent │ ├── __init__.py │ ├── utilities.py │ ├── core.py │ └── helper.py ├── doc ├── usage.rst ├── modules.rst ├── modules │ ├── core.rst │ └── helper.rst ├── index.rst ├── installation.rst ├── conf.py ├── usage │ ├── helper.rst │ └── automatic.rst └── Makefile ├── MANIFEST.in ├── .github ├── dependabot.yml └── workflows │ ├── python-publish.yml │ └── ci.yml ├── .gitignore ├── .readthedocs.yaml ├── tox.ini ├── .pre-commit-config.yaml ├── pyproject.toml ├── README.md ├── LICENSE └── CHANGELOG.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /dev-requirements.txt: -------------------------------------------------------------------------------- 1 | -e .[test,docstest] 2 | -------------------------------------------------------------------------------- /src/represent/__init__.py: -------------------------------------------------------------------------------- 1 | from .core import * 2 | from .helper import * 3 | 4 | __all__ = core.__all__ + helper.__all__ 5 | -------------------------------------------------------------------------------- /doc/usage.rst: -------------------------------------------------------------------------------- 1 | ***** 2 | Usage 3 | ***** 4 | 5 | .. toctree:: 6 | :maxdepth: 2 7 | 8 | usage/automatic 9 | usage/helper 10 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include CHANGELOG.md 2 | include LICENSE 3 | include tox.ini 4 | recursive-include tests *.py 5 | recursive-include doc * 6 | prune doc/_build 7 | -------------------------------------------------------------------------------- /doc/modules.rst: -------------------------------------------------------------------------------- 1 | **************** 2 | Module Reference 3 | **************** 4 | 5 | .. toctree:: 6 | :maxdepth: 2 7 | 8 | modules/core 9 | modules/helper 10 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: / 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 1024 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | *.py[cod] 3 | *.sublime* 4 | .venv 5 | build/ 6 | *.egg-info/ 7 | _build/ 8 | .ipynb_checkpoints/ 9 | dist/ 10 | .tox/ 11 | .cache/ 12 | htmlcov/ 13 | .coverage* 14 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | python: 4 | install: 5 | - path: . 6 | extra_requirements: 7 | - docstest 8 | 9 | build: 10 | os: ubuntu-22.04 11 | tools: 12 | python: "3.10" 13 | -------------------------------------------------------------------------------- /doc/modules/core.rst: -------------------------------------------------------------------------------- 1 | ************** 2 | represent.core 3 | ************** 4 | 5 | .. note:: 6 | 7 | Names in this module should be imported from `represent`. They are in 8 | `represent.core` for structural reasons. 9 | 10 | .. automodule:: represent.core 11 | :members: 12 | :show-inheritance: 13 | -------------------------------------------------------------------------------- /doc/modules/helper.rst: -------------------------------------------------------------------------------- 1 | **************** 2 | represent.helper 3 | **************** 4 | 5 | .. note:: 6 | 7 | Names in this module should be imported from `represent`. They are in `represent.helper` for structural reasons. 8 | 9 | .. automodule:: represent.helper 10 | :members: 11 | :show-inheritance: 12 | 13 | .. autoclass:: represent.helper.BaseReprHelper 14 | :members: 15 | -------------------------------------------------------------------------------- /doc/index.rst: -------------------------------------------------------------------------------- 1 | .. Represent documentation master file 2 | 3 | Represent documentation 4 | ======================= 5 | 6 | Contents: 7 | 8 | .. toctree:: 9 | :maxdepth: 3 10 | 11 | installation 12 | usage 13 | modules 14 | Change Log 15 | GitHub 16 | 17 | Indices and tables 18 | ================== 19 | 20 | * :ref:`genindex` 21 | * :ref:`modindex` 22 | * :ref:`search` 23 | -------------------------------------------------------------------------------- /doc/installation.rst: -------------------------------------------------------------------------------- 1 | ************ 2 | Installation 3 | ************ 4 | 5 | The recommended installation method is using ``pip``. 6 | 7 | pip 8 | === 9 | 10 | .. code:: bash 11 | 12 | $ pip install represent 13 | 14 | Git 15 | === 16 | 17 | .. code-block:: sh 18 | 19 | $ git clone https://github.com/RazerM/represent.git 20 | Cloning into 'represent'... 21 | 22 | Check out a `release tag`_ 23 | 24 | .. parsed-literal:: 25 | 26 | $ cd represent 27 | $ git checkout |version| 28 | 29 | .. _`release tag`: https://github.com/RazerM/represent/releases 30 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | name: Upload Python Package 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | id-token: write 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | - name: Set up Python 17 | uses: actions/setup-python@v5 18 | with: 19 | python-version: "3.x" 20 | - name: Install dependencies 21 | run: pip install --upgrade build 22 | - name: Build 23 | run: python -m build 24 | - name: Publish 25 | uses: pypa/gh-action-pypi-publish@release/v1 26 | -------------------------------------------------------------------------------- /src/represent/utilities.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | 3 | 4 | def inherit_docstrings(cls): 5 | """Add docstrings from superclass if missing. 6 | 7 | Adapted from this StackOverflow answer by Raymond Hettinger: 8 | http://stackoverflow.com/a/8101598/2093785 9 | """ 10 | for name, func in vars(cls).items(): 11 | if not func.__doc__: 12 | for parent in cls.__bases__: 13 | parfunc = getattr(parent, name, None) 14 | if parfunc and getattr(parfunc, "__doc__", None): 15 | func.__doc__ = parfunc.__doc__ 16 | break 17 | return cls 18 | 19 | 20 | Parantheses = namedtuple("Parantheses", "left, right") 21 | ReprInfo = namedtuple("ReprInfo", "fstr, args, kw") 22 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist=py38,py39,py310,py311,py312,docs 3 | [testenv] 4 | deps= 5 | coverage[toml] 6 | .[test] 7 | commands= 8 | # We use parallel mode and then combine here so that coverage.py will take 9 | # the paths like .tox/py34/lib/python3.4/site-packages/represent/__init__.py 10 | # and collapse them into represent/__init__.py. 11 | coverage run --parallel-mode -m pytest {posargs} 12 | coverage combine 13 | coverage report -m 14 | 15 | [pytest] 16 | addopts=-r s 17 | 18 | [testenv:docs] 19 | basepython=python3 20 | extras = 21 | docstest 22 | commands= 23 | sphinx-build -W -b html -d {envtmpdir}/doctrees doc doc/_build/html 24 | 25 | [gh-actions] 26 | python = 27 | 3.8: py38 28 | 3.9: py39 29 | 3.10: py310 30 | 3.11: py311 31 | 3.12: py312, docs 32 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.5.0 4 | hooks: 5 | - id: check-toml 6 | - id: check-yaml 7 | - id: check-added-large-files 8 | - id: check-merge-conflict 9 | - id: end-of-file-fixer 10 | - id: trailing-whitespace 11 | args: [--markdown-linebreak-ext=md] 12 | - repo: https://github.com/pre-commit/mirrors-prettier 13 | rev: v3.1.0 14 | hooks: 15 | - id: prettier 16 | - repo: https://github.com/asottile/pyupgrade 17 | rev: v3.15.0 18 | hooks: 19 | - id: pyupgrade 20 | args: [--py38-plus] 21 | - repo: https://github.com/PyCQA/isort 22 | rev: 5.13.2 23 | hooks: 24 | - id: isort 25 | - repo: https://github.com/psf/black 26 | rev: 23.12.1 27 | hooks: 28 | - id: black 29 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: CI 3 | 4 | on: 5 | push: 6 | branches: ["master"] 7 | pull_request: 8 | branches: ["master"] 9 | workflow_dispatch: 10 | 11 | jobs: 12 | tests: 13 | name: "Python ${{ matrix.python-version }}" 14 | runs-on: "ubuntu-latest" 15 | 16 | strategy: 17 | matrix: 18 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] 19 | 20 | steps: 21 | - uses: "actions/checkout@v4" 22 | - uses: "actions/setup-python@v5" 23 | with: 24 | python-version: "${{ matrix.python-version }}" 25 | - name: "Install dependencies" 26 | run: | 27 | set -xe 28 | python -VV 29 | python -m site 30 | python -m pip install --upgrade pip setuptools wheel 31 | python -m pip install --upgrade coverage[toml] virtualenv tox tox-gh-actions 32 | 33 | - name: "Run tox targets for ${{ matrix.python-version }}" 34 | run: "python -m tox" 35 | - name: "Convert coverage" 36 | run: "python -m coverage xml" 37 | - name: "Upload coverage to Codecov" 38 | uses: "codecov/codecov-action@v4" 39 | with: 40 | fail_ci_if_error: true 41 | token: ${{ secrets.CODECOV_TOKEN }} 42 | 43 | pre-commit: 44 | runs-on: ubuntu-latest 45 | steps: 46 | - uses: actions/checkout@v4 47 | - uses: actions/setup-python@v5 48 | with: 49 | python-version: "3.12" 50 | - uses: pre-commit/action@v3.0.1 51 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "Represent" 7 | version = "2.1" 8 | description = "Create __repr__ automatically or declaratively." 9 | readme = "README.md" 10 | requires-python = ">=3.8" 11 | license = { text = "MIT" } 12 | authors = [ 13 | { name = "Frazer McLean", email = "frazer@frazermclean.co.uk" }, 14 | ] 15 | classifiers = [ 16 | "Development Status :: 5 - Production/Stable", 17 | "License :: OSI Approved :: MIT License", 18 | "Programming Language :: Python :: 3 :: Only", 19 | "Programming Language :: Python :: 3.8", 20 | "Programming Language :: Python :: 3.9", 21 | "Programming Language :: Python :: 3.10", 22 | "Programming Language :: Python :: 3.11", 23 | "Programming Language :: Python :: 3.12", 24 | ] 25 | 26 | [project.urls] 27 | Repository = "https://github.com/RazerM/represent" 28 | Documentation = "https://represent.readthedocs.io" 29 | 30 | [project.optional-dependencies] 31 | test = [ 32 | "ipython", 33 | "pytest", 34 | "rich", 35 | ] 36 | docstest = [ 37 | "parver", 38 | "sphinx", 39 | "furo", 40 | ] 41 | 42 | [tool.setuptools] 43 | package-dir = { "" = "src" } 44 | 45 | [tool.setuptools.packages.find] 46 | where = ["src"] 47 | 48 | [tool.coverage.run] 49 | branch = true 50 | source = ["represent", "tests/"] 51 | 52 | [tool.coverage.paths] 53 | source = ["represent", ".tox/*/lib/python*/site-packages/represent"] 54 | 55 | [tool.coverage.report] 56 | precision = 1 57 | exclude_lines = ["pragma: no cover", "pass"] 58 | 59 | [tool.isort] 60 | profile = "black" 61 | -------------------------------------------------------------------------------- /doc/conf.py: -------------------------------------------------------------------------------- 1 | """Configuration file for the Sphinx documentation builder. 2 | 3 | This file only contains a selection of the most common options. For a full 4 | list see the documentation: 5 | https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | """ 7 | import re 8 | from importlib.metadata import distribution 9 | 10 | from parver import Version 11 | 12 | # -- Project information ----------------------------------------------------- 13 | 14 | dist = distribution("Represent") 15 | 16 | _release = Version.parse(dist.version) 17 | # Truncate release to x.y 18 | _version = Version(release=_release.release[:2]).truncate(min_length=3) 19 | 20 | author_email = dist.metadata["Author-email"] 21 | author, _ = re.match(r"(.*) <(.*)>", author_email).groups() 22 | 23 | project = "Represent" 24 | copyright = f"2023, {author}" 25 | 26 | # The full version, including alpha/beta/rc tags 27 | release = str(_release) 28 | # The short X.Y.Z version matching the git tags 29 | version = str(_version) 30 | 31 | html_title = f"{project} {release}" 32 | 33 | 34 | # -- General configuration --------------------------------------------------- 35 | 36 | # Add any Sphinx extension module names here, as strings. They can be 37 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 38 | # ones. 39 | extensions = [ 40 | "sphinx.ext.autodoc", 41 | "sphinx.ext.intersphinx", 42 | "sphinx.ext.viewcode", 43 | ] 44 | 45 | # List of patterns, relative to source directory, that match files and 46 | # directories to ignore when looking for source files. 47 | # This pattern also affects html_static_path and html_extra_path. 48 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 49 | 50 | pygments_style = "tango" 51 | 52 | 53 | # -- Options for HTML output ------------------------------------------------- 54 | 55 | # The theme to use for HTML and HTML Help pages. See the documentation for 56 | # a list of builtin themes. 57 | # 58 | html_theme = "furo" 59 | 60 | # -- Extension configuration ------------------------------------------------- 61 | 62 | intersphinx_mapping = { 63 | "python": ("https://docs.python.org/3", None), 64 | "ipython": ("https://ipython.readthedocs.io/en/stable/", None), 65 | "rich": ("https://rich.readthedocs.io/en/stable/", None), 66 | } 67 | 68 | autodoc_member_order = "bysource" 69 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Represent 2 | 3 | [![PyPI Version][ppi]][ppl] [![Documentation][di]][dl] [![CI Status][gai]][gal] [![Coverage][cvi]][cvl] [![Python Version][pvi]][pvl] [![MIT License][mli]][mll] 4 | 5 | [ppi]: https://img.shields.io/pypi/v/represent.svg?style=flat-square 6 | [ppl]: https://pypi.python.org/pypi/represent/ 7 | [pvi]: https://img.shields.io/badge/python-2.7%2C%203-brightgreen.svg?style=flat-square 8 | [pvl]: https://www.python.org/downloads/ 9 | [mli]: https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square 10 | [mll]: https://raw.githubusercontent.com/RazerM/represent/master/LICENSE 11 | [di]: https://img.shields.io/badge/docs-latest-brightgreen.svg?style=flat-square 12 | [dl]: https://represent.readthedocs.io/en/latest/ 13 | [gai]: https://github.com/RazerM/represent/workflows/CI/badge.svg?branch=master 14 | [gal]: https://github.com/RazerM/represent/actions?workflow=CI 15 | [cvi]: https://img.shields.io/codecov/c/github/RazerM/represent/master.svg?style=flat-square 16 | [cvl]: https://codecov.io/github/RazerM/represent?branch=master 17 | 18 | ### Installation 19 | 20 | ```bash 21 | $ pip install represent 22 | ``` 23 | 24 | ### Automatic Generation 25 | 26 | ```python 27 | from represent import autorepr 28 | 29 | 30 | @autorepr 31 | class Rectangle: 32 | def __init__(self, name, color, width, height): 33 | self.name = name 34 | self.color = color 35 | self.width = width 36 | self.height = height 37 | 38 | rect = Rectangle('Timothy', 'red', 15, 4.5) 39 | print(rect) 40 | ``` 41 | 42 | ``` 43 | Rectangle(name='Timothy', color='red', width=15, height=4.5) 44 | ``` 45 | 46 | ### Declarative Generation 47 | 48 | ```python 49 | from represent import ReprHelperMixin 50 | 51 | 52 | class ContrivedExample(ReprHelperMixin, object): 53 | def __init__(self, description, radians, shape, color, miles): 54 | self.description = description 55 | self.degrees = radians * 180 / 3.141592654 56 | self.shape = shape 57 | self._color = color 58 | self.km = 1.60934 * miles 59 | 60 | def _repr_helper_(self, r): 61 | r.positional_from_attr('description') 62 | r.positional_with_value(self.degrees * 3.141592654 / 180) 63 | r.keyword_from_attr('shape') 64 | r.keyword_from_attr('color', '_color') 65 | r.keyword_with_value('miles', self.km / 1.60934) 66 | 67 | ce = ContrivedExample('does something', 0.345, 'square', 'red', 22) 68 | 69 | print(ce) 70 | from IPython.lib.pretty import pprint 71 | pprint(ce) 72 | ``` 73 | 74 | ``` 75 | ContrivedExample('does something', 0.345, shape='square', color='red', miles=22.0) 76 | ContrivedExample('does something', 77 | 0.345, 78 | shape='square', 79 | color='red', 80 | miles=22.0) 81 | ``` 82 | -------------------------------------------------------------------------------- /doc/usage/helper.rst: -------------------------------------------------------------------------------- 1 | .. _declarative-generation: 2 | 3 | Declarative Generation 4 | ====================== 5 | 6 | Helper Mixin 7 | ------------ 8 | 9 | If you cannot use, or prefer not to use :class:`~represent.core.ReprMixin`, 10 | there is an alternative declarative syntax. 11 | 12 | :class:`~represent.core.ReprHelperMixin` provides ``__repr__``, 13 | ``_repr_pretty_`` (for :mod:`IPython.lib.pretty`), and ``__rich_repr__`` (for 14 | :mod:`rich.pretty`), all of which use a user defined function called 15 | ``_repr_helper_``. 16 | 17 | All possible method calls on the passed object `r` are shown here: 18 | 19 | .. code:: python 20 | 21 | def _repr_helper_(self, r): 22 | r.positional_from_attr('attrname') 23 | r.positional_with_value(value) 24 | r.keyword_from_attr('attrname') 25 | r.keyword_from_attr('keyword', 'attrname') 26 | r.keyword_with_value('keyword', value) 27 | 28 | The passed object, `r`, is a :class:`~represent.helper.ReprHelper` or 29 | :class:`~represent.helper.PrettyReprHelper` instance, depending on whether 30 | ``__repr__`` or ``_repr_pretty_`` was called. These classes have an 31 | identical API after instantiation (which is handled by the mixin class). 32 | 33 | .. code-block:: python 34 | :linenos: 35 | :emphasize-lines: 22 36 | 37 | from datetime import datetime 38 | from IPython.lib.pretty import pprint 39 | from represent import ReprHelperMixin 40 | 41 | 42 | class ContrivedExample(ReprHelperMixin, object): 43 | def __init__(self, description, radians, shape, color, miles, cls): 44 | self.description = description 45 | self.degrees = radians * 180 / 3.141592654 46 | self.shape = shape 47 | self._color = color 48 | self.km = 1.60934 * miles 49 | self.cls = cls 50 | 51 | def _repr_helper_(self, r): 52 | r.positional_from_attr('description') 53 | r.positional_with_value(self.degrees * 3.141592654 / 180) 54 | r.keyword_from_attr('shape') 55 | r.keyword_from_attr('color', '_color') 56 | r.keyword_with_value('miles', self.km / 1.60934) 57 | qual_name = '{cls.__module__}.{cls.__name__}'.format(cls=self.cls) 58 | r.keyword_with_value('cls', qual_name, raw=True) 59 | 60 | 61 | ce = ContrivedExample('something', 0.345, 'square', 'red', 22, datetime) 62 | 63 | print(ce) 64 | pprint(ce) 65 | 66 | .. code-block:: none 67 | 68 | ContrivedExample('does something', 0.345, shape='square', color='red', miles=22.0, cls=datetime.datetime) 69 | ContrivedExample('does something', 70 | 0.345, 71 | shape='square', 72 | color='red', 73 | miles=22.0, 74 | cls=datetime.datetime) 75 | 76 | Note that ``raw=True`` on line 22 presents the string without quotes, because 77 | ``cls='datetime.datetime'`` would be incorrect. 78 | 79 | Manual Helpers 80 | -------------- 81 | 82 | To use the declarative style without using 83 | :class:`~represent.core.ReprHelperMixin`, refer to the documentation for 84 | :class:`~represent.helper.ReprHelper`, 85 | :class:`~represent.helper.PrettyReprHelper`, and 86 | :class:`~represent.helper.RichReprHelper`. 87 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Represent is covered by the following license: 2 | 3 | The MIT License (MIT) 4 | 5 | Copyright (c) 2015 Frazer McLean 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in all 15 | copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | SOFTWARE. 24 | 25 | The deprecation functionality in represent/utilities.py is from the pyca/cryptography 26 | project on GitHub. It is covered by the following license: 27 | 28 | Copyright (c) Individual contributors. 29 | All rights reserved. 30 | 31 | Redistribution and use in source and binary forms, with or without 32 | modification, are permitted provided that the following conditions are met: 33 | 34 | 1. Redistributions of source code must retain the above copyright notice, 35 | this list of conditions and the following disclaimer. 36 | 37 | 2. Redistributions in binary form must reproduce the above copyright 38 | notice, this list of conditions and the following disclaimer in the 39 | documentation and/or other materials provided with the distribution. 40 | 41 | 3. Neither the name of PyCA Cryptography nor the names of its contributors 42 | may be used to endorse or promote products derived from this software 43 | without specific prior written permission. 44 | 45 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 46 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 47 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 48 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 49 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 50 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 51 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 52 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 53 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 54 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 55 | 56 | Authors: 57 | 58 | - Alex Gaynor 59 | - Hynek Schlawack 60 | - Donald Stufft 61 | - Laurens Van Houtven 62 | - Christian Heimes 63 | - Paul Kehrer 64 | - Jarret Raim 65 | - Alex Stapleton 66 | - David Reid 67 | - Konstantinos Koukopoulos 68 | - Stephen Holsapple 69 | - Terry Chia 70 | - Matthew Iversen 71 | - Mohammed Attia 72 | - Michael Hart 73 | - Mark Adams 74 | - Gregory Haynes 75 | - Chelsea Winfree 76 | - Steven Buss 77 | -------------------------------------------------------------------------------- /doc/usage/automatic.rst: -------------------------------------------------------------------------------- 1 | Automatic Generation 2 | ==================== 3 | 4 | In order to automatically generate a :code:`__repr__` for our class, we use 5 | the :func:`~represent.core.autorepr` class decorator. 6 | 7 | For automatic :code:`__repr__` creation, Represent assumes that the 8 | arguments for :code:`__init__` are available as instance variables. If this 9 | is not the case, you should use :ref:`declarative-generation`. 10 | 11 | Simple Example 12 | -------------- 13 | 14 | .. code:: python 15 | 16 | from represent import autorepr 17 | 18 | 19 | @autorepr 20 | class Rectangle: 21 | def __init__(self, name, color, width, height): 22 | self.name = name 23 | self.color = color 24 | self.width = width 25 | self.height = height 26 | 27 | rect = Rectangle('Timothy', 'red', 15, 4.5) 28 | print(rect) 29 | 30 | .. code-block:: none 31 | 32 | Rectangle(name='Timothy', color='red', width=15, height=4.5) 33 | 34 | Pretty Printer 35 | -------------- 36 | 37 | :func:`~represent.core.autorepr` also provides a 38 | :code:`_repr_pretty_` method for :mod:`IPython.lib.pretty` and a 39 | :code:`__rich_repr__` method for :mod:`rich.pretty`. 40 | 41 | Therefore, with the simple example above, we can pretty print: 42 | 43 | .. code:: python 44 | 45 | from IPython.lib.pretty import pprint 46 | 47 | rect.name = 'Something really long to force pretty printing line break' 48 | pprint(rect) 49 | 50 | .. code-block:: none 51 | 52 | Rectangle('Something really long to force pretty printing line break', 53 | color='red', 54 | width=15, 55 | height=4.5) 56 | 57 | .. code:: python 58 | 59 | from rich.pretty import pprint 60 | 61 | pprint(rect) 62 | 63 | .. code-block:: none 64 | 65 | Rectangle( 66 | name='Something really long to force pretty printing line break', 67 | color='red', 68 | width=15, 69 | height=4.5 70 | ) 71 | 72 | Positional Arguments 73 | -------------------- 74 | 75 | Using the :code:`positional` argument of :func:`~represent.core.autorepr` 76 | prints some arguments without their keyword as shown here: 77 | 78 | .. code:: python 79 | 80 | @autorepr(positional=1) 81 | class Rectangle: 82 | def __init__(self, name, color, width, height): 83 | self.name = name 84 | self.color = color 85 | self.width = width 86 | self.height = height 87 | 88 | rect = Rectangle('Timothy', 'red', 15, 4.5) 89 | print(rect) 90 | 91 | .. code-block:: none 92 | 93 | Rectangle('Timothy', color='red', width=15, height=4.5) 94 | 95 | In this case, we passed the number of positional arguments. Similarly, we 96 | could have done any of the following: 97 | 98 | .. code:: python 99 | 100 | @autorepr(positional='name') 101 | 102 | .. code:: python 103 | 104 | @autorepr(positional=2) 105 | 106 | .. code:: python 107 | 108 | @autorepr(positional=['name', 'color']) 109 | 110 | Inheritance 111 | ----------- 112 | 113 | 114 | Using :func:`~represent.core.autorepr` is like defining the following 115 | method on the base class: 116 | 117 | .. code-block:: python 118 | 119 | def __repr__(self): 120 | return '{self.__class__.__name__}({self.a}, {self.b})'.format(self=self) 121 | 122 | Therefore, subclasses will correctly show their own class name, but showing 123 | the same attributes as the base class's ``__init__``. 124 | 125 | .. code-block:: python 126 | 127 | @autorepr 128 | class Rectangle: 129 | def __init__(self, width, height): 130 | self.width = width 131 | self.height = height 132 | 133 | class Cuboid(Rectangle): 134 | def __init__(self, width, height, depth): 135 | super().__init__(width, height) 136 | self.depth = depth 137 | 138 | rectangle = Rectangle(1, 2) 139 | print(rectangle) 140 | 141 | cuboid = Cuboid(1, 2, 3) 142 | print(cuboid) 143 | 144 | Clearly, ``Cuboid.__repr__`` is incorrect in this case: 145 | 146 | .. code-block:: none 147 | 148 | Rectangle(width=1, height=2) 149 | Cuboid(width=1, height=2) 150 | 151 | This is easily fixed by using :func:`~represent.core.autorepr` on 152 | subclasses if their arguments are different: 153 | 154 | .. code-block:: python 155 | 156 | @autorepr 157 | class Cuboid(Rectangle): 158 | def __init__(self, width, height, depth): 159 | super().__init__(width, height) 160 | self.depth = depth 161 | 162 | Pickle Support 163 | -------------- 164 | 165 | The deprecated :class:`~represent.deprecated.ReprMixin` (the predecessor to 166 | :func:`~represent.core.autorepr`) class required special care when using 167 | pickle since it created ``__repr__`` during ``__init__``. 168 | 169 | :func:`~represent.core.autorepr` has no such limitations, as it creates 170 | ``__repr__`` when the class is created. 171 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## [Unreleased][unreleased] 4 | 5 | N/A 6 | 7 | ## [2.1.0] - 2024-01-23 8 | 9 | ### Added 10 | 11 | - Support for `rich.pretty` to `autorepr` and `ReprHelperMixin`. 12 | - `RichReprHelper`. 13 | 14 | ## [2.0.0] - 2023-12-30 15 | 16 | ### Changed 17 | 18 | - Dropped support for Python < 3.8 19 | 20 | ### Removed 21 | 22 | - All previously deprecated items. 23 | - `represent.__version__` etc. use `importlib.metadata` instead. 24 | 25 | ## [1.6.0.post0] - 2020-12-22 26 | 27 | ### Changed 28 | 29 | - Modernised packaging and ensure Python 3.8-3.9 are tested on CI. No 30 | functional changes. 31 | 32 | ## [1.6.0] - 2019-01-30 33 | 34 | ### Added 35 | 36 | - `autorepr` and `ReprHelperMixin` now use [`reprlib.recursive_repr`][rrr] if 37 | available. (#3) 38 | 39 | ### Changed 40 | 41 | - `autorepr` now assigns one `namedtuple` to `cls._represent` instead of using 42 | three different class variables as before. 43 | 44 | ### Fixed 45 | 46 | - Deprecation warnings were always raised instead of only when deprecated 47 | names were imported. 48 | 49 | [rrr]: https://docs.python.org/3.5/library/reprlib.html#reprlib.recursive_repr 50 | 51 | ## [1.5.1] - 2016-05-29 52 | 53 | ### Added 54 | 55 | - More tests for areas missing from code coverage. 56 | - Links in the README to readthedocs and codecov. 57 | 58 | ### Fixed 59 | 60 | - `autorepr` wouldn't create methods if a subclass had the same name as its 61 | parent. This check has been removed. 62 | 63 | ## [1.5.0] - 2016-05-25 64 | 65 | ### Added 66 | 67 | - `autorepr` class decorator to replace `ReprMixin`. `autorepr` is a much 68 | cleaner solution, moving errors to class creation time instead of during 69 | `__init__`. There are no caveats with pickling anymore. 70 | 71 | ### Deprecated 72 | 73 | The following names will raise deprecation warnings. They will be removed 74 | completely in 2.0.0. 75 | 76 | - `ReprMixin` 77 | - `ReprMixinBase` 78 | 79 | ## [1.4.1] - 2016-03-11 80 | 81 | ### Fixed 82 | 83 | - `__slots__` added to `ReprHelperMixin` to allow subclasses to use `__slots__`. 84 | 85 | ## [1.4.0] - 2015-09-02 86 | 87 | ### Added 88 | 89 | - `BaseReprHelper`, base class for `ReprHelper` and `PrettyReprHelper` to handle 90 | common functionality and enforce same API and docstrings. 91 | - `BaseReprHelper.parantheses` tuple can be set to something other than normal 92 | brackets, e.g. `('<', '>')` 93 | 94 | ### Fixed 95 | 96 | - `BaseReprHelper.keyword_from_attr` parameter names swapped. `attr_name` used 97 | to refer to keyword name, which doesn't make sense. 98 | 99 | ## [1.3.0] - 2015-05-07 100 | 101 | ### Added 102 | 103 | - `ReprHelperMixin` to simplify [manual generation][man] 104 | 105 | [man]: http://represent.readthedocs.io/en/latest/usage/helper.html 106 | 107 | ### Fixed 108 | 109 | - `PrettyReprHelper.positional_from_attr()` didn't check for cycle, causing 110 | recursion limit to be reached for self-referential objects. 111 | 112 | ## [1.2.1] - 2015-05-02 113 | 114 | ### Fixed 115 | 116 | - `__init__.py` was missing from represent.compat, so wasn't packaged for PyPI. 117 | 118 | ## [1.2.0] - 2015-05-02 119 | 120 | ### Changed 121 | 122 | - `RepresentationMixin` has been renamed to `ReprMixin`. 123 | - `RepresentationHelper` has been renamed to `ReprHelper`. 124 | - `PrettyRepresentationHelper` has been renamed to `PrettyReprHelper`. 125 | 126 | ### Added 127 | 128 | - `ReprMixinBase` is available if user does not want to inherit `__getstate__` 129 | and `__setstate__` from `ReprMixin`. 130 | - Documentation about Pickle support for ReprMixin (#2) 131 | 132 | ### Deprecated 133 | 134 | These aliases will raise deprecation warnings: 135 | 136 | - `RepresentationMixin` 137 | - `RepresentationHelper` 138 | - `PrettyRepresentationHelper` 139 | 140 | ## [1.1.0] - 2015-04-16 141 | 142 | ### Added 143 | 144 | - Add pickle support by instantiating `RepresentationMixin` in `__setstate__`. 145 | 146 | ## [1.0.2] - 2015-04-06 147 | 148 | ### Fixed 149 | 150 | - Improve control flow for class variable check, which could previously cause a 151 | bug if assertions were disabled. 152 | 153 | ## [1.0.1] - 2015-04-05 154 | 155 | ### Fixed 156 | 157 | - Remove extra print statement (#1) 158 | 159 | ## [1.0] - 2015-03-28 160 | 161 | ### Changed 162 | 163 | - Only create class variables during first instantiation. 164 | 165 | [unreleased]: https://github.com/RazerM/represent/compare/2.1.0...HEAD 166 | [2.1.0]: https://github.com/RazerM/represent/compare/2.0.0..2.1.0 167 | [2.0.0]: https://github.com/RazerM/represent/compare/1.6.0.post0..2.0.0 168 | [1.6.0.post0]: https://github.com/RazerM/represent/compare/1.6.0...1.6.0.post0 169 | [1.6.0]: https://github.com/RazerM/represent/compare/1.5.1...1.6.0 170 | [1.5.1]: https://github.com/RazerM/represent/compare/1.5.0...1.5.1 171 | [1.5.0]: https://github.com/RazerM/represent/compare/1.4.1...1.5.0 172 | [1.4.1]: https://github.com/RazerM/represent/compare/1.4.0...1.4.1 173 | [1.4.0]: https://github.com/RazerM/represent/compare/1.3.0...1.4.0 174 | [1.3.0]: https://github.com/RazerM/represent/compare/1.2.1...1.3.0 175 | [1.2.1]: https://github.com/RazerM/represent/compare/1.2.0...1.2.1 176 | [1.2.0]: https://github.com/RazerM/represent/compare/1.1.0...1.2.0 177 | [1.1.0]: https://github.com/RazerM/represent/compare/1.0.2...1.1.0 178 | [1.0.2]: https://github.com/RazerM/represent/compare/1.0.1...1.0.2 179 | [1.0.1]: https://github.com/RazerM/represent/compare/1.0...1.0.1 180 | [1.0]: https://github.com/RazerM/represent/compare/1.0b1...1.0 181 | -------------------------------------------------------------------------------- /doc/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/orbital.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/orbital.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/orbital" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/orbital" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /tests/test_autorepr.py: -------------------------------------------------------------------------------- 1 | from contextlib import contextmanager 2 | from functools import partial 3 | from textwrap import dedent 4 | from types import MethodType 5 | from unittest.mock import Mock, patch 6 | 7 | import pytest 8 | from IPython.lib.pretty import pretty 9 | from rich.pretty import pretty_repr 10 | 11 | from represent import autorepr 12 | 13 | 14 | class WrappedMethod: 15 | def __init__(self, method): 16 | self.mock = Mock(method, wraps=method) 17 | 18 | def __get__(self, instance, owner): 19 | if instance is None: 20 | return self.mock 21 | return partial(self.mock, instance) 22 | 23 | 24 | @contextmanager 25 | def spy_on_method(target, attribute): 26 | wrapped = WrappedMethod(getattr(target, attribute)) 27 | with patch.object(target, attribute, wrapped): 28 | yield wrapped.mock 29 | 30 | 31 | def test_standard(): 32 | @autorepr 33 | class A: 34 | def __init__(self): 35 | pass 36 | 37 | @autorepr 38 | class B: 39 | def __init__(self, a, b, c=5): 40 | self.a = a 41 | self.b = b 42 | self.c = c 43 | 44 | assert repr(A()) == "A()" 45 | 46 | with spy_on_method(A, "_repr_pretty_"): 47 | assert pretty(A()) == "A()" 48 | assert A._repr_pretty_.called 49 | 50 | with spy_on_method(A, "__rich_repr__"): 51 | assert pretty_repr(A()) == "A()" 52 | assert A.__rich_repr__.called 53 | 54 | assert repr(B(1, 2)) == "B(a=1, b=2, c=5)" 55 | 56 | with spy_on_method(B, "_repr_pretty_"): 57 | assert pretty(B(1, 2)) == "B(a=1, b=2, c=5)" 58 | assert B._repr_pretty_.called 59 | 60 | with spy_on_method(B, "__rich_repr__"): 61 | assert pretty_repr(B(1, 2)) == "B(a=1, b=2, c=5)" 62 | assert B.__rich_repr__.called 63 | 64 | 65 | def test_positional(): 66 | @autorepr(positional=1) 67 | class A: 68 | def __init__(self, a, b, c=5): 69 | self.a = a 70 | self.b = b 71 | self.c = c 72 | 73 | @autorepr(positional=2) 74 | class B: 75 | def __init__(self, a, b, c=5): 76 | self.a = a 77 | self.b = b 78 | self.c = c 79 | 80 | @autorepr(positional="a") 81 | class C: 82 | def __init__(self, a, b, c=5): 83 | self.a = a 84 | self.b = b 85 | self.c = c 86 | 87 | @autorepr(positional=["a", "b"]) 88 | class D: 89 | def __init__(self, a, b, c=5): 90 | self.a = a 91 | self.b = b 92 | self.c = c 93 | 94 | assert repr(A(1, 2)) == "A(1, b=2, c=5)" 95 | 96 | with spy_on_method(A, "_repr_pretty_"): 97 | assert pretty(A(1, 2)) == "A(1, b=2, c=5)" 98 | assert A._repr_pretty_.called 99 | 100 | with spy_on_method(A, "__rich_repr__"): 101 | assert pretty_repr(A(1, 2)) == "A(1, b=2, c=5)" 102 | assert A.__rich_repr__.called 103 | 104 | assert repr(B(1, 2)) == "B(1, 2, c=5)" 105 | 106 | with spy_on_method(B, "_repr_pretty_"): 107 | assert pretty(B(1, 2)) == "B(1, 2, c=5)" 108 | assert B._repr_pretty_.called 109 | 110 | with spy_on_method(B, "__rich_repr__"): 111 | assert pretty_repr(B(1, 2)) == "B(1, 2, c=5)" 112 | assert B.__rich_repr__.called 113 | 114 | assert repr(C(1, 2)) == "C(1, b=2, c=5)" 115 | 116 | with spy_on_method(C, "_repr_pretty_"): 117 | assert pretty(C(1, 2)) == "C(1, b=2, c=5)" 118 | assert C._repr_pretty_.called 119 | 120 | with spy_on_method(C, "__rich_repr__"): 121 | assert pretty_repr(C(1, 2)) == "C(1, b=2, c=5)" 122 | assert C.__rich_repr__.called 123 | 124 | assert repr(D(1, 2)) == "D(1, 2, c=5)" 125 | 126 | with spy_on_method(D, "_repr_pretty_"): 127 | assert pretty(D(1, 2)) == "D(1, 2, c=5)" 128 | assert D._repr_pretty_.called 129 | 130 | with spy_on_method(D, "__rich_repr__"): 131 | assert pretty_repr(D(1, 2)) == "D(1, 2, c=5)" 132 | assert D.__rich_repr__.called 133 | 134 | with pytest.raises(ValueError): 135 | 136 | @autorepr(positional="b") 137 | class E: 138 | def __init__(self, a, b): 139 | pass 140 | 141 | 142 | def test_kwonly(): 143 | with pytest.raises(ValueError): 144 | 145 | @autorepr(positional="a") 146 | class A: 147 | def __init__(self, *, a): 148 | pass 149 | 150 | 151 | def test_exceptions(): 152 | with pytest.raises(TypeError): 153 | autorepr(1) 154 | 155 | with pytest.raises(TypeError): 156 | autorepr(1, 2) 157 | 158 | with pytest.raises(TypeError): 159 | autorepr(wrongkeyword=True) 160 | 161 | with pytest.raises(TypeError): 162 | autorepr() 163 | 164 | class B: 165 | def __init__(self): 166 | pass 167 | 168 | with pytest.raises(TypeError): 169 | autorepr(B, positional=1) 170 | 171 | 172 | def test_cycle(): 173 | @autorepr 174 | class A: 175 | def __init__(self, a=None): 176 | self.a = a 177 | 178 | a = A() 179 | a.a = a 180 | 181 | assert repr(a) == "A(a=...)" 182 | 183 | with spy_on_method(A, "_repr_pretty_"): 184 | assert pretty(a) == "A(a=A(...))" 185 | assert A._repr_pretty_.call_count == 2 186 | 187 | with spy_on_method(A, "__rich_repr__"): 188 | assert pretty_repr(a) == "A(a=...)" 189 | assert A.__rich_repr__.call_count == 1 190 | 191 | 192 | def test_reuse(): 193 | """autorepr was looking at classname to determine whether or not to add the 194 | methods, but this assumption isn't valid in some cases. 195 | """ 196 | 197 | @autorepr 198 | class A: 199 | def __init__(self, a): 200 | self.a = a 201 | 202 | _A = A 203 | 204 | @autorepr 205 | class A(_A): 206 | def __init__(self, a, b): 207 | super().__init__(a=a) 208 | self.b = b 209 | 210 | a = A(1, 2) 211 | assert repr(a) == "A(a=1, b=2)" 212 | 213 | 214 | def test_recursive_repr(): 215 | """Test that autorepr applies the :func:`reprlib.recursive_repr` decorator.""" 216 | 217 | @autorepr 218 | class A: 219 | def __init__(self, a=None): 220 | self.a = a 221 | 222 | a = A() 223 | a.a = a 224 | 225 | reprstr = "A(a=...)" 226 | assert repr(a) == reprstr 227 | 228 | 229 | @pytest.mark.parametrize("include_pretty", [False, True]) 230 | def test_include_pretty(include_pretty): 231 | @autorepr(include_pretty=include_pretty) 232 | class A: 233 | def __init__(self, a): 234 | self.a = a 235 | 236 | a = A(1) 237 | reprstr = "A(a=1)" 238 | assert repr(a) == reprstr 239 | 240 | if include_pretty: 241 | with spy_on_method(A, "_repr_pretty_"): 242 | assert pretty(a) == reprstr 243 | assert A._repr_pretty_.call_count == 1 244 | else: 245 | # check pretty falls back to __repr__ (to make sure we didn't leave a 246 | # broken _repr_pretty_ on the class) 247 | assert pretty(a) == reprstr 248 | assert not hasattr(A, "_repr_pretty_") 249 | 250 | 251 | @pytest.mark.parametrize("include_rich", [False, True]) 252 | def test_include_rich(include_rich): 253 | @autorepr(include_rich=include_rich) 254 | class A: 255 | def __init__(self, a): 256 | self.a = a 257 | 258 | a = A(1) 259 | reprstr = "A(a=1)" 260 | assert repr(a) == reprstr 261 | 262 | if include_rich: 263 | with spy_on_method(A, "__rich_repr__"): 264 | assert pretty_repr(a) == reprstr 265 | assert A.__rich_repr__.call_count == 1 266 | else: 267 | # check rich falls back to __repr__ (to make sure we didn't leave a 268 | # broken _repr_pretty_ on the class) 269 | assert pretty_repr(a) == reprstr 270 | assert not hasattr(A, "__rich_repr__") 271 | -------------------------------------------------------------------------------- /src/represent/core.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from functools import partial 3 | from reprlib import recursive_repr 4 | 5 | from .helper import PrettyReprHelper, ReprHelper, RichReprHelper 6 | from .utilities import ReprInfo 7 | 8 | __all__ = ["ReprHelperMixin", "autorepr"] 9 | 10 | 11 | _DEFAULT_INCLUDE_PRETTY = True 12 | _DEFAULT_INCLUDE_RICH = True 13 | 14 | 15 | def autorepr(*args, **kwargs): 16 | """Class decorator to construct :code:`__repr__` **automatically** 17 | based on the arguments to ``__init__``. 18 | 19 | :code:`_repr_pretty_` for :py:mod:`IPython.lib.pretty` is constructed 20 | unless `include_pretty=False`. 21 | 22 | :code:`__rich_repr__` for :py:mod:`rich.pretty` is constructed 23 | unless `include_rich=False`. 24 | 25 | :param positional: Mark arguments as positional by number, or a list of 26 | argument names. 27 | :param include_pretty: Add a ``_repr_pretty_`` to the class (defaults to 28 | True). 29 | :param include_rich: Add a ``__rich_repr__`` to the class (defaults to True). 30 | 31 | Example: 32 | 33 | .. code-block:: python 34 | 35 | >>> @autorepr 36 | ... class A: 37 | ... def __init__(self, a, b): 38 | ... self.a = a 39 | ... self.b = b 40 | 41 | >>> print(A(1, 2)) 42 | A(a=1, b=2) 43 | 44 | .. code-block:: python 45 | 46 | >>> @autorepr(positional=1) 47 | ... class B: 48 | ... def __init__(self, a, b): 49 | ... self.a = a 50 | ... self.b = b 51 | 52 | >>> print(A(1, 2)) 53 | A(1, b=2) 54 | 55 | .. versionadded:: 1.5.0 56 | """ 57 | cls = positional = None 58 | include_pretty = _DEFAULT_INCLUDE_PRETTY 59 | include_rich = _DEFAULT_INCLUDE_RICH 60 | 61 | # We allow using @autorepr or @autorepr(positional=..., ...), so check 62 | # how we were called. 63 | 64 | if args and not kwargs: 65 | if len(args) != 1: 66 | raise TypeError("Class must be only positional argument.") 67 | 68 | (cls,) = args 69 | 70 | if not isinstance(cls, type): 71 | raise TypeError( 72 | "The sole positional argument must be a class. To use the " 73 | "'positional' argument, use a keyword." 74 | ) 75 | 76 | elif not args and kwargs: 77 | valid_kwargs = {"positional", "include_pretty", "include_rich"} 78 | invalid_kwargs = set(kwargs) - valid_kwargs 79 | 80 | if invalid_kwargs: 81 | error = f"Unexpected keyword arguments: {invalid_kwargs}" 82 | raise TypeError(error) 83 | 84 | positional = kwargs.get("positional") 85 | include_pretty = kwargs.get("include_pretty", include_pretty) 86 | include_rich = kwargs.get("include_rich", include_rich) 87 | 88 | elif (args and kwargs) or (not args and not kwargs): 89 | raise TypeError("Use bare @autorepr or @autorepr(...) with keyword args.") 90 | 91 | # Define the methods we'll add to the decorated class. 92 | 93 | @recursive_repr() 94 | def __repr__(self): 95 | return self.__class__._represent.fstr.format(self=self) 96 | 97 | repr_pretty = rich_repr = None 98 | if include_pretty: 99 | repr_pretty = _make_repr_pretty() 100 | if include_rich: 101 | rich_repr = _make_rich_repr() 102 | 103 | if cls is not None: 104 | return _autorepr_decorate( 105 | cls, repr=__repr__, repr_pretty=repr_pretty, rich_repr=rich_repr 106 | ) 107 | else: 108 | return partial( 109 | _autorepr_decorate, 110 | repr=__repr__, 111 | repr_pretty=repr_pretty, 112 | rich_repr=rich_repr, 113 | positional=positional, 114 | include_pretty=include_pretty, 115 | include_rich=include_rich, 116 | ) 117 | 118 | 119 | def _make_repr_pretty(): 120 | def _repr_pretty_(self, p, cycle): 121 | """Pretty printer for :class:`IPython.lib.pretty`""" 122 | cls = self.__class__ 123 | clsname = cls.__name__ 124 | 125 | if cycle: 126 | p.text(f"{clsname}(...)") 127 | else: 128 | positional_args = cls._represent.args 129 | keyword_args = cls._represent.kw 130 | 131 | with p.group(len(clsname) + 1, clsname + "(", ")"): 132 | for i, positional in enumerate(positional_args): 133 | if i: 134 | p.text(",") 135 | p.breakable() 136 | p.pretty(getattr(self, positional)) 137 | 138 | for i, keyword in enumerate(keyword_args, start=len(positional_args)): 139 | if i: 140 | p.text(",") 141 | p.breakable() 142 | with p.group(len(keyword) + 1, keyword + "="): 143 | p.pretty(getattr(self, keyword)) 144 | 145 | return _repr_pretty_ 146 | 147 | 148 | def _make_rich_repr(): 149 | def __rich_repr__(self): 150 | """Pretty printer for :mod:`rich.pretty`""" 151 | cls = self.__class__ 152 | 153 | positional_args = cls._represent.args 154 | keyword_args = cls._represent.kw 155 | 156 | for positional in positional_args: 157 | yield getattr(self, positional) 158 | 159 | for keyword in keyword_args: 160 | yield keyword, getattr(self, keyword) 161 | 162 | return __rich_repr__ 163 | 164 | 165 | def _getparams(cls): 166 | signature = inspect.signature(cls) 167 | params = list(signature.parameters) 168 | kwonly = { 169 | p.name 170 | for p in signature.parameters.values() 171 | if p.kind == inspect.Parameter.KEYWORD_ONLY 172 | } 173 | 174 | return params, kwonly 175 | 176 | 177 | def _autorepr_decorate( 178 | cls, 179 | repr, 180 | repr_pretty, 181 | rich_repr, 182 | positional=None, 183 | include_pretty=_DEFAULT_INCLUDE_PRETTY, 184 | include_rich=_DEFAULT_INCLUDE_RICH, 185 | ): 186 | params, kwonly = _getparams(cls) 187 | 188 | # Args can be opted in as positional 189 | if positional is None: 190 | positional = [] 191 | elif isinstance(positional, int): 192 | positional = params[:positional] 193 | elif isinstance(positional, str): 194 | positional = [positional] 195 | 196 | # Ensure positional args can't follow keyword args. 197 | keyword_started = None 198 | 199 | # _repr_pretty_ uses lists for the pretty printer calls 200 | repr_args = [] 201 | repr_kw = [] 202 | 203 | # Construct format string for __repr__ 204 | repr_fstr_parts = ["{self.__class__.__name__}", "("] 205 | for i, arg in enumerate(params): 206 | if i: 207 | repr_fstr_parts.append(", ") 208 | 209 | if arg in positional: 210 | repr_fstr_parts.append(f"{{self.{arg}!r}}") 211 | repr_args.append(arg) 212 | 213 | if arg in kwonly: 214 | raise ValueError(f"keyword only argument '{arg}' cannot be positional") 215 | if keyword_started: 216 | raise ValueError( 217 | "positional argument '{}' cannot follow keyword" 218 | " argument '{}'".format(arg, keyword_started) 219 | ) 220 | else: 221 | keyword_started = arg 222 | repr_fstr_parts.append("{0}={{self.{0}!r}}".format(arg)) 223 | repr_kw.append(arg) 224 | 225 | repr_fstr_parts.append(")") 226 | 227 | # Store as class variable. 228 | cls._represent = ReprInfo("".join(repr_fstr_parts), repr_args, repr_kw) 229 | 230 | cls.__repr__ = repr 231 | if include_pretty: 232 | cls._repr_pretty_ = repr_pretty 233 | if include_rich: 234 | cls.__rich_repr__ = rich_repr 235 | 236 | return cls 237 | 238 | 239 | class ReprHelperMixin: 240 | """Mixin to provide :code:`__repr__` and :code:`_repr_pretty_` for 241 | :py:mod:`IPython.lib.pretty` from user defined :code:`_repr_helper_` 242 | function. 243 | 244 | For full API, see :py:class:`represent.helper.BaseReprHelper`. 245 | 246 | .. code-block:: python 247 | 248 | def _repr_helper_(self, r): 249 | r.positional_from_attr('attrname') 250 | r.positional_with_value(value) 251 | r.keyword_from_attr('attrname') 252 | r.keyword_from_attr('keyword', 'attrname') 253 | r.keyword_with_value('keyword', value) 254 | 255 | .. versionadded:: 1.3 256 | """ 257 | 258 | __slots__ = () 259 | 260 | @recursive_repr() 261 | def __repr__(self): 262 | r = ReprHelper(self) 263 | self._repr_helper_(r) 264 | return str(r) 265 | 266 | def _repr_pretty_(self, p, cycle): 267 | with PrettyReprHelper(self, p, cycle) as r: 268 | self._repr_helper_(r) 269 | 270 | def __rich_repr__(self): 271 | r = RichReprHelper(self) 272 | self._repr_helper_(r) 273 | yield from r 274 | -------------------------------------------------------------------------------- /src/represent/helper.py: -------------------------------------------------------------------------------- 1 | from abc import ABCMeta, abstractmethod 2 | 3 | from .utilities import Parantheses, inherit_docstrings 4 | 5 | __all__ = ["ReprHelper", "PrettyReprHelper", "RichReprHelper"] 6 | 7 | 8 | class BaseReprHelper(metaclass=ABCMeta): 9 | def __init__(self, other): 10 | self.parantheses = Parantheses(left="(", right=")") 11 | self.other = other 12 | self.other_cls = other.__class__ 13 | self.iarg = 0 14 | self.keyword_started = False 15 | 16 | @property 17 | def parantheses(self): 18 | return self._parantheses 19 | 20 | @parantheses.setter 21 | def parantheses(self, value): 22 | self._parantheses = Parantheses._make(value) 23 | 24 | @abstractmethod 25 | def positional_from_attr(self, attr_name): 26 | """Add positional argument by retrieving attribute `attr_name` 27 | 28 | :param str attr_name: Attribute name such that 29 | :code:`getattr(self, attr_name)` returns the correct value. 30 | """ 31 | 32 | @abstractmethod 33 | def positional_with_value(self, value, raw=False): 34 | """Add positional argument with value `value` 35 | 36 | :param value: Value for positional argument. 37 | :param bool raw: If false (default), :code:`repr(value)` is used. 38 | Otherwise, the value is used as is. 39 | """ 40 | 41 | @abstractmethod 42 | def keyword_from_attr(self, name, attr_name=None): 43 | """Add keyword argument from attribute `attr_name` 44 | 45 | :param str name: Keyword name. Also used as attribute name such that 46 | :code:`getattr(self, name)` returns the correct value. 47 | :param str attr_name: Attribute name, if different than `name`. 48 | 49 | .. versionchanged:: 1.4 50 | Method argument names swapped, didn't make sense before. 51 | """ 52 | 53 | @abstractmethod 54 | def keyword_with_value(self, name, value, raw=False): 55 | """Add keyword argument `name` with value `value`. 56 | 57 | :param str name: Keyword name. 58 | :param value: Value for keyword argument. 59 | :param bool raw: If false (default), :code:`repr(value)` is used. 60 | Otherwise, the value is used as is. 61 | """ 62 | 63 | 64 | @inherit_docstrings 65 | class ReprHelper(BaseReprHelper): 66 | """Help manual construction of :code:`__repr__`. 67 | 68 | It should be used as follows: 69 | 70 | .. code-block:: python 71 | 72 | def __repr__(self) 73 | r = ReprHelper(self) 74 | r.keyword_from_attr('name') 75 | return str(r) 76 | 77 | .. versionchanged:: 1.4 78 | 79 | `parantheses` property added. Must be set before `str(r)` is called: 80 | 81 | .. code-block:: python 82 | 83 | def __repr__(self) 84 | r = ReprHelper(self) 85 | r.parantheses = ('<', '>') 86 | r.keyword_from_attr('name') 87 | return str(r) 88 | """ 89 | 90 | def __init__(self, other): 91 | self.repr_parts = [] 92 | super().__init__(other) 93 | 94 | def positional_from_attr(self, attr_name): 95 | if self.keyword_started: 96 | raise ValueError("positional arguments cannot follow keyword arguments") 97 | self._ensure_comma() 98 | self.repr_parts.append(repr(getattr(self.other, attr_name))) 99 | self.iarg += 1 100 | 101 | def positional_with_value(self, value, raw=False): 102 | if self.keyword_started: 103 | raise ValueError("positional arguments cannot follow keyword arguments") 104 | self._ensure_comma() 105 | value = value if raw else repr(value) 106 | self.repr_parts.append(value) 107 | self.iarg += 1 108 | 109 | def keyword_from_attr(self, name, attr_name=None): 110 | self.keyword_started = True 111 | self._ensure_comma() 112 | attr_name = attr_name or name 113 | self.repr_parts.append(f"{name}={getattr(self.other, attr_name)!r}") 114 | self.iarg += 1 115 | 116 | def keyword_with_value(self, name, value, raw=False): 117 | self.keyword_started = True 118 | self._ensure_comma() 119 | value = value if raw else repr(value) 120 | self.repr_parts.append(f"{name}={value}") 121 | self.iarg += 1 122 | 123 | def _ensure_comma(self): 124 | if self.iarg: 125 | self.repr_parts.append(", ") 126 | 127 | def __str__(self): 128 | beginning = [self.other_cls.__name__, self.parantheses.left] 129 | end = [self.parantheses.right] 130 | all_parts = beginning + self.repr_parts + end 131 | return "".join(all_parts) 132 | 133 | 134 | class PrettyReprHelper(BaseReprHelper): 135 | """Help manual construction of :code:`_repr_pretty_` for 136 | :py:mod:`IPython.lib.pretty`. 137 | 138 | It should be used as follows: 139 | 140 | .. code-block:: python 141 | 142 | def _repr_pretty_(self, p, cycle) 143 | with PrettyReprHelper(self, p, cycle) as r: 144 | r.keyword_from_attr('name') 145 | 146 | .. versionchanged:: 1.4 147 | `parantheses` property added. Must be set before 148 | :py:meth:`PrettyReprHelper.open` is called (usually by context manager). 149 | 150 | .. code-block:: python 151 | 152 | def _repr_pretty_(self, p, cycle) 153 | r = PrettyReprHelper(self, p, cycle) 154 | r.parantheses = ('<', '>') 155 | with r: 156 | r.keyword_from_attr('name') 157 | """ 158 | 159 | def __init__(self, other, p, cycle): 160 | self.p = p 161 | self.cycle = cycle 162 | super().__init__(other) 163 | 164 | def positional_from_attr(self, attr_name): 165 | if self.cycle: 166 | return 167 | 168 | if self.keyword_started: 169 | raise ValueError("positional arguments cannot follow keyword arguments") 170 | self._ensure_comma() 171 | self.p.pretty(getattr(self.other, attr_name)) 172 | self.iarg += 1 173 | 174 | def positional_with_value(self, value, raw=False): 175 | if self.cycle: 176 | return 177 | 178 | if self.keyword_started: 179 | raise ValueError("positional arguments cannot follow keyword arguments") 180 | self._ensure_comma() 181 | if raw: 182 | self.p.text(str(value)) 183 | else: 184 | self.p.pretty(value) 185 | self.iarg += 1 186 | 187 | def keyword_from_attr(self, name, attr_name=None): 188 | if self.cycle: 189 | return 190 | 191 | self.keyword_started = True 192 | self._ensure_comma() 193 | attr_name = attr_name or name 194 | with self.p.group(len(name) + 1, name + "="): 195 | self.p.pretty(getattr(self.other, attr_name)) 196 | self.iarg += 1 197 | 198 | def keyword_with_value(self, name, value, raw=False): 199 | if self.cycle: 200 | return 201 | 202 | self.keyword_started = True 203 | self._ensure_comma() 204 | with self.p.group(len(name) + 1, name + "="): 205 | if raw: 206 | self.p.text(str(value)) 207 | else: 208 | self.p.pretty(value) 209 | self.iarg += 1 210 | 211 | def _ensure_comma(self): 212 | if self.iarg: 213 | self.p.text(",") 214 | self.p.breakable() 215 | 216 | def __enter__(self): 217 | """Return self for use as context manager. 218 | 219 | Context manager calls self.close() on exit.""" 220 | self.open() 221 | return self 222 | 223 | def __exit__(self, exc_type, exc_val, exc_tb): 224 | """Call self.close() during exit from context manager.""" 225 | if exc_type: 226 | return False 227 | 228 | self.close() 229 | 230 | def open(self): 231 | """Open group with class name. 232 | 233 | This is normally called by using as a context manager. 234 | """ 235 | clsname = self.other_cls.__name__ 236 | self.p.begin_group(len(clsname) + 1, clsname + self.parantheses.left) 237 | 238 | def close(self): 239 | """Close group with final bracket. 240 | 241 | This is normally called by using as a context manager. 242 | """ 243 | if self.cycle: 244 | self.p.text("...") 245 | clsname = self.other_cls.__name__ 246 | self.p.end_group(len(clsname) + 1, self.parantheses.right) 247 | 248 | 249 | class RawReprWrapper: 250 | """rich.pretty calls repr for us, so to support raw=True we need a wrapper 251 | object which returns str() when repr() is called. 252 | """ 253 | 254 | def __init__(self, o: object): 255 | self._object = o 256 | 257 | def __repr__(self): 258 | return str(self._object) 259 | 260 | 261 | class RichReprHelper(BaseReprHelper): 262 | """Help manual construction of :code:`__rich_repr__` for 263 | :py:mod:`rich.pretty`. 264 | 265 | It should be used as follows: 266 | 267 | .. code-block:: python 268 | 269 | def __rich_repr__(self) 270 | r = RichReprHelper(self) 271 | r.keyword_from_attr('name') 272 | yield from r 273 | """ 274 | 275 | def __init__(self, other): 276 | self._tuples = [] 277 | super().__init__(other) 278 | 279 | def positional_from_attr(self, attr_name): 280 | if self.keyword_started: 281 | raise ValueError("positional arguments cannot follow keyword arguments") 282 | self._tuples.append((None, getattr(self.other, attr_name))) 283 | 284 | def positional_with_value(self, value, raw=False): 285 | if self.keyword_started: 286 | raise ValueError("positional arguments cannot follow keyword arguments") 287 | self._tuples.append((None, RawReprWrapper(value) if raw else value)) 288 | 289 | def keyword_from_attr(self, name, attr_name=None): 290 | self.keyword_started = True 291 | attr_name = attr_name or name 292 | self._tuples.append((name, getattr(self.other, attr_name))) 293 | 294 | def keyword_with_value(self, name, value, raw=False): 295 | self.keyword_started = True 296 | return self._tuples.append((name, RawReprWrapper(value) if raw else value)) 297 | 298 | def __iter__(self): 299 | return iter(self._tuples) 300 | -------------------------------------------------------------------------------- /tests/test_helper.py: -------------------------------------------------------------------------------- 1 | import textwrap 2 | 3 | import pytest 4 | from IPython.lib.pretty import pretty 5 | from rich.pretty import pretty_repr 6 | 7 | from represent import PrettyReprHelper, ReprHelper, ReprHelperMixin, RichReprHelper 8 | 9 | 10 | def test_helper_methods(): 11 | class ContrivedExample: 12 | def __init__(self, description, radians, shape, color, miles): 13 | self.description = description 14 | self.degrees = radians * 180 / 3.141592654 15 | self.shape = shape 16 | self._color = color 17 | self.km = 1.60934 * miles 18 | 19 | def _repr_helper(self, r): 20 | r.positional_from_attr("description") 21 | r.positional_with_value(self.degrees * 3.141592654 / 180) 22 | r.keyword_from_attr("shape") 23 | r.keyword_from_attr("color", "_color") 24 | r.keyword_with_value("miles", self.km / 1.60934) 25 | 26 | def __repr__(self): 27 | r = ReprHelper(self) 28 | self._repr_helper(r) 29 | return str(r) 30 | 31 | def _repr_pretty_(self, p, cycle): 32 | with PrettyReprHelper(self, p, cycle) as r: 33 | self._repr_helper(r) 34 | 35 | def __rich_repr__(self): 36 | r = RichReprHelper(self) 37 | self._repr_helper(r) 38 | yield from r 39 | 40 | ce = ContrivedExample("does something", 0.345, "square", "red", 22) 41 | assert repr(ce) == ( 42 | "ContrivedExample('does something', 0.345, " 43 | "shape='square', color='red', miles=22.0)" 44 | ) 45 | prettystr = """ 46 | ContrivedExample('does something', 47 | 0.345, 48 | shape='square', 49 | color='red', 50 | miles=22.0)""" 51 | assert pretty(ce) == textwrap.dedent(prettystr).lstrip() 52 | prettystr = """ 53 | ContrivedExample( 54 | 'does something', 55 | 0.345, 56 | shape='square', 57 | color='red', 58 | miles=22.0 59 | )""" 60 | assert pretty_repr(ce) == textwrap.dedent(prettystr).lstrip() 61 | 62 | class RecursionChecker: 63 | def __init__(self, a, b, c, d, e): 64 | self.a = a 65 | self.b = b 66 | self.c = c 67 | self._d = d 68 | self.e = e 69 | 70 | def _repr_helper(self, r): 71 | r.positional_from_attr("a") 72 | r.positional_with_value(self.b) 73 | r.keyword_from_attr("c") 74 | r.keyword_from_attr("d", "_d") 75 | r.keyword_with_value("e", self.e) 76 | 77 | def _repr_pretty_(self, p, cycle): 78 | with PrettyReprHelper(self, p, cycle) as r: 79 | self._repr_helper(r) 80 | 81 | def __rich_repr__(self): 82 | r = RichReprHelper(self) 83 | self._repr_helper(r) 84 | yield from r 85 | 86 | rc = RecursionChecker(None, None, None, None, None) 87 | rc.a = rc 88 | rc.b = rc 89 | rc.c = rc 90 | rc._d = rc 91 | rc.e = rc 92 | prettystr = """ 93 | RecursionChecker(RecursionChecker(...), 94 | RecursionChecker(...), 95 | c=RecursionChecker(...), 96 | d=RecursionChecker(...), 97 | e=RecursionChecker(...))""" 98 | assert pretty(rc) == textwrap.dedent(prettystr).lstrip() 99 | assert pretty_repr(rc) == "RecursionChecker(..., ..., c=..., d=..., e=...)" 100 | 101 | 102 | def test_helper_exceptions(): 103 | class A: 104 | def __init__(self, a, b): 105 | self.a = a 106 | self.b = b 107 | 108 | def _repr_helper(self, r): 109 | # Try to make a repr where positional arg follows keyword arg. 110 | # Will raise ValueError when repr/pretty is called. 111 | r.keyword_from_attr("a") 112 | r.positional_from_attr("b") 113 | 114 | def __repr__(self): 115 | r = ReprHelper(self) 116 | self._repr_helper(r) 117 | raise RuntimeError("unreachable") # pragma: no cover 118 | 119 | def _repr_pretty_(self, p, cycle): 120 | with PrettyReprHelper(self, p, cycle) as r: 121 | self._repr_helper(r) 122 | 123 | def __rich_repr__(self): 124 | """Important that this is a generator, because rich catches errors 125 | that happen when the function is called. As a generator, we can 126 | postpone them until next() is called and then rich can't swallow 127 | the error. 128 | """ 129 | r = RichReprHelper(self) 130 | self._repr_helper(r) 131 | yield from r # pragma: no cover 132 | raise RuntimeError("unreachable") # pragma: no cover 133 | 134 | a = A(1, 2) 135 | 136 | with pytest.raises(ValueError): 137 | repr(a) 138 | 139 | with pytest.raises(ValueError): 140 | pretty(a) 141 | 142 | with pytest.raises(ValueError): 143 | pretty_repr(a) 144 | 145 | class B: 146 | def __init__(self, a, b): 147 | self.a = a 148 | self.b = b 149 | 150 | def _repr_helper(self, r): 151 | # Try to make a repr where positional arg follows keyword arg. 152 | # Will raise ValueError when repr/pretty is called. 153 | r.keyword_from_attr("a") 154 | r.positional_with_value(self.b) 155 | 156 | def __repr__(self): 157 | r = ReprHelper(self) 158 | self._repr_helper(r) 159 | raise RuntimeError("unreachable") # pragma: no cover 160 | 161 | def _repr_pretty_(self, p, cycle): 162 | with PrettyReprHelper(self, p, cycle) as r: 163 | self._repr_helper(r) 164 | 165 | def __rich_repr__(self): 166 | """Important that this is a generator, because rich catches errors 167 | that happen when the function is called. As a generator, we can 168 | postpone them until next() is called and then rich can't swallow 169 | the error. 170 | """ 171 | r = RichReprHelper(self) 172 | self._repr_helper(r) 173 | yield from r # pragma: no cover 174 | raise RuntimeError("unreachable") # pragma: no cover 175 | 176 | b = B(1, 2) 177 | 178 | with pytest.raises(ValueError): 179 | repr(b) 180 | 181 | with pytest.raises(ValueError): 182 | pretty(b) 183 | 184 | with pytest.raises(ValueError): 185 | pretty_repr(b) 186 | 187 | 188 | def test_helper_raw(): 189 | class A(ReprHelperMixin): 190 | def __init__(self, a, b): 191 | self.a = a 192 | self.b = b 193 | 194 | def _repr_helper_(self, r): 195 | r.positional_with_value(self.a, raw=True) 196 | r.keyword_with_value("b", self.b, raw=True) 197 | 198 | a = A("a", "b") 199 | assert repr(a) == "A(a, b=b)" 200 | assert pretty(a) == "A(a, b=b)" 201 | assert pretty_repr(a) == "A(a, b=b)" 202 | 203 | 204 | def test_helper_mixin(): 205 | """Verify that both __repr__ and _repr_pretty_ get called.""" 206 | 207 | class ContrivedExample(ReprHelperMixin): 208 | def __init__(self, description, radians, shape, color, miles): 209 | self.description = description 210 | self.degrees = radians * 180 / 3.141592654 211 | self.shape = shape 212 | self._color = color 213 | self.km = 1.60934 * miles 214 | 215 | def _repr_helper_(self, r): 216 | r.positional_from_attr("description") 217 | r.positional_with_value(self.degrees * 3.141592654 / 180) 218 | r.keyword_from_attr("shape") 219 | r.keyword_from_attr("color", "_color") 220 | r.keyword_with_value("miles", self.km / 1.60934) 221 | 222 | ce = ContrivedExample("does something", 0.345, "square", "red", 22) 223 | assert repr(ce) == ( 224 | "ContrivedExample('does something', 0.345, " 225 | "shape='square', color='red', miles=22.0)" 226 | ) 227 | prettystr = """ 228 | ContrivedExample('does something', 229 | 0.345, 230 | shape='square', 231 | color='red', 232 | miles=22.0)""" 233 | assert pretty(ce) == textwrap.dedent(prettystr).lstrip() 234 | prettystr = """ 235 | ContrivedExample( 236 | 'does something', 237 | 0.345, 238 | shape='square', 239 | color='red', 240 | miles=22.0 241 | )""" 242 | assert pretty_repr(ce) == textwrap.dedent(prettystr).lstrip() 243 | 244 | class ContrivedExampleKeywords(ContrivedExample): 245 | def _repr_helper_(self, r): 246 | r.positional_from_attr(attr_name="description") 247 | r.positional_with_value(value=self.degrees * 3.141592654 / 180) 248 | r.keyword_from_attr(name="shape") 249 | r.keyword_from_attr(name="color", attr_name="_color") 250 | r.keyword_with_value(name="miles", value=self.km / 1.60934) 251 | 252 | ce = ContrivedExampleKeywords("does something", 0.345, "square", "red", 22) 253 | assert repr(ce) == ( 254 | "ContrivedExampleKeywords('does something', 0.345, " 255 | "shape='square', color='red', miles=22.0)" 256 | ) 257 | prettystr = """ 258 | ContrivedExampleKeywords('does something', 259 | 0.345, 260 | shape='square', 261 | color='red', 262 | miles=22.0)""" 263 | assert pretty(ce) == textwrap.dedent(prettystr).lstrip() 264 | prettystr = """ 265 | ContrivedExampleKeywords( 266 | 'does something', 267 | 0.345, 268 | shape='square', 269 | color='red', 270 | miles=22.0 271 | )""" 272 | assert pretty_repr(ce) == textwrap.dedent(prettystr).lstrip() 273 | 274 | 275 | def test_helper_mixin_recursive(): 276 | """Test that the mixin applies the :func:`reprlib.recursive_repr` decorator.""" 277 | 278 | class A(ReprHelperMixin): 279 | def __init__(self, a=None): 280 | self.a = a 281 | 282 | def _repr_helper_(self, r): 283 | r.keyword_from_attr("a") 284 | 285 | a = A() 286 | a.a = a 287 | 288 | reprstr = "A(a=...)" 289 | assert repr(a) == reprstr 290 | 291 | 292 | def test_helper_parantheses(): 293 | class A: 294 | def __repr__(self): 295 | r = ReprHelper(self) 296 | r.parantheses = ("<", ">") 297 | r.keyword_with_value("id", hex(id(self)), raw=True) 298 | return str(r) 299 | 300 | def _repr_pretty_(self, p, cycle): 301 | r = PrettyReprHelper(self, p, cycle) 302 | r.parantheses = ("<", ">") 303 | with r: 304 | r.keyword_with_value("id", hex(id(self)), raw=True) 305 | 306 | a = A() 307 | assert repr(a) == f"A" 308 | assert pretty(a) == f"A" 309 | 310 | # Test namedtuple for parantheses property 311 | r = ReprHelper(a) 312 | assert repr(r.parantheses) == "Parantheses(left='(', right=')')" 313 | r.parantheses = ("<", ">") 314 | assert repr(r.parantheses) == "Parantheses(left='<', right='>')" 315 | --------------------------------------------------------------------------------