Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

PEP 647 (TypeGuard) tracker #5406

Closed
4 of 5 tasks
JelleZijlstra opened this issue May 11, 2021 · 33 comments
Closed
4 of 5 tasks

PEP 647 (TypeGuard) tracker #5406

JelleZijlstra opened this issue May 11, 2021 · 33 comments
Labels
project: feature tracker Tracks whether a typing feature can be used in typeshed stubs

Comments

@JelleZijlstra
Copy link
Member

JelleZijlstra commented May 11, 2021

I'd like to start using TypeGuard in typeshed. Here's what we need:

@JelleZijlstra
Copy link
Member Author

@sproshev does PyCharm support TypeGuard already?

@sproshev
Copy link
Contributor

Not even planned :( It is going to be released in Python 3.10, right?

@JelleZijlstra
Copy link
Member Author

That's right. It's probably pretty simple to get enough support to allow TypeGuard in typeshed: just interpret TypeGuard[...] as an alias for bool.

@JelleZijlstra
Copy link
Member Author

Some examples of functions where this could be useful:

  • builtins.callable should return TypeGuard[Callable[..., Any]]
  • Various functions in inspect like inspect.isfunction, isclass, etc.

@jakebailey
Copy link
Contributor

Unless I'm mistaken, TypeGuard and bool aren't type compatible (the PEP said so), so wouldn't replacing those functions return types be breaking changes, and only new functions can be type guards?

@hauntsaninja
Copy link
Collaborator

(Why does the PEP say so?)

@jakebailey
Copy link
Contributor

I think there was some bad interplay with overload type overlapping that necessitated them being unique (e.g. conditions where a function overload couldn't have a more specific return in the case of a TypeGuard parameter).

@gvanrossum or @erictraut may remember; I wasn't there for the discussion and I don't want to go too far into second-hand explanation past citing the PEP.

@gvanrossum
Copy link
Member

Testing this, mypy and pyright seem to disagree. I wrote this code:

from typing import Callable, Any
from typing_extensions import TypeGuard

class SomeType:
    ...

def guard(x: Any) -> TypeGuard[SomeType]:
    return True

def hof(func: Callable[[Any], bool]):
    ...

hof(guard)

def notguard(x: Any) -> bool:
    ...

def hof2(func: Callable[[Any], TypeGuard[SomeType]]):
    ...

hof2(notguard)

Mypy accepts this, but pyright gives errors on the hof(guard) and the hof2(notguard) calls:

  C:\Users\gvanrossum\cpython\guard.py:13:5 - error: Argument of type "(x: Any) -> TypeGuard[SomeType]" cannot be assigned to parameter "func" of type "(_p0: Any) -> bool" in function "hof"
    Type "(x: Any) -> TypeGuard[SomeType]" cannot be assigned to type "(_p0: Any) -> bool"
      Function return type "TypeGuard[SomeType]" is incompatible with type "bool"
        "TypeGuard[SomeType]" is incompatible with "bool" (reportGeneralTypeIssues)
  C:\Users\gvanrossum\cpython\guard.py:21:6 - error: Argument of type "(x: Any) -> bool" cannot be assigned to parameter "func" of type "(_p0: Any) -> TypeGuard[SomeType]" in function "hof2"
    Type "(x: Any) -> bool" cannot be assigned to type "(_p0: Any) -> TypeGuard[SomeType]"
      Function return type "bool" is incompatible with type "TypeGuard[SomeType]"
        "bool" is incompatible with "TypeGuard[SomeType]" (reportGeneralTypeIssues)

@erictraut
Copy link
Contributor

PEP 647 says:

In all other respects, TypeGuard is a distinct type from bool. It is not a subtype of bool. Therefore, Callable[..., TypeGuard[int]] is not assignable to Callable[..., bool].

I added this to the spec at your recommendation, Guido, because you argued (convincingly) that there were cases where we would want to distinguish between bool and TypeGuard[T] in overloads.

So it appears that mypy is not conforming to the PEP.

@JelleZijlstra
Copy link
Member Author

Thanks for the explanation! It's very limiting though if we can't safely change existing functions like inspect.is* to use TypeGuards. Do you see a way to allow that?

As a user I'd expect Guido's hof(guard) example to succeed but hof2(notguard) to fail. Treating TypeGuard as a subtype of bool would do that.

@gvanrossum
Copy link
Member

gvanrossum commented May 12, 2021 via email

@JelleZijlstra
Copy link
Member Author

JelleZijlstra commented May 12, 2021

I think you can, just tried this with mypy:

from typing import Any, Callable, overload

@overload
def f(x: Callable[[], bool]) -> str: ...
@overload
def f(x: Callable[[], int]) -> float: ...
def f(x: Any) -> Any: ...

def b() -> bool: return True
def i() -> int: return 0

reveal_type(f(b))  # Revealed type is 'builtins.str'
reveal_type(f(i))  # Revealed type is 'builtins.float'

It does complain that the overloads overlap, but it infers the correct types.

@jakebailey
Copy link
Contributor

I tried to create some concrete examples to show this back in the "final call for comments" thread in relation to python/typing#253 (comment), and some seemed to think that this restriction was unnecessary, but I gave up thinking about it after getting berated enough to not want to continue participating in the thread anymore.

@srittau
Copy link
Collaborator

srittau commented May 12, 2021

If TypeGuard is treated as a subtype of bool, the overloads should work, as long as the more specific overload (TypeGuard) is listed first. We have quite a few cases in typeshed where we do that. (Not with TypeGuard obviously.)

@gvanrossum
Copy link
Member

@jakebailey

I tried to create some concrete examples to show this

What does "this" refer to? The restriction on overloads being distinct? Or something related to TypeGuard?

after getting berated enough to not want to continue participating

Sorry for your experience. We should do better.

@gvanrossum
Copy link
Member

@JelleZijlstra

It does complain that the overloads overlap, but it infers the correct types.

But that's the problem, right? There's a reason for the complaint:

# Building on your example:
def example(a: Callable[[], int]):
    x = f(a())  # Inferred as float
example(b())  # But at runtime x will be str

@jakebailey
Copy link
Contributor

What does "this" refer to? The restriction on overloads being distinct? Or something related to TypeGuard?

Both, together (if I'm understanding correctly). https://mail.python.org/archives/list/typing-sig@python.org/message/KFGHKN32LCWKHIDGLJIUUP2E67MMDARR/

Picking int and float, as ints are assignable to floats but not the other way around (which is how I would mentally expect TypeGuard to operate with relation to bool), and an esoteric mismatch in return type (versus trying to construct something with Iterable and such):

from typing import Any, Callable, overload

Bool = float
TypeGuard = int

@overload
def func(x: Callable[..., TypeGuard]) -> str: ...
@overload
def func(x: Callable[..., Bool]) -> int: ...

pyright errors, mypy doesn't due to python/mypy#10143.

Then depending on how you obtain x, the type checker may say one thing and pick an overload, but not actually be correct.

But, I'm willing to be wrong about this. I'd certainly like if existing functions could become type guards (like they did in TS).

@gvanrossum
Copy link
Member

@jakebailey

Picking int and float, as ints are assignable to floats but not the other way around (which is how I would mentally expect TypeGuard to operate with relation to bool),

I agree that mypy should error on that example. (@JelleZijlstra's example does complain, because there the subclass relationship is explicit -- bool is declared as a subclass of int in typeshed.)

FWIW the issue is presumably limited to overloading on types involving Callable; I presume if you actually call a type guard and assign it to a variable the return type really is treated as bool.

@jakebailey
Copy link
Contributor

Yeah, in my case, I was trying to come up with two types that were close to bool and TypeGuard, since the latter doesn't actually exist at runtime and I'd expect the relationship to also be an implicit subtype for purposes of type annotations only. I can't off the top of my head think of another pairing like it, besides maybe TypedDict.

@jakebailey
Copy link
Contributor

jakebailey commented May 12, 2021

FWIW the issue is presumably limited to overloading on types involving Callable; I presume if you actually call a type guard and assign it to a variable the return type really is treated as bool.

Is that defined in the PEP? I'd be surprised if that happened.

image

EDIT: oops, bad screenshot.

@gvanrossum
Copy link
Member

Ehh, that's weird. IMO, TypeGuard has no business being inferred like that. But Eric probably weasel-worded the PEP so that pyright can do this, and then you could use variables as type guards, e.g.

x = is_str_list(a)
if x:
    reveal_type(a)  # List[str]

I have no desire to implement that in mypy though, we don't support this for things like isinstance() either -- type narrowing requires specific constructs in an if (or maybe a few other implicitly conditional contexts). But type narrowing isn't specified by any PEPs anyway.

@jakebailey
Copy link
Contributor

No, we don't go that far:

image

@gvanrossum
Copy link
Member

gvanrossum commented May 12, 2021 via email

@JelleZijlstra
Copy link
Member Author

pyright doesn't actually support Guido's example, but it does infer x to be of type TypeGuard[] and prevent you from passing x to a function that takes a bool.

Stepping back, the reason I'm pushing for a change here is that it would be unfortunate if we can't take advantage of TypeGuard on any existing functions. Users who want narrowing from functions like inspect.isfunction would have to use a copy of the function that returns a TypeGuard instead. That's not an outcome I'd like to see. Of course, I should have brought this up while the PEP was under discussion instead of now; sorry for missing it.

I still think that making TypeGuard a subclass of bool would work well in practice. There are some soundness problems, but those seem mostly theoretical. I have a hard time imagining the unsoundness manifesting as anything worse than a missed narrowing. I'm open to other solutions though.

@jakebailey
Copy link
Contributor

jakebailey commented May 12, 2021

Hm, then what's the point of inferring TypeGuard instead of bool?

TypeGuard[List[str]] is the type of the expression, and assigning it just uses that type. Transparently converting it to bool in specific circumstances sounds like it would cause oddities (as "getting the type" of this call is the same operation if you were to want to use it by passing it to something that accepts a type guard, like a potential filter function).

Of course, if TypeGuard were compatible in some sense, then how this is chosen wouldn't really matter, except maybe that variables may be inferred to have a type that's more specific than what a user wants, potentially (but that's nothing new).

@srittau srittau added the project: feature tracker Tracks whether a typing feature can be used in typeshed stubs label May 13, 2021
@gvanrossum
Copy link
Member

@jakebailey

TypeGuard[List[str]] is the type of the expression

I guess in our minds we think of this fundamentally differently. To me, TypeGuard[List[str]] is special syntax that adds an attribute to the function outside the type system proper, while its return type is just bool.

If you consider the TypeScript equivalent, arg is SomeType, I'd say that clearly the return type is not arg is SomeType, the return type is boolean, and the special syntax makes the function special in certain contexts.

In Python, TypeGuard[...] is not a generic type, it just looks like one because that's pretty much the only syntax we have available.

@erictraut
Copy link
Contributor

Just catching up on this thread...

I confirmed that there was a bug in pyright's implementation when evaluating the return type of a call to a user-defined type guard. It should evaluate the return type as bool, not as TypeGuard[T]. That bug will be fixed in the next release.

The question that is being raised here is whether a function that accepts a callback of type Callable[..., bool] should accept a user-defined type guard function. I also see no reason why it shouldn't, since the runtime return type of such a function is bool. I've made the change in pyright to allow this, and it will be in the next release also.

@JelleZijlstra
Copy link
Member Author

Thanks Eric, that's great to hear! Then I think the only thing blocking us from using TypeGuard in typeshed is PyCharm.

@srittau
Copy link
Collaborator

srittau commented May 16, 2021

I think PyCharm should mostly be considered a "soft blocker". While we shouldn't break central, often used functions, adding TypeGuard to less often used modules shouldn't be a problem. The worst that PyCharm does is to warn about something it doesn't understand. As a PyCharm user I'm quite used to a fair amount of warnings, unfortunately.

@erictraut
Copy link
Contributor

erictraut commented May 16, 2021

I just noticed that there's still a small difference between the behavior of mypy and pyright. Mypy apparently allows non-type-guard functions (those that return a bool) to be passed to a function that indicates it requires a type guard function. This can be seen using Guido's test code above.

def notguard(x: Any) -> bool: ...

def hof2(func: Callable[[Any], TypeGuard[SomeType]]): ...

hof2(notguard) # Pyright flags this as an error, mypy does not

@gvanrossum, can you explain why you think this should not be an error? Is there a compelling reason to support this? I think it creates some problems, specifically for overload matching. It also creates problems for generics used in TypeGuards. For example, what would you expect the return result to be for the following call?

def my_filter(func: Callable[[Any], TypeGuard[T]]) -> List[T]: ...
my_filter(notguard) # What is the return type of this call? List[Any]?

Maybe it's OK for mypy and pyright to differ in this specific use case, but I want to make you all aware of this because pyright's current behavior (with my recent changes noted above) requires that any use of TypeGuard callbacks in typeshed must be done with overloads, and the TypeGuard overload must appear prior to the non-TypeGuard overload, like this:

@overload
def my_filter(func: Callable[[Any], TypeGuard[T]]) -> List[T]: ...
@overload
def my_filter(func: Callable[[Any], bool) -> List[Any]: ...

JelleZijlstra added a commit to JelleZijlstra/typeshed that referenced this issue May 16, 2021
- Use TypeGuard for various is* functions (refer to python#5406)
- Use collections.abc and builtin containers
@gvanrossum
Copy link
Member

I wasn’t expressing an opinion on what is correct, just observing a difference. I don’t think I will get to changing mypy even if it is decided it’s wrong, but someone else might. Sorry!

@JelleZijlstra
Copy link
Member Author

I agree that the mypy behavior Eric highlighted doesn't make sense, so I opened an issue about it. With Sebastian's comment on PyCharm, I think we're OK to start using TypeGuard in typeshed, so I'm closing this issue.

Thanks everyone for the work on TypeGuard!

@jakebailey
Copy link
Contributor

Bumped pyright's locked version to 1.1.140, which includes the aforementioned fixes.

Happy to know that the difference was a bug; I probably should have known better when I went to TS, wrote the same type guard, and the hover said boolean, not something special... 🙂

srittau pushed a commit that referenced this issue May 29, 2021
- Use TypeGuard for various is* functions (refer to #5406)
- Use collections.abc and builtin containers
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
project: feature tracker Tracks whether a typing feature can be used in typeshed stubs
Projects
None yet
Development

No branches or pull requests

7 participants