Skip to content
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

Merged
merged 4 commits into from
Dec 25, 2019

Conversation

Michael0x2a
Copy link
Collaborator

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:

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

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.)
Copy link
Member

@ilevkivskyi ilevkivskyi left a 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.

# 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.
Copy link
Member

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.

Copy link
Collaborator Author

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]
Copy link
Member

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.

Copy link
Collaborator Author

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)}
Copy link
Member

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?

Copy link
Collaborator Author

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, {}
Copy link
Member

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.

Copy link
Collaborator Author

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]
Copy link
Member

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?

Copy link
Collaborator Author

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.

mypy/checker.py Show resolved Hide resolved
@@ -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:
Copy link
Member

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.

Copy link
Collaborator Author

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.
Copy link
Member

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?

Copy link
Collaborator Author

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.

Copy link
Member

@ilevkivskyi ilevkivskyi left a 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?

mypy/checker.py Outdated Show resolved Hide resolved
@Michael0x2a Michael0x2a merged commit 9101707 into python:master Dec 25, 2019
sthagen added a commit to sthagen/python-mypy that referenced this pull request Dec 25, 2019
Make reachability code understand chained comparisons (v2) (python#8148)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants