python / typing

Python static typing home. Hosts the documentation and a user help forum.
https://typing.readthedocs.io/
Other
1.6k stars 241 forks source link

Allow `float("inf")` and `float("-inf")` in literals #1160

Open antonagestam opened 2 years ago

antonagestam commented 2 years ago

I have been frequently using positive and negative infinity as default values for values that should otherwise be ints. Because of Python's duck typing this is a convenient pattern that sometimes allows reducing some special-casing logic.

def less_than(value: int, limit: int | None) -> bool:
    if limit is None:
        return True
    return value < limit

vs

def less_than(value: int, limit: int | Literal[float("inf")]) -> bool:
    return value < limit

Would it be feasible to special-case the expressions float("inf") and float("-inf") and make type-checkers regard them as literal values, even though they aren't strictly speaking language-level literals?


For posterity, PEP 586 mentions this with:

Representing Literals of infinity or NaN in a clean way is tricky; real-world APIs are unlikely to vary their behavior based on a float parameter.

So I guess what I'm asking here is: am I an odd duckling or could it be worth considering +/- infinity as legal values in literal types?

srittau commented 2 years ago

It makes sense to me to allow inf, -inf, and nan as literal values. After all, we allow all other float values as literal values as well.

JelleZijlstra commented 2 years ago

After all, we allow all other float values as literal values as well.

We don't. PEP 586 lists all valid types for literals, and float isn't among them.

erictraut commented 2 years ago

Yes, float literals are not supported.

Also, call expressions are not (nor should be) allowed in type annotations. It's important that we keep type annotation expressions simple — for reasons of consistency and evaluation performance.

JelleZijlstra commented 2 years ago

An approach that avoids call expressions could be to allow Literal[math.inf] (and Literal[math.nan]?). Type checkers would then have to special case math.inf as referring to float("inf").

Not sure this is worth changing in the type system though. In the OP's case, they could just write limit: int | float (or limit: float).

Gobot1234 commented 2 years ago

I currently am designing an api which looks like:

def redirect_to(self, somewhere_else: Self, * for: timedelta | Literal[float("inf")]): ...

Because I want to make sure people don't have to deal with the units they are passing. Specifying this is in seconds I think at least is a bit annoying especially if the endpoint I'm calling changes it's units without warning.

antonagestam commented 2 years ago

@JelleZijlstra Just a nitpick but "they could just write limit: int | float", that wouldn't be equivalent as that wouldn't give a type error for less_than(1, 5.5) (I really only wanted to allow ints and infinity, not all floats).

@erictraut That makes sense, would it be less complex to do what Jelle suggests? Although I realize this feature would probably have a quite low "return on investment" in terms of gained typing accuracy per time invested in building and specifying it.

Kyrixty commented 2 years ago

My code has float("-inf") bugs 😎

Avasam commented 2 years ago

Just my 2 cents, I am no professional on the matter. Could inf, +inf, -inf and nan (with names that are actually usable as types) be type aliases of float? And special-cased by type checkers that supports them. Or maybe a new PEP needs to be suggested 🤷 ie:

NegativeInfinity: TypeAlias = float
PositiveInfinity: TypeAlias = float
Infinity: NegativeInfinity | PositiveInfinity
NotANumber: TypeAlias = float
# idk if this is even feasable, or even needed.
# Maybe it could be useful in some arithmetic cases, or when delaying.
# Reminds me of LiteralString for its safety applications.
FiniteFloat: TypeAlias = float 

float("-inf")  # type: NegativeInfinity  # type: Literal[NegativeInfinity]
float("+inf")  # type: PositiveInfinity  # type: Literal[PositiveInfinity]
float("inf")  # type: PositiveInfinity  # type: Literal[PositiveInfinity]
float("nan")  # type: NotANumber  # type: Literal[NotANumber]

I agree this is "low return on investment". Still something I would like as a type user.

hauntsaninja commented 2 years ago

For OP's use case, maybe a "comparable-to-int" protocol could work? This is probably a good type to choose for the pyyaml PR that got linked here

antonagestam commented 2 years ago

@hauntsaninja No that would not have worked, as already repeated, the real usecase really required int.

HeWeMel commented 1 year ago

Here is my use case for the same feature request:

I am the author of NoGraphs, a library dealing with implicit graphs. Here, the application can choose what data types to use (including rare choices like Decimal or arbitrary precision floats), optionally made type-safe by type annotations.

A common choice for graph applications is to use int for edge weights and distances. But just because an infinity value is needed as default, the annotation needs to be int | float (or just float). This is disturbing from the perspective of the application, because it uses int to exclude precision problems - and then, float occurs "everywhere" in the application...

(I hope, this can be helpful for the discussion - I am no specialist in the topic. I might be completely wrong...)

erictraut commented 1 year ago

Perhaps a better way to handle such an API is to use a dedicated sentinel value rather than float("inf") to indicate the default. The builtin symbol None is often used as such a sentinel. Ellipsis can also work for this purpose. The value of float("inf") is not a good sentinel because it cannot be described unambiguously in the type system.

HeWeMel commented 1 year ago

I think so, too. Thanks. None or Ellipsis are valid alternatives.

I also agree with the perspective w.r.t. the type system. Furthermore, in the use case, Optional[int] with None as sentinel is less disturbing than int | float, if avoiding precision problems is the goal.

In some cases, these alternatives might also have disadvantages: Since int are not comparable to NoneType or EllipsisType, whilst 5 < float("infinity") works, the new sentinel has to be handled as special case in all comparisons in the package (here: in each graph algorithm at several places). And in the area of graph algorithms, the literature nearly always uses the term "infinity" for the default (ok, in textbooks and wikipedia, and just in this area...). And the application might expect to deal with distances, including infinite distance, and now, it also has to deal with None as special case. And in stackoverflow, obscure proposals like to use inf = cast(int, math.inf) or to subclass int come up, in order to better live with the situation.

Although: Solving the issue might just be not worth it, or too complicated, or would have side effects... (My perspective and knowledge about such topics is very limited. And I can perfectly live without inf as literal.)

Avasam commented 1 year ago

@HeWeMel You can create a new nographs.Infinity type based on float("inf") (https://docs.python.org/3/library/typing.html#newtype) and expose an instance of it as a helper constant (nographs.INFINITY for example). Similarly to how the math library has math.inf. Then type your parameters as int | nographs.Infinity (this has the advantage of letting the users know when they can use Infinity or not). Then your runtime checks basically stay the same as you can check for float("inf"), which avoids breaking old code and untyped code.

This way users of your library who also want strict typing can pass an instance of nographs.Infinity (which is just a float("inf")) and users who don't use type-checkers can also pass float("inf") interchangeably.


That being said, I would also prefer being able to simply use a literal infinity directly from python's type system. 😄

HeWeMel commented 1 year ago

@Avasam, this sounds like a very good idea. I will definitely try this. Thanks!

antonagestam commented 1 year ago

@erictraut Please note that such usage of None is acknowledged in the original post of this topic.

I do hear you that it's not worth spoiling the otherwise simpler syntax of current type hints for this rather special case though. It's a minor flaw that I think we can live with.

nstarman commented 1 year ago

In mathematical contexts `inf is a relatively common default:

So a Literal[inf] would be very useful in those contexts.

BlueGlassBlock commented 1 year ago

How about using enum?

class Bound(float, enum.Enum):
    Inf = float("inf")
    NegInf = float("-inf")

Constants could be easily extracted via Inf: Final = Bound.Inf.

Literal wrapping and comparison also works.

nstarman commented 1 year ago

This would really only be practical if the enums were "batteries included" in base python. Having every library define their own Enum and then, for correct type checking, require users to use that specific implementation is burdensome. I've tried and abandoned this in personal projects.

BlueGlassBlock commented 1 year ago

This would really only be practical if the enums were "batteries included" in base python. Having every library define their own Enum and then, for correct type checking, require users to use that specific implementation is burdensome. I've tried and abandoned this in personal projects.

Agree. I considered adding this to typing module, and annotate that float.__new__ will return this enum. However float("inf") creates different objects, which violates enum's usage (allowing identification with is)

wxgeo commented 7 months ago

As already said, using positive infinity as a default value for integers is a very common and natural choice.

Using types in Python has for me two benefits:

Writing

def my_code(value: int | float = math.inf):
    ...

while valid, is both less explicit, and less safe than int | Literal[math.inf].

On the other hand, using None as a sentinel value is safe but less explicit and leads to more verbose and slighter less readable code.

Perhaps using a custom Enum as suggested by @BlueGlassBlock is the cleaner option at this stage, at least if user is not supposed to enter default infinity value manually, since forcing users to use our custom infinity wrapper is not so nice.

Generally speaking, infinities are treated as second class citizens in Python (I had to patch ast.literal_eval() to support them for example...). Since Python is so often used by scientists, having a better support for them would be really nice (at least, float.inf instead of having to import math, or ideally a builtin name, as suggested in the past, or Infinity() as a subclass of float...), but this is beyond the scope of this issue. ;)

`