Clarifying the float/int/complex special case

I don’t see a problem in the formulation. It says that this “shortcut” is limited to this case only:

def foo(n: float):
   pass

foo(1) # OK

No other special treatment of int vs float should be done. In particular:

def foo(n: float):
   n.hex() # OK, as n is really a float
   if isinstance(n, int):
       # should be marked as unreachable
   
x: float = 1 # Error, 1 is int not float
y: int = 1 # OK
foo(y) # also OK, as a special case

This may be surprising, or inconsistent but is pretty much what the spec say.

So this means if you want to have consistent typing in your own code, you better use float | int if you want to accept both (and float if you only want floats). The special casing of calling float-annotated methods with ints, would then help with backwards compatibility / interfacing with other external libraries in cases such as the following:

def foo(n: float | int):
   external_lib.method_only_with_float_annotated_but_handling_int_as_well(n)
   
x: float | int = 1
foo(x)

What would be helpful though is adding these examples that are not valid to the type spec tests, so that the behavior of the type checkers gets more consistent (and more in line with what the specs actually say). In my opinion, it would also be helpful if type checkers allow to disable this “shortcut”. For example, if my project is not using external libs and I use float | int consistently, I want to have foo(1) being marked as wrong if foo really only allows floats.


Another instance where mixing float and int is confusing, would be in the context of type guards. For example, math.isinf(n) == Truereally means that n is a float now, because an integer can never be infinity. So your typing system should be able to distinguish between float and int.


As a general remark, I would echo Oscar’s opinion that a clear distinction between float and int is essential for everything that seriously cares about correct results (i.e. everything in the scientific python ecosystem).

8 Likes

I find another case where the current behavior is very confusing and arguably incorrect.

from dataclasses import dataclass
from typing import assert_never

@dataclass(frozen=True)
class Wrapper:
    value: float | str

    def unwrap(self) -> float:
        match self.value:
            case float() as f:
                return f
            case str() as s:
                return float(s)
            case unknown:
                assert_never(unknown)  # nominally unreachable

Wrapper(value=1).unwrap()      # int is assignable to float under PEP 484
Wrapper(value=True).unwrap()   # not executed because it already raised, but also surprising that mypy is OK with this

mypy says everything is fine, even when explicitly using exhaustion check, but of course it fails in runtime:

$ mypy --strict --enable-error-code=exhaustive-match t2.py 
Success: no issues found in 1 source file
$ python t2.py 
Traceback (most recent call last):
  File "/home/luca/devel/common-client/t2.py", line 17, in <module>
    Wrapper(value=1).unwrap()          # int is assignable to float under PEP 484
    ^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/luca/devel/common-client/t2.py", line 15, in unwrap
    assert_never(unknown)  # nominally unreachable
    ^^^^^^^^^^^^^^^^^^^^^
  File "/home/luca/.local/share/uv/python/cpython-3.11.10-linux-x86_64-gnu/lib/python3.11/typing.py", line 2542, in assert_never
    raise AssertionError(f"Expected code to be unreachable, but got: {value}")
AssertionError: Expected code to be unreachable, but got: 1

pyright flags this correctly at least.

1 Like

I must say, the more I’ve engaged with python typing the more I’ve been frustrated with this special case. I still don’t really understand the rationale for deliberately being incorrect here

4 Likes