Options for a long term fix of the special case for float/int/complex

I’m not sure I understand how float and float | int differ in your proposal. If a library has a function

def f(x: float) -> None: ...

and a function

def g(x: float | int) -> None: ...

what does that tell me as a library user?

In this special case it doesn’t yet do anything different, and the __annotations__ attribute is equal from the perspective of type checkers. However in his ‘idea’ this behavior doesn’t really change, which is why other ideas were suggested, e.g. some typing.Strict. If I missed something however please let me know.

Personally, I expect a significant number of cases of bare float to mean “I only ever use floats here, and I have no opinion on what should happen if an int is pased”. Remember, the majority of code isn’t public libraries that need to cater for arbitrary users passing unexpected values.

The biggest exception I can think of is code that passes integer literals to functions that expect a float. For that code, I can appreciate that adding .0 to the literal is annoying and feels like busywork, but honestly, it’s a relatively small price - and I suspect tools could be written to automate the conversion, if necessary.

8 Likes

To be clear mypy does not currently treat float as being equivalent to float | int. There is a parallel thread titled “clarifying the special case” or something in which there is a proposal that type checkers should treat float as equivalent to float | int but this not consistent type checker behaviour today.

Right now if you change x: float to x: float | int then mypy will behave differently. Here is an example:

def f(x: float):
    if not isinstance(x, float):
        # pyright says int here but mypy thinks unreachable:
        reveal_type(x)

def g(x: float | int):
    if not isinstance(x, float):
        # mypy and pyright agree this is int:
        reveal_type(x)

I think it is important to remember that the proposal for float to mean float | int is also a change to existing type checker behaviour. That proposal might seem less disruptive than removing the special case but it is still a change. Remember that much of the world of typed code in Python is checked by mypy and has never been seen by pyright or any other checker.

The way that mypy currently handles the special case seems to take the wording of PEP 484 as literally as possible while otherwise just treating float as an ordinary type:

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.

Someone with knowledge of mypy internals could say better than me but from a black box perspective I would say that in simple terms mypy treats the int type as being assignable to float but does not otherwise treat float as meaning the union float | int. The current behaviour of mypy for float seems suboptimal to me but I think it is better than pyright.

The downside of what mypy does compared to float means float is that it cannot help you find bugs where cos(1) or return 0 are not acceptable. If however you do correctly use floats rather than ints where needed then mypy will treat float as being a real type just like it really is at runtime. This means for example that mypy can correctly infer types and understands and allows overloads that distinguish int and float.

With pyright you also cannot find bugs from passing an int where a float is needed. What pyright also does though is to corrupt the meaning of float as an annotation entirely making it impossible to refer to float as a real type. With pyright’s behaviour you break the ability to distinguish ints from floats in the type system altogether which is what breaks downstream cases like accurate type annotations for np.array([1.0]).

The parallel proposal seeks to change mypy to behave like pyright but I think it is important to be clear how much mypy would be changed in that scenario.

3 Likes

f() accepts both float and int, and behaves the same (or as similarly as reasonable) in either case.

g() accepts both float and int, but the behavior in each case may be quite different (and should ideally be documented in docstring).

In most of these cases passing an int should not be harmful, by virtue of the design of the language. Many functions work with more types than they explicitly document.

I think mypy simply treats int as a subclass of float, which is why it can’t handle this correctly:

def f(x: float | str) -> str:
    if isinstance(x, float):
        return str(x)
    reveal_type(x)  # Revealed type is "builtins.str"
    return x

f(0)  # will return an `int` instead of a `str`

This is, I think, what PEP 484 says (that int should be a subclass of float), but I think Eric Traut from pyright wanted to change it.


Thanks, that makes me understand your proposal. (Though I’m not entirely convinced this is really useful information for the library user… I’d have to think about it more.)

The exact language from PEP 484 is

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.

There is also this example from the “Subtype relationships” section of PEP 483

A more formal example: Integers are subtype of real numbers. Indeed, every integer is of course also a real number, and integers support more operations, such as, e.g., bitwise shifts << and >>:

lucky_number = 3.14    # type: float
lucky_number = 42      # Safe
lucky_number * 2       # This works
lucky_number << 5      # Fails

unlucky_number = 13    # type: int
unlucky_number << 5    # This works
unlucky_number = 2.72  # Unsafe

(note: variable annotations were not supported until later with PEP 526, so this is using the special type comments)

From those two, it’s clear that the behavior of a type checker should be that an int can be assigned to a variable annotated as float or passed to a function argument annotated as float, but it doesn’t actually specify how that behavior should be implemented.

mypy uses heuristics that kind of treat int as a subclass of float but not always, while pyright moved to mostly doing the approach of “float in a type expression actually means float | int

There had already been an issue on the mypy issue tracker that suggested moving to that union approach. Carl Meyer (who has been working on Astral’s new type checker ty) opened an issue on the typing GitHub asking that the typing specification be clarified.

Jelle Zijlstra proposed that the typing specification be modified to explicitly specify the union approach, which mostly matches existing type checker results and the conformance test suite that goes along with the typing specification. He then opened a thread here on Discuss about that change, which then prompted this very discussion thread.

1 Like