-
-
Notifications
You must be signed in to change notification settings - Fork 3k
[PEP 695] Fix incorrect Variance Computation with Polymorphic Methods. #19466
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
base: master
Are you sure you want to change the base?
[PEP 695] Fix incorrect Variance Computation with Polymorphic Methods. #19466
Conversation
This comment has been minimized.
This comment has been minimized.
Co-authored-by: Stanislav Terliakov <[email protected]>
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
Also fixes #18334 and maybe something else. |
@sterliakov I added #18334 as a unit test. |
This comment has been minimized.
This comment has been minimized.
According to mypy_primer, this change doesn't affect type check results on a corpus of open source code. ✅ |
@sterliakov This and a few other small PRs (#19517, #19471, #19449) have been sitting since mid-July, I'm guessing you are rather swamped. Who else should I ask for review? |
I am a bit swamped indeed, and also I don't have merge powers here anyway:) This change is really non-trivial. I have already looked at this PR and, tbh, I'm still not 100% certain that binding self to Any-filled self is the correct move here. It makes some sense to me, but I'd love to see more tests with manually verified logic. I just took another look, and IMO the following snippet will be handled incorrectly: class Mixed[T, U]:
def new[S](self: "Mixed[S, U]", key: S, val: U) -> None: ...
x = Mixed[str, int]()
x.new(object(), 0) # Should error
# So this upcast is not really an upcast and should be rejected, T should be contravariant?
y: Mixed[object, int] = x
y.new(object(), 0) # Should not error
check_contra: Mixed[str, int] = Mixed[object, int]()
check_co: Mixed[object, int] = Mixed[str, int]() It's not strictly incorrect (because resolving I didn't try to compare this to Pyright or other type checkers. I'd suggest to also ping @JukkaL for review - he authored a huge part of PEP695 implementation, including this inference. |
I tested a variation of your example pyright-playground, mypy-playground from typing import cast
class Mixed[T, U]:
def new[S](self: "Mixed[S, U]", key: S, val: U) -> None: ...
def test_co(x: Mixed[str, int]) -> Mixed[object, int]:
return x # master: ❌ PR: ✅, pyright: ✅
def test_contra(x: Mixed[object, int]) -> Mixed[str, int]:
return x # master: ✅ PR: ❌, pyright: ❌
def test_now_sub(y: Mixed[object, int]) -> None:
# str value is assignable to object type.
y.new(key=str(), val=0) # master: ✅ PR: ✅, pyright: ✅
def test_new_super(x: Mixed[str, int]) -> None:
# object type is not assignable to str variable.
x.new(key=object(), val=1) # master: ❌ PR: ❌, pyright: ❌
def test_new_upcast(x: Mixed[str, int]) -> None:
# technically, one could upcast first:
z: Mixed[object, int] = x # master: ❌ PR: ✅, pyright: ✅
z = Mixed[object, int]()
z.new(key=object(), val=1) # master: ✅ PR: ✅, pyright: ✅ So both |
That just feels horribly wrong. LSP asserts that, given two types Please also note that Pyright isn't bug-free, so it makes sense to compare and look closer at any deviations, but it isn't a "golden standard" we aim to match perfectly. Right now I'm moderately certain that Mixed should be contravariant in T (as inferred by current mypy master), not covariant as this PR and Pyright think. I crafted the example to demo the problem with Any substitution. It also means some weird inconsistency: now T is inferred covariant (my vision: contravariant). You can add NB: this is the only category of problems with Any substitution I see right now - when a method has its own type variables parameterizing |
But where is this violated? Given
Technically, in this example Whether So the result is not technically wrong, as explained above Moreover, if we added a covariant constraint like from typing import TypeVar, Generic
T = TypeVar("T", covariant=True, contravariant=False)
U = TypeVar("U", covariant=False, contravariant=False)
S = TypeVar("S", covariant=False, contravariant=False)
class Mixed(Generic[T, U]): # OK, no variance error detected.
def get(self) -> T: ... # force covariance
def new(self: "Mixed[S, U]", key: S, val: U) -> None: ... # does not impose constraints on T https://mypy-play.net/?mypy=latest&python=3.12&gist=811ae6429e7e0e05ba06e34d2daed000 |
Here: def test_new_upcast(x: Mixed[str, int]) -> None:
x.new(object(), 1) # master: err, PR: err, pyright: err
# technically, one could upcast first:
z: Mixed[object, int] = x # master: ❌ PR: ✅, pyright: ✅
z = Mixed[object, int]() # Only to counter weird narrowing by pyright
z.new(key=object(), val=1) # master: ✅ PR: ✅, pyright: ✅ So
I'm trying to say that IMO T should be inferred invariant in this case to reject the upcast shown above. That |
I think that's just incorrect. Again, there's nothing inside the body of The relevant section of the typing spec states:
So in our case, when we infer
being a subtype of
Imposes any constraints on |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Okay, and that was convincing! Seems like this PR implements the spec correctly, and I should move this discussion to typing spec repository or Discourse instead. Thank you for clarification!
(though "replace all type parameters other than the one being inferred by a dummy type instance" definitely does not mean substituting dummies for S
, but that will not impact the outcome here)
I'll post a link here when I have enough energy to check the PEP695 history to see if this has already been discussed and, if not, write a detailed overview for Discourse.
Right. I'll edit my comment; but I do not see how it makes any difference whatsoever. |
Self
type on an attribute #18334testPEP695InferVariancePolymorphicMethod
testPEP695SelfAttribute
My idea for fixing it was to replace
typ = find_member(member, self_type, self_type)
withtyp = find_member(member, self_type, plain_self)
inside the functioninfer_variance
, whereplain_self
is the type of self without any type variables.To be frank, I do not myself 100% understand why it works / if it is safe, but below is my best effort explanation.
Maybe a better solution is to substitute all function variables with
UninhabitedType()
?But I am not sure how to do this directly, since the type is only obtained within
find_member
.According to the docstring of
find_member_simple
:Since
plain_self
is always a supertype of the self type, however it may be parametrized, thetyp
we get this way should be compatible with thetyp
we get using the concreteself_type
. However, by binding self only toplain_self
, it replaces substituted polymorphic variables withNever
.Examples:
With this patch:
Foo.new
becomesdef [S] (self: tmp_d.Foo[Never], arg: builtins.list[Never]) -> tmp_d.Foo[Never]
in typeops.py#L470def (arg: builtins.list[Never]) -> tmp_d.Foo[Never]
in subtypes.py#L2211Bar.new
becomesdef (arg: builtins.list[T`1]) -> tmp_d.Bar[T`1]
(✅)Without this patch:
Foo.new
becomesdef [S] (self: tmp_d.Foo[T`1], arg: builtins.list[T`1]) -> tmp_d.Foo[T`1]
in typeops.py#L470 (❌)def (arg: builtins.list[T`1]) -> tmp_d.Foo[T`1]
in subtypes.py#L2211 (❌)Bar.new
becomesdef (arg: builtins.list[T`1]) -> tmp_d.Bar[T`1]
(✅)Another way to think about it is we can generally assume a signature of the form:
Now, given
self_type
isClass[T]
, it first solvesClass[T] = Class[TypeForm[S, T]]
forS
insidebind_self
, giving us some solutionS(T)
, and then substitutes it giving us some non-polymorphic methoddef method(self: Class[T], arg: TypeForm[T]) -> TypeForm[T]
and then drops the first argument, so we get the bound method
method(arg: TypeForm[T]) -> TypeForm[T]
.By providing the
plain_self
, the solution we get isS = Never
, which solve the problem.