Search code examples
pythongenericspython-typingmypy

Modifying rich comparison TypeError exception message for Generic Types


I have the following code where I create a generic type that supports comparison. When I compare different types, the code yields an exception (as expected). However, I want to modify the exception's message to make it more transparent.

Here is the code as it stands.

import abc
import typing

class Comparable(typing.Protocol):
    """A simple protocol to signal to TypeVar that each 
        value will have a less than (lt) dunder available."""

    @abc.abstractmethod
    def __lt__(self, other: typing.Any, /) -> bool:
        raise NotImplementedError

# each value that will be assigned to T will be 'Comparable', 
# i.e., meets Comparable's interface
T = typing.TypeVar("T", bound=Comparable)


class Node(typing.Generic[T]):
    """Acts as a wrapper around any value.
    This is to show in code the issue I am trying to fix"""

    def __init__(self, value: T) -> None:
        self.value = value

    def __lt__(self, __other: typing.Union[T, Node[T]]) -> bool:
        """Implements support for the '<' operator"""
        try:
            if isinstance(__other, Node):
                return self.value < __other.value
            return self.value < __other
        except TypeError:
            return NotImplemented

The code above works as expected, and MyPy is happy. Types are inferred when an instance of Node is created with some value, and Node[type] can be used to annotate, as expected.

Here is some example of using Node and the issue I am facing.

value = Node(1)  # value: Node[int] = Node(1) -- value has a type of Node[int]
value2 = Node(2)  # likewise value2 has a type of Node[int]

# Example 1
print(
    value < 1
)  # -> False; the less than (lt) dunder can deal with Node[int] < int. 
# As you recall, __other is meant to accept T and Node[T]. 
# In this case, __other is 1, an int which is T.

# Example 2
print(
    value < value2
)  # -> True; the less than (lt) dunder should be able to deal with 
# Node[int] < Node[int] as __other would be Node[T]


# consider this
print(
    value < "0"
)  # As expected, this will fail because we cannot compare int and str; 
# likewise, we can't compare Node[int] with Node[str].
# Yields; <<Exeption>> 
# TypeError: '<' not supported between instances of 'Node' and 'str'

I am not sure if this is possible; however, I want to modify the output for the following exception such that it prints:

TypeError: '<' not supported between instances of 'Node[int]' and 'str'

Because technically < is supported between Node and str.


Solution

  • I'm afraid you are out of luck, if you want to have that exact same error message but with the added type argument. The logic that handles the error message that you get, when a rich comparison method returns NotImplemented is an implementation detail of Python itself. You can for example see, how the error message is formed in the CPython 3.11.1 implementation of do_richcompare.

    The closest you can get is modifying the error message and re-raising the TypeError manually, but it will include the entire stack trace including the line(s) from your __lt__ method.

    Here is a full working example:

    from __future__ import annotations
    from typing import Any, Generic, Protocol, TypeVar, Union, cast
    
    
    class Comparable(Protocol):
        def __lt__(self, other: Any, /) -> bool:
            ...
    
    
    T = TypeVar("T", bound=Comparable)
    
    
    class Node(Generic[T]):
        def __init__(self, value: T) -> None:
            self.value = value
    
        def __lt__(self, __other: Union[T, Node[T]]) -> bool:
            try:
                if isinstance(__other, Node):
                    return self.value < __other.value
                return self.value < __other
            except TypeError as exc:
                # Modify exception message to to specify Node[T]
                # and raise the TypeError manually
                cls_name = self.value.__class__.__name__
                msg = cast(str, exc.args[0])
                msg = msg.replace(f"{cls_name}", f"Node[{cls_name}]", 1)
                if isinstance(__other, Node):
                    cls_name = __other.value.__class__.__name__
                    msg = msg.replace(
                        f"and '{cls_name}'",
                        f"and 'Node[{cls_name}]'",
                    )
                exc.args = (msg, ) + exc.args[1:]
                raise exc
    
    
    def test() -> None:
        print(Node(1) < 1)
        print(Node(1) < Node(2))
        # Node(1) < "0"
        # Node(1) < Node("0")
    
    
    if __name__ == "__main__":
        test()
    

    The output like this is still False \ True.

    If you uncomment Node(1) < "0", you'll get something like this:

    Traceback (most recent call last):
      File "[...].py", line 47, in <module>
        test()
      File "[...].py", line 42, in test
        Node(1) < "0"
      File "[...].py", line 36, in __lt__
        raise exc from None
      File "[...].py", line 22, in __lt__
        return self.value < __other
    TypeError: '<' not supported between instances of 'Node[int]' and 'str'
    

    If you instead uncomment the Node(1) < Node("0"), you'll get this:

    Traceback (most recent call last):
      File "[...].py", line 47, in <module>
        test()
      File "[...].py", line 43, in test
        Node(1) < Node("0")
      File "[...].py", line 36, in __lt__
        raise exc from None
      File "[...].py", line 21, in __lt__
        return self.value < __other.value
    TypeError: '<' not supported between instances of 'Node[int]' and 'Node[str]'
    

    I suppose, if instead of modifying the message on the existing exeption instance, you raised an entirely new instance of TypeError and added from None, you could cut off one additional step in the stack trace message, but that could also cost you some potentially useful additional exception details in some situations.


    On an unrelated note, there is no need for abc.abstractmethod on Comparable.__lt__ because a Protocol may never be instantiated anyway. The method also doesn't need a body.