Add missing default implementations of __le__ and __ge__

The following mathematical relationships between comparison relations (=, ≠, <, >, ≤ and ≥) are always valid and therefore implemented by default in Python (except for the 2 union relationships, which is the reason of this post):

  • 2 complementary relationships: “= and ≠ are each other’s complement”;
  • 6 converse relationships*: “= is the converse of itself”, “≠ is the converse of itself”, “< and > are each other’s converse”, and “≤ and ≥ are each other’s converse”;
  • 2 union relationships: “≤ is the union < and =” and “≥ is the union of > and =”.

The following relationships between comparison relations are only valid for total orders and therefore not implemented by default in Python (but users can conveniently implement them when they are valid with the class decorator functools.total_ordering provided by the Python standard library):

  • 4 complementary relationships: “< and ≥ are each other’s complement” and “> and ≤ are each other’s complement”.

So Python is only lacking the 2 union relationships above (“≤ is the union < and =” and “≥ is the union of > and =”), which seems arbitrary.

It should provide a default implementation of __le__ in terms of __lt__ and __eq__, and a default implementation of __ge__ in terms of __gt__ and __eq__, for instance like these (but probably in C for performance, like __ne__):

def __le__(self, other):
    result_1 = self.__lt__(other)
    result_2 = self.__eq__(other)
    if result_1 is not NotImplemented and result_2 is not NotImplemented:
        return result_1 or result_2
    return NotImplemented

def __ge__(self, other):
    result_1 = self.__gt__(other)
    result_2 = self.__eq__(other)
    if result_1 is not NotImplemented and result_2 is not NotImplemented:
        return result_1 or result_2
    return NotImplemented

The 2 union relationships are always valid so these default implementations would free users from having to provide them all the time (like here).

Here is the paragraph of the Python documentation which states explicitly that the 2 union relationships are not currently implemented by default (bold emphasis mine):

By default, __ne__() delegates to __eq__() and inverts the result
unless it is NotImplemented. There are no other implied
relationships among the comparison operators, for example, the truth
of (x<y or x==y) does not imply x<=y.


* Converse relationships are implemented in Python through the NotImplemented protocol.

It’s not entirely arbitrary. The two union relationships are the only ones that would have to be implemented by making two calls.

Note however that your version always makes both calls, whereas an optimal version should return a result if the outcome can be determined from the first call alone, e.g. if x<y is true, then x<=y can return True without testing x==y.

One concern I would have is deciding which of the two calls (e.g. < or =) should be made first.

Another concern is that if you are comparing two sequences using “lexicographic” ordering, your implementation would incur two loops, whereas an optimal version loops once until it finds a pair of elements that are not equal. Something like your version (even if optimized to skip the second call if possible) would encourage lazy developers to produce inefficient code.

1 Like

Thanks for your insight @guido, you raised important questions.

About the first concern, an argument for calling __eq__ before __lt__ or __gt__ that comes to mind is that __eq__ is more frequently implemented than __lt__ or __gt__. So the optimised default implementations of __le__ and __ge__ would look like these:

def __le__(self, other):
    result_1 = self.__eq__(other)
    if result_1 is True:
        return True
    result_2 = self.__lt__(other)
    if result_2 is True:
        return True
    if result_1 is False and result_2 is False:
        return False
    return NotImplemented

def __ge__(self, other):
    result_1 = self.__eq__(other)
    if result_1 is True:
        return True
    result_2 = self.__gt__(other)
    if result_2 is True:
        return True
    if result_1 is False and result_2 is False:
        return False
    return NotImplemented

About the second concern, I agree that these default implementations would not be optimised for all cases like lexicographic ordering. Now I am wondering if Python already provides reasonable default implementations in other parts of the language or if that would be the first occurrence. And if the benefits (not having to implement them every time) of default implementations would outweigh their drawbacks (lazy programmers always relying on them instead of optimising when necessary). Do you have other default implementation examples in mind? (I cannot think of one right now.)

__eq__ is actually always implemented in some form due to being made available on object (same with __ne__().

You also cannot guarantee a return value of True or False as the return values can be anything. You also need to take into consideration NotImplemented.

Lastly, my reading of the data model for rich comparisons does not suggest anything other than __eq__ and __ne__ being expected to be implemented on object (I think everything else just automatically returns NotImplemented). I believe the existence of the other methods on object are a side-effect of how Python handles things at the C level – there’s a single C function to implement all comparison functions – and thus can’t differentiate nor guarantee that the other methods do exist.

Now that doesn’t mean this couldn’t be done for the syntactic operations, but if this is going to be expected of the methods then the data model may need expanding to also say that object defines all the other methods as well (or at least be clear about that if that’s already the expectation).

I personally think this should not be changed. It would likely cause a mountain of subtle backward incompatibilities, for very little benefit.

1 Like

By “implemented” I actually meant by the user (overridden).

The given optimised default implementations of __le__ and __ge__ do take into consideration NotImplemented, by returning NotImplemented when there is no answer to the disjunction of result_1 and result_2, that is when (result_1, result_2) is (NotImplemented, NotImplemented) or (False, NotImplemented) or (NotImplemented, False).

As for the cases when __eq__ has been overridden to return a non-Boolean value, the given optimised default implementations of __le__ and __ge__ should of course be also overridden since, like __ne__ which is also defined in terms of __eq__ and therefore should be overridden as well, they cannot anticipate the user semantics.

I have the same understanding, except for __eq__ which I think also returns NotImplemented by default. Since as explained in the Python documentation:

By default, __ne__() delegates to __eq__() and inverts the result
unless it is NotImplemented. There are no other implied
relationships among the comparison operators, for example, the truth
of (x<y or x==y) does not imply x<=y.

Which can be translated into Python code as follows:

def __eq__(self, other):
    return NotImplemented

def __ne__(self, other):
    result = self.__eq__(other)
    if result is not NotImplemented:
        return not result
    return NotImplemented

def __lt__(self, other):
    return NotImplemented

def __gt__(self, other):
    return NotImplemented

def __le__(self, other):
    return NotImplemented

def __ge__(self, other):
    return NotImplemented

But those are the default comparison method implementations. So the 2 complementary relationships (“= and ≠ are each other’s complement”) are implemented at the method level.

Now my understanding of how the comparison operator implementations (in C) delegate to the previous methods is as follows (translated into Python code with the names eq for ==, ne for !=, lt for <, gt for >, le for <= and ge for >=):

def eq(left, right):
    if type(left) != type(right) and isinstance(right, type(left)):
        result = right.__eq__(left)
        if result is NotImplemented:
            result = left.__eq__(right)
    else:
        result = left.__eq__(right)
        if result is NotImplemented:
            result = right.__eq__(left)
    if result is NotImplemented:
        result = left is right
    return result

def ne(left, right):
    if type(left) != type(right) and isinstance(right, type(left)):
        result = right.__ne__(left)
        if result is NotImplemented:
            result = left.__ne__(right)
    else:
        result = left.__ne__(right)
        if result is NotImplemented:
            result = right.__ne__(left)
    if result is NotImplemented:
        result = left is not right
    return result

def lt(left, right):
    if type(left) != type(right) and isinstance(right, type(left)):
        result = right.__gt__(left)
        if result is NotImplemented:
            result = left.__lt__(right)
    else:
        result = left.__lt__(right)
        if result is NotImplemented:
            result = right.__gt__(left)
    if result is NotImplemented:
        raise TypeError(
            f"'<' not supported between instances of '{type(left).__name__}' "
            f"and '{type(right).__name__}'"
        )
    return result

def gt(left, right):
    if type(left) != type(right) and isinstance(right, type(left)):
        result = right.__lt__(left)
        if result is NotImplemented:
            result = left.__gt__(right)
    else:
        result = left.__gt__(right)
        if result is NotImplemented:
            result = right.__lt__(left)
    if result is NotImplemented:
        raise TypeError(
            f"'>' not supported between instances of '{type(left).__name__}' "
            f"and '{type(right).__name__}'"
        )
    return result

def le(left, right):
    if type(left) != type(right) and isinstance(right, type(left)):
        result = right.__ge__(left)
        if result is NotImplemented:
            result = left.__le__(right)
    else:
        result = left.__le__(right)
        if result is NotImplemented:
            result = right.__ge__(left)
    if result is NotImplemented:
        raise TypeError(
            f"'<=' not supported between instances of '{type(left).__name__}' "
            f"and '{type(right).__name__}'"
        )
    return result

def ge(left, right):
    if type(left) != type(right) and isinstance(right, type(left)):
        result = right.__le__(left)
        if result is NotImplemented:
            result = left.__ge__(right)
    else:
        result = left.__ge__(right)
        if result is NotImplemented:
            result = right.__le__(left)
    if result is NotImplemented:
        raise TypeError(
            f"'>=' not supported between instances of '{type(left).__name__}' "
            f"and '{type(right).__name__}'"
        )
    return result

So @brettcannon to me the fall back of == (equality comparison) to is (identity comparison) is not implemented at the method level (__eq__) but at the operator level (==, which I named eq in the last snippet).

And the 6 converse relationships (“= is the converse of itself”, “≠ is the converse of itself”, “< and > are each other’s converse”, and “≤ and ≥ are each other’s converse”) are also implemented at the operator level (cf. in each operator the fallback on the converse method of the other operand when the method of the first operand returns NotImplemented in the last snippet).

By the way, in addition to the missing implementations at the method level (__le__ and __ge__) of the 2 union relationships (“≤ is the union < and =” and “≥ is the union of > and =”) that we have been discussing, I realised this afternoon that another thing is missing. In the Python documentation, the reflexivity argument is given for motivating the default implementation of the comparison operator == (at the operator level) as an identity comparison (cf. the last snippet where ==, which I named eq, delegates to is as a last resort):

The default behavior for equality comparison (== and !=) is based on the identity of the objects. Hence, equality comparison of instances with the same identity results in equality, and equality comparison of instances with different identities results in inequality. A motivation for this default behavior is the desire that all objects should be reflexive (i.e. x is y implies x == y).

A default order comparison ( <, >, <=, and >=) is not provided; an attempt raises TypeError. A motivation for this default behavior is the lack of a similar invariant as for equality [incorrect for <=, and >= which are also reflexive!].

But this reflexivity argument also applies to the comparison operators <= and >= since they are also reflexive (as they are partial order, that is reflexive, antisymmetric and transitive)—but it does not apply to < and > since they are not reflexive (as they are strict order, that is irreflexive, antisymmetric and transitive) nor to != since it is not reflexive (it is only symmetric). Indeed, x is y implies x <= y and x >= y.

This means that instead of raising a TypeError when the converse methods of both operands return NotImplemented, <= and >= (named le and ge in the last snippet) should also delegate to is as a last resort (like ==, named eq in the last snippet, already does):

def le(left, right):
    if type(left) != type(right) and isinstance(right, type(left)):
        result = right.__ge__(left)
        if result is NotImplemented:
            result = left.__le__(right)
    else:
        result = left.__le__(right)
        if result is NotImplemented:
            result = right.__ge__(left)
    if result is NotImplemented:
        result = left is right  # instead of `raise TypeError([…])`
    return result

def ge(left, right):
    if type(left) != type(right) and isinstance(right, type(left)):
        result = right.__le__(left)
        if result is NotImplemented:
            result = left.__ge__(right)
    else:
        result = left.__ge__(right)
        if result is NotImplemented:
            result = right.__le__(left)
    if result is NotImplemented:
        result = left is right  # instead of `raise TypeError([…])`
    return result

I should mention I am in the middle of writing a blog post that outlines how Python does rich comparisons, so if you want links to the C source and example Python code you can wait for that to come out.

It’s actually implemented at both the operator level and the object level.

Correct.

I agree; I think practicality beats purity in this instance.

That will be awesome as I haven’t read the C source code myself (I deduced the above model for comparison operations only from the documentation and some testing). Please notify me when you publish your article, I have been really enjoying reading your Snarky blog. The funny thing is that I was sure you were preparing an article on this subject after reading you article on attribute access. If I remember well, you are writing a series on the Python data model as part of a project on a Rust implementation of Python, aren’t you?

Thanks for the links to the C source code! So what is the point of the implementation of the identity fallback at the object level since it is already implemented at the operator level?

Thanks for your feedback on the 2 missing union relationships<= is the union < and =” and “>= is the union of > and =”.

And do you also have the same opinion about the missing identity fallback (instead of raising a TypeError) for <= and >= (since they are reflexive like ==, that is x is y implies x <= y and x >= y) that I mentioned in my last comment?

I’ll try to remember, but you can also subscribe to the RSS/Atom feed or sign up for the newsletter if you want to be sure to find out.

Thanks! Glad you have been enjoying the posts.

It’s primarily to document what the bare minimum Python syntax that must be implemented is, and implementing everything else in Python code (e.g. functions for rich comparisons, but probably can’t get rid of if). Basically it’s what does the compiler really need to know about compared to the parser (via AST transforms)?

So what if you called object.__eq__(a, b) instead of a == b? You could just blindly return NotImplemented in the former case, but it’s also very cheap to implement the appropriate logic, so why not? Pragmatically, when implementing object.__eq__ and __ne__ is pretty small is that simple then it just makes sense to have it implemented anyway.

Oh I see, thanks! By the way, is there any particular reason you wrote:

return id(self) == id(other) or NotImplemented

instead of just:

return self is other or NotImplemented

in your object.__eq__ implementation (I thought it was equivalent)? Same question for:

result = not bool(result)

instead of just:

result = not result

in your object.__ne__ implementation.

And what is your opinion about having the same identity fallback than == for <= and >= (since they are reflexive like ==, that is x is y implies x <= y and x >= y) at the operator level, and now that we are talking about it, also having the same identity fallback than __eq__ at the method level for __le__ and __ge__?

Yes, because I have not implemented is yet in my project. :wink: Remember the whole point is to try and find what syntax or semantics must be supported in Python. Same goes for the explicit call to bool(); I have not analyzed the not keyword to make sure it is actually calling bool() underneath the hood.

It doesn’t work the same because you can’t provide default implementations that won’t just end up being an infinite loop trying to call the inverse of each other. With __eq__ and __ne__ you can guarantee that at least __eq__ will return something.

Alright. :slight_smile:

I am not sure we are talking about the same thing. I should have been more specific, let me explain better.

At the operator level (<= and >=), I meant (translated into Python, with le for <= and ge for >=):

def le(left, right):
    if type(left) != type(right) and isinstance(right, type(left)):
        result = right.__ge__(left)
        if result is NotImplemented:
            result = left.__le__(right)
    else:
        result = left.__le__(right)
        if result is NotImplemented:
            result = right.__ge__(left)
    if result is NotImplemented:
        result = left is right  # instead of `raise TypeError([…])`
    return result

and

def ge(left, right):
    if type(left) != type(right) and isinstance(right, type(left)):
        result = right.__le__(left)
        if result is NotImplemented:
            result = left.__ge__(right)
    else:
        result = left.__ge__(right)
        if result is NotImplemented:
            result = right.__le__(left)
    if result is NotImplemented:
        result = left is right  # instead of `raise TypeError([…])`
    return result

At the method level (__le__ and __ge__), I meant (translated into Python):

def __le__(self, other):
    return self is other or NotImplemented  # instead of `return NotImplemented`

and

def __ge__(self, other):
    return self is other or NotImplemented  # instead of `return NotImplemented`

All these default identity fallbacks would be valid since x is y implies x <= y, x >= y, x.__le__(y) and x.__ge__(y). That is if one evaluates x <= x, x >= x, x.__le__(x) or x.__ge__(x), he expects Python to return True, not to raise TypeError for the first two and return NotImplemented for the last two like currently.

In other words, the binary relations ≤ and ≥ are reflexive but this property is not currently taken into account by Python, which only takes into account the reflexivity of the binary relation =. Note that these default identity fallbacks are orthogonal to the 2 union relationships that we have been initially discussing in this thread. That is why I then asked @guido his opinion on this different subject (he only gave his opinion on the 2 union relationships so far, raising backward incompatibility concerns).

Remark. — Just for completeness, if we were to add to Python both the default identity fallback (at the operator and method levels) and the 2 union relationships (at the method level with @guido’s optimised version)—which would be the ideal—, the previous __le__ and __ge__ implementations in this answer would become:

def __le__(self, other):
    if self is other:
        return True
    result_1 = self.__eq__(other)
    if result_1 is True:
        return True
    result_2 = self.__lt__(other)
    if result_2 is True:
        return True
    if result_1 is False and result_2 is False:
        return False
    return NotImplemented

and

def __ge__(self, other):
    if self is other:
        return True
    result_1 = self.__eq__(other)
    if result_1 is True:
        return True
    result_2 = self.__gt__(other)
    if result_2 is True:
        return True
    if result_1 is False and result_2 is False:
        return False
    return NotImplemented

I still disagree with the idea. I think your assertion that a developer “expects Python to return True” doesn’t universally hold as I certainly don’t expect it to work as the > will potentially raise TypeError.

Anyway, I appreciate the ideas but I am sticking by my original belief that practicality beats purity here and changing has way too high of a potential backwards-compatibility cost compared to what small benefit we may get from this change.

So far we have been talking about the reflexibility property of:

  • == (x == x evaluates to True);
  • <= (x <= x should evaluate to True instead of raising TypeError);
  • >= (x >= x should evaluate to True instead of raising TypeError).

But today I have just realised that another thing is missing, we have overlooked the irreflexibility property of:

  • != (x != x evaluates to False);
  • < (x < x should evaluate to False instead of raising TypeError);
  • > (x > x should evaluate to False instead of raising TypeError).

Thus not only the reflexive operators <= and >= (and their associated methods __le__ and __ge__) but also the irreflexive operators < and > (and their associated methods __lt__ and __gt__) do not behave as I would have expected by default: when the operands are identical, the former do not return True while the latter do not return False.

Only the reflexive operator == (and its associated method __eq__) and irreflexive operator != (and its associated method __ne__) behave as I would have expected by default: when the operands are identical, the former returns True while the latter returns False. And Python goes even further: when the operands are not identical, the former returns False and the latter returns True, which this time is arbitrary since it is not a consequence of the properties of the operators. It could have raised TypeError as well.

I agree with you, there are several possible behaviours (I think there are three) for the default implementations of the comparison operators:

  1. Never answer (by always raising TypeError).
  2. Answer only non-arbitrarily (by leveraging the reflexivity and irreflexivity properties when possible, otherwise by raising TypeError).
  3. Always answer (by leveraging the reflexivity and irreflexivity properties when possible, otherwise arbitrarily).

But all comparison operators should follow the same behaviour. The problem is that it is currently not the case, Python does a mix: == and != follow behaviour 3, while <, >, <= and >= follow behaviour 1.

Just an FYI that I don’t have any more time to dedicate to this topic. I published https://snarky.ca/unravelling-rich-comparison-operators/ and I personally don’t have any interest in proposing any changes to Python’s semantics around comparisons.

Thanks for the article, it was a very informative read. This paragraph especially caught my attention as it is relevant to our current discussion:

Back in the Python 2 days, you could compare any objects using any comparison operator and you would get a result. But those semantics led to odd cases where bad data in a list, for instance, would still be sortable. By making only == and != always succeed (unless their special methods raise an exception), you prevent such unexpected interactions between objects and having silent errors pass (although some people wish even this special case for == and != didn’t exist).

I was not aware that comparison operators in Python 2 never raised TypeError by default (if you have a link to the debate that introduced the change in Python 3, please share it, I am intrigued). That is interesting as it provides an argument in favor of behaviours 1 or 2 over behaviour 3 in my last answer. Behaviour 1 has been adopted for <, >, <= and >= in Python 3 but behaviour 3 has been kept for == and !=, which I deplore (so does “some people” refer to me?). Behaviour 2 (for all operators) seems to be the superior solution as it keeps the best of both worlds (behaviours 1 and 3): answer but only when you are sure (that is when the answer is a consequence of the operator’s reflexiblility/irreflexibility property).

Initially I had this hope, but since @guido said it might lead to backward incompatibilities without enough benefits, I think I have lost this hope (though he was only referring to adding the union relationships that was originally discussed, not to adding the reflexibility/irreflexibilty properties that was lately discussed, but I assume he has the same opinion on that). And he is probably right, I don’t see myself a lot of benefits besides enforcing the only 2 missing mathematical features of the default comparison operators: their union relationships and reflexivity/irreflexivity properties (all their other mathematical relationships and properties are already enforced by default, which is already quite good).

Géry Ogam wrote:

“All comparison operators should follow the same behaviour.”

Why?

"The problem is that it is currently not the case, Python does a mix:

== and != follow 3, while <, >, <= and >= follow 1."

How is it a problem? What code is broken by the current design, or made

unnecessarily and significantly more difficult?

For context, behaviour 3 is that (in)equality operators have default

implementations somewhat close to:

def __eq__(self, other): return self is other

def __ne__(self, other): return self is not other

while behaviour 3 is that the other comparisons have defaults

somewhat like:

def __lt__(self, other): return NotImplemented

When both operands return NotImplemented, TypeError is raised, so

behaviour 3 is almost equivalent to not implementing the dunder method

at all. The difference is that:

hasattr(obj, '__lt__')

will return True even if obj does not actually support the < operator,

which is mildly inconvenient for “Look Before You Leap” introspection.

For reference, here after is how I see now the implementations (given in Python for clarity instead of C like the actual CPython implementations) of the comparison operators (==, !=, <, >, <= and >=) and their associated default special comparison methods (__eq__, __ne__, __lt__, __gt__, __le__ and __ge__) that would add the missing mathematical features from the theory of binary relations that we have been discussing in this thread with @guido and @brettcannon, which are:

  • the union relationships ≤ is the union of < and =, and ≥ is the union of > and =;
  • the irreflexivity properties of < and >;
  • the reflexivity properties of ≤ and ≥.

Following these implementations is a test suite for which every assertion fails with the current implementations but succeeds with the new implementations. The aim is to show the differences in semantics as well as the possible benefits and drawbacks of the new implementations.

Implementations of the comparison operators

The differences between the current and new implementations are indicated. The name eq stands for ==, ne for !=, lt for <, gt for >, le for <= and ge for >=.

def eq(left, right):
    if type(left) != type(right) and isinstance(right, type(left)):
        result = right.__eq__(left)
        if result is NotImplemented:
            result = left.__eq__(right)
    else:
        result = left.__eq__(right)
        if result is NotImplemented:
            result = right.__eq__(left)
    if result is NotImplemented:
        # Current implementation: return left is right
        # New implementation:
        raise TypeError(
            "'==' not supported between instances of "
            f"'{type(left).__name__}' and '{type(right).__name__}'"
        )
    return result
def ne(left, right):
    if type(left) != type(right) and isinstance(right, type(left)):
        result = right.__ne__(left)
        if result is NotImplemented:
            result = left.__ne__(right)
    else:
        result = left.__ne__(right)
        if result is NotImplemented:
            result = right.__ne__(left)
    if result is NotImplemented:
        # Current implementation: return left is not right
        # New implementation:
        raise TypeError(
            "'!=' not supported between instances of "
            f"'{type(left).__name__}' and '{type(right).__name__}'"
        )
    return result
def lt(left, right):
    if type(left) != type(right) and isinstance(right, type(left)):
        result = right.__gt__(left)
        if result is NotImplemented:
            result = left.__lt__(right)
    else:
        result = left.__lt__(right)
        if result is NotImplemented:
            result = right.__gt__(left)
    if result is NotImplemented:
        raise TypeError(
            "'<' not supported between instances of "
            f"'{type(left).__name__}' and '{type(right).__name__}'"
        )
    return result
def gt(left, right):
    if type(left) != type(right) and isinstance(right, type(left)):
        result = right.__lt__(left)
        if result is NotImplemented:
            result = left.__gt__(right)
    else:
        result = left.__gt__(right)
        if result is NotImplemented:
            result = right.__lt__(left)
    if result is NotImplemented:
        raise TypeError(
            "'>' not supported between instances of "
            f"'{type(left).__name__}' and '{type(right).__name__}'"
        )
    return result
def le(left, right):
    if type(left) != type(right) and isinstance(right, type(left)):
        result = right.__ge__(left)
        if result is NotImplemented:
            result = left.__le__(right)
    else:
        result = left.__le__(right)
        if result is NotImplemented:
            result = right.__ge__(left)
    if result is NotImplemented:
        raise TypeError(
            "'<=' not supported between instances of "
            f"'{type(left).__name__}' and '{type(right).__name__}'"
        )
    return result
def ge(left, right):
    if type(left) != type(right) and isinstance(right, type(left)):
        result = right.__le__(left)
        if result is NotImplemented:
            result = left.__ge__(right)
    else:
        result = left.__ge__(right)
        if result is NotImplemented:
            result = right.__le__(left)
    if result is NotImplemented:
        raise TypeError(
            "'>=' not supported between instances of "
            f"'{type(left).__name__}' and '{type(right).__name__}'"
        )
    return result

Implementations of the default special comparison methods

The differences between the current and new implementations are indicated.

def __eq__(self, other):
    return self is other or NotImplemented  # reflexivity property
def __ne__(self, other):
    result = self.__eq__(other)
    if result is not NotImplemented:
        return not result
    return NotImplemented
def __lt__(self, other):
    # Current implementation: return NotImplemented
    # New implementation:
    return self is not other and NotImplemented  # irreflexivity property
def __gt__(self, other):
    # Current implementation: return NotImplemented
    # New implementation:
    return self is not other and NotImplemented  # irreflexivity property
def __le__(self, other):
    # Current implementation: return NotImplemented
    # New implementation:
    result_1 = self.__eq__(other)
    if result_1 is True:
        return True  # union relationship
    result_2 = self.__lt__(other)
    if result_2 is True:
        return True  # union relationship
    if result_1 is False and result_2 is False:
        return False  # union relationship
    return NotImplemented
def __ge__(self, other):
    # Current implementation: return NotImplemented
    # New implementation:
    result_1 = self.__eq__(other)
    if result_1 is True:
        return True  # union relationship
    result_2 = self.__gt__(other)
    if result_2 is True:
        return True  # union relationship
    if result_1 is False and result_2 is False:
        return False  # union relationship
    return NotImplemented

Benefits and drawbacks of the new implementations

For the union relationships:

class X:
    def __init__(self, attr):
        self.attr = attr
    def __eq__(self, other):
        if isinstance(other, __class__):
            return self.attr == other.attr
        return NotImplemented
    def __lt__(self, other):
        if isinstance(other, __class__):
            return self.attr < other.attr
        return NotImplemented
    def __gt__(self, other):
        if isinstance(other, __class__):
            return self.attr > other.attr
        return NotImplemented

x, y = X(3), X(3)

assert x <= y is True  # instead of raising a `TypeError`
assert x >= y is True  # instead of raising a `TypeError`

assert x.__le__(y) is True  # instead of returning `NotImplemented`
assert x.__ge__(y) is True  # instead of returning `NotImplemented`
  • Benefits: no need to override __le__ and __ge__ anymore.
  • Drawbacks: @guido raised concerns about possible backward compatibility issues.

For the irreflexivity properties:

class X:
    pass

x, y = X(), X()

assert x < x is False  # instead of raising a `TypeError`
assert x > x is False  # instead of raising a `TypeError`

assert x.__lt__(x) is False  # instead of returning `NotImplemented`
assert x.__gt__(x) is False  # instead of returning `NotImplemented`

try:
    x != y
except TypeError:
    pass  # instead of returning `True`
else:
    assert False
  • Benefits: <, >, __lt__ and __gt__ return the only possible result for identical objects, and != does not return an arbitrary result for non-identical objects (and consequently the user is fully in control of the behaviour of != by overriding __ne__ since != raises a TypeError instead of returning an arbitrary fallback when both __ne__ return NotImplemented).
  • Drawbacks: [to be determined].

For the reflexivity properties:

class X:
    pass

x, y = X(), X()

assert x <= x is True  # instead of raising a `TypeError`
assert x >= x is True  # instead of raising a `TypeError`

assert x.__le__(x) is True  # instead of returning `NotImplemented`
assert x.__ge__(x) is True  # instead of returning `NotImplemented`

try:
    x == y
except TypeError:
    pass  # instead of returning `False`
else:
    assert False
  • Benefits: <=, >=, __le__ and __ge__ return the only possible result for identical objects, and == does not return an arbitrary result for non-identical objects (and consequently the user is fully in control of the behaviour of == by overriding __eq__ since == raises a TypeError instead of returning an arbitrary fallback when both __eq__ return NotImplemented).
  • Drawbacks: [to be determined].