Repository: python-attrs/attrs

Problem statement:
Auto-detect user-written methods
Currently, if you want to implement a method yourself, you have to remember to do two things:

```python
@attr.s(repr=False)
class C:
    def __repr__(self):
        return "whatever"
```

I’ve seen quite a few people be confused/frustrated by that.

***

I think unless `these` is set, it would be kind of cool if we detected that the current class (and not some super class) has a user-written method and set the flag to false automatically such that

```python
@attr.s
class C:
    def __repr__(self):
        return "whatever"
```

does what you’d expect.

Opinions?


Hints:
👍 
Same with `__init__`?  (I ask because the mypy plugin will have to deal with that too.)
The idea is to support it for all of them.  It gets a bit hairy when we get to comparison because it’s several ones.  Would we stop completely?  Or just not replace one of them?
With comparison methods, perhaps [total_ordering](https://docs.python.org/3/library/functools.html#functools.total_ordering) could provide guidance; if you provide enough for `total_ordering` to work, we apply it or do something similar?

It's a bit of feature creep, but it feels in line with the developer doing as little work related to attributes as necessary and we take care of the rest.
The more I think about it the more I think we should just skip methods that have been defined and be done with it.  That allows people to customise their classes even better.

Patch:
diff --git a/src/attr/_make.py b/src/attr/_make.py
--- a/src/attr/_make.py
+++ b/src/attr/_make.py
@@ -218,7 +218,7 @@ def attrib(
     .. deprecated:: 19.2.0 *cmp* Removal on or after 2021-06-01.
     .. versionadded:: 19.2.0 *eq* and *order*
     """
-    eq, order = _determine_eq_order(cmp, eq, order)
+    eq, order = _determine_eq_order(cmp, eq, order, True)
 
     if hash is not None and hash is not True and hash is not False:
         raise TypeError(
@@ -308,20 +308,32 @@ def _is_class_var(annot):
     return str(annot).startswith(_classvar_prefixes)
 
 
-def _get_annotations(cls):
+def _has_own_attribute(cls, attrib_name):
     """
-    Get annotations for *cls*.
+    Check whether *cls* defines *attrib_name* (and doesn't just inherit it).
+
+    Requires Python 3.
     """
-    anns = getattr(cls, "__annotations__", None)
-    if anns is None:
-        return {}
+    attr = getattr(cls, attrib_name, _sentinel)
+    if attr is _sentinel:
+        return False
 
-    # Verify that the annotations aren't merely inherited.
     for base_cls in cls.__mro__[1:]:
-        if anns is getattr(base_cls, "__annotations__", None):
-            return {}
+        a = getattr(base_cls, attrib_name, None)
+        if attr is a:
+            return False
+
+    return True
+
+
+def _get_annotations(cls):
+    """
+    Get annotations for *cls*.
+    """
+    if _has_own_attribute(cls, "__annotations__"):
+        return cls.__annotations__
 
-    return anns
+    return {}
 
 
 def _counter_getter(e):
@@ -754,10 +766,10 @@ def _add_method_dunders(self, method):
 )
 
 
-def _determine_eq_order(cmp, eq, order):
+def _determine_eq_order(cmp, eq, order, default_eq):
     """
     Validate the combination of *cmp*, *eq*, and *order*. Derive the effective
-    values of eq and order.
+    values of eq and order.  If *eq* is None, set it to *default_eq*.
     """
     if cmp is not None and any((eq is not None, order is not None)):
         raise ValueError("Don't mix `cmp` with `eq' and `order`.")
@@ -768,9 +780,10 @@ def _determine_eq_order(cmp, eq, order):
 
         return cmp, cmp
 
-    # If left None, equality is on and ordering mirrors equality.
+    # If left None, equality is set to the specified default and ordering
+    # mirrors equality.
     if eq is None:
-        eq = True
+        eq = default_eq
 
     if order is None:
         order = eq
@@ -781,14 +794,38 @@ def _determine_eq_order(cmp, eq, order):
     return eq, order
 
 
+def _determine_whether_to_implement(cls, flag, auto_detect, dunders):
+    """
+    Check whether we should implement a set of methods for *cls*.
+
+    *flag* is the argument passed into @attr.s like 'init', *auto_detect* the
+    same as passed into @attr.s and *dunders* is a tuple of attribute names
+    whose presence signal that the user has implemented it themselves.
+
+    auto_detect must be False on Python 2.
+    """
+    if flag is True or flag is None and auto_detect is False:
+        return True
+
+    if flag is False:
+        return False
+
+    # Logically, flag is None and auto_detect is True here.
+    for dunder in dunders:
+        if _has_own_attribute(cls, dunder):
+            return False
+
+    return True
+
+
 def attrs(
     maybe_cls=None,
     these=None,
     repr_ns=None,
-    repr=True,
+    repr=None,
     cmp=None,
     hash=None,
-    init=True,
+    init=None,
     slots=False,
     frozen=False,
     weakref_slot=True,
@@ -799,6 +836,7 @@ def attrs(
     auto_exc=False,
     eq=None,
     order=None,
+    auto_detect=False,
 ):
     r"""
     A class decorator that adds `dunder
@@ -823,6 +861,32 @@ def attrs(
     :param str repr_ns: When using nested classes, there's no way in Python 2
         to automatically detect that.  Therefore it's possible to set the
         namespace explicitly for a more meaningful ``repr`` output.
+    :param bool auto_detect: Instead of setting the *init*, *repr*, *eq*,
+        *order*, and *hash* arguments explicitly, assume they are set to
+        ``True`` **unless any** of the involved methods for one of the
+        arguments is implemented in the *current* class (i.e. it is *not*
+        inherited from some base class).
+
+        So for example by implementing ``__eq__`` on a class yourself,
+        ``attrs`` will deduce ``eq=False`` and won't create *neither*
+        ``__eq__`` *nor* ``__ne__`` (but Python classes come with a sensible
+        ``__ne__`` by default, so it *should* be enough to only implement
+        ``__eq__`` in most cases).
+
+        .. warning::
+
+           If you prevent ``attrs`` from creating the ordering methods for you
+           (``order=False``, e.g. by implementing ``__le__``), it becomes
+           *your* responsibility to make sure its ordering is sound. The best
+           way is to use the `functools.total_ordering` decorator.
+
+
+        Passing ``True`` or ``False`` to *init*, *repr*, *eq*, *order*,
+        *cmp*, or *hash* overrides whatever *auto_detect* would determine.
+
+        *auto_detect* requires Python 3. Setting it ``True`` on Python 2 raises
+        a `PythonTooOldError`.
+
     :param bool repr: Create a ``__repr__`` method with a human readable
         representation of ``attrs`` attributes..
     :param bool str: Create a ``__str__`` method that is identical to
@@ -891,8 +955,8 @@ def attrs(
 
     :param bool weakref_slot: Make instances weak-referenceable.  This has no
         effect unless ``slots`` is also enabled.
-    :param bool auto_attribs: If True, collect `PEP 526`_-annotated attributes
-        (Python 3.6 and later only) from the class body.
+    :param bool auto_attribs: If ``True``, collect `PEP 526`_-annotated
+        attributes (Python 3.6 and later only) from the class body.
 
         In this case, you **must** annotate every field.  If ``attrs``
         encounters a field that is set to an `attr.ib` but lacks a type
@@ -957,8 +1021,15 @@ def attrs(
     .. versionadded:: 19.1.0 *auto_exc*
     .. deprecated:: 19.2.0 *cmp* Removal on or after 2021-06-01.
     .. versionadded:: 19.2.0 *eq* and *order*
+    .. versionadded:: 20.1.0 *auto_detect*
     """
-    eq, order = _determine_eq_order(cmp, eq, order)
+    if auto_detect and PY2:
+        raise PythonTooOldError(
+            "auto_detect only works on Python 3 and later."
+        )
+
+    eq_, order_ = _determine_eq_order(cmp, eq, order, None)
+    hash_ = hash  # workaround the lack of nonlocal
 
     def wrap(cls):
 
@@ -978,16 +1049,31 @@ def wrap(cls):
             cache_hash,
             is_exc,
         )
-
-        if repr is True:
+        if _determine_whether_to_implement(
+            cls, repr, auto_detect, ("__repr__",)
+        ):
             builder.add_repr(repr_ns)
         if str is True:
             builder.add_str()
-        if eq is True and not is_exc:
+
+        eq = _determine_whether_to_implement(
+            cls, eq_, auto_detect, ("__eq__", "__ne__")
+        )
+        if not is_exc and eq is True:
             builder.add_eq()
-        if order is True and not is_exc:
+        if not is_exc and _determine_whether_to_implement(
+            cls, order_, auto_detect, ("__lt__", "__le__", "__gt__", "__ge__")
+        ):
             builder.add_order()
 
+        if (
+            hash_ is None
+            and auto_detect is True
+            and _has_own_attribute(cls, "__hash__")
+        ):
+            hash = False
+        else:
+            hash = hash_
         if hash is not True and hash is not False and hash is not None:
             # Can't use `hash in` because 1 == True for example.
             raise TypeError(
@@ -1015,7 +1101,9 @@ def wrap(cls):
                 )
             builder.make_unhashable()
 
-        if init is True:
+        if _determine_whether_to_implement(
+            cls, init, auto_detect, ("__init__",)
+        ):
             builder.add_init()
         else:
             if cache_hash:
@@ -1832,7 +1920,7 @@ def __init__(
         eq=None,
         order=None,
     ):
-        eq, order = _determine_eq_order(cmp, eq, order)
+        eq, order = _determine_eq_order(cmp, eq, order, True)
 
         # Cache this descriptor here to speed things up later.
         bound_setattr = _obj_setattr.__get__(self, Attribute)
@@ -2178,7 +2266,10 @@ def make_class(name, attrs, bases=(object,), **attributes_arguments):
         attributes_arguments["eq"],
         attributes_arguments["order"],
     ) = _determine_eq_order(
-        cmp, attributes_arguments.get("eq"), attributes_arguments.get("order")
+        cmp,
+        attributes_arguments.get("eq"),
+        attributes_arguments.get("order"),
+        True,
     )
 
     return _attrs(these=cls_dict, **attributes_arguments)(type_)
diff --git a/src/attr/exceptions.py b/src/attr/exceptions.py
--- a/src/attr/exceptions.py
+++ b/src/attr/exceptions.py
@@ -51,7 +51,8 @@ class UnannotatedAttributeError(RuntimeError):
 
 class PythonTooOldError(RuntimeError):
     """
-    An ``attrs`` feature requiring a more recent python version has been used.
+    It was attempted to use an ``attrs`` feature that requires a newer Python
+    version.
 
     .. versionadded:: 18.2.0
     """


Test patch:
diff --git a/tests/test_make.py b/tests/test_make.py
--- a/tests/test_make.py
+++ b/tests/test_make.py
@@ -5,6 +5,7 @@
 from __future__ import absolute_import, division, print_function
 
 import copy
+import functools
 import gc
 import inspect
 import itertools
@@ -29,6 +30,7 @@
     _ClassBuilder,
     _CountingAttr,
     _determine_eq_order,
+    _determine_whether_to_implement,
     _transform_attrs,
     and_,
     fields,
@@ -1588,16 +1590,16 @@ class B(A):
 class TestDetermineEqOrder(object):
     def test_default(self):
         """
-        If all are set to None, do the default: True, True
+        If all are set to None, set both eq and order to the passed default.
         """
-        assert (True, True) == _determine_eq_order(None, None, None)
+        assert (42, 42) == _determine_eq_order(None, None, None, 42)
 
     @pytest.mark.parametrize("eq", [True, False])
     def test_order_mirrors_eq_by_default(self, eq):
         """
         If order is None, it mirrors eq.
         """
-        assert (eq, eq) == _determine_eq_order(None, eq, None)
+        assert (eq, eq) == _determine_eq_order(None, eq, None, True)
 
     def test_order_without_eq(self):
         """
@@ -1606,7 +1608,7 @@ def test_order_without_eq(self):
         with pytest.raises(
             ValueError, match="`order` can only be True if `eq` is True too."
         ):
-            _determine_eq_order(None, False, True)
+            _determine_eq_order(None, False, True, True)
 
     @given(cmp=booleans(), eq=optional_bool, order=optional_bool)
     def test_mix(self, cmp, eq, order):
@@ -1618,7 +1620,7 @@ def test_mix(self, cmp, eq, order):
         with pytest.raises(
             ValueError, match="Don't mix `cmp` with `eq' and `order`."
         ):
-            _determine_eq_order(cmp, eq, order)
+            _determine_eq_order(cmp, eq, order, True)
 
     def test_cmp_deprecated(self):
         """
@@ -1669,3 +1671,346 @@ class A(object):
                 A.__qualname__
             )
             assert expected == method.__doc__
+
+
+@pytest.mark.skipif(not PY2, reason="Needs to be only caught on Python 2.")
+def test_auto_detect_raises_on_py2():
+    """
+    Trying to pass auto_detect=True to attr.s raises PythonTooOldError.
+    """
+    with pytest.raises(PythonTooOldError):
+        attr.s(auto_detect=True)
+
+
+class BareC(object):
+    pass
+
+
+class BareSlottedC(object):
+    __slots__ = ()
+
+
+@pytest.mark.skipif(PY2, reason="Auto-detection is Python 3-only.")
+class TestAutoDetect:
+    @pytest.mark.parametrize("C", (BareC, BareSlottedC))
+    def test_determine_detects_non_presence_correctly(self, C):
+        """
+        On an empty class, nothing should be detected.
+        """
+        assert True is _determine_whether_to_implement(
+            C, None, True, ("__init__",)
+        )
+        assert True is _determine_whether_to_implement(
+            C, None, True, ("__repr__",)
+        )
+        assert True is _determine_whether_to_implement(
+            C, None, True, ("__eq__", "__ne__")
+        )
+        assert True is _determine_whether_to_implement(
+            C, None, True, ("__le__", "__lt__", "__ge__", "__gt__")
+        )
+
+    @pytest.mark.parametrize("slots", [True, False])
+    @pytest.mark.parametrize("frozen", [True, False])
+    def test_make_all_by_default(self, slots, frozen):
+        """
+        If nothing is there to be detected, imply init=True, repr=True,
+        hash=None, eq=True, order=True.
+        """
+
+        @attr.s(auto_detect=True, slots=slots, frozen=frozen)
+        class C(object):
+            x = attr.ib()
+
+        i = C(1)
+        o = object()
+
+        assert i.__init__ is not o.__init__
+        assert i.__repr__ is not o.__repr__
+        assert i.__eq__ is not o.__eq__
+        assert i.__ne__ is not o.__ne__
+        assert i.__le__ is not o.__le__
+        assert i.__lt__ is not o.__lt__
+        assert i.__ge__ is not o.__ge__
+        assert i.__gt__ is not o.__gt__
+
+    @pytest.mark.parametrize("slots", [True, False])
+    @pytest.mark.parametrize("frozen", [True, False])
+    def test_detect_auto_init(self, slots, frozen):
+        """
+        If auto_detect=True and an __init__ exists, don't write one.
+        """
+
+        @attr.s(auto_detect=True, slots=slots, frozen=frozen)
+        class CI(object):
+            x = attr.ib()
+
+            def __init__(self):
+                object.__setattr__(self, "x", 42)
+
+        assert 42 == CI().x
+
+    @pytest.mark.parametrize("slots", [True, False])
+    @pytest.mark.parametrize("frozen", [True, False])
+    def test_detect_auto_repr(self, slots, frozen):
+        """
+        If auto_detect=True and an __repr__ exists, don't write one.
+        """
+
+        @attr.s(auto_detect=True, slots=slots, frozen=frozen)
+        class C(object):
+            x = attr.ib()
+
+            def __repr__(self):
+                return "hi"
+
+        assert "hi" == repr(C(42))
+
+    @pytest.mark.parametrize("slots", [True, False])
+    @pytest.mark.parametrize("frozen", [True, False])
+    def test_detect_auto_hash(self, slots, frozen):
+        """
+        If auto_detect=True and an __hash__ exists, don't write one.
+        """
+
+        @attr.s(auto_detect=True, slots=slots, frozen=frozen)
+        class C(object):
+            x = attr.ib()
+
+            def __hash__(self):
+                return 0xC0FFEE
+
+        assert 0xC0FFEE == hash(C(42))
+
+    @pytest.mark.parametrize("slots", [True, False])
+    @pytest.mark.parametrize("frozen", [True, False])
+    def test_detect_auto_eq(self, slots, frozen):
+        """
+        If auto_detect=True and an __eq__ or an __ne__, exist, don't write one.
+        """
+
+        @attr.s(auto_detect=True, slots=slots, frozen=frozen)
+        class C(object):
+            x = attr.ib()
+
+            def __eq__(self, o):
+                raise ValueError("worked")
+
+        with pytest.raises(ValueError, match="worked"):
+            C(1) == C(1)
+
+        @attr.s(auto_detect=True, slots=slots, frozen=frozen)
+        class D(object):
+            x = attr.ib()
+
+            def __ne__(self, o):
+                raise ValueError("worked")
+
+        with pytest.raises(ValueError, match="worked"):
+            D(1) != D(1)
+
+    @pytest.mark.parametrize("slots", [True, False])
+    @pytest.mark.parametrize("frozen", [True, False])
+    def test_detect_auto_order(self, slots, frozen):
+        """
+        If auto_detect=True and an __ge__, __gt__, __le__, or and __lt__ exist,
+        don't write one.
+
+        It's surprisingly difficult to test this programmatically, so we do it
+        by hand.
+        """
+
+        def assert_not_set(cls, ex, meth_name):
+            __tracebackhide__ = True
+
+            a = getattr(cls, meth_name)
+            if meth_name == ex:
+                assert a == 42
+            else:
+                assert a is getattr(object, meth_name)
+
+        def assert_none_set(cls, ex):
+            __tracebackhide__ = True
+
+            for m in ("le", "lt", "ge", "gt"):
+                assert_not_set(cls, ex, "__" + m + "__")
+
+        @attr.s(auto_detect=True, slots=slots, frozen=frozen)
+        class LE(object):
+            __le__ = 42
+
+        @attr.s(auto_detect=True, slots=slots, frozen=frozen)
+        class LT(object):
+            __lt__ = 42
+
+        @attr.s(auto_detect=True, slots=slots, frozen=frozen)
+        class GE(object):
+            __ge__ = 42
+
+        @attr.s(auto_detect=True, slots=slots, frozen=frozen)
+        class GT(object):
+            __gt__ = 42
+
+        assert_none_set(LE, "__le__")
+        assert_none_set(LT, "__lt__")
+        assert_none_set(GE, "__ge__")
+        assert_none_set(GT, "__gt__")
+
+    @pytest.mark.parametrize("slots", [True, False])
+    @pytest.mark.parametrize("frozen", [True, False])
+    def test_override_init(self, slots, frozen):
+        """
+        If init=True is passed, ignore __init__.
+        """
+
+        @attr.s(init=True, auto_detect=True, slots=slots, frozen=frozen)
+        class C(object):
+            x = attr.ib()
+
+            def __init__(self):
+                pytest.fail("should not be called")
+
+        assert C(1) == C(1)
+
+    @pytest.mark.parametrize("slots", [True, False])
+    @pytest.mark.parametrize("frozen", [True, False])
+    def test_override_repr(self, slots, frozen):
+        """
+        If repr=True is passed, ignore __repr__.
+        """
+
+        @attr.s(repr=True, auto_detect=True, slots=slots, frozen=frozen)
+        class C(object):
+            x = attr.ib()
+
+            def __repr__(self):
+                pytest.fail("should not be called")
+
+        assert "C(x=1)" == repr(C(1))
+
+    @pytest.mark.parametrize("slots", [True, False])
+    @pytest.mark.parametrize("frozen", [True, False])
+    def test_override_hash(self, slots, frozen):
+        """
+        If hash=True is passed, ignore __hash__.
+        """
+
+        @attr.s(hash=True, auto_detect=True, slots=slots, frozen=frozen)
+        class C(object):
+            x = attr.ib()
+
+            def __hash__(self):
+                pytest.fail("should not be called")
+
+        assert hash(C(1))
+
+    @pytest.mark.parametrize("slots", [True, False])
+    @pytest.mark.parametrize("frozen", [True, False])
+    def test_override_eq(self, slots, frozen):
+        """
+        If eq=True is passed, ignore __eq__ and __ne__.
+        """
+
+        @attr.s(eq=True, auto_detect=True, slots=slots, frozen=frozen)
+        class C(object):
+            x = attr.ib()
+
+            def __eq__(self, o):
+                pytest.fail("should not be called")
+
+            def __ne__(self, o):
+                pytest.fail("should not be called")
+
+        assert C(1) == C(1)
+
+    @pytest.mark.parametrize("slots", [True, False])
+    @pytest.mark.parametrize("frozen", [True, False])
+    @pytest.mark.parametrize(
+        "eq,order,cmp",
+        [
+            (True, None, None),
+            (True, True, None),
+            (None, True, None),
+            (None, None, True),
+        ],
+    )
+    def test_override_order(self, slots, frozen, eq, order, cmp, recwarn):
+        """
+        If order=True is passed, ignore __le__, __lt__, __gt__, __ge__.
+
+        eq=True and cmp=True both imply order=True so test it too.
+        """
+
+        def meth(self, o):
+            pytest.fail("should not be called")
+
+        @attr.s(
+            cmp=cmp,
+            order=order,
+            eq=eq,
+            auto_detect=True,
+            slots=slots,
+            frozen=frozen,
+        )
+        class C(object):
+            x = attr.ib()
+            __le__ = __lt__ = __gt__ = __ge__ = meth
+
+        assert C(1) < C(2)
+        assert C(1) <= C(2)
+        assert C(2) > C(1)
+        assert C(2) >= C(1)
+
+        if cmp:
+            assert 1 == len(recwarn.list)
+        else:
+            assert 0 == len(recwarn.list)
+
+    @pytest.mark.parametrize("slots", [True, False])
+    @pytest.mark.parametrize("first", [True, False])
+    def test_total_ordering(self, slots, first):
+        """
+        functools.total_ordering works as expected if an order method and an eq
+        method are detected.
+
+        Ensure the order doesn't matter.
+        """
+
+        class C(object):
+            x = attr.ib()
+            own_eq_called = attr.ib(default=False)
+            own_le_called = attr.ib(default=False)
+
+            def __eq__(self, o):
+                self.own_eq_called = True
+                return self.x == o.x
+
+            def __le__(self, o):
+                self.own_le_called = True
+                return self.x <= o.x
+
+        if first:
+            C = functools.total_ordering(
+                attr.s(auto_detect=True, slots=slots)(C)
+            )
+        else:
+            C = attr.s(auto_detect=True, slots=slots)(
+                functools.total_ordering(C)
+            )
+
+        c1, c2 = C(1), C(2)
+
+        assert c1 < c2
+        assert c1.own_le_called
+
+        c1, c2 = C(1), C(2)
+
+        assert c2 > c1
+        assert c2.own_le_called
+
+        c1, c2 = C(1), C(2)
+
+        assert c2 != c1
+        assert c1 == c1
+
+        assert c1.own_eq_called
diff --git a/tests/typing_example.py b/tests/typing_example.py
--- a/tests/typing_example.py
+++ b/tests/typing_example.py
@@ -183,3 +183,13 @@ class WithCustomRepr:
 class OrderFlags:
     a = attr.ib(eq=False, order=False)
     b = attr.ib(eq=True, order=True)
+
+
+# Auto-detect
+# XXX: needs support in mypy
+# @attr.s(auto_detect=True)
+# class AutoDetect:
+#     x: int
+
+#     def __init__(self, x: int):
+#         self.x = x