├── .github └── workflows │ └── pytest.yml ├── .gitignore ├── LICENSE.txt ├── MANIFEST.in ├── README.md ├── cheap_repr ├── __init__.py └── utils.py ├── make_release.sh ├── pyproject.toml ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── fake_django_settings.py ├── test_cheap_repr.py └── utils.py └── tox.ini /.github/workflows/pytest.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | on: [push, pull_request] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | strategy: 7 | matrix: 8 | python-version: [3.8, 3.9, '3.10', 3.11, 3.12, 3.13-dev] 9 | steps: 10 | - uses: actions/checkout@v2 11 | - name: Set up Python ${{ matrix.python-version }} 12 | uses: actions/setup-python@v2 13 | with: 14 | python-version: ${{ matrix.python-version }} 15 | - name: run tests 16 | run: | 17 | pip install -U pip 18 | pip install --upgrade coveralls asttokens pytest setuptools setuptools_scm pep517 19 | pip install .[tests] 20 | coverage run --source cheap_repr -m pytest 21 | coverage report -m 22 | - name: Coveralls Python 23 | uses: AndreMiras/coveralls-python-action@v20201129 24 | with: 25 | parallel: true 26 | flag-name: test-${{ matrix.python-version }} 27 | coveralls_finish: 28 | needs: build 29 | runs-on: ubuntu-latest 30 | steps: 31 | - name: Coveralls Finished 32 | uses: AndreMiras/coveralls-python-action@v20201129 33 | with: 34 | parallel-finished: true 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .eggs 2 | .tox 3 | *.egg-info 4 | *.pyc 5 | build 6 | dist 7 | .coverage 8 | 9 | /cheap_repr/version.py 10 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Alex Hall 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE.txt 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | cheap_repr 2 | ========== 3 | 4 | [![Tests](https://github.com/alexmojaki/cheap_repr/actions/workflows/pytest.yml/badge.svg)](https://github.com/alexmojaki/cheap_repr/actions/workflows/pytest.yml) [![Coverage Status](https://coveralls.io/repos/github/alexmojaki/cheap_repr/badge.svg?branch=master)](https://coveralls.io/github/alexmojaki/cheap_repr?branch=master) [![Supports Python versions 3.8+](https://img.shields.io/pypi/pyversions/cheap_repr.svg)](https://pypi.python.org/pypi/cheap_repr) 5 | 6 | This library provides short, fast, configurable string representations, and an easy API for registering your own. It's an improvement of the standard library module `reprlib` (`repr` in Python 2). 7 | 8 | Just use the `cheap_repr` function instead of `repr`: 9 | 10 | ```python 11 | >>> from cheap_repr import cheap_repr 12 | >>> cheap_repr(list(range(100))) 13 | '[0, 1, 2, ..., 97, 98, 99]' 14 | ``` 15 | 16 | `cheap_repr` knows how to handle many different types out of the box. You can register a function for any type, and pull requests to make these part of the library are welcome. If it doesn't know how to handle a particular type, the default `repr()` is used, possibly truncated: 17 | 18 | ```python 19 | >>> class MyClass(object): 20 | ... def __init__(self, items): 21 | ... self.items = items 22 | ... 23 | ... def __repr__(self): 24 | ... return 'MyClass(%r)' % self.items 25 | ... 26 | >>> c = MyClass(list(range(20))) 27 | >>> c 28 | MyClass([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19]) 29 | >>> cheap_repr(c) 30 | 'MyClass([0, 1, 2, 3, 4, 5, 6... 13, 14, 15, 16, 17, 18, 19])' 31 | ``` 32 | 33 | ## Suppression of long reprs 34 | 35 | `cheap_repr` is meant to prevent slow, expensive computations of string representations. So if it finds that a particular class can potentially produce very long representations, the class will be *suppressed*, meaning that in future the `__repr__` won't be calculated at all: 36 | 37 | ```python 38 | >>> cheap_repr(MyClass(list(range(1000)))) 39 | 'MyClass([0, 1, 2, 3, 4, 5, 6...94, 995, 996, 997, 998, 999])' 40 | .../cheap_repr/__init__.py:80: ReprSuppressedWarning: MyClass.__repr__ is too long and has been suppressed. Register a repr for the class to avoid this warning and see an informative repr again, or increase cheap_repr.suppression_threshold 41 | >>> cheap_repr(MyClass(list(range(1000)))) 42 | '' 43 | ``` 44 | 45 | `cheap_repr.suppression_threshold` refers to the attribute on the function itself, not the module. By default it's 300, meaning that a `repr` longer than 300 characters will trigger the suppression. 46 | 47 | ## Registering your own repr function 48 | 49 | For example: 50 | 51 | ```python 52 | >>> from cheap_repr import register_repr 53 | >>> @register_repr(MyClass) 54 | ... def repr_my_class(x, helper): 55 | ... return helper.repr_iterable(x.items, 'MyClass([', '])') 56 | ... 57 | >>> cheap_repr(MyClass(list(range(1000)))) 58 | 'MyClass([0, 1, 2, 3, 4, 5, ...])' 59 | ``` 60 | 61 | In general, write a function that takes two arguments `(x, helper)` and decorate it with `register_repr(cls)`. Then `cheap_repr(x)` where `isinstance(x, cls)` will dispatch to that function, unless there is also a registered function for a subclass which `x` is also an instance of. More precisely, the function corresponding to the first class in the MRO will be used. This is in contrast to the standard library module `reprlib`, which cannot handle subclasses that aren't explicitly 'registered', or classes with the same name. 62 | 63 | The `helper` argument is an object with a couple of useful attributes and methods: 64 | 65 | - `repr_iterable(iterable, left, right, end=False, length=None)` produces a comma-separated representation of `iterable`, automatically handling nesting and iterables that are too long, surrounded by `left` and `right`. The number of items is limited to `func.maxparts` (see the configuration section below). 66 | 67 | Set `end=True` to include items from both the beginning and end, possibly leaving out items 68 | in the middle. Only do this if `iterable` supports efficient slicing at the end, e.g. `iterable[-3:]`. 69 | 70 | Provide the `length` parameter if `len(iterable)` doesn't work. Usually this is not needed. 71 | - `truncate(string)` returns a version of `string` at most `func.maxparts` characters long, with the middle replaced by `...` if necessary. 72 | - `level` indicates how much nesting is still allowed in the result. If it's 0, return something minimal such as `[...]` to indicate that the original object is too deep to show all its contents. Otherwise, if you use `cheap_repr` on several items inside `x`, pass `helper.level - 1` as the second argument, e.g. `', '.join(cheap_repr(item, helper.level - 1) for item in x)`. 73 | 74 | ## Exceptions in repr functions 75 | 76 | If an exception occurs in `cheap_repr`, whether from a registered repr function or the usual `__repr__`, the exception will be caught and the cheap repr of the class will be suppressed: 77 | 78 | ```python 79 | >>> @register_repr(MyClass) 80 | ... def repr_my_class(x, helper): 81 | ... return 'MyClass([%r, ...])' % x.items[0] 82 | ... 83 | >>> cheap_repr(MyClass([])) 84 | '' 85 | .../cheap_repr/__init__.py:123: ReprSuppressedWarning: Exception 'IndexError: list index out of range' in repr_my_class for object of type MyClass. The repr has been suppressed for this type. 86 | ... 87 | >>> cheap_repr(MyClass([1, 2, 3])) 88 | '' 89 | ``` 90 | 91 | If you would prefer exceptions to bubble up normally, you can: 92 | 93 | 1. Set `cheap_repr.raise_exceptions = True` to globally make all exceptions bubble up. 94 | 2. To bubble exceptions from the `__repr__` of a class, call `raise_exceptions_from_default_repr()`. 95 | 3. Set `repr_my_class.raise_exceptions = True` (substituting your own registered repr function) to make exceptions bubble from that function. The way to find the relevant function is in the next section. 96 | 97 | ## Configuration: 98 | 99 | ### Configuration for specific functions 100 | 101 | To configure a specific function, you set attributes on that function. To find the function corresponding to a class, use `find_repr_function`: 102 | 103 | ```python 104 | >>> from cheap_repr import find_repr_function 105 | >>> find_repr_function(MyClass) 106 | 107 | ``` 108 | 109 | For most functions, there are two attributes available to configure, but contributors and library writers are encouraged to add arbitrary attributes for their own functions. The first attribute is `raise_exceptions`, described in the previous section. 110 | 111 | ### maxparts 112 | 113 | The other configurable attribute is `maxparts`. All registered repr functions have this attribute. It determines the maximum number of 'parts' (e.g. list elements or string characters, the meaning depends on the function) from the input that the output can display without truncation. The default value is 6. The decorator `@maxparts(n)` conveniently sets the attribute to make writing your own registered functions nicer. For example: 114 | 115 | ```python 116 | >>> from cheap_repr import maxparts 117 | >>> @register_repr(MyClass) 118 | ... @maxparts(2) 119 | ... def repr_my_class(x, helper): 120 | ... return helper.repr_iterable(x.items, 'MyClass([', '])') 121 | ... 122 | >>> cheap_repr(MyClass([1, 2, 3, 4])) 123 | 'MyClass([1, 2, ...])' 124 | >>> find_repr_function(MyClass).maxparts = 3 125 | >>> cheap_repr(MyClass([1, 2, 3, 4])) 126 | 'MyClass([1, 2, 3, ...])' 127 | ``` 128 | 129 | ### pandas 130 | 131 | The functions for `DataFrame`s and `Series` from the `pandas` library don't use `maxparts`. 132 | For the `DataFrame` function there's `max_rows` and `max_cols`. For the `Series` function there's just `max_rows`. 133 | 134 | ### level and max_level 135 | 136 | `cheap_repr` takes an optional argument `level` which controls the display of nested objects. Typically this decreases through recursive calls, and when it's 0, the contents of the object aren't shown. See 'Registering your own repr function' for more details. This means you can change the amount of nested data in the output of `cheap_repr` by changing the `level` argument. The default value is `cheap_repr.max_level`, which is initially 3. This means that changing `cheap_repr.max_level` will effectively change the `level` argument whenever it isn't explicitly specified. 137 | 138 | ### Global configuration 139 | 140 | These things that can be configured globally: 141 | 142 | 1. `cheap_repr.suppression_threshold`, discussed in the 'Suppression of long reprs' section. 143 | 2. The handling of exceptions, discussed in the 'Exceptions in repr functions' section, which can be changed by setting `cheap_repr.raise_exceptions = True` or calling `raise_exceptions_from_default_repr()`. 144 | 3. `cheap_repr.max_level`, discussed above. 145 | 146 | ## Other miscellaneous functions 147 | 148 | `basic_repr(x)` returns a string that looks like the default `object.__repr__`. This is handy if you don't want to write your own repr function to register. Simply register this function instead, e.g. 149 | 150 | ```python 151 | >>> from cheap_repr import basic_repr 152 | >>> register_repr(MyClass)(basic_repr) 153 | >>> cheap_repr(MyClass([1, 2, 3, 4])) 154 | '' 155 | ``` 156 | 157 | `normal_repr(x)` returns `repr(x)` - register it with a class to indicate that its own `__repr__` method is already fine. This prevents it from being supressed when its output is a bit long. 158 | 159 | `try_register_repr` is handy when you want to register a repr function for a class that may not exist, e.g. if the class is in a third party package that may not be installed. See the docstring for more details. 160 | -------------------------------------------------------------------------------- /cheap_repr/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | MIT License 3 | 4 | Copyright (c) 2021 Alex Hall 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | """ 24 | 25 | import inspect 26 | import sys 27 | import warnings 28 | from array import array 29 | from collections import defaultdict, deque 30 | from importlib import import_module 31 | from itertools import islice, repeat 32 | 33 | from cheap_repr.utils import type_name, exception_string, safe_qualname, viewitems, PY2, PY3 34 | 35 | if PY2: 36 | from itertools import izip as zip, izip_longest as zip_longest 37 | from collections import Mapping, Set 38 | else: 39 | from itertools import zip_longest 40 | from collections.abc import Mapping, Set 41 | 42 | try: 43 | from .version import __version__ 44 | except ImportError: # pragma: no cover 45 | # version.py is auto-generated with the git tag when building 46 | __version__ = "???" 47 | 48 | 49 | class ReprSuppressedWarning(Warning): 50 | """ 51 | This warning is raised when a class is supressed from having a 52 | repr calculated for it by cheap_repr in the future. 53 | Instead the output will be of the form: 54 | 55 | This can happen when either: 56 | 1. An exception is raised by either repr(x) or f(x) where 57 | f is a registered repr function for that class. See 58 | 'Exceptions in repr functions' in the README for more. 59 | 2. The output of repr(x) is longer than 60 | cheap_repr.suppression_threshold characters. 61 | """ 62 | 63 | 64 | repr_registry = {} 65 | 66 | 67 | def try_register_repr(module_name, class_name): 68 | """ 69 | This tries to register a repr function for a class that may not exist, 70 | e.g. if the class is in a third party package that may not be installed. 71 | module_name and class_name are strings. If the class can be imported, 72 | then: 73 | 74 | @try_register_repr(module_name, class_name) 75 | def repr_function(...) 76 | ... 77 | 78 | is equivalent to: 79 | 80 | from import 81 | @register_repr() 82 | def repr_function(...) 83 | ... 84 | 85 | If the class cannot be imported, nothing happens. 86 | """ 87 | try: 88 | cls = getattr(import_module(module_name), class_name) 89 | return register_repr(cls) 90 | except Exception: 91 | return lambda x: x 92 | 93 | 94 | def register_repr(cls): 95 | """ 96 | Register a repr function for cls. The function must accept two arguments: 97 | the object to be represented as a string, and an instance of ReprHelper. 98 | The registered function will be used by cheap_repr when appropriate, 99 | and can be retrieved by find_repr_function(cls). 100 | """ 101 | 102 | assert inspect.isclass(cls), 'register_repr must be called with a class. ' \ 103 | 'The type of %s is %s' % (cheap_repr(cls), type_name(cls)) 104 | 105 | def decorator(func): 106 | repr_registry[cls] = func 107 | func.__dict__.setdefault('maxparts', 6) 108 | return func 109 | 110 | return decorator 111 | 112 | 113 | def maxparts(num): 114 | """ 115 | See the maxparts section in the README. 116 | """ 117 | 118 | def decorator(func): 119 | func.maxparts = num 120 | return func 121 | 122 | return decorator 123 | 124 | 125 | @try_register_repr('pandas.core.internals', 'BlockManager') 126 | def basic_repr(x, *_): 127 | return '<%s instance at %#x>' % (type_name(x), id(x)) 128 | 129 | 130 | @try_register_repr('importlib.machinery', 'ModuleSpec') 131 | @register_repr(type(register_repr)) 132 | @register_repr(type(_ for _ in [])) 133 | @register_repr(type(inspect)) 134 | def normal_repr(x, *_): 135 | """ 136 | Register this with a class to indicate that its own 137 | __repr__ method is already fine. This prevents it from 138 | being supressed when its output is a bit long. 139 | """ 140 | return repr(x) 141 | 142 | 143 | suppressed_classes = set() 144 | 145 | 146 | @register_repr(object) 147 | @maxparts(60) 148 | def repr_object(x, helper): 149 | s = repr(x) 150 | if type(x).__repr__ is object.__repr__: 151 | return s 152 | 153 | if len(s) > cheap_repr.suppression_threshold: 154 | cls = x.__class__ 155 | suppressed_classes.add(cls) 156 | warnings.warn(ReprSuppressedWarning( 157 | '%s.__repr__ is too long and has been suppressed. ' 158 | 'Register a repr for the class to avoid this warning ' 159 | 'and see an informative repr again, ' 160 | 'or increase cheap_repr.suppression_threshold' % safe_qualname(cls))) 161 | return helper.truncate(s) 162 | 163 | 164 | def find_repr_function(cls): 165 | for cls in inspect.getmro(cls): 166 | func = repr_registry.get(cls) 167 | if func: 168 | return func 169 | 170 | 171 | __raise_exceptions_from_default_repr = False 172 | 173 | 174 | def raise_exceptions_from_default_repr(): 175 | global __raise_exceptions_from_default_repr 176 | __raise_exceptions_from_default_repr = True 177 | repr_object.raise_exceptions = True 178 | 179 | 180 | def cheap_repr(x, level=None, target_length=None): 181 | """ 182 | Return a short, computationally inexpensive string 183 | representation of x, with approximately up to `level` 184 | levels of nesting. 185 | """ 186 | if level is None: 187 | level = cheap_repr.max_level 188 | x_cls = getattr(x, '__class__', type(x)) 189 | for cls in inspect.getmro(x_cls): 190 | if cls in suppressed_classes: 191 | return _basic_but('repr suppressed', x) 192 | func = repr_registry.get(cls) 193 | if func: 194 | helper = ReprHelper(level, func, target_length) 195 | return _try_repr(func, x, helper) 196 | 197 | # Old-style classes in Python 2. 198 | return _try_repr(repr, x) 199 | 200 | 201 | cheap_repr.suppression_threshold = 300 202 | cheap_repr.raise_exceptions = False 203 | cheap_repr.max_level = 3 204 | 205 | 206 | def _try_repr(func, x, *args): 207 | try: 208 | return func(x, *args) 209 | except BaseException as e: 210 | should_raise = (cheap_repr.raise_exceptions or 211 | getattr(func, 'raise_exceptions', False) or 212 | func is repr and __raise_exceptions_from_default_repr) 213 | if should_raise: 214 | raise 215 | cls = x.__class__ 216 | if cls not in suppressed_classes: 217 | suppressed_classes.add(cls) 218 | warnings.warn(ReprSuppressedWarning( 219 | "Exception '%s' in %s for object of type %s. " 220 | "The repr has been suppressed for this type." % 221 | (exception_string(e), func.__name__, safe_qualname(cls)))) 222 | return _basic_but('exception in repr', x) 223 | 224 | 225 | def _basic_but(message, x): 226 | return '%s (%s)>' % (basic_repr(x)[:-1], message) 227 | 228 | 229 | class ReprHelper(object): 230 | __slots__ = ('level', 'func', 'target_length') 231 | 232 | def __init__(self, level, func, target_length): 233 | self.level = level 234 | self.func = func 235 | self.target_length = target_length 236 | 237 | def repr_iterable(self, iterable, left, right, end=False, length=None): 238 | """ 239 | Produces a comma-separated representation of `iterable`, automatically handling nesting and iterables 240 | that are too long, surrounded by `left` and `right`. 241 | The number of items is at most `maxparts` (see the configuration section in the README). 242 | 243 | Set `end=True` to include items from both the beginning and end, possibly leaving out items 244 | in the middle. Only do this if `iterable` supports efficient slicing at the end, e.g. `iterable[-3:]`. 245 | 246 | Provide the `length` parameter if `len(iterable)` doesn't work. Usually this is not needed. 247 | """ 248 | 249 | if length is None: 250 | length = len(iterable) 251 | if self.level <= 0 and length: 252 | s = '...' 253 | else: 254 | newlevel = self.level - 1 255 | max_parts = original_maxparts = self.func.maxparts 256 | truncate = length > max_parts 257 | target_length = self.target_length 258 | 259 | if target_length is None or not truncate: 260 | if end and truncate: 261 | # Round up from half, e.g. 7 -> 4 262 | max_parts -= max_parts // 2 263 | 264 | pieces = [cheap_repr(elem, newlevel) for elem in islice(iterable, max_parts)] 265 | 266 | if truncate: 267 | pieces.append('...') 268 | 269 | if end: 270 | max_parts = original_maxparts - max_parts 271 | 272 | pieces += [cheap_repr(elem, newlevel) for elem in iterable[-max_parts:]] 273 | 274 | else: 275 | pieces = [] 276 | right_pieces = [] 277 | total_length = 3 + len(left) + len(right) 278 | 279 | if end: 280 | sentinel = object() 281 | 282 | def parts_gen(): 283 | for left_part, right_part in zip_longest( 284 | iterable[:length - length // 2], 285 | islice(reversed(iterable), length // 2), 286 | fillvalue=sentinel, 287 | ): 288 | yield left_part, pieces 289 | if right_part is not sentinel: 290 | yield right_part, right_pieces 291 | 292 | parts = parts_gen() 293 | else: 294 | parts = zip(iterable, repeat(pieces)) 295 | 296 | for i, (part, pieces_list) in enumerate(parts): 297 | r = cheap_repr(part, newlevel) 298 | pieces_list.append(r) 299 | total_length += len(r) + 2 300 | if max_parts <= i + 1 < length and total_length >= target_length: 301 | pieces_list.append('...') 302 | break 303 | 304 | if end: 305 | pieces += right_pieces[::-1] 306 | 307 | s = ', '.join(pieces) 308 | return left + s + right 309 | 310 | def truncate(self, string, middle='...'): 311 | """ 312 | Returns a version of `string` at most `maxparts` characters long, 313 | with the middle replaced by `...` if necessary. 314 | """ 315 | max_parts = self.func.maxparts 316 | if len(string) > max_parts: 317 | i = max(0, (max_parts - 3) // 2) 318 | j = max(0, max_parts - 3 - i) 319 | string = string[:i] + middle + string[len(string) - j:] 320 | return string 321 | 322 | 323 | @register_repr(type(ReprHelper(0, None, None).truncate)) 324 | def repr_bound_method(meth, _helper): 325 | obj = meth.__self__ 326 | return '' % ( 327 | type_name(obj), meth.__name__, cheap_repr(obj)) 328 | 329 | 330 | @register_repr(tuple) 331 | def repr_tuple(x, helper): 332 | if len(x) == 1: 333 | return '(%s,)' % cheap_repr(x[0], helper.level) 334 | else: 335 | return helper.repr_iterable(x, '(', ')', end=True) 336 | 337 | 338 | @register_repr(list) 339 | @try_register_repr('UserList', 'UserList') 340 | @try_register_repr('collections', 'UserList') 341 | def repr_list(x, helper): 342 | return helper.repr_iterable(x, '[', ']', end=True) 343 | 344 | 345 | @register_repr(array) 346 | @maxparts(5) 347 | def repr_array(x, helper): 348 | if not x: 349 | return repr(x) 350 | return helper.repr_iterable(x, "array('%s', [" % x.typecode, '])', end=True) 351 | 352 | 353 | @register_repr(set) 354 | def repr_set(x, helper): 355 | if not x: 356 | return repr(x) 357 | elif PY2: 358 | return helper.repr_iterable(x, 'set([', '])') 359 | else: 360 | return helper.repr_iterable(x, '{', '}') 361 | 362 | 363 | @register_repr(frozenset) 364 | def repr_frozenset(x, helper): 365 | if not x: 366 | return repr(x) 367 | elif PY2: 368 | return helper.repr_iterable(x, 'frozenset([', '])') 369 | else: 370 | return helper.repr_iterable(x, 'frozenset({', '})') 371 | 372 | 373 | @register_repr(Set) 374 | def repr_Set(x, helper): 375 | if not x: 376 | return '%s()' % type_name(x) 377 | else: 378 | return helper.repr_iterable(x, '%s({' % type_name(x), '})') 379 | 380 | 381 | @register_repr(deque) 382 | def repr_deque(x, helper): 383 | return helper.repr_iterable(x, 'deque([', '])') 384 | 385 | 386 | class _DirectRepr(str): 387 | def __repr__(self): 388 | return self 389 | 390 | 391 | register_repr(_DirectRepr)(normal_repr) 392 | 393 | 394 | @register_repr(dict) 395 | @maxparts(4) 396 | def repr_dict(x, helper): 397 | newlevel = helper.level - 1 398 | 399 | return helper.repr_iterable( 400 | ( 401 | _DirectRepr('%s: %s' % ( 402 | cheap_repr(key, newlevel), 403 | cheap_repr(x[key], newlevel), 404 | )) 405 | for key in x 406 | ), 407 | '{', '}', length=len(x), 408 | ) 409 | 410 | 411 | @try_register_repr('__builtin__', 'unicode') 412 | @register_repr(str) 413 | @maxparts(60) 414 | def repr_str(x, helper): 415 | return repr(helper.truncate(x)) 416 | 417 | 418 | if PY3: 419 | @register_repr(bytes) 420 | @maxparts(60) 421 | def repr_bytes(x, helper): 422 | return repr(helper.truncate(x, middle=b'...')) 423 | 424 | 425 | @register_repr(int) 426 | @try_register_repr('__builtin__', 'long') 427 | @maxparts(40) 428 | def repr_int(x, helper): 429 | return helper.truncate(repr(x)) 430 | 431 | 432 | @try_register_repr('numpy', 'ndarray') 433 | def repr_ndarray(x, _helper): 434 | # noinspection PyPackageRequirements 435 | import numpy as np 436 | 437 | dims = len(x.shape) 438 | if ( 439 | # Too many dimensions to be concise 440 | dims > 6 or 441 | # There's a bug with array_repr and matrices 442 | isinstance(x, np.matrix) and np.lib.NumpyVersion(np.__version__) < '1.14.0' or 443 | # and with masked arrays... 444 | isinstance(x, np.ma.MaskedArray) 445 | 446 | ): 447 | name = type_name(x) 448 | if name == 'ndarray': 449 | name = 'array' 450 | return '%s(%r, shape=%r)' % (name, x.dtype, x.shape) 451 | 452 | edgeitems = repr_ndarray.maxparts // 2 453 | if dims == 3: 454 | edgeitems = min(edgeitems, 2) 455 | elif dims > 3: 456 | edgeitems = 1 457 | 458 | opts = np.get_printoptions() 459 | try: 460 | np.set_printoptions(threshold=repr_ndarray.maxparts, edgeitems=edgeitems) 461 | return np.array_repr(x) 462 | finally: 463 | np.set_printoptions(**opts) 464 | 465 | 466 | @try_register_repr('pandas', 'DataFrame') 467 | def repr_DataFrame(df, _): 468 | """ 469 | This function can be configured by setting `max_rows` or `max_cols` attributes. 470 | """ 471 | from pandas import get_option 472 | 473 | return df.to_string( 474 | max_rows=repr_DataFrame.max_rows, 475 | max_cols=repr_DataFrame.max_cols, 476 | show_dimensions=get_option("display.show_dimensions"), 477 | ) 478 | 479 | 480 | repr_DataFrame.max_rows = 8 481 | repr_DataFrame.max_cols = 8 482 | 483 | 484 | @try_register_repr('pandas', 'Series') 485 | def repr_pandas_Series(series, _): 486 | """ 487 | This function can be configured by setting the `max_rows` attributes. 488 | """ 489 | from pandas import get_option 490 | 491 | return series.to_string( 492 | max_rows=repr_pandas_Series.max_rows, 493 | name=series.name, 494 | dtype=series.dtype, 495 | length=get_option("display.show_dimensions"), 496 | ) 497 | 498 | 499 | repr_pandas_Series.max_rows = 8 500 | 501 | 502 | def _repr_pandas_index_generic(index, helper, attrs, long_space=False): 503 | klass = index.__class__.__name__ 504 | if long_space: 505 | space = '\n%s' % (' ' * (len(klass) + 1)) 506 | else: 507 | space = ' ' 508 | 509 | prepr = (",%s" % space).join( 510 | "%s=%s" % (k, cheap_repr(v, helper.level - 1)) 511 | for k, v in attrs) 512 | return "%s(%s)" % (klass, prepr) 513 | 514 | 515 | @try_register_repr('pandas', 'Index') 516 | def repr_pandas_Index(index, helper): 517 | attrs = [['dtype', index.dtype]] 518 | if index.name is not None: 519 | attrs.append(['name', index.name]) 520 | attrs.append(['length', len(index)]) 521 | return _repr_pandas_index_generic(index, helper, attrs) 522 | 523 | 524 | @try_register_repr('pandas', 'IntervalIndex') 525 | def repr_pandas_IntervalIndex(index, helper): 526 | attrs = [['closed', index.closed]] 527 | if index.name is not None: 528 | attrs.append(['name', index.name]) 529 | attrs.append(['dtype', index.dtype]) 530 | return _repr_pandas_index_generic(index, helper, attrs, long_space=True) 531 | 532 | 533 | @try_register_repr('pandas', 'RangeIndex') 534 | def repr_pandas_RangeIndex(index, helper): 535 | attrs = index._format_attrs() 536 | return _repr_pandas_index_generic(index, helper, attrs) 537 | 538 | 539 | @try_register_repr('pandas', 'MultiIndex') 540 | def repr_pandas_MultiIndex(index, helper): 541 | attrs = [('levels', index._levels)] 542 | 543 | try: 544 | attrs.append(('labels', index._labels)) 545 | except AttributeError: 546 | attrs.append(('codes', index.codes)) 547 | 548 | attrs.append(('names', index.names)) 549 | 550 | if index.sortorder is not None: 551 | attrs.append(('sortorder', index.sortorder)) 552 | return _repr_pandas_index_generic(index, helper, attrs, long_space=True) 553 | 554 | 555 | @try_register_repr('pandas', 'CategoricalIndex') 556 | def repr_pandas_CategoricalIndex(index, helper): 557 | attrs = [('categories', index.categories), 558 | ('ordered', index.ordered)] 559 | if index.name is not None: 560 | attrs.append(['name', index.name]) 561 | attrs.append(['dtype', index.dtype.name]) 562 | attrs.append(['length', len(index)]) 563 | return _repr_pandas_index_generic(index, helper, attrs) 564 | 565 | 566 | @try_register_repr('django.db.models', 'QuerySet') 567 | def repr_QuerySet(x, _): 568 | try: 569 | model_name = x.model._meta.object_name 570 | except AttributeError: 571 | model_name = type_name(x.model) 572 | return '<%s instance of %s at %#x>' % (type_name(x), model_name, id(x)) 573 | 574 | 575 | @try_register_repr('collections', 'ChainMap') 576 | @try_register_repr('chainmap', 'ChainMap') 577 | @maxparts(4) 578 | def repr_ChainMap(x, helper): 579 | return helper.repr_iterable(x.maps, type_name(x) + '(', ')', end=True) 580 | 581 | 582 | def _register_ordered_dict(f): 583 | f = try_register_repr('collections', 'OrderedDict')(f) 584 | f = try_register_repr('ordereddict', 'OrderedDict')(f) 585 | f = try_register_repr('backport_collections', 'OrderedDict')(f) 586 | return f 587 | 588 | @try_register_repr('collections', 'UserDict') 589 | @try_register_repr('UserDict', 'UserDict') 590 | @register_repr(Mapping) 591 | @maxparts(5) 592 | def repr_Mapping(x, helper): 593 | if not x: 594 | return type_name(x) + '()' 595 | return '{0}({1})'.format(type_name(x), repr_dict(x, helper)) 596 | 597 | 598 | if sys.version_info < (3, 12): 599 | @maxparts(4) 600 | def repr_OrderedDict(x, helper): 601 | if not x: 602 | return repr(x) 603 | helper.level += 1 604 | return helper.repr_iterable(viewitems(x), type_name(x) + '([', '])', length=len(x)) 605 | 606 | 607 | _register_ordered_dict(repr_OrderedDict) 608 | else: 609 | _register_ordered_dict(repr_Mapping) 610 | 611 | @try_register_repr('collections', 'Counter') 612 | @try_register_repr('counter', 'Counter') 613 | @try_register_repr('backport_collections', 'Counter') 614 | @maxparts(5) 615 | def repr_Counter(x, helper): 616 | length = len(x) 617 | if length <= repr_Counter.maxparts: 618 | return repr_Mapping(x, helper) 619 | else: 620 | # The default repr of Counter gives the items in decreasing order 621 | # of frequency. We don't do that because it would be expensive 622 | # to compute. We also don't show a sample of random keys 623 | # because someone familiar with the default repr might be misled 624 | # into thinking that they are the most common. 625 | return '{0}({1} keys)'.format(type_name(x), length) 626 | 627 | 628 | @register_repr(defaultdict) 629 | @maxparts(4) 630 | def repr_defaultdict(x, helper): 631 | return '{0}({1}, {2})'.format(type_name(x), 632 | x.default_factory, 633 | repr_dict(x, helper)) 634 | 635 | 636 | def repr_Printer(x, _helper): 637 | contents = repr(x) 638 | return '{0}({1})'.format(type_name(x), 639 | cheap_repr(contents)) 640 | 641 | 642 | try: 643 | register_repr(type(copyright)) 644 | except NameError: 645 | pass 646 | 647 | if PY3: 648 | @register_repr(type({}.keys())) 649 | def repr_dict_keys(x, helper): 650 | return helper.repr_iterable(x, 'dict_keys([', '])') 651 | 652 | 653 | @register_repr(type({}.values())) 654 | def repr_dict_values(x, helper): 655 | return helper.repr_iterable(x, 'dict_values([', '])') 656 | 657 | 658 | @register_repr(type({}.items())) 659 | @maxparts(4) 660 | def repr_dict_items(x, helper): 661 | helper.level += 1 662 | return helper.repr_iterable(x, 'dict_items([', '])') 663 | -------------------------------------------------------------------------------- /cheap_repr/utils.py: -------------------------------------------------------------------------------- 1 | import traceback 2 | 3 | from sys import version_info 4 | 5 | try: 6 | from qualname import qualname 7 | except ImportError: 8 | def qualname(cls): 9 | return cls.__qualname__ 10 | 11 | PY2 = version_info[0] == 2 12 | PY3 = not PY2 13 | 14 | if PY2: 15 | def viewitems(d): 16 | return d.viewitems() 17 | else: 18 | def viewitems(d): 19 | return d.items() 20 | 21 | 22 | def safe_qualname(cls): 23 | # type: (type) -> str 24 | result = _safe_qualname_cache.get(cls) 25 | if not result: 26 | try: 27 | result = qualname(cls) 28 | except (AttributeError, IOError, SyntaxError): 29 | result = cls.__name__ 30 | if '' not in result: 31 | _safe_qualname_cache[cls] = result 32 | return result 33 | 34 | 35 | _safe_qualname_cache = {} 36 | 37 | 38 | def type_name(x): 39 | return safe_qualname(x.__class__) 40 | 41 | 42 | def exception_string(exc): 43 | assert isinstance(exc, BaseException) 44 | return ''.join(traceback.format_exception_only(type(exc), exc)).strip() 45 | -------------------------------------------------------------------------------- /make_release.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -eux 3 | 4 | # Ensure that there are no uncommitted changes 5 | # which would mess up using the git tag as a version 6 | [ -z "$(git status --porcelain)" ] 7 | 8 | if [ -z "${1+x}" ] 9 | then 10 | set +x 11 | echo Provide a version argument 12 | echo "${0} .." 13 | exit 1 14 | else 15 | if [[ ${1} =~ ^([0-9]+)(\.[0-9]+)?(\.[0-9]+)?$ ]]; then 16 | : 17 | else 18 | echo "Not a valid release tag." 19 | exit 1 20 | fi 21 | fi 22 | 23 | tox -p auto 24 | 25 | export TAG="v${1}" 26 | git tag "${TAG}" 27 | git push origin master "${TAG}" 28 | rm -rf ./build ./dist 29 | python -m build --sdist --wheel . 30 | twine upload ./dist/*.whl dist/*.tar.gz 31 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools", "wheel", "setuptools_scm[toml]"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [tool.setuptools_scm] 6 | write_to = "cheap_repr/version.py" 7 | write_to_template = "__version__ = '{version}'" 8 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = cheap_repr 3 | author = Alex Hall 4 | author_email = alex.mojaki@gmail.com 5 | license = MIT 6 | description = Better version of repr/reprlib for short, cheap string representations. 7 | long_description = file: README.md 8 | long_description_content_type = text/markdown 9 | url = https://github.com/alexmojaki/cheap_repr 10 | classifiers = 11 | License :: OSI Approved :: MIT License 12 | Programming Language :: Python 13 | Programming Language :: Python :: 3.8 14 | Programming Language :: Python :: 3.9 15 | Programming Language :: Python :: 3.10 16 | Programming Language :: Python :: 3.11 17 | Programming Language :: Python :: 3.12 18 | Programming Language :: Python :: 3.13 19 | 20 | [options] 21 | packages = cheap_repr 22 | include_package_data = True 23 | setup_requires = setuptools; setuptools_scm[toml] 24 | 25 | tests_require = 26 | pytest 27 | 28 | pandas>=0.24.2; platform_python_implementation != 'PyPy' 29 | numpy>=1.16.3; platform_python_implementation != 'PyPy' 30 | 31 | Django 32 | 33 | [options.extras_require] 34 | tests = 35 | pytest 36 | 37 | pandas>=0.24.2; platform_python_implementation != 'PyPy' 38 | numpy>=1.16.3; platform_python_implementation != 'PyPy' 39 | 40 | Django 41 | 42 | [coverage:run] 43 | relative_files = True 44 | 45 | [bdist_wheel] 46 | universal=1 47 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | if __name__ == "__main__": 4 | setup() 5 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexmojaki/cheap_repr/d022d34374a165d0f5f7f654e534f4540e52a85d/tests/__init__.py -------------------------------------------------------------------------------- /tests/fake_django_settings.py: -------------------------------------------------------------------------------- 1 | SECRET_KEY = 'jhfhkghg' 2 | INSTALLED_APPS = ['django.contrib.contenttypes'] -------------------------------------------------------------------------------- /tests/test_cheap_repr.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import sys 4 | import unittest 5 | from array import array 6 | from collections import defaultdict, deque 7 | from sys import version_info, version 8 | from unittest import skipIf 9 | 10 | from tests.utils import TestCaseWithUtils, temp_attrs, assert_unique, Counter, skipUnless, OldStyleClass 11 | 12 | try: 13 | from collections import OrderedDict 14 | except ImportError: 15 | from ordereddict import OrderedDict 16 | 17 | try: 18 | from collections import ChainMap 19 | except ImportError: 20 | from chainmap import ChainMap 21 | 22 | # Python 3.9 compatibility (importing Set from collections is deprecated) 23 | if version_info.major == 2: 24 | from collections import Set 25 | else: 26 | from collections.abc import Set 27 | 28 | from cheap_repr import basic_repr, register_repr, cheap_repr, PY2, PY3, ReprSuppressedWarning, find_repr_function, \ 29 | raise_exceptions_from_default_repr, repr_registry 30 | 31 | PYPY = 'pypy' in version.lower() 32 | 33 | 34 | class FakeExpensiveReprClass(object): 35 | def __repr__(self): 36 | return 'bad' 37 | 38 | 39 | register_repr(FakeExpensiveReprClass)(basic_repr) 40 | 41 | 42 | class ErrorClass(object): 43 | def __init__(self, error=False): 44 | self.error = error 45 | 46 | def __repr__(self): 47 | if self.error: 48 | raise ValueError() 49 | return 'bob' 50 | 51 | 52 | class ErrorClassChild(ErrorClass): 53 | pass 54 | 55 | 56 | class OldStyleErrorClass: 57 | def __init__(self, error=False): 58 | self.error = error 59 | 60 | def __repr__(self): 61 | if self.error: 62 | raise ValueError() 63 | return 'bob' 64 | 65 | 66 | class OldStyleErrorClassChild(OldStyleErrorClass): 67 | pass 68 | 69 | 70 | class DirectRepr(object): 71 | def __init__(self, r): 72 | self.r = r 73 | 74 | def __repr__(self): 75 | return self.r 76 | 77 | 78 | class RangeSet(Set): 79 | def __init__(self, length): 80 | self.length = length 81 | 82 | def __contains__(self, x): 83 | pass 84 | 85 | def __iter__(self): 86 | for x in range(self.length): 87 | yield x 88 | 89 | def __len__(self): 90 | return self.length 91 | 92 | 93 | class NormalClass(object): 94 | def __init__(self, x): 95 | self.x = x 96 | 97 | def __repr__(self): 98 | return repr(self.x) 99 | 100 | def foo(self): 101 | pass 102 | 103 | 104 | class TestCheapRepr(TestCaseWithUtils): 105 | maxDiff = None 106 | 107 | def normalise_repr(self, string): 108 | string = re.sub(r'0x[0-9a-f]+', '0xXXX', string) 109 | string = re.sub('\\s+\n', '\n', string) 110 | string = re.sub('\n\n', '\n', string) 111 | return string 112 | 113 | def assert_cheap_repr(self, x, expected_repr): 114 | actual = self.normalise_repr(cheap_repr(x)) 115 | self.assertEqual(actual, expected_repr) 116 | 117 | def assert_usual_repr(self, x, normalise=False): 118 | expected = repr(x) 119 | if normalise: 120 | expected = self.normalise_repr(expected) 121 | self.assert_cheap_repr(x, expected) 122 | 123 | def assert_cheap_repr_evals(self, s): 124 | self.assert_cheap_repr(eval(s), s) 125 | 126 | def assert_cheap_repr_warns(self, x, message, expected_repr): 127 | self.assert_warns(ReprSuppressedWarning, 128 | message, 129 | lambda: self.assert_cheap_repr(x, expected_repr)) 130 | 131 | def test_registered_default_repr(self): 132 | x = FakeExpensiveReprClass() 133 | self.assertEqual(repr(x), 'bad') 134 | self.assert_cheap_repr(x, r'') 135 | 136 | def test_bound_method(self): 137 | self.assert_usual_repr(NormalClass('hello').foo) 138 | self.assert_cheap_repr( 139 | RangeSet(10).__len__, 140 | '') 141 | 142 | def test_chain_map(self): 143 | self.assert_usual_repr(ChainMap({1: 2, 3: 4}, dict.fromkeys('abcd'))) 144 | 145 | ex = ( 146 | "ChainMap(" 147 | "OrderedDict([('1', 0), ('2', 0), ('3', 0), ('4', 0), ...]), " 148 | "OrderedDict([('1', 0), ('2', 0), ('3', 0), ('4', 0), ...]), " 149 | "..., " 150 | "OrderedDict([('1', 0), ('2', 0), ('3', 0), ('4', 0), ...]), " 151 | "OrderedDict([('1', 0), ('2', 0), ('3', 0), ('4', 0), ...])" 152 | ")" 153 | ) if sys.version_info < (3, 12) else ( 154 | "ChainMap(" 155 | "OrderedDict({'1': 0, '2': 0, '3': 0, '4': 0, '5': 0, ...}), " 156 | "OrderedDict({'1': 0, '2': 0, '3': 0, '4': 0, '5': 0, ...}), " 157 | "..., " 158 | "OrderedDict({'1': 0, '2': 0, '3': 0, '4': 0, '5': 0, ...}), " 159 | "OrderedDict({'1': 0, '2': 0, '3': 0, '4': 0, '5': 0, ...})" 160 | ")" 161 | ) 162 | self.assert_cheap_repr(ChainMap(*[OrderedDict.fromkeys('1234567890', 0) for _ in range(10)]), 163 | ex) 164 | 165 | def test_list(self): 166 | self.assert_usual_repr([]) 167 | self.assert_usual_repr([1, 2, 3]) 168 | self.assert_cheap_repr([1, 2, 3] * 10 + [4, 5, 6, 7], '[1, 2, 3, ..., 5, 6, 7]') 169 | 170 | def test_tuple(self): 171 | self.assert_usual_repr(()) 172 | self.assert_usual_repr((1,)) 173 | self.assert_usual_repr((1, 2, 3)) 174 | self.assert_cheap_repr((1, 2, 3) * 10 + (4, 5, 6, 7), '(1, 2, 3, ..., 5, 6, 7)') 175 | 176 | def test_sets(self): 177 | self.assert_usual_repr(set()) 178 | self.assert_usual_repr(frozenset()) 179 | self.assert_usual_repr(set([1, 2, 3])) 180 | self.assert_usual_repr(frozenset([1, 2, 3])) 181 | self.assert_cheap_repr(set(range(10)), 182 | 'set([0, 1, 2, 3, 4, 5, ...])' if PY2 else 183 | '{0, 1, 2, 3, 4, 5, ...}') 184 | 185 | def test_dict(self): 186 | self.assert_usual_repr({}) 187 | d1 = {1: 2, 2: 3, 3: 4} 188 | self.assert_usual_repr(d1) 189 | d2 = dict((x, x * 2) for x in range(10)) 190 | self.assert_cheap_repr(d2, '{0: 0, 1: 2, 2: 4, 3: 6, ...}') 191 | self.assert_cheap_repr( 192 | {'a' * 100: 'b' * 100}, 193 | "{'aaaaaaaaaaaaaaaaaaaaaaaaaaaa...aaaaaaaaaaaaaaaaaaaaaaaaaaaaa': 'bbbbbbbbbbbbbbbbbbbbbbbbbbbb...bbbbbbbbbbbbbbbbbbbbbbbbbbbbb'}") 194 | 195 | if PY3: 196 | self.assert_usual_repr({}.keys()) 197 | self.assert_usual_repr({}.values()) 198 | self.assert_usual_repr({}.items()) 199 | 200 | self.assert_usual_repr(d1.keys()) 201 | self.assert_usual_repr(d1.values()) 202 | self.assert_usual_repr(d1.items()) 203 | 204 | self.assert_cheap_repr(d2.keys(), 205 | 'dict_keys([0, 1, 2, 3, 4, 5, ...])') 206 | self.assert_cheap_repr(d2.values(), 207 | 'dict_values([0, 2, 4, 6, 8, 10, ...])') 208 | self.assert_cheap_repr(d2.items(), 209 | 'dict_items([(0, 0), (1, 2), (2, 4), (3, 6), ...])') 210 | 211 | def test_defaultdict(self): 212 | d = defaultdict(int) 213 | self.assert_usual_repr(d) 214 | d.update({1: 2, 2: 3, 3: 4}) 215 | self.assert_usual_repr(d) 216 | d.update(dict((x, x * 2) for x in range(10))) 217 | self.assertTrue(cheap_repr(d) in 218 | ("defaultdict(%r, {0: 0, 1: 2, 2: 4, 3: 6, ...})" % int, 219 | "defaultdict(%r, {1: 2, 2: 4, 3: 6, 0: 0, ...})" % int)) 220 | 221 | def test_deque(self): 222 | self.assert_usual_repr(deque()) 223 | self.assert_usual_repr(deque([1, 2, 3])) 224 | self.assert_cheap_repr(deque(range(10)), 'deque([0, 1, 2, 3, 4, 5, ...])') 225 | 226 | def test_ordered_dict(self): 227 | self.assert_usual_repr(OrderedDict()) 228 | self.assert_usual_repr(OrderedDict((x, x * 2) for x in range(3))) 229 | self.assert_cheap_repr(OrderedDict((x, x * 2) for x in range(10)), 230 | 'OrderedDict([(0, 0), (1, 2), (2, 4), (3, 6), ...])' 231 | if sys.version_info < (3, 12) else 232 | 'OrderedDict({0: 0, 1: 2, 2: 4, 3: 6, 4: 8, ...})') 233 | 234 | def test_counter(self): 235 | self.assert_usual_repr(Counter()) 236 | self.assert_cheap_repr_evals('Counter({0: 0, 2: 1, 4: 2})') 237 | self.assert_cheap_repr(Counter(dict((x * 2, x) for x in range(10))), 238 | 'Counter(10 keys)') 239 | 240 | def test_array(self): 241 | self.assert_usual_repr(array('l', [])) 242 | self.assert_usual_repr(array('l', [1, 2, 3, 4, 5])) 243 | self.assert_cheap_repr(array('l', range(10)), 244 | "array('l', [0, 1, 2, ..., 8, 9])") 245 | 246 | def test_django_queryset(self): 247 | os.environ['DJANGO_SETTINGS_MODULE'] = 'tests.fake_django_settings' 248 | import django 249 | 250 | django.setup() 251 | from django.contrib.contenttypes.models import ContentType 252 | self.assert_cheap_repr(ContentType.objects.all(), 253 | '') 254 | 255 | if not PYPY and version_info[:2] < (3, 8): 256 | def test_numpy_array(self): 257 | import numpy 258 | 259 | self.assert_usual_repr(numpy.array([])) 260 | self.assert_usual_repr(numpy.array([1, 2, 3, 4, 5])) 261 | self.assert_cheap_repr(numpy.array(range(10)), 262 | 'array([0, 1, 2, ..., 7, 8, 9])') 263 | 264 | self.assert_cheap_repr(numpy.arange(100).reshape(10, 10), 265 | """\ 266 | array([[ 0, 1, 2, ..., 7, 8, 9], 267 | [10, 11, 12, ..., 17, 18, 19], 268 | [20, 21, 22, ..., 27, 28, 29], 269 | ..., 270 | [70, 71, 72, ..., 77, 78, 79], 271 | [80, 81, 82, ..., 87, 88, 89], 272 | [90, 91, 92, ..., 97, 98, 99]])""") 273 | 274 | self.assert_cheap_repr(numpy.arange(1000).reshape(10, 10, 10), 275 | """\ 276 | array([[[ 0, 1, ..., 8, 9], 277 | [ 10, 11, ..., 18, 19], 278 | ..., 279 | [ 80, 81, ..., 88, 89], 280 | [ 90, 91, ..., 98, 99]], 281 | [[100, 101, ..., 108, 109], 282 | [110, 111, ..., 118, 119], 283 | ..., 284 | [180, 181, ..., 188, 189], 285 | [190, 191, ..., 198, 199]], 286 | ..., 287 | [[800, 801, ..., 808, 809], 288 | [810, 811, ..., 818, 819], 289 | ..., 290 | [880, 881, ..., 888, 889], 291 | [890, 891, ..., 898, 899]], 292 | [[900, 901, ..., 908, 909], 293 | [910, 911, ..., 918, 919], 294 | ..., 295 | [980, 981, ..., 988, 989], 296 | [990, 991, ..., 998, 999]]])""") 297 | 298 | self.assert_cheap_repr(numpy.arange(10000).reshape(10, 10, 10, 10), 299 | """\ 300 | array([[[[ 0, ..., 9], 301 | ..., 302 | [ 90, ..., 99]], 303 | ..., 304 | [[ 900, ..., 909], 305 | ..., 306 | [ 990, ..., 999]]], 307 | ..., 308 | [[[9000, ..., 9009], 309 | ..., 310 | [9090, ..., 9099]], 311 | ..., 312 | [[9900, ..., 9909], 313 | ..., 314 | [9990, ..., 9999]]]])""") 315 | 316 | self.assert_cheap_repr(numpy.arange(128).reshape(2, 2, 2, 2, 2, 2, 2), 317 | "array(dtype('int64'), shape=(2, 2, 2, 2, 2, 2, 2))") 318 | 319 | self.assert_cheap_repr(numpy.ma.array([1, 2, 3], mask=[0, 1, 0]), 320 | "MaskedArray(dtype('int64'), shape=(3,))") 321 | 322 | self.assert_cheap_repr(numpy.matrix([[1, 2], [3, 4]]), 323 | """\ 324 | matrix([[1, 2], 325 | [3, 4]])""") 326 | 327 | def test_pandas(self): 328 | # noinspection PyPackageRequirements 329 | import pandas as pd 330 | 331 | df = pd.DataFrame({'a': [1, 2], 'b': [3, 4]}) 332 | self.assert_usual_repr(df) 333 | self.assert_usual_repr(df.index) 334 | self.assert_usual_repr(df.a) 335 | self.assert_usual_repr(df.b) 336 | 337 | df = pd.DataFrame( 338 | dict((k, range(100)) for k in 'abcdefghijkl') 339 | ).set_index(['a', 'b']) 340 | 341 | self.assert_cheap_repr(df, 342 | """\ 343 | c d e f ... i j k l 344 | a b ... 345 | 0 0 0 0 0 0 ... 0 0 0 0 346 | 1 1 1 1 1 1 ... 1 1 1 1 347 | 2 2 2 2 2 2 ... 2 2 2 2 348 | 3 3 3 3 3 3 ... 3 3 3 3 349 | ... .. .. .. .. ... .. .. .. .. 350 | 96 96 96 96 96 96 ... 96 96 96 96 351 | 97 97 97 97 97 97 ... 97 97 97 97 352 | 98 98 98 98 98 98 ... 98 98 98 98 353 | 99 99 99 99 99 99 ... 99 99 99 99 354 | [100 rows x 10 columns]""") 355 | 356 | self.assert_cheap_repr(df.c, 357 | """\ 358 | a b 359 | 0 0 0 360 | 1 1 1 361 | 2 2 2 362 | 3 3 3 363 | .. 364 | 96 96 96 365 | 97 97 97 366 | 98 98 98 367 | 99 99 99 368 | Name: c, Length: 100, dtype: int64""") 369 | 370 | if version_info[:2] < (3, 6): 371 | self.assert_cheap_repr(df.index, 372 | """\ 373 | MultiIndex(levels=[Int64Index(dtype=dtype('int64'), name='a', length=100), Int64Index(dtype=dtype('int64'), name='b', length=100)], 374 | codes=[FrozenNDArray([ 0, 1, 2, ..., 97, 98, 99], dtype=int8), FrozenNDArray([ 0, 1, 2, ..., 97, 98, 99], dtype=int8)], 375 | names=['a', 'b'])""") 376 | else: 377 | self.assert_cheap_repr(df.index, 378 | """\ 379 | MultiIndex(levels=[Int64Index(dtype=dtype('int64'), length=100), Int64Index(dtype=dtype('int64'), length=100)], 380 | codes=[array([ 0, 1, 2, ..., 97, 98, 99], dtype=int8), array([ 0, 1, 2, ..., 97, 98, 99], dtype=int8)], 381 | names=['a', 'b'])""") 382 | 383 | values = [4, 2, 3, 1] 384 | cats = pd.Categorical([1, 2, 3, 4], categories=values) 385 | self.assert_cheap_repr( 386 | pd.DataFrame( 387 | {"strings": ["a", "b", "c", "d"], "values": values}, 388 | index=cats).index, 389 | "CategoricalIndex(categories=Int64Index(dtype=dtype('int64'), " 390 | "length=4), ordered=False, dtype='category', length=4)" 391 | ) 392 | 393 | if sys.version_info[:2] == (3, 7): 394 | expected = """\ 395 | IntervalIndex(closed='right', 396 | dtype=interval[int64, right])""" 397 | else: 398 | expected = """\ 399 | IntervalIndex(closed='right', 400 | dtype=interval[int64])""" 401 | self.assert_cheap_repr(pd.interval_range(start=0, end=5), expected) 402 | 403 | def test_bytes(self): 404 | self.assert_usual_repr(b'') 405 | self.assert_usual_repr(b'123') 406 | self.assert_cheap_repr(b'abc' * 50, 407 | "b'abcabcabcabcabcabcabcabcabca...bcabcabcabcabcabcabcabcabcabc'" 408 | .lstrip('b' if PY2 else '')) 409 | 410 | def test_str(self): 411 | self.assert_usual_repr('') 412 | self.assert_usual_repr(u'') 413 | self.assert_usual_repr(u'123') 414 | self.assert_usual_repr('123') 415 | self.assert_cheap_repr('abc' * 50, 416 | "'abcabcabcabcabcabcabcabcabca...bcabcabcabcabcabcabcabcabcabc'") 417 | 418 | def test_inheritance(self): 419 | class A(object): 420 | def __init__(self): 421 | pass 422 | 423 | class B(A): 424 | pass 425 | 426 | class C(A): 427 | pass 428 | 429 | class D(C): 430 | pass 431 | 432 | class C2(C): 433 | pass 434 | 435 | class C3(C, B): 436 | pass 437 | 438 | class B2(B, C): 439 | pass 440 | 441 | class A2(A): 442 | pass 443 | 444 | @register_repr(A) 445 | def repr_A(_x, _helper): 446 | return 'A' 447 | 448 | @register_repr(C) 449 | def repr_C(_x, _helper): 450 | return 'C' 451 | 452 | @register_repr(B) 453 | def repr_B(_x, _helper): 454 | return 'B' 455 | 456 | @register_repr(D) 457 | def repr_D(_x, _helper): 458 | return 'D' 459 | 460 | self.assert_cheap_repr(A(), 'A') 461 | self.assert_cheap_repr(B(), 'B') 462 | self.assert_cheap_repr(C(), 'C') 463 | self.assert_cheap_repr(D(), 'D') 464 | self.assert_cheap_repr(C2(), 'C') 465 | self.assert_cheap_repr(C3(), 'C') 466 | self.assert_cheap_repr(B2(), 'B') 467 | self.assert_cheap_repr(A2(), 'A') 468 | 469 | self.assertEqual(find_repr_function(A), repr_A) 470 | self.assertEqual(find_repr_function(B), repr_B) 471 | self.assertEqual(find_repr_function(C), repr_C) 472 | self.assertEqual(find_repr_function(D), repr_D) 473 | self.assertEqual(find_repr_function(C2), repr_C) 474 | self.assertEqual(find_repr_function(C3), repr_C) 475 | self.assertEqual(find_repr_function(B2), repr_B) 476 | self.assertEqual(find_repr_function(A2), repr_A) 477 | 478 | def test_exceptions(self): 479 | with temp_attrs(cheap_repr, 'raise_exceptions', True): 480 | self.assertRaises(ValueError, 481 | lambda: cheap_repr(ErrorClass(True))) 482 | 483 | for C in [ErrorClass, OldStyleErrorClass]: 484 | name = C.__name__ 485 | self.assert_usual_repr(C()) 486 | warning_message = "Exception 'ValueError' in repr_object for object of type %s. " \ 487 | "The repr has been suppressed for this type." % name 488 | if PY2 and C is OldStyleErrorClass: 489 | warning_message = warning_message.replace('repr_object', 'repr') 490 | self.assert_cheap_repr_warns( 491 | C(True), 492 | warning_message, 493 | '<%s instance at 0xXXX (exception in repr)>' % name, 494 | ) 495 | self.assert_cheap_repr(C(), '<%s instance at 0xXXX (repr suppressed)>' % name) 496 | for C in [ErrorClassChild, OldStyleErrorClassChild]: 497 | name = C.__name__ 498 | self.assert_cheap_repr(C(), '<%s instance at 0xXXX (repr suppressed)>' % name) 499 | 500 | def test_func_raise_exceptions(self): 501 | class T(object): 502 | pass 503 | 504 | @register_repr(T) 505 | def bad_repr(*_): 506 | raise TypeError() 507 | 508 | bad_repr.raise_exceptions = True 509 | 510 | self.assertRaises(TypeError, lambda: cheap_repr(T())) 511 | 512 | class X(object): 513 | def __repr__(self): 514 | raise IOError() 515 | 516 | class Y: # old-style in python 2 517 | def __repr__(self): 518 | raise IOError() 519 | 520 | raise_exceptions_from_default_repr() 521 | 522 | for C in [X, Y]: 523 | self.assertRaises(IOError, lambda: cheap_repr(C())) 524 | 525 | def test_default_too_long(self): 526 | self.assert_usual_repr(DirectRepr('hello')) 527 | self.assert_cheap_repr_warns( 528 | DirectRepr('long' * 500), 529 | 'DirectRepr.__repr__ is too long and has been suppressed. ' 530 | 'Register a repr for the class to avoid this warning ' 531 | 'and see an informative repr again, ' 532 | 'or increase cheap_repr.suppression_threshold', 533 | 'longlonglonglonglonglonglong...glonglonglonglonglonglonglong') 534 | self.assert_cheap_repr(DirectRepr('hello'), 535 | '') 536 | 537 | def test_maxparts(self): 538 | self.assert_cheap_repr(list(range(8)), 539 | '[0, 1, 2, ..., 5, 6, 7]') 540 | self.assert_cheap_repr(list(range(20)), 541 | '[0, 1, 2, ..., 17, 18, 19]') 542 | with temp_attrs(find_repr_function(list), 'maxparts', 10): 543 | self.assert_cheap_repr(list(range(8)), 544 | '[0, 1, 2, 3, 4, 5, 6, 7]') 545 | self.assert_cheap_repr(list(range(20)), 546 | '[0, 1, 2, 3, 4, ..., 15, 16, 17, 18, 19]') 547 | 548 | def test_recursive(self): 549 | lst = [1, 2, 3] 550 | lst.append(lst) 551 | self.assert_cheap_repr(lst, '[1, 2, 3, [1, 2, 3, [1, 2, 3, [...]]]]') 552 | 553 | d = {1: 2, 3: 4} 554 | d[5] = d 555 | self.assert_cheap_repr( 556 | d, '{1: 2, 3: 4, 5: {1: 2, 3: 4, 5: {1: 2, 3: 4, 5: {...}}}}') 557 | 558 | def test_custom_set(self): 559 | self.assert_cheap_repr(RangeSet(0), 'RangeSet()') 560 | self.assert_cheap_repr(RangeSet(3), 'RangeSet({0, 1, 2})') 561 | self.assert_cheap_repr(RangeSet(10), 'RangeSet({0, 1, 2, 3, 4, 5, ...})') 562 | 563 | def test_repr_function(self): 564 | def some_really_really_long_function_name(): 565 | yield 3 566 | 567 | self.assert_usual_repr(some_really_really_long_function_name, normalise=True) 568 | self.assert_usual_repr(some_really_really_long_function_name(), normalise=True) 569 | self.assert_usual_repr(os) 570 | 571 | @skipIf(PY2 and PYPY, "Not supported in pypy2") 572 | def test_repr_long_class_name(self): 573 | class some_really_really_long_class_name(object): 574 | pass 575 | 576 | self.assert_usual_repr(some_really_really_long_class_name(), normalise=True) 577 | 578 | def test_function_names_unique(self): 579 | # Duplicate function names can lead to mistakes 580 | assert_unique(f.__name__ for f in set(repr_registry.values())) 581 | 582 | @skipUnless(PY2, "Old style classes only exist in Python 2") 583 | def test_old_style_class(self): 584 | self.assert_cheap_repr(OldStyleClass, '') 585 | 586 | def test_target_length(self): 587 | target = 100 588 | lst = [] 589 | for i in range(100): 590 | lst.append(i) 591 | r = cheap_repr(lst, target_length=target) 592 | usual = repr(lst) 593 | assert ( 594 | (r == usual and len(r) < target) ^ 595 | ('...' in r and len(r) > target) 596 | ) 597 | 598 | self.assertEqual( 599 | cheap_repr(list(range(100)), target_length=target), 600 | '[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, ..., 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99]' 601 | ) 602 | 603 | # Don't want to deal with ordering in older pythons 604 | if version_info[:2] >= (3, 6): 605 | self.assertEqual( 606 | cheap_repr(set(range(100)), target_length=target), 607 | '{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, ...}' 608 | ) 609 | 610 | self.assertEqual( 611 | cheap_repr({x: x * 2 for x in range(100)}, target_length=target), 612 | '{0: 0, 1: 2, 2: 4, 3: 6, 4: 8, 5: 10, 6: 12, 7: 14, 8: 16, 9: 18, 10: 20, 11: 22, 12: 24, 13: 26, ...}', 613 | ) 614 | 615 | 616 | if __name__ == '__main__': 617 | unittest.main() 618 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | from contextlib import contextmanager 3 | from unittest import TestCase 4 | 5 | try: 6 | from collections import Counter 7 | except ImportError: 8 | from counter import Counter 9 | 10 | try: 11 | from unittest import skip, skipUnless 12 | except ImportError: 13 | def skip(_f): 14 | return lambda self: None 15 | 16 | 17 | def skipUnless(condition, _reason): 18 | if condition: 19 | return lambda x: x 20 | else: 21 | return lambda x: None 22 | 23 | 24 | @contextmanager 25 | def temp_attrs(*attrs): 26 | if len(attrs) == 3 and not isinstance(attrs[1], tuple): 27 | attrs = (attrs,) 28 | previous = [] 29 | try: 30 | for obj, attr, val in attrs: 31 | old = getattr(obj, attr) 32 | previous.append((obj, attr, old)) 33 | setattr(obj, attr, val) 34 | yield 35 | finally: 36 | for t in previous: 37 | setattr(*t) 38 | 39 | 40 | class TestCaseWithUtils(TestCase): 41 | def assert_warns(self, category, message, f): 42 | with warnings.catch_warnings(record=True) as warning_list: 43 | warnings.simplefilter('always') 44 | result = f() 45 | self.assertEqual(len(warning_list), 1) 46 | self.assertEqual(warning_list[0].category, category) 47 | self.assertEqual(str(warning_list[0].message), message) 48 | return result 49 | 50 | 51 | def assert_unique(items): 52 | counts = Counter(items) 53 | dups = [k for k, v in counts.items() 54 | if v > 1] 55 | if dups: 56 | raise ValueError('Duplicates: %s' % dups) 57 | 58 | 59 | class OldStyleClass: 60 | pass 61 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py38,py39,py310,py311,py312,py313 3 | 4 | [testenv] 5 | commands = 6 | pip install .[tests] 7 | pytest 8 | 9 | [coverage:run] 10 | relative_files = True 11 | --------------------------------------------------------------------------------