Repository: python-attrs/attrs
Pull request #627 (2020-02-22 11:48:40)

Allow user to customize how an attribute is compared (#435)
Apologies for resubmitting a PR. I had made a mess in my fork and this was a side-effect.

The idea of this PR is to add a ``comparator`` argument to ``attr.ib( )``, in which the user could provide a simple class implementing equality and ordering methods to suit their needs. 

- [x] Added **tests** for changed code.
- [ ] New features have been added to our [Hypothesis testing strategy](https://github.com/python-attrs/attrs/blob/master/tests/strategies.py).
- [x] Changes or additions to public APIs are reflected in our type stubs (files ending in ``.pyi``).
    - [ ] ...and used in the stub test file `tests/typing_example.py`.
- [ ] Updated **documentation** for changed code.
    - [ ] New functions/classes have to be added to `docs/api.rst` by hand.
    - [ ] Changes to the signature of `@attr.s()` have to be added by hand too.
    - [x] Changed/added classes/methods/functions have appropriate `versionadded`, `versionchanged`, or `deprecated` [directives](http://www.sphinx-doc.org/en/stable/markup/para.html#directive-versionadded).  Find the appropriate next version in our [``__init__.py``](https://github.com/python-attrs/attrs/blob/master/src/attr/__init__.py) file.
- [ ] Documentation in `.rst` files is written using [semantic newlines](https://rhodesmill.org/brandon/2012/one-sentence-per-line/).
- [x] Changes (and possible deprecations) have news fragments in [`changelog.d`](https://github.com/python-attrs/attrs/blob/master/changelog.d).


botant: The name ``comparator`` is not great but we're struggling to come up with an alternative.
Other ideas were ``cmpspec``, ``cmp_class``, ``compare``...
Any suggestions?
hynek: Heyyy. :)

Thanks for taking this on and sorry for the long time waiting.

As a quick idea, we do have the precedence of passing a callable:

```pycon
In [1]: import attr

In [2]: @attr.s
   ...: class C:
   ...:     x = attr.ib(repr=lambda v: f">>> { v } <<<")
   ...:

In [3]: C(42)
Out[3]: C(x=>>> 42 <<<)
```

So I think we should stay consistent and not introduce a new argument.

Does anything speak against something like this:

```python
from typing import Any

import abc

import attr


class Equality(abc.ABC):
    @abc.abstractmethod
    def eq(self, o1: Any, o2: Any):
        pass

    def ne(self, o1: Any, o2: Any):
        return not self.eq(o1, o2)


class Order(abc.ABC):
    @abc.abstractmethod
    def lt(self, o1: Any, o2: Any):
        pass

    def le(self, o1: Any, o2: Any):
        if o1 == o2:
            return True

        return self.lt(o1, o2)

    # ...


class NumpyEquality(Equality):
    def eq(self, o1: Any, o2: Any):
        if type(o1) is not type(o2):
            return NotImplemented

        return (o1 == o2.all()


@attr.s
class C:
    x = attr.ib(eq=NumpyEquality)
```

?

---

Other than that: `cmp` is dead and shouldn't appear anywhere in new APIs.
botant: I think that passing custom "comparators" via `eq` and `order` works and is cleaner than adding a 4th equality-related argument (in addition to `cmp`, `eq` and `order`).  As it is, we have not used `cmp` in the new API but added a new argument `comparator`.

About the snippet you suggest above, I'm not sure how we would integrate this with the tuple comparison utilized in `_make_eq`:
```python
class NumpyEquality(Equality):
    def eq(self, o1: Any, o2: Any):
        if type(o1) is not type(o2):
            return NotImplemented
        return (o1 == o2).all()
```

We took a slightly different approach: the comparison class *wraps* the attribute when we build the tuple:
```python
        for a in attrs:
            lines.append("        self.%s," % (a.name,))
            others.append("        other.%s," % (a.name,))
            if a.comparator:
                cmp_name = "_%s_comparator" % (a.name,)
                # Add the cmp class to the global namespace of the evaluated
                # function, since local does not work in all python versions.
                global_vars[cmp_name] = a.comparator
                lines.append("        %s(self.%s)," % (cmp_name, a.name,))
                others.append("        %s(other.%s)," % (cmp_name, a.name,))
            else:
                lines.append("        self.%s," % (a.name,))
                others.append("        other.%s," % (a.name,))
```

Another question we have to answer is what kind of comparison functions or classes we provide off-the-shelf, and whether we enforce some sort of hierarchy or protocol.  Comparing complex objects such as numpy arrays or pandas dataframes is a bit domain/application-specific, and I'm concerned that nothing we provide will be generic enough.

With that in mind, we have been experimenting (here https://github.com/botant/attrs/pull/4) with a `comparators` module that (loosely) follows the pattern of `validators`.  Two functions have been implemented: `using_function` (uses a function to compare values) and `using_key` (compares values after calling the `key` function, a la `sort`).  

Usage would look like this:
```python
@attr.s
class C:
    # case insensitive
    name = attr.ib(eq=attr.comparators.using_key(lambda x: x.lower()))
    # absolute value
    value = attr.ib(eq=attr.comparators.using_key(abs))
    # numpy array
    data = attr.ib(eq=attr.comparators.using_function(np.array_equal))
```

What do you think?

botant: @hynek  I'm having second thoughts about reusing `eq` and `order`.  Whatever way you choose to compare an attribute, it is likely that equality and ordering operators will be applied in a consistent manner - absolute value, case insensitive, all, any, etc., which means specifying `eq` *and* `order` in a redundant way.  

I'm trying to avoid something like this:

```python
@attr.s
class C:
    x = attr.ib(eq=abs, order=abs)
    y = attr.ib(eq=lambda s: s.lower(), order=lambda s: s.lower())
```

I would suggest we keep `eq` and `order` as they are today - booleans that tell the library whether an attribute should be included in the operators or not.  Introduce a new argument `comparator` (or something better named) to specify *how* the comparison should be done.




wsanchez: There are two approaches being discussed here. @hynek is proposing an API in which you define comparison, and @botant points our that you may have to express the same thing twice, which might be solved if the `Order` ABC is a subclass of `Equality`, such that `order=abs` implies `eq=abs`?

We could have a helper function that build an `Equality` or `Order` for you:

```
def equality(eq=object.__eq__):
    class ConcreteEquality(Equality):
        def eq(self, o1, o2):
            return eq(o1, o2)
    return ConcreteEquality

def order(eq=object.__eq__, lt=object.__lt__):
    class ConcreteOrder(Order):
        def eq(self, o1, o2):
            return eq(o1, o2)
        def lt(self, o1, o2):
            return lt(o1, o2)
    return ConcreteOrder

x = attr.ib(eq=equality(eq=lambda a, b: a.lower() == b.lower()))
y = attr.ib(order=order(lt=lambda a, b: a.value < b.value, eq=lambda a, b: a.value == b.value)
```

Then @botant is proposing a new argument called `comparator`, which I think my be the wrong term, since I think it's actually a normalization function to be use when comparing things.  I think you can do that on top of the the comparison API  idea:

```
def normalized_equality(f):
    return equality(eq=lambda a, b: f(a) == f(b))

def normalized_order(f):
    return order(eq=lambda a, b: f(a) == f(b), lt=lambda a, b: f(a) < f(b))

x = attr.ib(eq=normalized_equality(lambda a: a.lower())
y = attr.ib(order=normalized_order(lambda a: a.value)
```

botant: The API we're proposing is a bit different.  

The two use cases we're trying to address are:
* Using a normalization function such as `abs` or `lower`.
* Using a function to compare objects such as `numpy.array_equal` or `pandas.frame_equal`.

In order to do this *and* keep the current `_make_eq` implementation, we added an argument called `comparator` to `attrib` (please bear with the horrible `comparator` name for now), to which you pass a class like this:
```python
@attr.s
class MyComparator:
   value = attr.ib()
   def __eq__ ...
   def __lt__ ...
```

This specification fits `_make_eq` nicely because `MyComparator` wraps the attribute value and implements `eq`, `lt`, etc itself, and therefore can be added to the attribute tuple and compared like any other attribute value.  No other assumption is made about `MyComparator`.

To facilitate the use of this API, we provide two functions that cover the use cases described above:
* `using_key(key: Callable[[Any], Any])` which normalizes the attribute value using the function `key`:
```python
def __eq__(self, other):
   return self.key(self.value) == self.key(other.value)
```
* `using_function(func: Callable[[Any, Any], bool])` which caters for the `numpy`/`pandas` use case:
```python
def __eq__(self, other):
   return self.func(self.value, other.value)
```

In a nutshell, the API can be described like this:
* `eq` and `order` determine whether the attribute is included in the attrs-generated `eq` and `lt` dunders.
* `comparator` determines how that comparison is done.

Things we wanted achieved:
* Keep the API around `eq` and `order` intact.
* Easy integration with current code, especially `_make_eq`.
* Simple specification for `Comparator` class, and avoid inheritance, abstract methods or protocols.
* Provide viable solutions for the use cases mentioned above, and hopefully other use cases as well.

Things we didn't achieve:
* Find a good name for the new argument.

Hope this makes our suggestion a bit clearer.

Thanks!


hynek: A few quick questions:

>                # Add the cmp class to the global namespace of the evaluated
>                # function, since local does not work in all python versions.

What Python versions are those? I'm happy to make this feature Python 3-only.

---

I understand your concern about consistency between eq and order, and since you seem to come from the scientific community that makes a lot of sense, however I consider having `order=True` by default a mistake in hindsight and plan on making it False in `import attrs`.  Most people don't need it, so it's eating up precious resources (for example, I don't think _I_ have ever used it).

I think this is something that could be solved either with a warning or some kind of syntactic sugar/helper class that combines both?  But it shouldn't be mushed together again by default.  

Maybe we could keep `cmp` for that but moving it from a prime API to syntactic sugar.

---

I agree that we couldn't ever give people what everybody needs.  I could live with not giving them anything at all, as long as we give them a good API to build it themselves. :). I have only glanced at the APIs but I think it has legs!

It just needs to be split up with an helper.  How would you (and this includes @wsanchez) feel about this:

```python
@attr.s
class C:
    # Only change equality
    x = attr.ib(eq=attr.eq.using_key(lambda x: x.lower()))
    # Only change order
    y = attr.ib(order=attr.order.using_key(abs))
    # Change both
    data = attr.ib(cmp=attr.cmp.using_function(np.array_equal))
```

This would give me the flexibility I want and you'd get safety/comfort (`cmp` would become an comparator-only keyword in new APIs).
botant: ```python
           # Add the cmp class to the global namespace of the evaluated
           # function, since local does not work in all python versions.
```
This should work on all versions from 2.7 to 3.8.   We did try using `locals` but that didn't work in 2.7, thus the comment.

---

Yes! I explicitly set `order=False` on almost all my classes. That would be a most welcome change, but it also seems a bit dangerous.

How would you achieve that change in behaviour? A transition period where calls to `__lt__` raise a deprecation warning if `order=True` was implicitly assumed?

What do you mean by: 
> making it False in `import attrs`?

---

This looks like a nice compromise between consistency and flexibility.  

```python
    x = attr.ib(eq=attr.eq.using_key(lambda x: x.lower()))
    y = attr.ib(order=attr.order.using_key(abs))
    data = attr.ib(cmp=attr.cmp.using_function(np.array_equal))
```

My only concern is that we're talking about potentially breaking changes, even though they would suit me. As a first-time contributor, I'd never have suggested that myself. 

I'm looking forward to seeing how an API change like this is managed.




wsanchez: @hynek I'm good with that lat proposal from you, though I find the terms `using_key` and `using_function` rather non-useful.

That is, if `using_key` is a function that normalizes the things to compare, and `using_function` is a function that does the comparison, then those terms don't imply either of those things.

So I'd suggest `using_normalizer` and `using_comparator`, or something like that.
botant: @wsanchez `using_key` was a reference to `sort`'s `key` argument:
```python
>>> sorted("This is a test string from Andrew".split(), key=str.lower)
['a', 'Andrew', 'from', 'is', 'string', 'test', 'This']
```

And `using_function` was, well, the first I could think of :-)

I agree the names I chose aren't great, but the truth is that it will be hard to pick intuitive and self-explanatory names given that this is a new API.  Unfortunately we can't reuse a standard library name like in `validators.isinstance`... 



botant: @hynek @wsanchez what are the next steps then?  Should I update the code to fit the API you described?
wsanchez: @botant I think so.  I'm not going to object loudly based on the names, so the priority I'd suggest is what @hynek described above, though I do think that "normalizer" and "comparator" are much better than "key" and "function", as these are terms we can use in the documentation and it would be good for the API to match the terms we use in the docs.

Better terms are welcome, but I don't think "key" and "function" make much sense.

botant: Cool, I'll get started then.

I'll rename the functions as you suggested.

Maybe we'll think of better names once all the pieces are in place...

On Mon, 9 Mar 2020, 21:08 Wilfredo Sánchez Vega, <notifications@github.com>
wrote:

> @botant <https://github.com/botant> I think so. I'm not going to object
> loudly based on the names, so the priority I'd suggest is what @hynek
> <https://github.com/hynek> described above, though I do think that
> "normalizer" and "comparator" are much better than "key" and "function", as
> these are terms we can use in the documentation and it would be good for
> the API to match the terms we use in the docs.
>
> Better terms are welcome, but I don't think "key" and "function" make much
> sense.
>
> —
> You are receiving this because you were mentioned.
> Reply to this email directly, view it on GitHub
> <https://github.com/python-attrs/attrs/pull/627?email_source=notifications&email_token=ABMJFPBBFPHO7QZKAMWVIHTRGVSHNA5CNFSM4KZQ2XI2YY3PNVWWK3TUL52HS4DFVREXG43VMVBW63LNMVXHJKTDN5WW2ZLOORPWSZGOEOJCJNY#issuecomment-596780215>,
> or unsubscribe
> <https://github.com/notifications/unsubscribe-auth/ABMJFPEAKUEWFU24K3544IDRGVSHNANCNFSM4KZQ2XIQ>
> .
>

hynek: No, wait don’t. :)

Normalizer and comparator may be technically correct, but it goes too far into `characteristic` territory that is easy to forget and hard to type. And `key` is totally a Pythonic thing, permeating throughout the stdlib so I have hinkt it’s good to keep it. 🤔

We can still bikeshed over the names later (I would prefer `with_` instead of `using_` for example) but let’s focus on everything else first. I hope I’ll find the time this week.
wsanchez: @hynek: `characteristic` wasn't all bad.  ;-)

If we want `stdlib`-flavors for names, I'd say use `key` and `cmp`, eg: `with_key` and `with_cmp`.

I'll put down the paint bucket now.
hynek: Can you give me quick examples of `cmp` so I don't have to search myself? 😇
botant: with_key and with_cmp is a lovely pair of names;  if anything, they are
super easy to type :-)

On Tue, 10 Mar 2020, 17:57 Wilfredo Sánchez Vega, <notifications@github.com>
wrote:

> @hynek <https://github.com/hynek>: characteristic wasn't all bad. ;-)
>
> If we want stdlib-flavors for names, I'd say use key and cmp, eg: with_key
> and with_cmp.
>
> I'll put down the paint bucket now.
>
> —
> You are receiving this because you were mentioned.
> Reply to this email directly, view it on GitHub
> <https://github.com/python-attrs/attrs/pull/627?email_source=notifications&email_token=ABMJFPD4LSNIURIGBDFI4CTRGZ5P7A5CNFSM4KZQ2XI2YY3PNVWWK3TUL52HS4DFVREXG43VMVBW63LNMVXHJKTDN5WW2ZLOORPWSZGOEOMPURI#issuecomment-597228101>,
> or unsubscribe
> <https://github.com/notifications/unsubscribe-auth/ABMJFPHOOG2P6ITX66WIT3LRGZ5P7ANCNFSM4KZQ2XIQ>
> .
>

wsanchez: > Can you give me quick examples of `cmp` so I don't have to search myself? 😇

https://docs.python.org/2.7/library/functions.html#cmp
hynek: lol: https://docs.python.org/3.8/library/functools.html#functools.cmp_to_key

Yeah I think those two are good names.
hynek: @botant just to be clear: do you need anything from us or are you good 
botant: Hi Hynek, thanks for checking, and apologies for the radio silence.

The lockdown has been quite distracting and I haven't managed to do much
besides regular work and kid entertainment.

I'm going to dedicate a few hours to it this week, and hopefully you'll see
some updates soon.

Best regards,
Antonio

On Sun, 29 Mar 2020, 06:59 Hynek Schlawack, <notifications@github.com>
wrote:

> @botant <https://github.com/botant> just to be clear: do you need
> anything from us or are you good
>
> —
> You are receiving this because you were mentioned.
> Reply to this email directly, view it on GitHub
> <https://github.com/python-attrs/attrs/pull/627#issuecomment-605587905>,
> or unsubscribe
> <https://github.com/notifications/unsubscribe-auth/ABMJFPCLZGQPOROYRPYQLJLRJ3PV5ANCNFSM4KZQ2XIQ>
> .
>

hynek: No problem, just checking whether you're blocked unbeknownst to me. Stay healthy!
botant: Hello @hynek and @wsanchez, hope you are well.

Now that I've started working on this, I'm worried that the proposed API may lead to bugs very difficult to spot and debug.

Consider the following code:
```python
@attr.s
class Test:
    x = attr.ib(eq=attr.eq.using_key(str.lower))
    y = attr.ib(order=attr.eq.using_key(abs))
```
That is clearly wrong. Surely the user wanted one of the following:
```python
    y = attr.ib(eq=attr.eq.using_key(abs))
```
or
```python
    y = attr.ib(order=attr.order.using_key(abs))
```
My concern is that a bug like this might be invisible to unit tests unless the user is actively testing for that and might show up at run time only.

I see a few options:

1) Continue as planned.

2) Add validation around what can be passed to `eq` and `order`. Certainly doable, but it does add a layer of complexity to the API.

3) Change the API slightly. How about this:
```python
@attr.s
class Test:
    x = attr.ib(eq=True, cmp=attr.cmp.using_key(str.lower))
    y = attr.ib(order=True, cmp=attr.cmp.using_key(abs))
```
In this case, we would preserve the current meaning of `eq` and `order` as flags that determine whether an attribute is used in the equality and comparison dunders, respectively. We would "upcycle" `cmp` to allow the users to specify *how* comparisons should be done.  This is also a bit easier to explain.

Looking forward to hearing what you think.








botant: Hi @hynek,

Whether we go with this:
```python
@attr.s
class Test:
    x = attr.ib(eq=True, cmp=attr.cmp.using_key(str.lower))
    y = attr.ib(order=True, cmp=attr.cmp.using_key(abs))
```

Or this:
```python
@attr.s
class Test:
    x = attr.ib(eq=attr.eq.using_key(str.lower))
    y = attr.ib(order=attr.order.using_key(abs))
```

What are we doing about `cmp`? Are we breaking completely with the previous API where the user could specify a `bool`, or are we maintaining backwards compatibility?



Julian: I am late to the party, and have clearly thought about this less than others earlier, so apologies if this comment is completely unhelpful --

But I don't see why things ended up needing helpers (i.e. why `eq=attr.cmp.with_key(str.lower)` isntead of just `eq=str.lower`?

I see what the helpers/full API does -- they let someone override everything about equality even beyond the transformation of the attribute, including specifically whether a type check is first performed. Is that really a common enough case to support? (I'm asking a leading question obviously, and I think the answer is "no" and that those cases should just define `__eq__` fully manually, but just want to make sure I follow the reasoning).

I also see the back and forth about how cmp and order are really related (i.e. that usually you want to normalize them the same way), but if that's the primary reason for the helpers, I guess my vote if it counts would be for the API in the later comments, but without the helper, i.e. `y = attr.ib(order=True, cmp=abs)`, which triggers "generate an order, use the same normalization as for cmp".
botant: Hi Julian,

We'd like to support at least two use cases:

1) lower(a) == lower(b) (a.k.a normalisation function)

2) np.is_equal(a, b) (a.k.a custom equality function)

If all you pass to attr.ib( ) is a function, there is no way to know how it
is supposed to be used.

Also, there might be other use cases out there that do not fit either
pattern, so the API should be open enough to allow the user to provide
their own class supporting their exact use case.




On Thu, 21 May 2020, 15:54 Julian Berman, <notifications@github.com> wrote:

> I am late to the party, and have clearly thought about this less than
> others earlier, so apologies if this comment is completely unhelpful --
>
> But I don't see why things ended up needing helpers (i.e. why
> eq=attr.cmp.with_key(str.lower) isntead of just eq=str.lower?
>
> I see what the helpers/full API does -- they let someone override
> everything about equality even beyond the transformation of the attribute,
> including specifically whether a type check is first performed. Is that
> really a common enough case to support? (I'm asking a leading question
> obviously, and I think the answer is "no" and that those cases should just
> define __eq__ fully manually, but just want to make sure I follow the
> reasoning).
>
> I also see the back and forth about how cmp and order are really related
> (i.e. that usually you want to normalize them the same way), but if that's
> the primary reason for the helpers, I guess my vote if it counts would be
> for the API in the later comments, but without the helper, i.e. y =
> attr.ib(order=True, cmp=abs), which triggers "generate an order, use the
> same normalization as for cmp".
>
> —
> You are receiving this because you were mentioned.
> Reply to this email directly, view it on GitHub
> <https://github.com/python-attrs/attrs/pull/627#issuecomment-632131399>,
> or unsubscribe
> <https://github.com/notifications/unsubscribe-auth/ABMJFPHZW3ZEAT5QTXSLODDRSU6CPANCNFSM4KZQ2XIQ>
> .
>

Julian: I saw that about numpy too, but that to me instinctively should be supported in the same API -- i.e. just via a key -- so someone should have a `array-to-eq` wrapper thing that takes a numpy array and instead returns an object whose `__eq__` does the right thing (i.e. what any other python object would do for equality).

It's not a common thing outside of the numpy ecosystem, so I'd expect it be supported by allowing numpy things to be wrapped into objects that behave natively, and then you can just use the "normal" way. That may be my numpy-is-weird bias showing though in full disclosure :).
botant: I afraid I don't agree with that at all.

Both numpy/pandas and attrs are big components of the Python ecosystem.
Creating an equality API in attrs that excludes numpy because its
non-standard __eq__ behaviour feels like a waste of energy.

For full disclosure, I am a numpy user, and I'm also a bit annoyed about
the weird __eq__, but I can also see that it made sense in that particular
domain.

Ultimately, this is a decision for Hynek, Sanchez, etc.



On Thu, 21 May 2020, 17:20 Julian Berman, <notifications@github.com> wrote:

> I saw that about numpy too, but that to me instinctively should be
> supported in the same API -- i.e. just via a key -- so someone should have
> a array-to-eq wrapper thing that takes a numpy array and instead returns
> an object whose __eq__ does the right thing (i.e. what any other python
> object would do for equality).
>
> It's not a common thing outside of the numpy ecosystem, so I'd expect it
> be supported by allowing numpy things to be wrapped into objects that
> behave natively, and then you can just use the "normal" way. That may be my
> numpy-is-weird bias showing though in full disclosure :).
>
> —
> You are receiving this because you were mentioned.
> Reply to this email directly, view it on GitHub
> <https://github.com/python-attrs/attrs/pull/627#issuecomment-632187202>,
> or unsubscribe
> <https://github.com/notifications/unsubscribe-auth/ABMJFPBIHLIQVC2QQOWJ42TRSVIFPANCNFSM4KZQ2XIQ>
> .
>

Julian: To be clear, what I suggested doesn't exclude numpy in any way -- it just means only numpy has to do something different, not everything. But the API is just as usable as the one here I think, and means every other kind of object just gets to use the cleaner API.

i.e. for any other object, you use `attr.ib(cmp=str.lower)` -- for a numpy array, you use `attr.ib(cmp=lambda x: (wrap_numpy_array(x) * 2).any())` if you want to define some complex comparison.

But yep :) definitely just one vote.
pradyunsg: I spent a few minutes thinking about this while procrastinating on something else...

```py
# using str.lower'd value to compare, both order & eq follow default behaviors (i.e. True-ish)
attr.ib(eq=str.lower)
# no ordering!
attr.ib(eq=str.lower, order=False)
# using numpy.is_equal for equality, but no ordering!
attr.ib(eq=attr.compare_using_function(numpy.is_equal), order=False)
```

The helper function `compare_using_function` (whatever name is appropriate!) would give us `cmp(a, b)` like behavior. Otherwise, we'd use it like `key(a) == key(b)` like behaviors. IMO, this works well for the "simple" case of transforming the value for comparing and "grows" organically for cases requiring external comparators like numpy.

My only concern is, when using `attr.compare_using_function` for `eq`, it's also "inherited" for `order`, which isn't exactly what we want for things like `numpy.is_equal`. However, I think taking a "you're using more powerful tools, be more careful" approach to this is reasonabe. And, I don't think people end up doing `sorted(list_of_numpy_arrays)` anyway. :)

GiorgioBalestrieri: @botant this is great. Just to spell this out, what I've been wanting to be able to do for a long while is being able to use `math.isclose` or `np.isclose` for float comparisons.

Maybe I'm daydreaming, but something that would be awesome (and shouldn't get in the way of this PR!) is being able to specify a comparison strategy/callable _per type_.
botant: I can't see how you would specify a comparison strategy per type, but if
you can use one strategy only, then you could use single-dispatch functions
to handle different types, and that would work off the box.

Apart from that, you could also write your comparison class (something that
implements equality and ordering funders), or maybe a small factory
function.

I hope everything you need should be covered here!

On Wed, 27 May 2020, 09:57 GiorgioBalestrieri, <notifications@github.com>
wrote:

> @botant <https://github.com/botant> this is great. Just to spell this
> out, what I've been wanting to be able to do for a long while is being able
> to use math.isclose or np.isclose for float comparisons.
>
> Maybe I'm daydreaming, but something that would be awesome (and shouldn't
> get in the way of this PR!) is being able to specify a comparison
> strategy/callable *per type*.
>
> —
> You are receiving this because you were mentioned.
> Reply to this email directly, view it on GitHub
> <https://github.com/python-attrs/attrs/pull/627#issuecomment-634525645>,
> or unsubscribe
> <https://github.com/notifications/unsubscribe-auth/ABMJFPFJGPRE4BAEYJ3OBYLRTTIWLANCNFSM4KZQ2XIQ>
> .
>

wsanchez: > Now that I've started working on this, I'm worried that the proposed API may lead to bugs very difficult to spot and debug.
> 
> Consider the following code:
> 
> ```python
> @attr.s
> class Test:
>     x = attr.ib(eq=attr.eq.using_key(str.lower))
>     y = attr.ib(order=attr.eq.using_key(abs))
> ```
> 
> That is clearly wrong.

@botant Thanks for noticing this…

One (perhaps orthogonal) thought I have is that it might help if the functions names themselves were more explicit instead of putting them in separate modules:

```python
@attr.s
class Test:
    x = attr.ib(eq=attr.cmp.eq_using_key(str.lower))
    y = attr.ib(order=attr.cmp.order_using_key(abs))
```

That's partly because I (perhaps strangely) prefer to import symbols from modules and not modules themselves. That is, I wouldn't use `import attr` and so I'd have to use `from attr.eq import using_key as eq_using_key` which I'll probably end up thinking is an unfortunate thing.

Anyway, for some reason I think I'd spot the error more easily in that case, but I suspect that's not enough to address the concern.

Is it crazy for `attr.ib()` to check specifically for `order` and `eq` being assigned one of the wrong built-in types?  It's a bit belts-and-suspenders, but the cost overhead is low (right?) and only incurred at class creation time… and I bet the number of additional comparators people write will be few and far between.

hynek: Firstly, I'm sorry to be laggy in my replies but…_gestures at everything_.  OTOH I'm glad others have weighted in because I feel like we're getting somewhere!

I'd be OK with double-checking that eq isn't passed into order and vice versa but I actually like Pradyun's idea aka the fusion of botant and Julian:

1. passing a marker object that wraps a function into eq and order (and we don't need special versions for eq/order at all – that problem goes out of the window!) makes it use the wrapped function.
2. Passing anything else makes it use as a key-function.

That should cover all bases, is little prone to errors, and is easy to use for the simplest yet common case.

-----

@botant how do you feel about this proposal? And if you agree with it, would you be willing to implement it?

I would also be willing to bake numpy support into attrs if it's installed since it's such a common thing in Python (different PR tho).

Everybody else: am I missing anything?
botant: Just to check I understand what you mean, the API could be used in these
two ways:

attr.ib(eq=True, order=False, cmp=attr.cmp.with_function(np.is_equal))

attr.ib(eq=str.lower)

And the heuristic to determine what to do is something like this:

1) if eq or order are callables, use that as key.

2) if cmp is specified and it is not a boolean, then eq and order must be
either missing or booleans.

3) if cmp is specified and it is a boolean, then eq and order must be
missing (backward compatibility).

Have I missed anything on the heuristic?

A few questions:

1) Do we want to keep cmp backwards compatible (i.e. cmp = eq && order)?

2) I think we need to work on the relationship between eq and order.
Imagine the following case:

attr.ib(eq=str.lower)

Does this mean (1) do not order, (2) order using the default dunders or (3)
order using str.lower?

------------------------------------

Of course I'm willing to implement this (thanks for asking) as well as the
future PR(s) for numpy and pandas.

How do I feel about it? I can see that it makes the API easier to use for
the simple key case, but this comes at a cost of complexity, i.e. we are
introducing multiple ways of doing one thing.



On Sat, 30 May 2020, 07:24 Hynek Schlawack, <notifications@github.com>
wrote:

> Firstly, I'm sorry to be laggy in my replies but…*gestures at everything*.
> OTOH I'm glad others have weighted in because I feel like we're getting
> somewhere!
>
> I'd be OK with double-checking that eq isn't passed into order and vice
> versa but I actually like Pradyun's idea aka the fusion of botant and
> Julian:
>
>    1. passing a marker object that wraps a function into eq and order
>    (and we don't need special versions for eq/order at all – that problem goes
>    out of the window!) makes it use the wrapped function.
>    2. Passing anything else makes it use as a key-function.
>
> That should cover all bases, is little prone to errors, and is easy to use
> for the simplest yet common case.
> ------------------------------
>
> @botant <https://github.com/botant> how do you feel about this proposal?
> And if you agree with it, would you be willing to implement it?
>
> I would also be willing to bake numpy support into attrs if it's installed
> since it's such a common thing in Python (different PR tho).
>
> Everybody else: am I missing anything?
>
> —
> You are receiving this because you were mentioned.
> Reply to this email directly, view it on GitHub
> <https://github.com/python-attrs/attrs/pull/627#issuecomment-636285282>,
> or unsubscribe
> <https://github.com/notifications/unsubscribe-auth/ABMJFPCQYUJ73CPQO47SLR3RUCRB3ANCNFSM4KZQ2XIQ>
> .
>

hynek: Not quite. I’m considering to undeprecated `cmp` as a shortcut to set both eq and order at once.  But passing both `(eq or order) and cmp` will remain an error.

Basically the same semantics as now, except that cmp gets undeprecated (that shouldn’t happen as part of this PR to keep it small) as a convenience helper: `attr.ib(cmp=str.lower)` is the same as `attr.ib(eq=str.lower, order=str.lower)`.

To be clear: please ignore `cmp` for this PR.

Thoughts?
botant: Got it.  How about the heuristic on eq and order arguments, is that correct?

Both eq and order will now take a boolean (usual comparison), a callable
(key) or an object that implements dunders (like the objects returned by
with_key and with_function).

Makes sense?




On Sat, 30 May 2020, 10:33 Hynek Schlawack, <notifications@github.com>
wrote:

> Not quite. I’m considering to undeprecated cmp as a shortcut to set both
> eq and order at once. But passing both (eq or order) and cmp will remain
> an error.
>
> Basically the same semantics as now, except that cmp gets undeprecated
> (that shouldn’t happen as part of this PR to keep it small) as a
> convenience helper: attr.ib(cmp=str.lower) is the same as attr.ib(eq=str.lower,
> order=str.lower).
>
> To be clear: please ignore cmp for this PR.
>
> Thoughts?
>
> —
> You are receiving this because you were mentioned.
> Reply to this email directly, view it on GitHub
> <https://github.com/python-attrs/attrs/pull/627#issuecomment-636305471>,
> or unsubscribe
> <https://github.com/notifications/unsubscribe-auth/ABMJFPBBMWERVEA4X6CKSBDRUDHHJANCNFSM4KZQ2XIQ>
> .
>

hynek: I don’t think we need `with_key`.

1. bool (on/off)
2. callable (on, with key, normalization)
3. some object that  (on, whatever details we agree on)

Seems the way for me? Simple, clean

***

How would you feel about implementing just the callable/key case first, since we all seem to be in accord and it would keep the diff smaller. Then we’d discuss the more complex case in a new PR?

Assuming @wsanchez , @pradyunsg and @Julian are actually in accord?
botant: What if we did what @Julian (I think) suggested and handled the numpy case
by providing a key function that wraps the object and returns an object
that implements `__eq__` in the usual way?

This way we could eliminate (3) altogether, and all comparisons are "key
normalised".

Just to be clear, what exactly is `order` in that case:

attr.ib(eq=func)







On Sat, 30 May 2020, 11:35 Hynek Schlawack, <notifications@github.com>
wrote:

> I don’t think we need with_key.
>
>    1. bool (on/off)
>    2. callable (on, with key, normalization)
>    3. some object that (on, whatever details we agree on)
>
> Seems the way for me? Simple, clean
> ------------------------------
>
> How would you feel about implementing just the callable/key case first,
> since we all seem to be in accord and it would keep the diff smaller. Then
> we’d discuss the more complex case in a new PR?
>
> Assuming @wsanchez <https://github.com/wsanchez> , @pradyunsg
> <https://github.com/pradyunsg> and @Julian <https://github.com/Julian>
> are actually in accord?
>
> —
> You are receiving this because you were mentioned.
> Reply to this email directly, view it on GitHub
> <https://github.com/python-attrs/attrs/pull/627#issuecomment-636312010>,
> or unsubscribe
> <https://github.com/notifications/unsubscribe-auth/ABMJFPHMGHG62SUJP5YQGADRUDOO5ANCNFSM4KZQ2XIQ>
> .
>

hynek: > What if we did what @Julian (I think) suggested and handled the numpy case by providing a key function that wraps the object and returns an object that implements `__eq__` in the usual way? This way we could eliminate (3) altogether, and all comparisons are "key normalised". 

That sounds great – could I see an example of that?

> Just to be clear, what exactly is `order` in that case: attr.ib(eq=func)

A warning. In the future order is False by default, so it will take more effort to trigger it.
botant: So if order is missing I should I raise a deprecation warning to let the
user know that `order` was set to True but that this behaviour will change
in the future?

I will work on a new prototype with all these changes, including @Julian's
numpy-cmp-as-key idea. It seems we're close to a consensus.

On Sat, 30 May 2020, 12:28 Hynek Schlawack, <notifications@github.com>
wrote:

> What if we did what @Julian <https://github.com/Julian> (I think)
> suggested and handled the numpy case by providing a key function that wraps
> the object and returns an object that implements __eq__ in the usual way?
> This way we could eliminate (3) altogether, and all comparisons are "key
> normalised".
>
> That sounds great – could I see an example of that?
>
> Just to be clear, what exactly is order in that case: attr.ib(eq=func)
>
> A warning. In the future order is False by default, so it will take more
> effort to trigger it.
>
> —
> You are receiving this because you were mentioned.
> Reply to this email directly, view it on GitHub
> <https://github.com/python-attrs/attrs/pull/627#issuecomment-636317710>,
> or unsubscribe
> <https://github.com/notifications/unsubscribe-auth/ABMJFPFB6DAP7QTYE7RB3VDRUDUXBANCNFSM4KZQ2XIQ>
> .
>

hynek: No, a regular warning ("You have customized the behavior of `eq` but not of `order` -- this is probably a bug." or something like that). It'll still be possible to get into that situation later when order is False by default. It will just take more effort.
pradyunsg: > How would you feel about implementing just the callable/key case first, since we all seem to be in accord and it would keep the diff smaller. Then we’d discuss the more complex case in a new PR?

I like this. :)

botant: Got it.

On Sat, 30 May 2020, 13:43 Hynek Schlawack, <notifications@github.com>
wrote:

> No, a regular warning ("You have customized the behavior of eq but not of
> order -- this is probably a bug." or something like that). It'll still be
> possible to get into that situation later when order is False by default.
> It will just take more effort.
>
> —
> You are receiving this because you were mentioned.
> Reply to this email directly, view it on GitHub
> <https://github.com/python-attrs/attrs/pull/627#issuecomment-636325952>,
> or unsubscribe
> <https://github.com/notifications/unsubscribe-auth/ABMJFPGO6IRUTVNA3WPRS5TRUD5NVANCNFSM4KZQ2XIQ>
> .
>

wsanchez: I think it would help nail this down if @botant could write another usage example with what the intended new API looks like, to make sure we're all thinking of the same thing, but I'm pretty sure I'm on board.
botant: @wsanchez (and everyone else): here is a summary of what I have understood.

* No change in the relationship of (`eq`, `order`) and (`cmp`), i.e. they continue mutually exclusive and `cmp` remains a shortcut to set `eq` and `order` in one go.
* A `callable` may be passed to `eq`, `order` or `cmp`, in which case it will be used as key.
* If one of `eq` and `order` is customised (i.e. a `callable`) and the other is missing, raise a warning alerting of a possible bug.
* `numpy` and `pandas` key functions will be added in a later PR.

The API will look like this:

```python
@attr.s
class Test:
    # Implies eq = order = True.
    a = attr.ib()

    # eq = True, order = True.
    # Use abs( ) in equality dunder only.
    # A warning will be raised.
    b = attr.ib(eq=abs)

    # eq = True, order = False.
    # Use abs( ) in equality dunder.
    # No warnings.
    c = attr.ib(eq=abs, order=False)
```

The `numpy` wrapper would look like this as per @Julian's suggestion:

```python
@attr.s(eq=False)
class NumpyWrapper:
    value = attr.ib()
    def __eq__(self, other):
        return np.array_equal(self, other)

def numpy_wrapper(value):
    return NumpyWrapper(value)
```

Let me know if I have missed or misunderstood anything.




Julian: Nice! Thumbs up from me.

Will point out one last thing about the wrapper, which is I guess there *is* some leeway to decide how general to make it. Rather than the very-specific-for-numpy version you have there (in pseudocode) you could instead do:

```python
def compare_thusly(new_eq):
    @attr.s(eq=False)
    class NewlyComparable:
        value = attr.ib()

        def __eq__(self, other):
            if self.value.__class__ is not other.__class__:
                return NotImplemented
            return new_eq(self.value, other)

    return NewlyComparable


numpy_wrapper = compare_thusly(numpy.array_equal)
```
botant: @Julian, thanks, that is indeed the plan but I didn't want to "overspecify"
the wrapper since it will be the subject of a different PR. Cheers!

On Wed, 3 Jun 2020, 14:32 Julian Berman, <notifications@github.com> wrote:

> Nice! Thumbs up from me.
>
> Will point out one last thing about the wrapper, which is I guess there
> *is* some leeway to decide how general to make it. Rather than the
> very-specific-for-numpy version you have there (in pseudocode) you could
> instead do:
>
> def compare_thusly(new_eq):
>     @attr.s(eq=False)
>     class NewlyComparable:
>         value = attr.ib()
>
>         def __eq__(self, other):
>             if self.value.__class__ is not other.__class__:
>                 return NotImplemented
>             return new_eq(self.value, other)
>
>     return NewlyComparable
>
> numpy_wrapper = compare_thusly(numpy.array_equal)
>
> —
> You are receiving this because you were mentioned.
> Reply to this email directly, view it on GitHub
> <https://github.com/python-attrs/attrs/pull/627#issuecomment-638199398>,
> or unsubscribe
> <https://github.com/notifications/unsubscribe-auth/ABMJFPEUKW2T7TPBHM4U4A3RUZGIFANCNFSM4KZQ2XIQ>
> .
>

wsanchez: @botant Thanks for the write-up. I like it.
hynek: yep LGTM
botant: Quick question: my branch has now become quite outdated.  Is it okay to restart from a new branch that equals master?
hynek: Do whatever is the most convenient to you! You should be able to force push a fresh branch against this PR branch FWIW.
botant: Just committed the new version of the API.  Let me know what you think.
hynek: That’s quite the diff! 😅 On a quick scroll: the next release is 20.1.0 and the newsfragments don’t carry the links to the issues.
botant: About ``eq`` and ``eq_key``: you right and I did experiment with different ways of passing the callable in.  At the end, I decided to store boolean and callable in different variables to keep backward compatibility. I assumed backward compatibility was important because ``cmp`` was added as a ``@property`` to ``Attribute``. I'm guessing that when ``repr`` was added to the API it already supported both boolean and callable types?

Happy to do either, and I'll clean up ``_CountingAttr`` as well (sorry for that).


hynek: Hey just a quick heads up: you are not forgotten, I'm tormented by guilt, but I finally had to draw a line and finish all pre-requisites for `import attrs`.

I want to merge the final piece of the setattr work (#663) ASAP…hoping for some reviews…get 20.1.0 out (it's more than overdue). After that, **this** and the serialization PR will be my next focus. I just had to tackle my own stuff, because I kept prioritizing others work and effectively procrastinated on my own work for several years. Unfortunately the work also dived into the deep innards of `__init__`. :(

This feature is very dear to my heart and I hope we'll land it for 20.2.0 with much fanfare!
rhofour: I just found myself looking for this behavior to make floating point attributes use approximate equality. Let me know if there's anything I can do to help.
hynek: Yeah is there anything we can do to unstuck this?
botant: @hynek I was not aware this was stuck. Do you remember what were the blockers?
hynek: Oh my bad! Probably, I was the blocker. 🙈 I guess I was confused by the PR still being marked as a draft? If you think it's ready, please rebase and mark as ready for review – thanks!
botant: Happy New Year ;-)
hynek: You too! Looks like the doctests are failing. /0\ 
botant: All fixed now.
ali5h: this is exactly what we need, just wanted to say thanks
hynek: Ping? 😇
botant: Apologies, I'll address the questions later today!

On Thu, 11 Feb 2021 at 06:54, Hynek Schlawack <notifications@github.com>
wrote:

> Ping? 😇
>
> —
> You are receiving this because you were mentioned.
> Reply to this email directly, view it on GitHub
> <https://github.com/python-attrs/attrs/pull/627#issuecomment-777238513>,
> or unsubscribe
> <https://github.com/notifications/unsubscribe-auth/ABMJFPGRHN7247OUKQHZXYDS6N5LJANCNFSM4KZQ2XIQ>
> .
>

hynek: No need for apologies! Just making sure we’re not both waiting for each other. :)
botant: On `cmp`: not sure what you mean. :-)
botant: On `global`: I was under the impression that passing the key function as `global` was required for PY2, but `local` doesn't work for me in Python 3.8.  Here is some code to exemplify the issue:

```
Python 3.8.5 (default, Jul 28 2020, 12:59:40) 
[GCC 9.3.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> def func():
...     return 2
... 
>>> x = eval('lambda: f()', {'f': func}, {})
>>> x()
2
>>> x = eval('lambda: f()', {}, {'f': func})
>>> x()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<string>", line 1, in <lambda>
NameError: name 'f' is not defined
>>> x = eval('lambda: f()', {'f': func}, {})
>>> x()
2
>>> 
```

Do you know if there is an alternative? Can you confirm on your side?

PS:  `_make_attr_tuple_class` and `_make_init` also use `eval` + `global` to achieve similar things. 
hynek: > On `cmp`: not sure what you mean. :-)

What part? 😅

> On global: I was under the impression that passing the key function as global was required for PY2, but local doesn't work for me in Python 3.8.

Just leave it then. We can look if we can improve the code later (narrator: they won't).
hynek: Hey, I'm sorry but #760 added some conflicts. But I suspect it might actually simplify some of your code around eval?
botant: On `cmp`, I didn't understand what you meant by this:
> It's still a derived attribute that can be used as syntactic sugar, to set eq and order at once – agreed?

On globals: I did a bit more digging and I don't think it will ever work with locals, because `__eq__` is not a closure. Either the key functions are in `__eq__.__globals__`, or `__eq__` doesn't know about them. I can see two alternatives: 
* Turn `__eq__` into a closure.
* Turn `__eq__` into a callable object.

I think we're better off leaving it as is, after cleaning my nonsensical comment. I can only imagine that I had inverted the `eval` arguments when I concluded that locals worked. 


hynek: Ehh I'm not sure either anymore, but removing the property would break backward compatibility, so we definitely still need it. Or am I misunderstanding?
botant: > Ehh I'm not sure either anymore, but removing the property would break backward compatibility, so we definitely still need it. Or am I misunderstanding?

Yes, I think we do need it. Or at least I don't want to be the one to take it off. :smiley:
botant: > Hey, I'm sorry but #760 added some conflicts. But I suspect it might actually simplify some of your code around eval?

Merged!

Patch:
diff --git a/changelog.d/435.change.rst b/changelog.d/435.change.rst
new file mode 100644
index 0000000..9286afd
--- /dev/null
+++ b/changelog.d/435.change.rst
@@ -0,0 +1,1 @@
+It's now possible to customize the behavior of ``eq`` and ``order`` by passing in a callable.
diff --git a/docs/api.rst b/docs/api.rst
index 5cc2f2d..08067d5 100644
--- a/docs/api.rst
+++ b/docs/api.rst
@@ -102,7 +102,7 @@ Core
       ... class C(object):
       ...     x = attr.ib()
       >>> attr.fields(C).x
-      Attribute(name='x', default=NOTHING, validator=None, repr=True, eq=True, order=True, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False, inherited=False, on_setattr=None)
+      Attribute(name='x', default=NOTHING, validator=None, repr=True, eq=True, eq_key=None, order=True, order_key=None, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False, inherited=False, on_setattr=None)
 
 
 .. autofunction:: attr.make_class
@@ -180,9 +180,9 @@ Helpers
       ...     x = attr.ib()
       ...     y = attr.ib()
       >>> attr.fields(C)
-      (Attribute(name='x', default=NOTHING, validator=None, repr=True, eq=True, order=True, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False, inherited=False, on_setattr=None), Attribute(name='y', default=NOTHING, validator=None, repr=True, eq=True, order=True, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False, inherited=False, on_setattr=None))
+      (Attribute(name='x', default=NOTHING, validator=None, repr=True, eq=True, eq_key=None, order=True, order_key=None, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False, inherited=False, on_setattr=None), Attribute(name='y', default=NOTHING, validator=None, repr=True, eq=True, eq_key=None, order=True, order_key=None, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False, inherited=False, on_setattr=None))
       >>> attr.fields(C)[1]
-      Attribute(name='y', default=NOTHING, validator=None, repr=True, eq=True, order=True, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False, inherited=False, on_setattr=None)
+      Attribute(name='y', default=NOTHING, validator=None, repr=True, eq=True, eq_key=None, order=True, order_key=None, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False, inherited=False, on_setattr=None)
       >>> attr.fields(C).y is attr.fields(C)[1]
       True
 
@@ -197,9 +197,9 @@ Helpers
       ...     x = attr.ib()
       ...     y = attr.ib()
       >>> attr.fields_dict(C)
-      {'x': Attribute(name='x', default=NOTHING, validator=None, repr=True, eq=True, order=True, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False, inherited=False, on_setattr=None), 'y': Attribute(name='y', default=NOTHING, validator=None, repr=True, eq=True, order=True, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False, inherited=False, on_setattr=None)}
+      {'x': Attribute(name='x', default=NOTHING, validator=None, repr=True, eq=True, eq_key=None, order=True, order_key=None, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False, inherited=False, on_setattr=None), 'y': Attribute(name='y', default=NOTHING, validator=None, repr=True, eq=True, eq_key=None, order=True, order_key=None, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False, inherited=False, on_setattr=None)}
       >>> attr.fields_dict(C)['y']
-      Attribute(name='y', default=NOTHING, validator=None, repr=True, eq=True, order=True, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False, inherited=False, on_setattr=None)
+      Attribute(name='y', default=NOTHING, validator=None, repr=True, eq=True, eq_key=None, order=True, order_key=None, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False, inherited=False, on_setattr=None)
       >>> attr.fields_dict(C)['y'] is attr.fields(C).y
       True
 
diff --git a/docs/extending.rst b/docs/extending.rst
index fed39a3..25765ee 100644
--- a/docs/extending.rst
+++ b/docs/extending.rst
@@ -16,7 +16,7 @@ So it is fairly simple to build your own decorators on top of ``attrs``:
    ... @attr.s
    ... class C(object):
    ...     a = attr.ib()
-   (Attribute(name='a', default=NOTHING, validator=None, repr=True, eq=True, order=True, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False, inherited=False, on_setattr=None),)
+   (Attribute(name='a', default=NOTHING, validator=None, repr=True, eq=True, eq_key=None, order=True, order_key=None, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False, inherited=False, on_setattr=None),)
 
 
 .. warning::
diff --git a/src/attr/__init__.pyi b/src/attr/__init__.pyi
index eb301b4..f3e8be8 100644
--- a/src/attr/__init__.pyi
+++ b/src/attr/__init__.pyi
@@ -37,6 +37,7 @@ __copyright__: str
 _T = TypeVar("_T")
 _C = TypeVar("_C", bound=type)
 
+_EqOrderType = Union[bool, Callable[[Any], Any]]
 _ValidatorType = Callable[[Any, Attribute[_T], _T], Any]
 _ConverterType = Callable[[Any], Any]
 _FilterType = Callable[[Attribute[_T], _T], bool]
@@ -72,9 +73,9 @@ class Attribute(Generic[_T]):
     default: Optional[_T]
     validator: Optional[_ValidatorType[_T]]
     repr: _ReprArgType
-    cmp: bool
-    eq: bool
-    order: bool
+    cmp: _EqOrderType
+    eq: _EqOrderType
+    order: _EqOrderType
     hash: Optional[bool]
     init: bool
     converter: Optional[_ConverterType]
@@ -114,7 +115,7 @@ def attrib(
     default: None = ...,
     validator: None = ...,
     repr: _ReprArgType = ...,
-    cmp: Optional[bool] = ...,
+    cmp: Optional[_EqOrderType] = ...,
     hash: Optional[bool] = ...,
     init: bool = ...,
     metadata: Optional[Mapping[Any, Any]] = ...,
@@ -122,8 +123,8 @@ def attrib(
     converter: None = ...,
     factory: None = ...,
     kw_only: bool = ...,
-    eq: Optional[bool] = ...,
-    order: Optional[bool] = ...,
+    eq: Optional[_EqOrderType] = ...,
+    order: Optional[_EqOrderType] = ...,
     on_setattr: Optional[_OnSetAttrArgType] = ...,
 ) -> Any: ...
 
@@ -134,7 +135,7 @@ def attrib(
     default: None = ...,
     validator: Optional[_ValidatorArgType[_T]] = ...,
     repr: _ReprArgType = ...,
-    cmp: Optional[bool] = ...,
+    cmp: Optional[_EqOrderType] = ...,
     hash: Optional[bool] = ...,
     init: bool = ...,
     metadata: Optional[Mapping[Any, Any]] = ...,
@@ -142,8 +143,8 @@ def attrib(
     converter: Optional[_ConverterType] = ...,
     factory: Optional[Callable[[], _T]] = ...,
     kw_only: bool = ...,
-    eq: Optional[bool] = ...,
-    order: Optional[bool] = ...,
+    eq: Optional[_EqOrderType] = ...,
+    order: Optional[_EqOrderType] = ...,
     on_setattr: Optional[_OnSetAttrArgType] = ...,
 ) -> _T: ...
 
@@ -153,7 +154,7 @@ def attrib(
     default: _T,
     validator: Optional[_ValidatorArgType[_T]] = ...,
     repr: _ReprArgType = ...,
-    cmp: Optional[bool] = ...,
+    cmp: Optional[_EqOrderType] = ...,
     hash: Optional[bool] = ...,
     init: bool = ...,
     metadata: Optional[Mapping[Any, Any]] = ...,
@@ -161,8 +162,8 @@ def attrib(
     converter: Optional[_ConverterType] = ...,
     factory: Optional[Callable[[], _T]] = ...,
     kw_only: bool = ...,
-    eq: Optional[bool] = ...,
-    order: Optional[bool] = ...,
+    eq: Optional[_EqOrderType] = ...,
+    order: Optional[_EqOrderType] = ...,
     on_setattr: Optional[_OnSetAttrArgType] = ...,
 ) -> _T: ...
 
@@ -172,7 +173,7 @@ def attrib(
     default: Optional[_T] = ...,
     validator: Optional[_ValidatorArgType[_T]] = ...,
     repr: _ReprArgType = ...,
-    cmp: Optional[bool] = ...,
+    cmp: Optional[_EqOrderType] = ...,
     hash: Optional[bool] = ...,
     init: bool = ...,
     metadata: Optional[Mapping[Any, Any]] = ...,
@@ -180,8 +181,8 @@ def attrib(
     converter: Optional[_ConverterType] = ...,
     factory: Optional[Callable[[], _T]] = ...,
     kw_only: bool = ...,
-    eq: Optional[bool] = ...,
-    order: Optional[bool] = ...,
+    eq: Optional[_EqOrderType] = ...,
+    order: Optional[_EqOrderType] = ...,
     on_setattr: Optional[_OnSetAttrArgType] = ...,
 ) -> Any: ...
 @overload
@@ -215,8 +216,8 @@ def field(
     converter: Optional[_ConverterType] = ...,
     factory: Optional[Callable[[], _T]] = ...,
     kw_only: bool = ...,
-    eq: Optional[bool] = ...,
-    order: Optional[bool] = ...,
+    eq: Optional[_EqOrderType] = ...,
+    order: Optional[_EqOrderType] = ...,
     on_setattr: Optional[_OnSetAttrArgType] = ...,
 ) -> _T: ...
 
@@ -233,8 +234,8 @@ def field(
     converter: Optional[_ConverterType] = ...,
     factory: Optional[Callable[[], _T]] = ...,
     kw_only: bool = ...,
-    eq: Optional[bool] = ...,
-    order: Optional[bool] = ...,
+    eq: Optional[_EqOrderType] = ...,
+    order: Optional[_EqOrderType] = ...,
     on_setattr: Optional[_OnSetAttrArgType] = ...,
 ) -> _T: ...
 
@@ -251,8 +252,8 @@ def field(
     converter: Optional[_ConverterType] = ...,
     factory: Optional[Callable[[], _T]] = ...,
     kw_only: bool = ...,
-    eq: Optional[bool] = ...,
-    order: Optional[bool] = ...,
+    eq: Optional[_EqOrderType] = ...,
+    order: Optional[_EqOrderType] = ...,
     on_setattr: Optional[_OnSetAttrArgType] = ...,
 ) -> Any: ...
 @overload
@@ -261,7 +262,7 @@ def attrs(
     these: Optional[Dict[str, Any]] = ...,
     repr_ns: Optional[str] = ...,
     repr: bool = ...,
-    cmp: Optional[bool] = ...,
+    cmp: Optional[_EqOrderType] = ...,
     hash: Optional[bool] = ...,
     init: bool = ...,
     slots: bool = ...,
@@ -272,8 +273,8 @@ def attrs(
     kw_only: bool = ...,
     cache_hash: bool = ...,
     auto_exc: bool = ...,
-    eq: Optional[bool] = ...,
-    order: Optional[bool] = ...,
+    eq: Optional[_EqOrderType] = ...,
+    order: Optional[_EqOrderType] = ...,
     auto_detect: bool = ...,
     collect_by_mro: bool = ...,
     getstate_setstate: Optional[bool] = ...,
@@ -286,7 +287,7 @@ def attrs(
     these: Optional[Dict[str, Any]] = ...,
     repr_ns: Optional[str] = ...,
     repr: bool = ...,
-    cmp: Optional[bool] = ...,
+    cmp: Optional[_EqOrderType] = ...,
     hash: Optional[bool] = ...,
     init: bool = ...,
     slots: bool = ...,
@@ -297,8 +298,8 @@ def attrs(
     kw_only: bool = ...,
     cache_hash: bool = ...,
     auto_exc: bool = ...,
-    eq: Optional[bool] = ...,
-    order: Optional[bool] = ...,
+    eq: Optional[_EqOrderType] = ...,
+    order: Optional[_EqOrderType] = ...,
     auto_detect: bool = ...,
     collect_by_mro: bool = ...,
     getstate_setstate: Optional[bool] = ...,
@@ -377,7 +378,7 @@ def make_class(
     bases: Tuple[type, ...] = ...,
     repr_ns: Optional[str] = ...,
     repr: bool = ...,
-    cmp: Optional[bool] = ...,
+    cmp: Optional[_EqOrderType] = ...,
     hash: Optional[bool] = ...,
     init: bool = ...,
     slots: bool = ...,
@@ -388,8 +389,8 @@ def make_class(
     kw_only: bool = ...,
     cache_hash: bool = ...,
     auto_exc: bool = ...,
-    eq: Optional[bool] = ...,
-    order: Optional[bool] = ...,
+    eq: Optional[_EqOrderType] = ...,
+    order: Optional[_EqOrderType] = ...,
     collect_by_mro: bool = ...,
     on_setattr: Optional[_OnSetAttrArgType] = ...,
     field_transformer: Optional[_FieldTransformer] = ...,
diff --git a/src/attr/_make.py b/src/attr/_make.py
index 76b1c62..44362c8 100644
--- a/src/attr/_make.py
+++ b/src/attr/_make.py
@@ -178,13 +178,26 @@ def attrib(
         as-is, i.e. it will be used directly *instead* of calling ``repr()``
         (the default).
     :type repr: a `bool` or a `callable` to use a custom function.
-    :param bool eq: If ``True`` (default), include this attribute in the
+
+    :param eq: If ``True`` (default), include this attribute in the
         generated ``__eq__`` and ``__ne__`` methods that check two instances
-        for equality.
-    :param bool order: If ``True`` (default), include this attributes in the
+        for equality. To override how the attribute value is compared,
+        pass a ``callable`` that takes a single value and returns the value
+        to be compared.
+    :type eq: a `bool` or a `callable`.
+
+    :param order: If ``True`` (default), include this attributes in the
         generated ``__lt__``, ``__le__``, ``__gt__`` and ``__ge__`` methods.
-    :param bool cmp: Setting to ``True`` is equivalent to setting ``eq=True,
-        order=True``. Deprecated in favor of *eq* and *order*.
+        To override how the attribute value is ordered,
+        pass a ``callable`` that takes a single value and returns the value
+        to be ordered.
+    :type order: a `bool` or a `callable`.
+
+    :param cmp: Setting to ``True`` is equivalent to setting ``eq=True,
+        order=True``. Can also be set to a ``callable``.
+        Deprecated in favor of *eq* and *order*.
+    :type cmp: a `bool` or a `callable`.
+
     :param Optional[bool] hash: Include this attribute in the generated
         ``__hash__`` method.  If ``None`` (default), mirror *eq*'s value.  This
         is the correct behavior according the Python spec.  Setting this value
@@ -232,14 +245,17 @@ def attrib(
     .. versionadded:: 18.1.0
        ``factory=f`` is syntactic sugar for ``default=attr.Factory(f)``.
     .. versionadded:: 18.2.0 *kw_only*
-    .. versionchanged:: 19.2.0 *convert* keyword argument removed
+    .. versionchanged:: 19.2.0 *convert* keyword argument removed.
     .. versionchanged:: 19.2.0 *repr* also accepts a custom callable.
     .. deprecated:: 19.2.0 *cmp* Removal on or after 2021-06-01.
     .. versionadded:: 19.2.0 *eq* and *order*
     .. versionadded:: 20.1.0 *on_setattr*
     .. versionchanged:: 20.3.0 *kw_only* backported to Python 2
+    .. versionchanged:: 21.1.0 *eq* and *order* also accept a custom callable.
     """
-    eq, order = _determine_eq_order(cmp, eq, order, True)
+    eq, eq_key, order, order_key = _determine_attrib_eq_order(
+        cmp, eq, order, True
+    )
 
     if hash is not None and hash is not True and hash is not False:
         raise TypeError(
@@ -281,7 +297,9 @@ def attrib(
         type=type,
         kw_only=kw_only,
         eq=eq,
+        eq_key=eq_key,
         order=order,
+        order_key=order_key,
         on_setattr=on_setattr,
     )
 
@@ -1042,8 +1060,13 @@ _CMP_DEPRECATION = (
     "2021-06-01.  Please use `eq` and `order` instead."
 )
 
+_EQ_ORDER_CUSTOMIZATION = (
+    "You have customized the behaviour of `eq` but not of `order`.  "
+    "This is probably a bug."
+)
+
 
-def _determine_eq_order(cmp, eq, order, default_eq):
+def _determine_attrs_eq_order(cmp, eq, order, default_eq):
     """
     Validate the combination of *cmp*, *eq*, and *order*. Derive the effective
     values of eq and order.  If *eq* is None, set it to *default_eq*.
@@ -1071,6 +1094,52 @@ def _determine_eq_order(cmp, eq, order, default_eq):
     return eq, order
 
 
+def _determine_attrib_eq_order(cmp, eq, order, default_eq):
+    """
+    Validate the combination of *cmp*, *eq*, and *order*. Derive the effective
+    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`.")
+
+    def decide_callable_or_boolean(value):
+        """
+        Decide whether a key function is used.
+        """
+        if callable(value):
+            value, key = True, value
+        else:
+            key = None
+        return value, key
+
+    # cmp takes precedence due to bw-compatibility.
+    if cmp is not None:
+        warnings.warn(_CMP_DEPRECATION, DeprecationWarning, stacklevel=3)
+
+        cmp, cmp_key = decide_callable_or_boolean(cmp)
+        return cmp, cmp_key, cmp, cmp_key
+
+    # If left None, equality is set to the specified default and ordering
+    # mirrors equality.
+    if eq is None:
+        eq, eq_key = default_eq, None
+    else:
+        eq, eq_key = decide_callable_or_boolean(eq)
+
+    if order is None:
+        if eq_key is not None:
+            warnings.warn(_EQ_ORDER_CUSTOMIZATION, SyntaxWarning, stacklevel=3)
+
+        order, order_key = eq, eq_key
+    else:
+        order, order_key = decide_callable_or_boolean(order)
+
+    if eq is False and order is True:
+        raise ValueError("`order` can only be True if `eq` is True too.")
+
+    return eq, eq_key, order, order_key
+
+
 def _determine_whether_to_implement(
     cls, flag, auto_detect, dunders, default=True
 ):
@@ -1369,7 +1438,7 @@ def attrs(
             "auto_detect only works on Python 3 and later."
         )
 
-    eq_, order_ = _determine_eq_order(cmp, eq, order, None)
+    eq_, order_ = _determine_attrs_eq_order(cmp, eq, order, None)
     hash_ = hash  # work around the lack of nonlocal
 
     if isinstance(on_setattr, (list, tuple)):
@@ -1520,13 +1589,6 @@ else:
         return cls.__setattr__ == _frozen_setattrs
 
 
-def _attrs_to_tuple(obj, attrs):
-    """
-    Create a tuple of all values of *obj*'s *attrs*.
-    """
-    return tuple(getattr(obj, a.name) for a in attrs)
-
-
 def _generate_unique_filename(cls, func_name):
     """
     Create a "filename" suitable for a function being generated.
@@ -1662,21 +1724,44 @@ def _make_eq(cls, attrs):
         "    if other.__class__ is not self.__class__:",
         "        return NotImplemented",
     ]
+
     # We can't just do a big self.x = other.x and... clause due to
     # irregularities like nan == nan is false but (nan,) == (nan,) is true.
+    globs = {}
     if attrs:
         lines.append("    return  (")
         others = ["    ) == ("]
         for a in attrs:
-            lines.append("        self.%s," % (a.name,))
-            others.append("        other.%s," % (a.name,))
+            if a.eq_key:
+                cmp_name = "_%s_key" % (a.name,)
+                # Add the key function to the global namespace
+                # of the evaluated function.
+                globs[cmp_name] = a.eq_key
+                lines.append(
+                    "        %s(self.%s),"
+                    % (
+                        cmp_name,
+                        a.name,
+                    )
+                )
+                others.append(
+                    "        %s(other.%s),"
+                    % (
+                        cmp_name,
+                        a.name,
+                    )
+                )
+            else:
+                lines.append("        self.%s," % (a.name,))
+                others.append("        other.%s," % (a.name,))
 
         lines += others + ["    )"]
     else:
         lines.append("    return True")
 
     script = "\n".join(lines)
-    return _make_method("__eq__", script, unique_filename)
+
+    return _make_method("__eq__", script, unique_filename, globs)
 
 
 def _make_order(cls, attrs):
@@ -1689,7 +1774,12 @@ def _make_order(cls, attrs):
         """
         Save us some typing.
         """
-        return _attrs_to_tuple(obj, attrs)
+        return tuple(
+            key(value) if key else value
+            for value, key in (
+                (getattr(obj, a.name), a.order_key) for a in attrs
+            )
+        )
 
     def __lt__(self, other):
         """
@@ -2394,6 +2484,7 @@ class Attribute(object):
     .. versionadded:: 20.1.0 *on_setattr*
     .. versionchanged:: 20.2.0 *inherited* is not taken into account for
         equality checks and hashing anymore.
+    .. versionadded:: 20.X.Y *eq_key* and *order_key*
 
     For the full version history of the fields, see `attr.ib`.
     """
@@ -2404,7 +2495,9 @@ class Attribute(object):
         "validator",
         "repr",
         "eq",
+        "eq_key",
         "order",
+        "order_key",
         "hash",
         "init",
         "metadata",
@@ -2430,10 +2523,14 @@ class Attribute(object):
         converter=None,
         kw_only=False,
         eq=None,
+        eq_key=None,
         order=None,
+        order_key=None,
         on_setattr=None,
     ):
-        eq, order = _determine_eq_order(cmp, eq, order, True)
+        eq, eq_key, order, order_key = _determine_attrib_eq_order(
+            cmp, eq_key or eq, order_key or order, True
+        )
 
         # Cache this descriptor here to speed things up later.
         bound_setattr = _obj_setattr.__get__(self, Attribute)
@@ -2445,7 +2542,9 @@ class Attribute(object):
         bound_setattr("validator", validator)
         bound_setattr("repr", repr)
         bound_setattr("eq", eq)
+        bound_setattr("eq_key", eq_key)
         bound_setattr("order", order)
+        bound_setattr("order_key", order_key)
         bound_setattr("hash", hash)
         bound_setattr("init", init)
         bound_setattr("converter", converter)
@@ -2561,7 +2660,9 @@ _a = [
         repr=True,
         cmp=None,
         eq=True,
+        # eq_key=None,
         order=False,
+        # order_key=None,
         hash=(name != "metadata"),
         init=True,
         inherited=False,
@@ -2592,7 +2693,9 @@ class _CountingAttr(object):
         "_default",
         "repr",
         "eq",
+        "eq_key",
         "order",
+        "order_key",
         "hash",
         "init",
         "metadata",
@@ -2613,7 +2716,9 @@ class _CountingAttr(object):
             init=True,
             kw_only=False,
             eq=True,
+            eq_key=None,
             order=False,
+            order_key=None,
             inherited=False,
             on_setattr=None,
         )
@@ -2638,7 +2743,9 @@ class _CountingAttr(object):
             init=True,
             kw_only=False,
             eq=True,
+            eq_key=None,
             order=False,
+            order_key=None,
             inherited=False,
             on_setattr=None,
         ),
@@ -2658,7 +2765,9 @@ class _CountingAttr(object):
         type,
         kw_only,
         eq,
+        eq_key,
         order,
+        order_key,
         on_setattr,
     ):
         _CountingAttr.cls_counter += 1
@@ -2668,7 +2777,9 @@ class _CountingAttr(object):
         self.converter = converter
         self.repr = repr
         self.eq = eq
+        self.eq_key = eq_key
         self.order = order
+        self.order_key = order_key
         self.hash = hash
         self.init = init
         self.metadata = metadata
@@ -2830,7 +2941,7 @@ def make_class(name, attrs, bases=(object,), **attributes_arguments):
     (
         attributes_arguments["eq"],
         attributes_arguments["order"],
-    ) = _determine_eq_order(
+    ) = _determine_attrs_eq_order(
         cmp,
         attributes_arguments.get("eq"),
         attributes_arguments.get("order"),


Test patch:
diff --git a/tests/test_dunders.py b/tests/test_dunders.py
index a34f8f4..3ff4c16 100644
--- a/tests/test_dunders.py
+++ b/tests/test_dunders.py
@@ -36,6 +36,31 @@ OrderCSlots = simple_class(order=True, slots=True)
 ReprC = simple_class(repr=True)
 ReprCSlots = simple_class(repr=True, slots=True)
 
+
+@attr.s(eq=True)
+class EqCallableC(object):
+    a = attr.ib(eq=str.lower, order=False)
+    b = attr.ib(eq=True)
+
+
+@attr.s(eq=True, slots=True)
+class EqCallableCSlots(object):
+    a = attr.ib(eq=str.lower, order=False)
+    b = attr.ib(eq=True)
+
+
+@attr.s(order=True)
+class OrderCallableC(object):
+    a = attr.ib(eq=True, order=str.lower)
+    b = attr.ib(order=True)
+
+
+@attr.s(order=True, slots=True)
+class OrderCallableCSlots(object):
+    a = attr.ib(eq=True, order=str.lower)
+    b = attr.ib(order=True)
+
+
 # HashC is hashable by explicit definition while HashCSlots is hashable
 # implicitly.  The "Cached" versions are the same, except with hash code
 # caching enabled
@@ -106,6 +131,16 @@ class TestEqOrder(object):
         assert cls(1, 2) == cls(1, 2)
         assert not (cls(1, 2) != cls(1, 2))
 
+    @pytest.mark.parametrize("cls", [EqCallableC, EqCallableCSlots])
+    def test_equal_callable(self, cls):
+        """
+        Equal objects are detected as equal.
+        """
+        assert cls("Test", 1) == cls("test", 1)
+        assert cls("Test", 1) != cls("test", 2)
+        assert not (cls("Test", 1) != cls("test", 1))
+        assert not (cls("Test", 1) == cls("test", 2))
+
     @pytest.mark.parametrize("cls", [EqC, EqCSlots])
     def test_unequal_same_class(self, cls):
         """
@@ -114,7 +149,17 @@ class TestEqOrder(object):
         assert cls(1, 2) != cls(2, 1)
         assert not (cls(1, 2) == cls(2, 1))
 
-    @pytest.mark.parametrize("cls", [EqC, EqCSlots])
+    @pytest.mark.parametrize("cls", [EqCallableC, EqCallableCSlots])
+    def test_unequal_same_class_callable(self, cls):
+        """
+        Unequal objects of correct type are detected as unequal.
+        """
+        assert cls("Test", 1) != cls("foo", 2)
+        assert not (cls("Test", 1) == cls("foo", 2))
+
+    @pytest.mark.parametrize(
+        "cls", [EqC, EqCSlots, EqCallableC, EqCallableCSlots]
+    )
     def test_unequal_different_class(self, cls):
         """
         Unequal objects of different type are detected even if their attributes
@@ -140,7 +185,21 @@ class TestEqOrder(object):
         ]:
             assert cls(*a) < cls(*b)
 
-    @pytest.mark.parametrize("cls", [OrderC, OrderCSlots])
+    @pytest.mark.parametrize("cls", [OrderCallableC, OrderCallableCSlots])
+    def test_lt_callable(self, cls):
+        """
+        __lt__ compares objects as tuples of attribute values.
+        """
+        # Note: "A" < "a"
+        for a, b in [
+            (("test1", 1), ("Test1", 2)),
+            (("test0", 1), ("Test1", 1)),
+        ]:
+            assert cls(*a) < cls(*b)
+
+    @pytest.mark.parametrize(
+        "cls", [OrderC, OrderCSlots, OrderCallableC, OrderCallableCSlots]
+    )
     def test_lt_unordable(self, cls):
         """
         __lt__ returns NotImplemented if classes differ.
@@ -161,7 +220,23 @@ class TestEqOrder(object):
         ]:
             assert cls(*a) <= cls(*b)
 
-    @pytest.mark.parametrize("cls", [OrderC, OrderCSlots])
+    @pytest.mark.parametrize("cls", [OrderCallableC, OrderCallableCSlots])
+    def test_le_callable(self, cls):
+        """
+        __le__ compares objects as tuples of attribute values.
+        """
+        # Note: "A" < "a"
+        for a, b in [
+            (("test1", 1), ("Test1", 1)),
+            (("test1", 1), ("Test1", 2)),
+            (("test0", 1), ("Test1", 1)),
+            (("test0", 2), ("Test1", 1)),
+        ]:
+            assert cls(*a) <= cls(*b)
+
+    @pytest.mark.parametrize(
+        "cls", [OrderC, OrderCSlots, OrderCallableC, OrderCallableCSlots]
+    )
     def test_le_unordable(self, cls):
         """
         __le__ returns NotImplemented if classes differ.
@@ -180,7 +255,21 @@ class TestEqOrder(object):
         ]:
             assert cls(*a) > cls(*b)
 
-    @pytest.mark.parametrize("cls", [OrderC, OrderCSlots])
+    @pytest.mark.parametrize("cls", [OrderCallableC, OrderCallableCSlots])
+    def test_gt_callable(self, cls):
+        """
+        __gt__ compares objects as tuples of attribute values.
+        """
+        # Note: "A" < "a"
+        for a, b in [
+            (("Test1", 2), ("test1", 1)),
+            (("Test1", 1), ("test0", 1)),
+        ]:
+            assert cls(*a) > cls(*b)
+
+    @pytest.mark.parametrize(
+        "cls", [OrderC, OrderCSlots, OrderCallableC, OrderCallableCSlots]
+    )
     def test_gt_unordable(self, cls):
         """
         __gt__ returns NotImplemented if classes differ.
@@ -201,7 +290,23 @@ class TestEqOrder(object):
         ]:
             assert cls(*a) >= cls(*b)
 
-    @pytest.mark.parametrize("cls", [OrderC, OrderCSlots])
+    @pytest.mark.parametrize("cls", [OrderCallableC, OrderCallableCSlots])
+    def test_ge_callable(self, cls):
+        """
+        __ge__ compares objects as tuples of attribute values.
+        """
+        # Note: "A" < "a"
+        for a, b in [
+            (("Test1", 1), ("test1", 1)),
+            (("Test1", 2), ("test1", 1)),
+            (("Test1", 1), ("test0", 1)),
+            (("Test1", 1), ("test0", 2)),
+        ]:
+            assert cls(*a) >= cls(*b)
+
+    @pytest.mark.parametrize(
+        "cls", [OrderC, OrderCSlots, OrderCallableC, OrderCallableCSlots]
+    )
     def test_ge_unordable(self, cls):
         """
         __ge__ returns NotImplemented if classes differ.
diff --git a/tests/test_make.py b/tests/test_make.py
index 4ba413a..c9085c7 100644
--- a/tests/test_make.py
+++ b/tests/test_make.py
@@ -29,7 +29,8 @@ from attr._make import (
     _Attributes,
     _ClassBuilder,
     _CountingAttr,
-    _determine_eq_order,
+    _determine_attrib_eq_order,
+    _determine_attrs_eq_order,
     _determine_whether_to_implement,
     _transform_attrs,
     and_,
@@ -222,7 +223,8 @@ class TestTransformAttrs(object):
             "No mandatory attributes allowed after an attribute with a "
             "default value or factory.  Attribute in question: Attribute"
             "(name='y', default=NOTHING, validator=None, repr=True, "
-            "eq=True, order=True, hash=None, init=True, "
+            "eq=True, eq_key=None, order=True, order_key=None, "
+            "hash=None, init=True, "
             "metadata=mappingproxy({}), type=None, converter=None, "
             "kw_only=False, inherited=False, on_setattr=None)",
         ) == e.value.args
@@ -1809,19 +1811,19 @@ class TestMakeOrder:
                 a > b
 
 
-class TestDetermineEqOrder(object):
+class TestDetermineAttrsEqOrder(object):
     def test_default(self):
         """
         If all are set to None, set both eq and order to the passed default.
         """
-        assert (42, 42) == _determine_eq_order(None, None, None, 42)
+        assert (42, 42) == _determine_attrs_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, True)
+        assert (eq, eq) == _determine_attrs_eq_order(None, eq, None, True)
 
     def test_order_without_eq(self):
         """
@@ -1830,7 +1832,7 @@ class TestDetermineEqOrder(object):
         with pytest.raises(
             ValueError, match="`order` can only be True if `eq` is True too."
         ):
-            _determine_eq_order(None, False, True, True)
+            _determine_attrs_eq_order(None, False, True, True)
 
     @given(cmp=booleans(), eq=optional_bool, order=optional_bool)
     def test_mix(self, cmp, eq, order):
@@ -1842,7 +1844,7 @@ class TestDetermineEqOrder(object):
         with pytest.raises(
             ValueError, match="Don't mix `cmp` with `eq' and `order`."
         ):
-            _determine_eq_order(cmp, eq, order, True)
+            _determine_attrs_eq_order(cmp, eq, order, True)
 
     def test_cmp_deprecated(self):
         """
@@ -1863,6 +1865,133 @@ class TestDetermineEqOrder(object):
         )
 
 
+class TestDetermineAttribEqOrder(object):
+    def test_default(self):
+        """
+        If all are set to None, set both eq and order to the passed default.
+        """
+        assert (42, None, 42, None) == _determine_attrib_eq_order(
+            None, None, None, 42
+        )
+
+    def test_eq_callable_order_boolean(self):
+        """
+        eq=callable or order=callable need to transformed into eq/eq_key
+        or order/order_key.
+        """
+        assert (True, str.lower, False, None) == _determine_attrib_eq_order(
+            None, str.lower, False, True
+        )
+
+    def test_eq_callable_order_callable(self):
+        """
+        eq=callable or order=callable need to transformed into eq/eq_key
+        or order/order_key.
+        """
+        assert (True, str.lower, True, abs) == _determine_attrib_eq_order(
+            None, str.lower, abs, True
+        )
+
+    def test_eq_boolean_order_callable(self):
+        """
+        eq=callable or order=callable need to transformed into eq/eq_key
+        or order/order_key.
+        """
+        assert (True, None, True, str.lower) == _determine_attrib_eq_order(
+            None, True, str.lower, True
+        )
+
+    @pytest.mark.parametrize("eq", [True, False])
+    def test_order_mirrors_eq_by_default(self, eq):
+        """
+        If order is None, it mirrors eq.
+        """
+        assert (eq, None, eq, None) == _determine_attrib_eq_order(
+            None, eq, None, True
+        )
+
+    def test_order_missing_and_custom_eq(self):
+        """
+        If eq is customized and order is missing, order mirrors eq
+        but a warning is raised.
+        """
+        with pytest.warns(None) as wr:
+
+            assert (
+                True,
+                str.lower,
+                True,
+                str.lower,
+            ) == _determine_attrib_eq_order(None, str.lower, None, True)
+
+        (w,) = wr.list
+
+        assert (
+            "You have customized the behaviour of `eq` but not of `order`.  "
+            "This is probably a bug." == w.message.args[0]
+        )
+
+    def test_order_without_eq(self):
+        """
+        eq=False, order=True raises a meaningful ValueError.
+        """
+        with pytest.raises(
+            ValueError, match="`order` can only be True if `eq` is True too."
+        ):
+            _determine_attrib_eq_order(None, False, True, True)
+
+    @given(cmp=booleans(), eq=optional_bool, order=optional_bool)
+    def test_mix(self, cmp, eq, order):
+        """
+        If cmp is not None, eq and order must be None and vice versa.
+        """
+        assume(eq is not None or order is not None)
+
+        with pytest.raises(
+            ValueError, match="Don't mix `cmp` with `eq' and `order`."
+        ):
+            _determine_attrib_eq_order(cmp, eq, order, True)
+
+    def test_boolean_cmp_deprecated(self):
+        """
+        Passing a cmp that is not None raises a DeprecationWarning.
+        """
+        with pytest.deprecated_call() as dc:
+
+            assert (True, None, True, None) == _determine_attrib_eq_order(
+                True, None, None, True
+            )
+
+        (w,) = dc.list
+
+        assert (
+            "The usage of `cmp` is deprecated and will be removed on or after "
+            "2021-06-01.  Please use `eq` and `order` instead."
+            == w.message.args[0]
+        )
+
+    def test_callable_cmp_deprecated(self):
+        """
+        Passing a cmp that is not None raises a DeprecationWarning.
+        """
+        with pytest.deprecated_call() as dc:
+
+            assert (
+                True,
+                str.lower,
+                True,
+                str.lower,
+            ) == _determine_attrib_eq_order(str.lower, None, None, True)
+
+        (w,) = dc.list
+
+        assert (
+            "The usage of `cmp` is deprecated and will be removed on or after "
+            "2021-06-01.  Please use `eq` and `order` instead."
+            == w.message.args[0]
+        )
+
+
 class TestDocs:
     @pytest.mark.parametrize(
         "meth_name",