Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Provide option in attrs.define to allow users to exclude parameters set to default value from repr #1276

Open
wants to merge 26 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
934a86d
Add only_non_default_attr_in_repr option for dynamic repr
RNKuhns Apr 11, 2024
c8081ec
Update define to accept only_non_default_attr_in_repr param
RNKuhns Apr 11, 2024
83782d1
Add versionadded to docstring
RNKuhns Apr 11, 2024
2305452
Update attrs typing defs
RNKuhns Apr 11, 2024
a8f80ca
Update define typing defs for new param
RNKuhns Apr 11, 2024
ddf1257
Add tests of only_non_default_attr_in_repr
RNKuhns Jun 8, 2024
06152e9
Test interaction with other repr attributes
RNKuhns Jun 8, 2024
addc6f9
Handle default factories and field default decorator
RNKuhns Jun 8, 2024
82e7f4e
Add test for default factories
RNKuhns Jun 8, 2024
34cfcdf
Add to examples.md
RNKuhns Jun 8, 2024
5af400b
Add test for interaction with converters and default
RNKuhns Jun 8, 2024
0601224
Update handling of defaults and default factories
RNKuhns Jun 8, 2024
c8d7ebc
Update example to cover interaction with converters
RNKuhns Jun 8, 2024
1217c7f
Fix incorrect str quotes in repr example
RNKuhns Jun 9, 2024
2042b0a
Merge branch 'main' into repr_optionally_exclude_param_defaults
RNKuhns Jun 9, 2024
1a686bc
Fix examples to use define decorator not attr.s
RNKuhns Jun 9, 2024
48b9f5f
Merge branch 'repr_optionally_exclude_param_defaults' of https://gith…
RNKuhns Jun 9, 2024
50ed7bb
Fix incorrect formatting in examples.md code example
RNKuhns Jun 9, 2024
98a7523
Another examples fix
RNKuhns Jun 9, 2024
17cdc44
Update name of class in example
RNKuhns Jun 9, 2024
bcbbe72
One last example update
RNKuhns Jun 9, 2024
149d7a3
Merge branch 'main' into repr_optionally_exclude_param_defaults
hynek Jul 17, 2024
c8fdfd0
Merge branch 'main' into repr_optionally_exclude_param_defaults
hynek Jul 18, 2024
5df2c90
Merge branch 'main' into repr_optionally_exclude_param_defaults
hynek Jul 22, 2024
01dd4f5
Merge branch 'main' into repr_optionally_exclude_param_defaults
hynek Jul 22, 2024
9f1963b
Merge branch 'main' into repr_optionally_exclude_param_defaults
hynek Jul 29, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions docs/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wouldn't it be nice for an explicit repr=True to override the defaults hiding?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah, no, because then this wouldn't be available if a custom repr is given. so perhaps add a new argument always_repr or something?

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)
```
Copy link

@mara004 mara004 Jul 26, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this functionality expects the defaults in the converted format.

I don't think this is correct (see the other comment). The real default for field x would be 1.5 here:

>>> import attrs
>>>
>>> @attrs.define()
... class Test:
...     x = attrs.field(default=1, converter=lambda v: v + 0.5)
... 
>>> test = Test()
>>> test.x
1.5

(note: expand the file to see context)


(examples-validators)=

## Validators
Expand Down
2 changes: 2 additions & 0 deletions src/attr/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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]]: ...
Expand Down
58 changes: 51 additions & 7 deletions src/attr/_make.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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*
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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",
Copy link

@mara004 mara004 Jul 26, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • I think you could generate the defaults only once and cache them for subsequent runs.
  • IIRC, attrs applies converters on defaults, so shouldn't this do, too?
  • Calling factories unconditionally is a bit problematic for things like factory=lambda: next(generator) because it would advance the caller's generator without an actual instantiation. Also, if a factory takes self, it might contain some more elaborate computation that should not be done over and over again (e.g. determining a file type). Overall, it might be better to keep track of the defaults produced on construction and compare against that?

FWIW, I had written a caller-side wrapper to hide defaults. (The way it works around the factory problem is by letting you mark a factory as dynamic if you don't want it called in repr.)

Disclaimer: I'm not an attrs maintainer, so my views may not matter here.

" 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))",
]
Expand All @@ -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


Expand Down
2 changes: 2 additions & 0 deletions src/attr/_next_gen.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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):
Expand Down
24 changes: 10 additions & 14 deletions src/attrs/__init__.pyi
Original file line number Diff line number Diff line change
@@ -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__
Expand All @@ -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
Expand Down Expand Up @@ -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))
Expand All @@ -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
Expand Down
Loading
Loading