You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
As spotted by @MichaReiser in #13992 (review), red-knot does not currently emit diagnostics if you attempt to iterate over an object that might be iterable, but also might not. For example, we should emit diagnostics on all of the following snippets, but currently do not:
Union where one element has no __iter__ method:
classTestIter:
def__next__(self) ->int:
return42classTest:
def__iter__(self) ->TestIter:
returnTestIter()
defcoinflip() ->bool:
returnTrueforxinTest() ifcoinflip() else42: # should trigger a diagnostic, we don't currentlypass
Union type as iterable where one union element has invalid __iter__ method
classTestIter:
def__next__(self) ->int:
return42classTest:
def__iter__(self) ->TestIter:
returnTestIter()
classTest2:
def__iter__(self) ->int:
return42defcoinflip() ->bool:
returnTrueforxinTest() ifcoinflip() elseTest2(): # should trigger a diagnostic, we don't currentlypass
Union type as iterator where one union element has no __next__ method
classTestIter:
def__next__(self) ->int:
return42classTest:
def__iter__(self) ->TestIter|int:
returnTestIter()
forxinTest(): # should trigger a diagnostic, we don't currentlypass
These are surprisingly hard to fix with our current architecture! I spent several hours yesterday attempting to fix them, and my conclusion is that while it is possible to fix them with our current design, it's messy and complicated enough that we should pursue some refactors first.
One of the issues is the design of our CallOutcome enum, which is returned from Type::call. Type::iterate() calls CallOutcome::return_ty to determine what the iterable's __iter__ and the iterator's __next__ methods return. CallOutcome::return_ty is supposed to help abstract over the fact that the type you're trying to "call" might not be callable (and this includes Type::Unbound): it returns None if this is the case, or Some(ty) if the type you're trying to call is callable. If the type you're trying to call is a union between callable and not-callable types, however, Some(ty) is returned; there's no signal to the caller of CallOutcome::return_ty that the call could fail if the runtime type of the variable is a member of one of the union elements that was not callable.
(This is precisely the situation we need to detect in the above snippets. If __iter__ might be callable, but also might not, then the variable might be iterable, but also might not, which means that we need to emit a diagnostic saying that the code trying to iterate over the maybe-iterable variable is potentially unsafe.)
It seemed like calling CallOutcome::return_ty is not the solution to this problem, so I explored other solutions that instead directly pattern matched on the possible variants of CallOutcome. This is possible, but messy and hard. For a start, CallOutcome has a dedicated variant just for reveal_type, which means that if you're matching over the possible variants of CallOutcome, you are forced to explicitly consider whether a type should be revealed by red-knot in cases like these:
fromtyping_extensionsimportreveal_typeclassFoo:
__iter__=reveal_typeforvarinFoo(): ... # should this... cause us to reveal a type?!# (`__iter__` is "called" on the iterable as part of the `for` loop!)classBar:
__next__=reveal_typeclassBaz:
def__iter__() ->Bar:
returnBar()
forvar2inBaz(): ... # should this... cause us to reveal a type?!# (`__next__` is "called" on the iterator as part of the `for` loop!)
I suppose from a purist perspective, this is something we should explicitly consider, but it doesn't... feel like a useful use of my time (and adds extra complexity) to explicitly consider it!
Another issue with attempting to explicitly match over the possible variants of a CallOutcome instance is that CallOutcome::Union is a recursive variant, meaning you have to write a recursive function just to match over it -- e.g. here's a function I wrote for a partial solution that fixes the first two false-negatives above but not the third one:
Annoyingly complicated recursive function matching on `CallOutcome` variants
I think there are ways to avoid making CallOutcome::Union non-recursive, and that we should pursue them.
All told, I think we should not attempt to fix these bugs for now until we've improved our code structure. A lot of this will look pretty different after @sharkdp's work to remove Type::Unbound in #13980, so I don't plan on looking into this anymore until that's landed.
The text was updated successfully, but these errors were encountered:
I think an alternative approach in cases like this, where the correct handling of a call to a union is complex, is to avoid calling union types altogether, and instead map over the union at a higher level. So wrap up the logic involving calling a dunder in a method, and if we see a union, call that method for every element in the union and fold the results back into a union.
As spotted by @MichaReiser in #13992 (review), red-knot does not currently emit diagnostics if you attempt to iterate over an object that might be iterable, but also might not. For example, we should emit diagnostics on all of the following snippets, but currently do not:
Union where one element has no
__iter__
method:Union type as iterable where one union element has invalid
__iter__
methodUnion type as iterator where one union element has no
__next__
methodThese are surprisingly hard to fix with our current architecture! I spent several hours yesterday attempting to fix them, and my conclusion is that while it is possible to fix them with our current design, it's messy and complicated enough that we should pursue some refactors first.
One of the issues is the design of our
CallOutcome
enum, which is returned fromType::call
.Type::iterate()
callsCallOutcome::return_ty
to determine what the iterable's__iter__
and the iterator's__next__
methods return.CallOutcome::return_ty
is supposed to help abstract over the fact that the type you're trying to "call" might not be callable (and this includesType::Unbound
): it returnsNone
if this is the case, orSome(ty)
if the type you're trying to call is callable. If the type you're trying to call is a union between callable and not-callable types, however,Some(ty)
is returned; there's no signal to the caller ofCallOutcome::return_ty
that the call could fail if the runtime type of the variable is a member of one of the union elements that was not callable.ruff/crates/red_knot_python_semantic/src/types.rs
Lines 1424 to 1450 in 76e4277
(This is precisely the situation we need to detect in the above snippets. If
__iter__
might be callable, but also might not, then the variable might be iterable, but also might not, which means that we need to emit a diagnostic saying that the code trying to iterate over the maybe-iterable variable is potentially unsafe.)It seemed like calling
CallOutcome::return_ty
is not the solution to this problem, so I explored other solutions that instead directly pattern matched on the possible variants ofCallOutcome
. This is possible, but messy and hard. For a start,CallOutcome
has a dedicated variant just forreveal_type
, which means that if you're matching over the possible variants ofCallOutcome
, you are forced to explicitly consider whether a type should be revealed by red-knot in cases like these:I suppose from a purist perspective, this is something we should explicitly consider, but it doesn't... feel like a useful use of my time (and adds extra complexity) to explicitly consider it!
Another issue with attempting to explicitly match over the possible variants of a
CallOutcome
instance is thatCallOutcome::Union
is a recursive variant, meaning you have to write a recursive function just to match over it -- e.g. here's a function I wrote for a partial solution that fixes the first two false-negatives above but not the third one:Annoyingly complicated recursive function matching on `CallOutcome` variants
I think there are ways to avoid making
CallOutcome::Union
non-recursive, and that we should pursue them.All told, I think we should not attempt to fix these bugs for now until we've improved our code structure. A lot of this will look pretty different after @sharkdp's work to remove
Type::Unbound
in #13980, so I don't plan on looking into this anymore until that's landed.The text was updated successfully, but these errors were encountered: