-
-
Notifications
You must be signed in to change notification settings - Fork 2.9k
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
Make reachability code understand chained comparisons (v2) #8148
Make reachability code understand chained comparisons (v2) #8148
Conversation
This pull request is v2 (well, more like v10...) of my attempts to make our reachability code better understand chained comparisons. Unlike python#7169, this diff focuses exclusively on adding support for chained operation comparisons and deliberately does not attempt to change any of the semantics of how identity and equality operations are performed. Specifically, mypy currently only examines the first two operands within a comparison expression when refining types. That means the following expressions all do not behave as expected: ```python x: MyEnum y: MyEnum if x is y is MyEnum.A: # x and y are not narrowed at all if x is MyEnum.A is y: # Only x is narrowed to Literal[MyEnum.A] ``` This pull request fixes this so we correctly infer the literal type for x and y in both conditionals. Some additional notes: 1. While analyzing our codebase, I found that while comparison expressions involving two or more `is` or `==` operators were somewhat common, there were almost no comparisons involving chains of `!=` or `is not` operators, and no comparisons involving "disjoint chains" -- e.g. expressions like `a == b < c == b` where there are multiple "disjoint" chains of equality comparisons. So, this diff is primarily designed to handle the case where a comparision expression has just one chain of `is` or `==`. For all other cases, I fall back to the more naive strategy of evaluating each comparision individually and and-ing the inferred types together without attempting to propagate any info. 2. I tested this code against one of our internal codebases. This ended up making mypy produce 3 or 4 new errors, but they all seemed legitimate, as far as I can tell. 3. I plan on submitting a follow-up diff that takes advantage of the work done in this diff to complete support for tagged unions using any Literal key, as previously promised. (I tried adding support for tagged unions in this diff, but attempting to simultaneously add support for chained comparisons while overhauling the semantics of `==` proved to be a little too overwhelming for me. So, baby steps.)
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.
Thanks, great work! I think I like the large-scale idea. I didn't check all the details, but here is a bunch of minor comments.
test-data/unit/check-enum.test
Outdated
# TODO: This should behave in the same way as above. | ||
# However, unlike the above, we currently don't progressively update the type of 'x' as | ||
# we check each individual comparison. So, when we do 'x is Foo.B', mypy still thinks | ||
# 'x' is of type 'Foo', which is why we get the below faulty result. |
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.
Would it be possible to fix this by merging the groups if they contain expressions with the same literal hash and literal level LITERAL_TYPE
? I mean my guess is that this fails because there are two groups in simplified_operator_list
that contain x
.
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.
I tried implementing this suggestion, and it seems to work!
It did make the grouping algorithm more complicated though -- the line-count roughly doubled in size, I think. I also tried implementing a more direct/naive "merge the groups after making them" approach, and that ended up being similarly complex.
Not sure if that's something we're ok with or not: this feels like a lot of code for what I suspect is ultimately a very rare edge case. Maybe it might be better to remove the changes I made and keep this TODO to try and help minimize the maintenance burden of these changes? LMK what you think.
reveal_type(x) # N: Revealed type is '__main__.Foo' | ||
reveal_type(x) # N: Revealed type is '__main__.Foo' | ||
|
||
[builtins fixtures/primitives.pyi] |
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.
Maybe one test with a tricky grouping, like two triples connected with <
? Ideally you could just add a unit test for the grouping algorithm, but it seems to me this is not easy.
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.
Done!
I also refactored out the grouping algorithm into a standalone function and added several unit tests for it to testinfer.py
. (Not sure is this is the right home for these tests though. LMK if you want me to move them.)
if node.operators == ['in']: | ||
return {expr: remove_optional(left_type)}, {} | ||
if node.operators == ['not in']: | ||
return {}, {expr: remove_optional(left_type)} |
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.
I can't find where the this whole chunk moved. Do we now support a in b in c
? If yes, is there a test?
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.
This chunk of code starts on line 3853. I guess the diff ended up being a little messy because I renamed the variables ("left_type" -> "item_type" and "right_type" -> "collection_type") and moved the logic around a bit.
But we don't do anything really special for this case: we treat a in b in c
as if it were a in b and b in c
and only narrow away Optionals from the LHS item type if it overlaps with whatever's inside the RHS collection.
So in the end, I don't think we've really improved support for this pattern.
if any(is_overlapping_erased_types(expr_type, t) for t in non_optional_types): | ||
if_map[operands[i]] = remove_optional(expr_type) | ||
|
||
return if_map, {} |
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.
Should this refine both x
and y
if I have:
x: Optional[int]
y: Optional[int]
if x == y == 1:
...
If yes, please add a test for this.
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.
It does! I added a test to check-optional.test
.
|
||
# Oh well, give up and just arbitrarily pick the last item. | ||
if singleton_index == -1: | ||
singleton_index = possible_singleton_indices[-1] |
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.
Do we have a test for this situation?
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.
Currently, no -- but I did spend some time trying to construct one and eventually ended up convincing myself that we'll always get the same result no matter which index we pick. I updated the comment to include the reasoning.
@@ -4587,6 +4763,75 @@ def or_conditional_maps(m1: TypeMap, m2: TypeMap) -> TypeMap: | |||
return result | |||
|
|||
|
|||
def or_partial_conditional_maps(m1: TypeMap, m2: TypeMap) -> TypeMap: |
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.
This name doesn't really reflect what this does. Maybe use combine_conditional_maps()
would be better? I however don't have strong feelings here.
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.
I think using the word "combine" would be a bit ambiguous because it'd be unclear whether the combining step will end up "and"-ing or "or"-ing the the two maps.
Basically, the way I was thinking about this change is that there are now two kinds of TypeMaps -- full ones, which represent all info we know about types within a certain "context", and partial ones which contain only some of the info.
Partial TypeMaps also only exist as an implementation detail of the narrowing logic: it always ends up returning full TypeMaps.
So if and_conditional_maps
and or_conditional_maps
functions are for full TypeMaps, I was thinking it made sense to add and_partial_conditional_maps
and or_partial_conditional_maps
functions for the partial ones.
But it also turned out that and_conditional_maps
and and_partial_conditional_maps
behave in the exact same way/would share the same implementation, so I didn't bother defining the latter.
The other approach I could take is to not define the or_partial_conditional_maps
and instead just rewrite reduce_partial_conditional_maps
function to produce the output maps more directly and do the (pseudo)-intersecting and unioning itself.
That works just as well (and is actually probably slightly more efficient), but does end up making it harder to see how the function relates to the existing conditional map logic.
) | ||
|
||
...where "PseudoIntersection[X, Y] == Y" because mypy actually doesn't understand intersections | ||
yet, so we settle for just arbitrarily picking the right expr's type. |
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.
Do we have a test whether this would actually make a difference?
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.
It seems yes -- if I modify and_conditional_maps
to bias towards picking elements from the left map, this ends up breaking the testEnumReachabilityWithMultipleEnums
test. Basically, when we do:
class Foo(Enum):
A = 1
B = 2
class Bar(Enum):
A = 1
B = 2
x3: Union[Foo, Bar]
if x3 is Foo.A or x3 is Bar.A:
reveal_type(x3)
else:
reveal_type(x3)
...we end up inferring the less precise type of Union[Literal[Foo.B], Bar]
instead of Union[Literal[Foo.B], Literal[Bar.B]]
in the else case -- the less precise narrowing we get from x3 is Foo.A
overrode anything we learned in the next clause.
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.
Not sure if that's something we're ok with or not: this feels like a lot of code for what I suspect is ultimately a very rare edge case. Maybe it might be better to remove the changes I made and keep this TODO to try and help minimize the maintenance burden of these changes? LMK what you think.
I think this is generally fine. Also do I understand correctly that the only reason for the more complex algorithm instead of few lines iterative group merging is that the naive algorithm is quadratic in the number of comparisons?
Make reachability code understand chained comparisons (v2) (python#8148)
This pull request is v2 (well, more like v10...) of my attempts to make our reachability code better understand chained comparisons.
Unlike #7169, this diff focuses exclusively on adding support for chained operation comparisons and deliberately does not attempt to change any of the semantics of how identity and equality operations are performed.
Specifically, mypy currently only examines the first two operands within a comparison expression when refining types. That means the following expressions all do not behave as expected:
This pull request fixes this so we correctly infer the literal type
for x and y in both conditionals.
Some additional notes:
While analyzing our codebase, I found that while comparison expressions involving two or more
is
or==
operators were somewhat common, there were almost no comparisons involving chains of!=
oris not
operators, and no comparisons involving "disjoint chains" -- e.g. expressions likea == b < c == b
where there are multiple "disjoint" chains of equality comparisons.So, this diff is primarily designed to handle the case where a comparision expression has just one chain of
is
or==
. For all other cases, I fall back to the more naive strategy of evaluating each comparision individually and and-ing the inferred types together without attempting to propagate any info.I tested this code against one of our internal codebases. This ended up making mypy produce 3 or 4 new errors, but they all seemed legitimate, as far as I can tell.
I plan on submitting a follow-up diff that takes advantage of the work done in this diff to complete support for tagged unions using any Literal key, as previously promised.
(I tried adding support for tagged unions in this diff, but attempting to simultaneously add support for chained comparisons while overhauling the semantics of
==
proved to be a little too overwhelming for me. So, baby steps.)