Support making a variable Optional in an else branch - #11002
Conversation
That is, support patterns such as:
```
if condition:
foo = Foo()
else:
foo = None
```
Currently this does not work, but the the *reverse* does (because foo
will be inferred as a PartialType).
I think it might be worth tackling this in a more general way, for
other types, though I think that is a little fiddlier and likely to be
more controversial, so I'm starting with something special-cased for
the "assigning literal None" case first.
The rule we implement is that we allow updating the type of a variable
when assigning `None` to it if the variable's type was inferred and it
was defined in an earlier branch of the same `if/then/else` statement.
Some infrastructure is added to make determinations about that.
Given that this is probably my single biggest frustration with mypy,
and that this PR took me less than three hours to prepare, I am pretty
angry at myself for not having done this three years ago.
f413067 to
eecc3b3
Compare
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.
emmatyping
left a comment
There was a problem hiding this comment.
This seems really great, its a common annoyance so fixing it would be a nice win :)
I had a couple of suggestions but otherwise looks good.
| main:4: note: "List" is invariant -- see https://mypy.readthedocs.io/en/stable/common_issues.html#variance | ||
| main:4: note: Consider using "Sequence" instead, which is covariant | ||
| [builtins fixtures/list.pyi] | ||
|
|
There was a problem hiding this comment.
Other ideas for tests would be mixing different kinds of frames and control flow e.g. try/except and with statements
There was a problem hiding this comment.
Hm, related to that: maybe it's worth supporting this pattern for try/except also?
There was a problem hiding this comment.
I decided to try that, though I could be easily convinced to drop it!
This comment has been minimized.
This comment has been minimized.
| return z # E: Incompatible return value type (got "Optional[int]", expected "int") | ||
|
|
||
| def f3(b: bool) -> int: | ||
| # XXX: This one is a little questionable! Maybe we *do* want to allow this? |
There was a problem hiding this comment.
I think this would be nice to have, but it probably should be discussed more (esp. since there are few cases where the type is different from the first assignment)
There was a problem hiding this comment.
Yeah, and if we want to allow this, this can be implemented in a separate PR.
|
@JukkaL what do you think? |
JukkaL
left a comment
There was a problem hiding this comment.
Thanks, this is a very nice feature that I've also wanted to have for a long time! I didn't do a full review yet, but I came up with various additional test cases.
| return z # E: Incompatible return value type (got "Optional[int]", expected "int") | ||
|
|
||
| def f3(b: bool) -> int: | ||
| # XXX: This one is a little questionable! Maybe we *do* want to allow this? |
There was a problem hiding this comment.
Yeah, and if we want to allow this, this can be implemented in a separate PR.
| else: | ||
| y = None | ||
| reveal_type(y) # N: Revealed type is "Union[builtins.int, None]" | ||
|
|
There was a problem hiding this comment.
Ideas for additional test cases below. I'm not sure what the semantics should be in some of these cases, but at least it makes sense to have some well-defined semantics for these. Omitted most reveal_type(z) etc. for brevity.
Case 1:
if b:
z = 2
elif c:
z = None
elif d:
z = 3
Case 2:
if c:
x = []
else:
x = None
Case 3:
if b:
z = 4
else:
if c:
z = 5
else:
z = None
Case 4:
Use this at module top level, or within class body.
Case 5:
if b:
z = 5
else:
z = None
if c:
z = 6
Case 6:
if b:
x = []
x.append(1)
else:
x = None
Case 7:
if b:
x: Any = 5
else:
x = None
reveal_type(x)
Case 8:
[Tricky, optional] Use at module top level, and have a forward reference that causes two passes over the top level during type checking.
Case 9:
[Tricky, optional] Have a forward reference where the type of the reference target is not ready yet within a function between the first and second assignment. This causes the function to be deferred.
Case 10:
Define an attribute using self.x = 5 / self.x = None. I think that it's not safe to allow this, and we may need to reject this, since the attribute type is visible outside the method (see below for an example where this might cause trouble).
Case 11:
[Very tricky, optional] Define an attribute, and cause the method to be deferred after the initial self.x assignment. Now another method refers to the x attribute, and it should not see the non-optional type.
There was a problem hiding this comment.
OK, I've addressed most of the tests. Some notes:
2. Demands a type annotation
4. Is disallowed at top level. I'm not sure it has to be, but there could be complications and it seems unlikely to come up? We ignore = None assignments at class level so that pre 3.6 code can write type comments, so nothing useful happens there.
6. Works
7. Infers Any
8, 10, 11: All are disallowed
9: Works fine
This comment has been minimized.
This comment has been minimized.
JukkaL
left a comment
There was a problem hiding this comment.
Thanks for the updates! Looks pretty good. It is hacky, but this is an important use case so it seems worth it, as I can't think of a cleaner way to do this (without a major redesign). Left a few remaining comments. Feel free to merge once you've addressed them.
| # directly or indirectly. | ||
| module_refs: Set[str] | ||
|
|
||
| # A map from variable nodes to a snapshot of the current frames |
There was a problem hiding this comment.
"Current frames" here means frames active when the variable is first initialized? Can you explain this in more detail here?
There was a problem hiding this comment.
Added some more text. Also realized that we don't need to store the bools at all, since we can just read it out from the frame
| # A map from variable nodes to a snapshot of the current frames | ||
| # and whether they are associated with `if` statements. This can | ||
| # be used to determine if a variable is defined in a different | ||
| # branch of the same `if` statement. |
There was a problem hiding this comment.
Mention that the inner dictionary is keyed by frame id.
Maybe clarify that if we have the same frame id in both first initialization and follow-up assignment with a true value, they are in different branches of the same if statement (assuming that I got this right)?
|
Diff from mypy_primer, showing the effect of this PR on open source code: jax (https://github.com/google/jax.git)
+ jax/interpreters/pxla.py:807: error: Argument 1 to "len" has incompatible type "Optional[List[Any]]"; expected "Sized" [arg-type]
- jax/_src/numpy/lax_numpy.py:1846: error: Argument 1 to "_wraps" has incompatible type "Optional[Any]"; expected "Callable[..., Any]" [arg-type]
+ jax/_src/numpy/lax_numpy.py:1846: note: (Skipping most remaining errors due to unresolved imports or missing stubs; fix these first)
- jax/experimental/host_callback.py:1705: note: (Skipping most remaining errors due to unresolved imports or missing stubs; fix these first)
aioredis (https://github.com/aio-libs/aioredis.git)
- aioredis/lock.py:218: error: Incompatible types in assignment (expression has type "None", variable has type "int")
- aioredis/client.py:3403: error: Incompatible types in assignment (expression has type "None", variable has type "ValuesView[Any]")
pandas (https://github.com/pandas-dev/pandas.git)
+ pandas/plotting/_matplotlib/core.py:1664: error: unused "type: ignore" comment
poetry (https://github.com/python-poetry/poetry.git)
+ poetry/mixology/failure.py:144: error: Item "NoVersionsCause" of "Union[RootCause, NoVersionsCause, DependencyCause, ConflictCause, PythonCause, PlatformCause, PackageNotFoundCause]" has no attribute "conflict"
|
|
@msullivan Am I missing something? Could not get this to work: https://mypy-play.net/?mypy=latest&python=3.10&gist=c62637d27e9292d01b8473f68b9e45f5 |
That is, support patterns such as:
Currently this does not work, but the the reverse does (because foo
will be inferred as a PartialType).
I think it might be worth tackling this in a more general way, for
other types, though I think that is a little fiddlier and likely to be
more controversial, so I'm starting with something special-cased for
the "assigning literal None" case first.
The rule we implement is that we allow updating the type of a variable
when assigning
Noneto it if the variable's type was inferred and itwas defined in an earlier branch of the same
if/then/elsestatement.Some infrastructure is added to make determinations about that.
Given that this is probably my single biggest frustration with mypy,
and that this PR took me less than three hours to prepare, I am pretty
angry at myself for not having done this three years ago.