mypy with numbers.Number

Viewed 69

I was hoping to use mypy's static duck typing facility to write a function that can process a sequence of number-like objects, where "number-like" is defined by numbers.Number:

from numbers import Number
from typing import Sequence

def something_numerical(xs: Sequence[Number]) -> Number:
  ...

print(something_numerical([1., 2., 3.]))

However, when I call this code with a list of floats or ints, I get a mypy error:

$ print(multiply([1., 2., 3.]))
foo/foo.py:9: error: List item 0 has incompatible type "float"; expected "Number"
foo/foo.py:9: error: List item 1 has incompatible type "float"; expected "Number"
foo/foo.py:9: error: List item 2 has incompatible type "float"; expected "Number"

I realize that the float type is not a subclass of numbers.Number. However, the numbers module provides a set of abstract base classes that are intended to be used to check whether an object has the requisite methods to do numerical operations. How might I rewrite this code so that (1) it can still process ints, floats, fractios.Fraction, and so on, and (2) so that it passes type checking by mypy?

3 Answers

The answer by @SUTerliakov is informative and accurate; however, while working on this problem I came across another way of solving the problem that I will post here for others who encounter this. I simply defined my own "Number" protocol from scratch:

from typing import Protocol

class Number(Protocol):
    def __abs__(self) -> 'Number':
        ...
    def __mul__(self, Number) -> 'Number':
        ...
    def __add__(self, Number) -> 'Number':
        ...
    def __lt__(self, Number) -> bool:
        ...
    def __gt__(self, Number) -> bool:
        ...
    def __truediv__(self, Number) -> 'Number':
        ...
    def __rtruediv__(self, Number) -> 'Number':
        ...

Here the choice to include __mul__, __abs__, __truediv__ and so on were based on the operations I needed to perform inside my function, rather than based on any abstract notion of what a number ought or ought not to be.

It seems that the "number" concept in python is quite complicated and hard to fit all cases, so it really might make logical sense to define a per-project protocol like this.

It seems using Union will be the way to go:

from numbers import Number
from typing import Sequence, Union
from fractions import Fraction


def something_numerical(xs: Sequence[Union[Number, float]]) -> Union[Number, float]:
    return sum(xs)


if __name__ == '__main__':
    print(something_numerical([1.2, 2, Fraction(1, 2)]))

It was stated in @bzu answer, but I'd like to add some explanation to it.

First thing to note: issubclass(int, Number) and issubclass(float, Number) both evaluate to True. This is very surprising type-checking behavior, but it was standardized in PEP484:

Rather than requiring that users write import numbers and then use numbers.Float etc., this PEP proposes a straightforward shortcut that is almost as effective: when an argument is annotated as having type float, an argument of type int is acceptable; similar, for an argument annotated as having type complex, arguments of type float or int are acceptable. This does not handle classes implementing the corresponding ABCs or the fractions.Fraction class, but we believe those use cases are exceedingly rare.

So to support built-in numbers you can use just int, float or complex. To handle other ABC's you should use appropriate numbers member. I don't know why float was not made compatible with numbers.Number.

For almost all cases you can use a type alias (TypeAlias was backported with typing_extensions module for python<3.10):

from fractions import Fraction
from numbers import Number
from typing import TypeAlias


AnyNumber: TypeAlias = Number | float

def f(x: AnyNumber) -> bool:
    return x == 0


f(1)
f(1.0)
f(Fraction(1, 3))

This typechecks. One incompatible class I'm aware of is decimal.Decimal: it is not compatible (it would be expected, if Number were made compatible with float, because Decimal is not and Decimal(1) / 2 fails - but it is not the case, as we'll see later).

If your function uses AnyNumber and int together, everything dies:

def f(x: AnyNumber) -> float:
    return x / 2 + 1  # E: Unsupported operand types for / ("Number" and "int")

Although you can, for example, do Fraction(1,2) / 2, Number does not guarantee int or float compatibility. You can use numbers.Real or numbers.Complex instead - they are compatible with float:

AnyReal: TypeAlias = Real | float

This allows x / 2 + 1 and remains incompatible with decimal.Decimal, but now it is intended behavior.

You can use this playground to investigate the topic further. Also having look at numbers in typeshed may help.

Related