diff --git a/docs/examples.md b/docs/examples.md index aa1d2fa46..982f6bfff 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -381,6 +381,80 @@ C(x=1, y=2, z=[]) Please keep in mind that the decorator approach *only* works if the attribute in question has a {func}`~attrs.field` assigned to it. As a result, annotating an attribute with a type is *not* enough if you use `@default`. +In the event you only want to include fields that are set to a non-default value +in your attrs repr output, you can use the `only_non_default_attr_in_repr` argument to {func}`~attrs.define`. + +When the argument isn't specified the repr works as expected. + +```{doctest} +>>> @define +... class C: +... x: int = 1 +... y: int = field(default=2) +>>> C() +C(x=1, y=2) +``` + +Instead, if `only_non_default_attr_in_repr=True` the parameters set to their +defaults won't be included in the repr output. + +```{doctest} +>>> @define(only_non_default_attr_in_repr=True) +... class C: +... x: int = 1 +... y: int = field(default=2) +>>> C() +C() +>>> C(x=2) +C(x=2) +>>> C(y=3) +C(y=3) +>>> C(x=2, y=3) +C(x=2, y=3) +``` + +Other attrs repr features, including turning the repr off for a field or +providing a custom callable for the repr of a field, will work as usual, when the +field's value is not the default. + +But the field is still excluded from the repr when it is set to the default value +because `only_non_default_attr_in_repr` overrides `repr=True` or the repr being a +custom callable when the field is set to its default value. + +```{doctest} +>>> @define(only_non_default_attr_in_repr=True) +... class C: +... x: int = field(default=1, repr=False) +... y: int = field(default=2, repr=lambda value: "foo: " + str(value)) +... z: int = field(default=3, repr=True) +>>> C() +C() +>>> C(y=3) +C(y=foo: 3) +>>> C(x=2, y=3) +C(y=foo: 3) +>>> C(z=4) +C(z=4) +``` + +The usual attrs order of execution applies. For each variable (in the order they +are specified), the default factory (or default value) is considered. Then it is +compared to set value of the field. When converters are applied, this occurs +after the default factory. So when `only_non_default_attr_in_repr=True` the +converted value will be checked against the default, meaning this functionality +expects the defaults in the converted format. + +```{doctest} +>>> @define(only_non_default_attr_in_repr=True) +... class C: +... x: int = field(default=1, converter=lambda value: value + 0.5) +... z: int = field(default=12, converter=int) +>>> C(x=0.5, z="12") +C() +>>> C(x=1) +C(x=1.5) +``` + (examples-validators)= ## Validators diff --git a/src/attr/__init__.pyi b/src/attr/__init__.pyi index 6ae0a83de..7ed4b1416 100644 --- a/src/attr/__init__.pyi +++ b/src/attr/__init__.pyi @@ -278,6 +278,7 @@ def attrs( field_transformer: _FieldTransformer | None = ..., match_args: bool = ..., unsafe_hash: bool | None = ..., + only_non_default_attr_in_repr: bool = ..., ) -> _C: ... @overload @dataclass_transform(order_default=True, field_specifiers=(attrib, field)) @@ -306,6 +307,7 @@ def attrs( field_transformer: _FieldTransformer | None = ..., match_args: bool = ..., unsafe_hash: bool | None = ..., + only_non_default_attr_in_repr: bool = ..., ) -> Callable[[_C], _C]: ... def fields(cls: type[AttrsInstance]) -> Any: ... def fields_dict(cls: type[AttrsInstance]) -> dict[str, Attribute[Any]]: ... diff --git a/src/attr/_make.py b/src/attr/_make.py index d7828f959..3f30a23c0 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -1036,9 +1036,14 @@ def _create_slots_class(self): cell.cell_contents = cls return cls - def add_repr(self, ns): + def add_repr(self, ns, only_non_default_attr_in_repr=False): self._cls_dict["__repr__"] = self._add_method_dunders( - _make_repr(self._attrs, ns, self._cls) + _make_repr( + self._attrs, + ns, + self._cls, + only_non_default_attr_in_repr=only_non_default_attr_in_repr, + ) ) return self @@ -1354,6 +1359,7 @@ def attrs( field_transformer=None, match_args=True, unsafe_hash=None, + only_non_default_attr_in_repr=False, ): r""" A class decorator that adds :term:`dunder methods` according to the @@ -1623,6 +1629,12 @@ class is ``object``, this means it will fall back to id-based non-keyword-only ``__init__`` parameter names on Python 3.10 and later. Ignored on older Python versions. + :param bool only_non_default_attr_in_repr: + If `False` (default), then the usual ``attrs`` repr is created. If `True` + then only parameters set to their non-default values will be printed. + This means when this is set to `True` the repr output is dynamic based + on the state of the class. + .. versionadded:: 16.0.0 *slots* .. versionadded:: 16.1.0 *frozen* .. versionadded:: 16.3.0 *str* @@ -1660,6 +1672,10 @@ class is ``object``, this means it will fall back to id-based .. versionadded:: 22.2.0 *unsafe_hash* as an alias for *hash* (for :pep:`681` compliance). .. deprecated:: 24.1.0 *repr_ns* + .. versionadded:: 24.1 + *only_non_default_attr_in_repr* added to allow users to choose to have their + classes dynamically include only those parameters whose values are set to + non-default values in the repr. """ if repr_ns is not None: import warnings @@ -1716,7 +1732,10 @@ def wrap(cls): if _determine_whether_to_implement( cls, repr, auto_detect, ("__repr__",) ): - builder.add_repr(repr_ns) + builder.add_repr( + repr_ns, + only_non_default_attr_in_repr=only_non_default_attr_in_repr, + ) if str is True: builder.add_str() @@ -2022,7 +2041,7 @@ def _add_eq(cls, attrs=None): return cls -def _make_repr(attrs, ns, cls): +def _make_repr(attrs, ns, cls, only_non_default_attr_in_repr=False): unique_filename = _generate_unique_filename(cls, "repr") # Figure out which attributes to include, and which function to use to # format them. The a.repr value can be either bool or a custom @@ -2069,7 +2088,27 @@ def _make_repr(attrs, ns, cls): " else:", " already_repring.add(id(self))", " try:", - f" return f'{cls_name_fragment}({repr_fragment})'", + f" if not {only_non_default_attr_in_repr}:", + f" return f'{cls_name_fragment}({repr_fragment})'", + " attr_frags = []", + " for a in getattr(self, '__attrs_attrs__', []):", + " value = getattr(self, a.name, NOTHING)", + " if hasattr(a.default, 'factory') and callable(a.default.factory):", + " if a.default.takes_self:", + " default_ = a.default.factory(self)", + " else:", + " default_ = a.default.factory()", + " else:", + " default_ = a.default", + " if (a.repr is False or value == default_):", + " frag = ''", + " else:", + " _repr = repr if a.repr is True else a.repr", + " frag = f'{a.name}={_repr(value)}'", + " attr_frags.append(frag)", + " repr_fragment = ', '.join(f for f in attr_frags if f != '')", + f" dynamic_repr = f'{cls_name_fragment}(' + repr_fragment + ')'", + " return dynamic_repr", " finally:", " already_repring.remove(id(self))", ] @@ -2079,14 +2118,19 @@ def _make_repr(attrs, ns, cls): ) -def _add_repr(cls, ns=None, attrs=None): +def _add_repr(cls, ns=None, attrs=None, only_non_default_attr_in_repr=False): """ Add a repr method to *cls*. """ if attrs is None: attrs = cls.__attrs_attrs__ - cls.__repr__ = _make_repr(attrs, ns, cls) + cls.__repr__ = _make_repr( + attrs, + ns, + cls, + only_non_default_attr_in_repr=only_non_default_attr_in_repr, + ) return cls diff --git a/src/attr/_next_gen.py b/src/attr/_next_gen.py index 10e05695e..e34ad177e 100644 --- a/src/attr/_next_gen.py +++ b/src/attr/_next_gen.py @@ -44,6 +44,7 @@ def define( on_setattr=None, field_transformer=None, match_args=True, + only_non_default_attr_in_repr=False, ): r""" Define an *attrs* class. @@ -112,6 +113,7 @@ def do_it(cls, auto_attribs): on_setattr=on_setattr, field_transformer=field_transformer, match_args=match_args, + only_non_default_attr_in_repr=only_non_default_attr_in_repr, ) def wrap(cls): diff --git a/src/attrs/__init__.pyi b/src/attrs/__init__.pyi index b2670de21..bee89722c 100644 --- a/src/attrs/__init__.pyi +++ b/src/attrs/__init__.pyi @@ -1,16 +1,12 @@ import sys - -from typing import ( - Any, - Callable, - Mapping, - Sequence, - overload, - TypeVar, -) +from typing import Any, Callable, Mapping, Sequence, TypeVar, overload # Because we need to type our own stuff, we have to make everything from # attr explicitly public too. +from attr import NOTHING as NOTHING +from attr import Attribute as Attribute +from attr import AttrsInstance as AttrsInstance +from attr import Factory as Factory from attr import __author__ as __author__ from attr import __copyright__ as __copyright__ from attr import __description__ as __description__ @@ -20,26 +16,24 @@ from attr import __title__ as __title__ from attr import __url__ as __url__ from attr import __version__ as __version__ from attr import __version_info__ as __version_info__ +from attr import asdict as asdict from attr import assoc as assoc -from attr import Attribute as Attribute -from attr import AttrsInstance as AttrsInstance +from attr import astuple as astuple +from attr import attrib from attr import cmp_using as cmp_using from attr import converters as converters from attr import Converter as Converter from attr import evolve as evolve from attr import exceptions as exceptions -from attr import Factory as Factory from attr import fields as fields from attr import fields_dict as fields_dict from attr import filters as filters from attr import has as has from attr import make_class as make_class -from attr import NOTHING as NOTHING from attr import resolve_types as resolve_types from attr import setters as setters from attr import validate as validate from attr import validators as validators -from attr import attrib, asdict as asdict, astuple as astuple if sys.version_info >= (3, 11): from typing import dataclass_transform @@ -168,6 +162,7 @@ def define( on_setattr: _OnSetAttrArgType | None = ..., field_transformer: _FieldTransformer | None = ..., match_args: bool = ..., + only_non_default_attr_in_repr: bool = ..., ) -> _C: ... @overload @dataclass_transform(field_specifiers=(attrib, field)) @@ -194,6 +189,7 @@ def define( on_setattr: _OnSetAttrArgType | None = ..., field_transformer: _FieldTransformer | None = ..., match_args: bool = ..., + only_non_default_attr_in_repr: bool = ..., ) -> Callable[[_C], _C]: ... mutable = define diff --git a/tests/test_dunders.py b/tests/test_dunders.py index 85c9309b3..2964513ae 100644 --- a/tests/test_dunders.py +++ b/tests/test_dunders.py @@ -4,7 +4,6 @@ Tests for dunder methods from `attrib._make`. """ - import copy import inspect import pickle @@ -409,9 +408,30 @@ def test_repr_uninitialized_member(self): repr signals unset attributes """ C = make_class("C", {"a": attr.ib(init=False)}) - assert "C(a=NOTHING)" == repr(C()) + @given(only_non_default_attr_in_repr=booleans()) + def test_repr_uninitialized_member_with_only_non_default_attr_in_repr( + self, only_non_default_attr_in_repr + ): + """ + repr signals unset attributes when using only_non_default_attr_in_repr. + """ + C = make_class( + "C", + {"a": attr.ib(init=False), "b": attr.ib(default=10)}, + only_non_default_attr_in_repr=only_non_default_attr_in_repr, + ) + # When using only_non_default_attr_in_repr we don't get signal that + # the value for param a was not initialized because it is set to implicit + # default value "NOTHING" + if only_non_default_attr_in_repr: + assert "C()" == repr(C()) + assert "C(b=7)" == repr(C(b=7)) + else: + assert "C(a=NOTHING, b=10)" == repr(C()) + assert "C(a=NOTHING, b=7)" == repr(C(b=7)) + @given(add_str=booleans(), slots=booleans()) def test_str(self, add_str, slots): """ @@ -429,6 +449,36 @@ class Error(Exception): assert (str(e) == repr(e)) is add_str + @given( + add_str=booleans(), + slots=booleans(), + only_non_default_attr_in_repr=booleans(), + ) + def test_str_with_only_non_default_attr_in_repr( + self, add_str, slots, only_non_default_attr_in_repr + ): + """ + If str is True, it returns the same as repr. + + This verifies this continues to work with use of only_non_default_attr_in_repr. + + This only makes sense when subclassing a class with an poor __str__ + (like Exceptions). + """ + + @attr.s( + str=add_str, + slots=slots, + only_non_default_attr_in_repr=only_non_default_attr_in_repr, + ) + class Error(Exception): + x = attr.ib() + y = attr.ib(default=False) + + e = Error(42) + + assert (str(e) == repr(e)) is add_str + def test_str_no_repr(self): """ Raises a ValueError if repr=False and str=True. @@ -440,6 +490,109 @@ def test_str_no_repr(self): "__str__ can only be generated if a __repr__ exists." ) == e.value.args[0] + def test_only_non_default_attr_in_repr(self): + """ + Validate repr behavior when using only_non_default_attr_in_repr parameter. + """ + + # Use the only_non_default_attr_in_repr option means params whose values + # equal their default aren't included in their repr + @attr.s(only_non_default_attr_in_repr=True) + class SomeClass: + positional: int = attr.ib() + something = attr.ib(default=None, repr=True) + something_else = attr.ib(default=False, repr=True) + another = attr.ib(default=11.0, init=False, repr=False) + + some_class = SomeClass(8, something=7) + r = "SomeClass(positional=8, something=7)" + assert r == repr(some_class) + + # If we wanted to exclude the something param from the repr in the field + # definition or override it with a callable for repr we still can + # but this is only used when the parameter has a non-default value + @attr.s(only_non_default_attr_in_repr=True) + class SomeClass: + something = attr.ib(default=None, repr=False) + something_else = attr.ib(default=False, repr=True) + another = attr.ib(default=11.0, init=False, repr=False) + another_one = attr.ib(default=17.0, repr=lambda x: "P") + + some_class = SomeClass(something=7, another_one=17.0) + r = "SomeClass()" + assert r == repr(some_class) + + # The default is equivalent of only_non_default_attr_in_repr=False, + # so existing behavior is default + @attr.s() + class SomeClass: + positional = attr.ib() + something = attr.ib(default=None) + something_else = attr.ib(default=False) + another = attr.ib(default=11.0, init=False, repr=False) + + some_class = SomeClass("P", something=7) + r = "SomeClass(positional='P', something=7, something_else=False)" + assert r == repr(some_class) + + # The use of repr in a field works just like it did before so users can still + # exclude individual attributes from the repr (or pass custom callable) + @attr.s() + class SomeClass: + positional = attr.ib(repr=lambda x: "P") + something = attr.ib(default=None, repr=False) + something_else = attr.ib(default=False) + another = attr.ib(default=11.0, init=False) + another1 = attr.ib(default="Something", repr=lambda x: 7) + + some_class = SomeClass(8, something=7) + r = "SomeClass(positional=P, something_else=False, another=11.0, another1=7)" + assert r == repr(some_class) + + def test_only_non_default_attr_in_repr_default_decorator(self): + """ + Validate repr behavior with default decorator and only_non_default_attr_in_repr. + """ + + @attr.s(only_non_default_attr_in_repr=True) + class SomeClass: + x: int = attr.ib(default=1) + y: int = attr.ib() + + @y.default + def _y(self): + """Default value for y.""" + return self.x + 1 + + some_class = SomeClass() + assert "SomeClass()" == repr(some_class) + some_class = SomeClass(y=3) + assert "SomeClass(y=3)" == repr(some_class) + + def test_only_non_default_attr_in_repr_with_converter(self): + """ + Validate repr behavior with converter and only_non_default_attr_in_repr. + """ + + @attr.s(only_non_default_attr_in_repr=True) + class SomeClass: + x: int = attr.ib(default=1, converter=lambda value: value + 0.5) + y: int = attr.ib() + z: int = attr.ib(default=12, converter=int) + + @y.default + def _y(self): + """Default value for y.""" + return self.x + 1 + + some_class = SomeClass(x=0.5, z="12") + # B/c converter applies before setting x, x will equal default value + # Likewise, z is converted to integer 12 before setting, so it equals default + assert "SomeClass()" == repr(some_class) + + some_class = SomeClass(x=1) + assert "SomeClass(x=1.5)" == repr(some_class) + # these are for use in TestAddHash.test_cache_hash_serialization # they need to be out here so they can be un-pickled @@ -481,12 +634,12 @@ def test_enforces_type(self): exc_args = ("Invalid value for hash. Must be True, False, or None.",) with pytest.raises(TypeError) as e: - make_class("C", {}, hash=1), + (make_class("C", {}, hash=1),) assert exc_args == e.value.args with pytest.raises(TypeError) as e: - make_class("C", {"a": attr.ib(hash=1)}), + (make_class("C", {"a": attr.ib(hash=1)}),) assert exc_args == e.value.args @@ -517,8 +670,7 @@ def test_enforce_no_cached_hash_without_init(self): but attrs is not requested to generate `__init__`. """ exc_args = ( - "Invalid value for cache_hash. To use hash caching," - " init must be True.", + "Invalid value for cache_hash. To use hash caching, init must be True.", ) with pytest.raises(TypeError) as e: make_class("C", {}, init=False, hash=True, cache_hash=True)