Skip to content

Commit 196d948

Browse files
authored
Add auto-detection of self-implemented methods (#607)
* Implement auto_detect Fixes #324 * Add test demonstrating total_ordering * Ensure the order of applying total_ordering does not matter * Warn if a method is missing * Revert "Warn if a method is missing" This reverts commit 590ef43. * Add stern warning that nobody will read
1 parent 94ad4f3 commit 196d948

File tree

7 files changed

+487
-33
lines changed

7 files changed

+487
-33
lines changed

changelog.d/607.change.rst

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
``attrs`` can now automatically detect your own implementations and infer ``init=False``, ``repr=False``, ``eq=False``, ``order=False``, and ``hash=False`` if you set ``@attr.s(auto_detect=True)``.
2+
``attrs`` will ignore inherited methods.
3+
If the argument implies more than one method (e.g. ``eq=True`` creates both ``__eq__`` and ``__ne__``), it's enough for *one* of them to exist and ``attrs`` will create *neither*.
4+
5+
This feature requires Python 3.

docs/api.rst

+1-1
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ What follows is the API explanation, if you'd like a more hands-on introduction,
1616
Core
1717
----
1818

19-
.. autofunction:: attr.s(these=None, repr_ns=None, repr=True, cmp=None, hash=None, init=True, slots=False, frozen=False, weakref_slot=True, str=False, auto_attribs=False, kw_only=False, cache_hash=False, auto_exc=False, eq=None, order=None)
19+
.. autofunction:: attr.s(these=None, repr_ns=None, repr=None, cmp=None, hash=None, init=None, slots=False, frozen=False, weakref_slot=True, str=False, auto_attribs=False, kw_only=False, cache_hash=False, auto_exc=False, eq=None, order=None, auto_detect=False)
2020

2121
.. note::
2222

src/attr/__init__.pyi

+2
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,7 @@ def attrs(
187187
auto_exc: bool = ...,
188188
eq: Optional[bool] = ...,
189189
order: Optional[bool] = ...,
190+
auto_detect: bool = ...,
190191
) -> _C: ...
191192
@overload
192193
def attrs(
@@ -207,6 +208,7 @@ def attrs(
207208
auto_exc: bool = ...,
208209
eq: Optional[bool] = ...,
209210
order: Optional[bool] = ...,
211+
auto_detect: bool = ...,
210212
) -> Callable[[_C], _C]: ...
211213

212214
# TODO: add support for returning NamedTuple from the mypy plugin

src/attr/_make.py

+117-26
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,7 @@ def attrib(
218218
.. deprecated:: 19.2.0 *cmp* Removal on or after 2021-06-01.
219219
.. versionadded:: 19.2.0 *eq* and *order*
220220
"""
221-
eq, order = _determine_eq_order(cmp, eq, order)
221+
eq, order = _determine_eq_order(cmp, eq, order, True)
222222

223223
if hash is not None and hash is not True and hash is not False:
224224
raise TypeError(
@@ -308,20 +308,32 @@ def _is_class_var(annot):
308308
return str(annot).startswith(_classvar_prefixes)
309309

310310

311-
def _get_annotations(cls):
311+
def _has_own_attribute(cls, attrib_name):
312312
"""
313-
Get annotations for *cls*.
313+
Check whether *cls* defines *attrib_name* (and doesn't just inherit it).
314+
315+
Requires Python 3.
314316
"""
315-
anns = getattr(cls, "__annotations__", None)
316-
if anns is None:
317-
return {}
317+
attr = getattr(cls, attrib_name, _sentinel)
318+
if attr is _sentinel:
319+
return False
318320

319-
# Verify that the annotations aren't merely inherited.
320321
for base_cls in cls.__mro__[1:]:
321-
if anns is getattr(base_cls, "__annotations__", None):
322-
return {}
322+
a = getattr(base_cls, attrib_name, None)
323+
if attr is a:
324+
return False
325+
326+
return True
327+
328+
329+
def _get_annotations(cls):
330+
"""
331+
Get annotations for *cls*.
332+
"""
333+
if _has_own_attribute(cls, "__annotations__"):
334+
return cls.__annotations__
323335

324-
return anns
336+
return {}
325337

326338

327339
def _counter_getter(e):
@@ -754,10 +766,10 @@ def _add_method_dunders(self, method):
754766
)
755767

756768

757-
def _determine_eq_order(cmp, eq, order):
769+
def _determine_eq_order(cmp, eq, order, default_eq):
758770
"""
759771
Validate the combination of *cmp*, *eq*, and *order*. Derive the effective
760-
values of eq and order.
772+
values of eq and order. If *eq* is None, set it to *default_eq*.
761773
"""
762774
if cmp is not None and any((eq is not None, order is not None)):
763775
raise ValueError("Don't mix `cmp` with `eq' and `order`.")
@@ -768,9 +780,10 @@ def _determine_eq_order(cmp, eq, order):
768780

769781
return cmp, cmp
770782

771-
# If left None, equality is on and ordering mirrors equality.
783+
# If left None, equality is set to the specified default and ordering
784+
# mirrors equality.
772785
if eq is None:
773-
eq = True
786+
eq = default_eq
774787

775788
if order is None:
776789
order = eq
@@ -781,14 +794,38 @@ def _determine_eq_order(cmp, eq, order):
781794
return eq, order
782795

783796

797+
def _determine_whether_to_implement(cls, flag, auto_detect, dunders):
798+
"""
799+
Check whether we should implement a set of methods for *cls*.
800+
801+
*flag* is the argument passed into @attr.s like 'init', *auto_detect* the
802+
same as passed into @attr.s and *dunders* is a tuple of attribute names
803+
whose presence signal that the user has implemented it themselves.
804+
805+
auto_detect must be False on Python 2.
806+
"""
807+
if flag is True or flag is None and auto_detect is False:
808+
return True
809+
810+
if flag is False:
811+
return False
812+
813+
# Logically, flag is None and auto_detect is True here.
814+
for dunder in dunders:
815+
if _has_own_attribute(cls, dunder):
816+
return False
817+
818+
return True
819+
820+
784821
def attrs(
785822
maybe_cls=None,
786823
these=None,
787824
repr_ns=None,
788-
repr=True,
825+
repr=None,
789826
cmp=None,
790827
hash=None,
791-
init=True,
828+
init=None,
792829
slots=False,
793830
frozen=False,
794831
weakref_slot=True,
@@ -799,6 +836,7 @@ def attrs(
799836
auto_exc=False,
800837
eq=None,
801838
order=None,
839+
auto_detect=False,
802840
):
803841
r"""
804842
A class decorator that adds `dunder
@@ -823,6 +861,32 @@ def attrs(
823861
:param str repr_ns: When using nested classes, there's no way in Python 2
824862
to automatically detect that. Therefore it's possible to set the
825863
namespace explicitly for a more meaningful ``repr`` output.
864+
:param bool auto_detect: Instead of setting the *init*, *repr*, *eq*,
865+
*order*, and *hash* arguments explicitly, assume they are set to
866+
``True`` **unless any** of the involved methods for one of the
867+
arguments is implemented in the *current* class (i.e. it is *not*
868+
inherited from some base class).
869+
870+
So for example by implementing ``__eq__`` on a class yourself,
871+
``attrs`` will deduce ``eq=False`` and won't create *neither*
872+
``__eq__`` *nor* ``__ne__`` (but Python classes come with a sensible
873+
``__ne__`` by default, so it *should* be enough to only implement
874+
``__eq__`` in most cases).
875+
876+
.. warning::
877+
878+
If you prevent ``attrs`` from creating the ordering methods for you
879+
(``order=False``, e.g. by implementing ``__le__``), it becomes
880+
*your* responsibility to make sure its ordering is sound. The best
881+
way is to use the `functools.total_ordering` decorator.
882+
883+
884+
Passing ``True`` or ``False`` to *init*, *repr*, *eq*, *order*,
885+
*cmp*, or *hash* overrides whatever *auto_detect* would determine.
886+
887+
*auto_detect* requires Python 3. Setting it ``True`` on Python 2 raises
888+
a `PythonTooOldError`.
889+
826890
:param bool repr: Create a ``__repr__`` method with a human readable
827891
representation of ``attrs`` attributes..
828892
:param bool str: Create a ``__str__`` method that is identical to
@@ -891,8 +955,8 @@ def attrs(
891955
892956
:param bool weakref_slot: Make instances weak-referenceable. This has no
893957
effect unless ``slots`` is also enabled.
894-
:param bool auto_attribs: If True, collect `PEP 526`_-annotated attributes
895-
(Python 3.6 and later only) from the class body.
958+
:param bool auto_attribs: If ``True``, collect `PEP 526`_-annotated
959+
attributes (Python 3.6 and later only) from the class body.
896960
897961
In this case, you **must** annotate every field. If ``attrs``
898962
encounters a field that is set to an `attr.ib` but lacks a type
@@ -957,8 +1021,15 @@ def attrs(
9571021
.. versionadded:: 19.1.0 *auto_exc*
9581022
.. deprecated:: 19.2.0 *cmp* Removal on or after 2021-06-01.
9591023
.. versionadded:: 19.2.0 *eq* and *order*
1024+
.. versionadded:: 20.1.0 *auto_detect*
9601025
"""
961-
eq, order = _determine_eq_order(cmp, eq, order)
1026+
if auto_detect and PY2:
1027+
raise PythonTooOldError(
1028+
"auto_detect only works on Python 3 and later."
1029+
)
1030+
1031+
eq_, order_ = _determine_eq_order(cmp, eq, order, None)
1032+
hash_ = hash # workaround the lack of nonlocal
9621033

9631034
def wrap(cls):
9641035

@@ -978,16 +1049,31 @@ def wrap(cls):
9781049
cache_hash,
9791050
is_exc,
9801051
)
981-
982-
if repr is True:
1052+
if _determine_whether_to_implement(
1053+
cls, repr, auto_detect, ("__repr__",)
1054+
):
9831055
builder.add_repr(repr_ns)
9841056
if str is True:
9851057
builder.add_str()
986-
if eq is True and not is_exc:
1058+
1059+
eq = _determine_whether_to_implement(
1060+
cls, eq_, auto_detect, ("__eq__", "__ne__")
1061+
)
1062+
if not is_exc and eq is True:
9871063
builder.add_eq()
988-
if order is True and not is_exc:
1064+
if not is_exc and _determine_whether_to_implement(
1065+
cls, order_, auto_detect, ("__lt__", "__le__", "__gt__", "__ge__")
1066+
):
9891067
builder.add_order()
9901068

1069+
if (
1070+
hash_ is None
1071+
and auto_detect is True
1072+
and _has_own_attribute(cls, "__hash__")
1073+
):
1074+
hash = False
1075+
else:
1076+
hash = hash_
9911077
if hash is not True and hash is not False and hash is not None:
9921078
# Can't use `hash in` because 1 == True for example.
9931079
raise TypeError(
@@ -1015,7 +1101,9 @@ def wrap(cls):
10151101
)
10161102
builder.make_unhashable()
10171103

1018-
if init is True:
1104+
if _determine_whether_to_implement(
1105+
cls, init, auto_detect, ("__init__",)
1106+
):
10191107
builder.add_init()
10201108
else:
10211109
if cache_hash:
@@ -1832,7 +1920,7 @@ def __init__(
18321920
eq=None,
18331921
order=None,
18341922
):
1835-
eq, order = _determine_eq_order(cmp, eq, order)
1923+
eq, order = _determine_eq_order(cmp, eq, order, True)
18361924

18371925
# Cache this descriptor here to speed things up later.
18381926
bound_setattr = _obj_setattr.__get__(self, Attribute)
@@ -2178,7 +2266,10 @@ def make_class(name, attrs, bases=(object,), **attributes_arguments):
21782266
attributes_arguments["eq"],
21792267
attributes_arguments["order"],
21802268
) = _determine_eq_order(
2181-
cmp, attributes_arguments.get("eq"), attributes_arguments.get("order")
2269+
cmp,
2270+
attributes_arguments.get("eq"),
2271+
attributes_arguments.get("order"),
2272+
True,
21822273
)
21832274

21842275
return _attrs(these=cls_dict, **attributes_arguments)(type_)

src/attr/exceptions.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,8 @@ class UnannotatedAttributeError(RuntimeError):
5151

5252
class PythonTooOldError(RuntimeError):
5353
"""
54-
An ``attrs`` feature requiring a more recent python version has been used.
54+
It was attempted to use an ``attrs`` feature that requires a newer Python
55+
version.
5556
5657
.. versionadded:: 18.2.0
5758
"""

0 commit comments

Comments
 (0)