Skip to content

[red-knot] Several failing tests for generics#16509

Merged
dcreager merged 17 commits intomainfrom
dcreager/generic-tests
Mar 5, 2025
Merged

[red-knot] Several failing tests for generics#16509
dcreager merged 17 commits intomainfrom
dcreager/generic-tests

Conversation

@dcreager
Copy link
Copy Markdown
Member

@dcreager dcreager commented Mar 4, 2025

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:

  • typevar tuples
  • param specs
  • variance
  • Self

But it's a good start! We can add more failing tests for those once we tackle these.

@dcreager dcreager added the ty Multi-file analysis & type inference label Mar 4, 2025
Comment on lines +112 to +116
class E[T]:
def __init__(self, x: T) -> None: ...

# TODO: revealed: E[float]
reveal_type(E(1.0)) # revealed: E
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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].

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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]]...

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Comment on lines +154 to +158
class Base[T]: ...
class Sub(Base[Sub]): ...

# TODO: revealed: Literal[Sub]
reveal_type(Sub) # revealed: Unknown
Copy link
Copy Markdown
Contributor

@sharkdp sharkdp Mar 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: Sub

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copy link
Copy Markdown
Contributor

@sharkdp sharkdp Mar 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 😄

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +46 to +47
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.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 …"?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By "the return type", do you mean "types of return expressions" 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 & int

instead of

reveal_type(x)  # revealed: int

I took a stab at rewording this, lmkwyt

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I took a stab at rewording this, lmkwyt

I think it's clear now, thank you.

Copy link
Copy Markdown
Contributor

@sharkdp sharkdp left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

@carljm carljm left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great!!

def f[T](x: T) -> T: ...

# TODO: no error
# TODO: revealed: int
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the principle by which this is int and not Literal[1]?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

@carljm carljm Mar 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shoot, I was hoping you'd have a carefully reasoned strong opinion here 😆 Mentioning both options in the TODO seems good for now.

Comment thread crates/red_knot_python_semantic/resources/mdtest/generics/functions.md Outdated
Comment thread crates/red_knot_python_semantic/resources/mdtest/generics/functions.md Outdated
return x

def bad[T: int](x: T) -> T:
# TODO: error: int is not assignable to T
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably implement checking of returned types against the return type annotation soon :)

Comment on lines +112 to +116
class E[T]:
def __init__(self, x: T) -> None: ...

# TODO: revealed: E[float]
reveal_type(E(1.0)) # revealed: E
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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]]...

Comment thread crates/red_knot_python_semantic/resources/mdtest/generics/scoping.md Outdated
Comment on lines +154 to +158
class Base[T]: ...
class Sub(Base[Sub]): ...

# TODO: revealed: Literal[Sub]
reveal_type(Sub) # revealed: Unknown
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread crates/red_knot_python_semantic/resources/mdtest/generics/scoping.md Outdated
Comment thread crates/red_knot_python_semantic/resources/mdtest/generics/scoping.md Outdated
Comment thread crates/red_knot_python_semantic/resources/mdtest/generics/scoping.md Outdated
@MichaReiser MichaReiser removed their request for review March 5, 2025 07:40
Copy link
Copy Markdown
Contributor

@sharkdp sharkdp Mar 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 | int

Or 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 | int

or 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] | None

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 []

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.:

Done

Copy link
Copy Markdown
Contributor

@carljm carljm left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

@dcreager dcreager merged commit ebd172e into main Mar 5, 2025
@dcreager dcreager deleted the dcreager/generic-tests branch March 5, 2025 22:21
Comment on lines +131 to +132
class Base[T]:
x: T
Copy link
Copy Markdown
Contributor

@sharkdp sharkdp Mar 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Suggested change
class Base[T]:
x: T
class Base[T]:
x: T | None = None

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ty Multi-file analysis & type inference

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants