[red-knot] Several failing tests for generics#16509
Conversation
| class E[T]: | ||
| def __init__(self, x: T) -> None: ... | ||
|
|
||
| # TODO: revealed: E[float] | ||
| reveal_type(E(1.0)) # revealed: E |
There was a problem hiding this comment.
Maybe use something other than float here, due to the special case for float and complex? I don't think anything is wrong with the assertion — we would probably show E[float] — but it could be confusing because specifying E[float] in a type expression would be equivalent to E[float | int].
There was a problem hiding this comment.
Agreed -- though it may also be that float was chosen intentionally as a builtin type without literals, to dodge the question of whether E(1) is E[int] or E[Literal[1]]...
There was a problem hiding this comment.
Is it fair to say that even if we infer E[Literal[1]] up above in the generic function example, for classes it's more likely that we'd want to infer the weaker E[int]? (For now I've listed both possibilities like above)
| class Base[T]: ... | ||
| class Sub(Base[Sub]): ... | ||
|
|
||
| # TODO: revealed: Literal[Sub] | ||
| reveal_type(Sub) # revealed: Unknown |
There was a problem hiding this comment.
Ha! I recently tried if CRTP was possible in Python. 😄
I don't think it would work like this, as the base of Sub is eagerly evaluated as a non-type expression. So even from __future__ import annotations wouldn't help the fact that Sub is not yet defined when you try to specify it as it's own base.
I'm not sure if you need CRTP in Python, given that it has Self?
from typing import Self
class Base:
def f(self) -> Self: ...
class Sub(Base): ...
reveal_type(Sub().f()) # revealed: SubThere was a problem hiding this comment.
Self may work for some CRTP use cases, but this recursive pattern is used in Python, and we need to support it. (Most notably, str inherits Sequence[str] in typeshed.) That's why we had a test for this in the old generics.md, which I assume this test was inspired by / replaces.
The two ways to make this code work are to a) put it in a stub file (where all annotations, and base class expressions, are lazy), or b) stringify the inner Sub (e.g. class Sub(Base["Sub"]): ...), which type checkers do understand (although it does lead to some extra indirection for anyone trying to introspect the type arguments at runtime).
I think we should expand this test to show that it works in a stub, it works with the stringified reference, and it does not work (emits an undefined-name diagnostic) if used as shown.
There was a problem hiding this comment.
The two ways to make this code work are to a) put it in a stub file
That would explain why it was in a stub file in the RFC 😂
I think we should expand this test to show that it works in a stub, it works with the stringified reference, and it does not work (emits an undefined-name diagnostic) if used as shown.
Done
| @@ -0,0 +1,51 @@ | |||
| # PEP 695 Generics | |||
There was a problem hiding this comment.
I'll probably not be able to finish my review of this PR today, but I did create a pep695_type_aliases.md test suite a while back, so it probably makes sense to check if there is any overlap (unless you already did).
Also, you might want to add
```toml
[environment]
python-version = "3.12"
```here, or @ntBre will soon make your tests fail 😄
There was a problem hiding this comment.
Don't worry too much! I backed out the red-knot integration for now. I probably won't get back to #16379 this week even. If this merges first, I'm happy to clean up the test failures.
| If a function is annotated as returning a typevar, the return type must be an instance of the actual | ||
| typevar, and not just something assignable to its upper bound. |
There was a problem hiding this comment.
By "the return type", do you mean "types of return expressions" or similar?
I think "must be an instance of the actual typevar" is too narrow? Shouldn't it be something like "if a function is annotated as returning a typevar T, types of return expressions must be assignable to T, and not …"?
There was a problem hiding this comment.
I think this whole sentence is an attempt to clarify what "assignable to T" means. That is, if T has an upper bound of int, that does not make int assignable to T.
There was a problem hiding this comment.
By "the return type", do you mean "types of
returnexpressions" or similar?
It should have been "the returned type".
I copied this example from the RFC. I think what it's trying to check also applies to the parameters, which might be clearer with some reveal_types in the body. i.e. something like
reveal_type(x) # revealed: T & intinstead of
reveal_type(x) # revealed: intI took a stab at rewording this, lmkwyt
There was a problem hiding this comment.
I took a stab at rewording this, lmkwyt
I think it's clear now, thank you.
sharkdp
left a comment
There was a problem hiding this comment.
Haven't finished the review, but this looks like a great start — thank you.
The old mdtest/generics.md test is something that I had to update quite a few times when making seemingly unrelated changes, so I'm kind of glad that it's gone. I hope that the "TODO" assertions in the tests here won't introduce similar problems. I guess we'll find out, but it looks like you already made the assertions rather non-specific (e.g. without precise error messages), which is good.
| def f[T](x: T) -> T: ... | ||
|
|
||
| # TODO: no error | ||
| # TODO: revealed: int |
There was a problem hiding this comment.
What is the principle by which this is int and not Literal[1]?
There was a problem hiding this comment.
In the RFC it seemed like we hadn't landed on which one we would want it to be, but that mypy and pyright both infer this as int. I put int not as a strong opinion about which result we want, but rather because I needed to put something and consistency with mypy/pyright seemed like a good mild tiebreaker. I can instead put both in the TODO along with a comment that we haven't actually decided yet.
There was a problem hiding this comment.
Shoot, I was hoping you'd have a carefully reasoned strong opinion here 😆 Mentioning both options in the TODO seems good for now.
| return x | ||
|
|
||
| def bad[T: int](x: T) -> T: | ||
| # TODO: error: int is not assignable to T |
There was a problem hiding this comment.
We should probably implement checking of returned types against the return type annotation soon :)
| class E[T]: | ||
| def __init__(self, x: T) -> None: ... | ||
|
|
||
| # TODO: revealed: E[float] | ||
| reveal_type(E(1.0)) # revealed: E |
There was a problem hiding this comment.
Agreed -- though it may also be that float was chosen intentionally as a builtin type without literals, to dodge the question of whether E(1) is E[int] or E[Literal[1]]...
| class Base[T]: ... | ||
| class Sub(Base[Sub]): ... | ||
|
|
||
| # TODO: revealed: Literal[Sub] | ||
| reveal_type(Sub) # revealed: Unknown |
There was a problem hiding this comment.
Self may work for some CRTP use cases, but this recursive pattern is used in Python, and we need to support it. (Most notably, str inherits Sequence[str] in typeshed.) That's why we had a test for this in the old generics.md, which I assume this test was inspired by / replaces.
The two ways to make this code work are to a) put it in a stub file (where all annotations, and base class expressions, are lazy), or b) stringify the inner Sub (e.g. class Sub(Base["Sub"]): ...), which type checkers do understand (although it does lead to some extra indirection for anyone trying to introspect the type arguments at runtime).
I think we should expand this test to show that it works in a stub, it works with the stringified reference, and it does not work (emits an undefined-name diagnostic) if used as shown.
There was a problem hiding this comment.
I understand that these test suites are not meant to be exhaustive yet, but it might make sense to add a few tests that may inform the design of the constraint solver? (similar to what you did with the Inferring “deep” generic parameter types test)
For example, a case which can not be solved by simply solving constraints eagerly for the first parameter, then for the second, …
For example:
def f[T](x: T, y: T) -> T:
return x
reveal_type(f("a", "b")) # str
reveal_type(f("a", 1)) # str | intOr this
def f[T](x: T | int, y: T) -> T:
return y
reveal_type(f(1, "a")) # str
reveal_type(f("a", "a")) # str
reveal_type(f(1, 1)) # int
reveal_type(f("a", 1)) # str | intor this
def f[T, S](x: T | S, y: tuple[T, S]) -> tuple[T, S]:
return y
reveal_type(f("a", ("a", 1))) # tuple[str, int]
reveal_type(f(1, ("a", 1))) # tuple[str, int](interesting: Pyright infers tuple[str | int, int] in the very last row here, which is also correct, but seems to imply some asymmetry in constraint solving?)
And maybe also add some generic function definitions that are not allowed:
def absurd[T]() -> T: ...Another thing that seems worth including is a test that makes sure that we can solve nested calls of generic functions (and don't conflate two occurrences of T in different functions), e.g.:
def f[T](x: T) -> tuple[T, int]:
return (x, 1)
def g[T](x: T) -> T | None:
return x
reveal_type(f(g("a"))) # tuple[str | None, int]
reveal_type(g(f("a"))) # tuple[str, int] | NoneThere was a problem hiding this comment.
but it might make sense to add a few tests that may inform the design of the constraint solver?
These are great examples, thank you! Added them all
There was a problem hiding this comment.
And maybe also add some generic function definitions that are not allowed:
def absurd[T]() -> T: ...
pyright doesn't consider this a separate class of error, it reports "typevar only appears once, just use object", just like for
def f[T](x: T) -> None: ...What about something like
def f[T]() -> list[T]:
return []There was a problem hiding this comment.
Another thing that seems worth including is a test that makes sure that we can solve nested calls of generic functions (and don't conflate two occurrences of
Tin different functions), e.g.:
Done
Co-authored-by: Carl Meyer <carl@astral.sh>
| class Base[T]: | ||
| x: T |
There was a problem hiding this comment.
I'm going to have to change this test in my attribute-access PR, because x: T is only a declaration, which means that we can access x on instances of Base[T], but not on class objects.
I'll probably change it to something like
| class Base[T]: | |
| x: T | |
| class Base[T]: | |
| x: T | None = None |
To kick off the work of supporting generics, this adds many new (currently failing) tests, showing the behavior we plan to support.
This is still missing a lot! Not included:
SelfBut it's a good start! We can add more failing tests for those once we tackle these.