-
-
Notifications
You must be signed in to change notification settings - Fork 2.9k
Implement per-file strict Optional #3206
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
Conversation
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.
What do you think of the idea of moving the context managers into the call sites? Much less code churn, which somehow is always high on my list of PR quality issues.
with self.binder.top_frame_context(): | ||
for d in self.tree.defs: | ||
self.accept(d) | ||
with experiments.strict_optional_set(self.options.strict_optional): |
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 wonder if it would be better to wrap the call sites in such a context manager? Fewer lines in the diff, and there's only one call site in build.py (and another in server/update.py which is only used by fine-grained incrementalism). Ditto for check_second_pass() and visit_file() -- these are each only called from one place in build.py and perhaps another place in server/.
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'm not sure how likely it is that we ever add more call sites, but I think that's a bug in the making. The context manager should always wrap this code -- I don't think there's any case where we'd want to call this while ignoring the per-file Strict Optional setting. I understand that you're not a huge fan of code churn, but I don't think it'd be a worthwhile tradeoff in this instance.
Also, FWIW git blame
has the -w
flag which ignores whitespace changes, so this doesn't have to mess up anyone's blame.
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.
Hm. git might have that flag but GitHub doesn't (as shown in this code review).
Maybe you can make it a decorator containing a context manager?
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 looks like that won't quite work, but apparently github has a -w
equivalent too! https://github.com/python/mypy/pull/3206/files?w=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.
Hm, the -w flag is flawed -- it doesn't show review comments, and it's not sticky.
saved = STRICT_OPTIONAL | ||
STRICT_OPTIONAL = value | ||
yield | ||
STRICT_OPTIONAL = saved |
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 know you said it was hacky, but this is where I realized quite how hacky... :-)
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.
Yep. :/
@@ -407,7 +407,7 @@ class HasNone(NamedTuple): | |||
x: int | |||
y: Optional[int] = None | |||
|
|||
reveal_type(HasNone(1)) # E: Revealed type is 'Tuple[builtins.int, builtins.int, fallback=__main__.HasNone]' | |||
reveal_type(HasNone(1)) # E: Revealed type is 'Tuple[builtins.int, Union[builtins.int, builtins.None], fallback=__main__.HasNone]' |
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.
Off-topic: I so wish that Union[X, None]
were rendered as Optional[X]
... (Hm, somewhere below there is a test that shows that: test-data/unit/check-isinstance.test -- what's the 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 is in most cases, actually. Mypy has 2 different ways of rendering types: an internal representation and an external representation. The external representation is what's used for all errors, and renders Union[X, None]
as Optional[X]
. The internal representation is used by reveal_type
for some reason that I no longer recall, and does not do this translation. I've been wanting to clean this up since forever (and make them all have a bit more PEP 484 style), but haven't ever had the time.
What are the plans concerning |
Regarding the roadmap, we all want this to become the default, but there are several roadblocks.
It would be acceptable to turn on the option by default only when we at least have decent tactics to prevent regressions in mypy itself and the Dropbox codebases. For mypy, that tactic could be to enable it only for a few files, and then gradually increase coverage. That's relatively straightforward (unlike actually getting 100% coverage). For the Dropbox codebases it's a little more complicated, because we are already actively using the old strict-optional settings in some places, and we have a complicated setup where we run mypy twice, once without strict-optional, and once with it, but suppressing errors in those files that aren't yet strict-optional-clean. I want us to switch to the new approach represented by this PR first before we can even think of making it the default in mypy, just so we don't have a flag that the whole world uses except us (there would be serious quality concerns there). Really, you could argue with all of this (why should Dropbox be the bar) but I'd like to have some more real-world experience before turning it on by default. If you find it simple to make mypy itself strict-optional-clean, please be our guest, but hopefully you can submit many small PRs rather than huge one that causes merge conflicts everywhere else. |
This is what I was thinking about. There are in total 547 |
Hmm. Sometimes fixing an error in one place makes several new errors appear. Yet I am optimistic. |
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.
Can you also make all the tests pass?
mypy/checker.py
Outdated
@@ -1459,6 +1461,9 @@ def check_multi_assignment(self, lvalues: List[Lvalue], | |||
rvalue_type = self.expr_checker.accept(rvalue) # TODO maybe elsewhere; redundant | |||
undefined_rvalue = False | |||
|
|||
if isinstance(rvalue_type, UnionType) and len(rvalue_type.relevant_items()) == 1: | |||
rvalue_type = rvalue_type.relevant_items()[0] |
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 don't like how this is calling relevant_items() twice.
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.
Yeah, that was something that I was thinking about as I was writing this. I could change this to:
if isinstance(rvalue_type, UnionType):
relevant_items = rvalue_type.relevant_items()
if len(relevant_items) == 1:
rvalue_type = relevant_items[0]
but I'm not sure that's particularly nicer? If it seems better to you, then I'm happy to do it.
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.
Yeah, I like that better (the if
condition was getting a bit long anyways).
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.
👍
mypy/checker.py
Outdated
rvalue_type = cast(TupleType, self.expr_checker.accept(rvalue, lvalue_type)) | ||
reinferred_rvalue_type = self.expr_checker.accept(rvalue, lvalue_type) | ||
if (isinstance(reinferred_rvalue_type, UnionType) and | ||
len(reinferred_rvalue_type.relevant_items()) == 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.
Again, this calls relevant_items() twice.
mypy/checker.py
Outdated
@@ -1486,7 +1491,12 @@ def check_multi_assignment_from_tuple(self, lvalues: List[Lvalue], rvalue: Expre | |||
if not undefined_rvalue: | |||
# Infer rvalue again, now in the correct type context. | |||
lvalue_type = self.lvalue_type_for_inference(lvalues, rvalue_type) | |||
rvalue_type = cast(TupleType, self.expr_checker.accept(rvalue, lvalue_type)) | |||
reinferred_rvalue_type = self.expr_checker.accept(rvalue, lvalue_type) | |||
if (isinstance(reinferred_rvalue_type, UnionType) and |
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.
Can you add a comment here about what's going on? I think this may be the crucial place where Optional[X] becomes X?
mypy/types.py
Outdated
for x in self.relevant_items()) | ||
|
||
def relevant_items(self) -> List[Type]: | ||
"""Returns all Union items relevant to current context. In particular, |
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 it would be better if you just wrote that this function removes None from unions when strict None checking is off. (And "context" is a technical term in most of mypy so that word threw me off for the first few milliseconds.)
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.
Yeah, that's definitely clearer.
🎉! I think this still needs a little bit more work before merging -- a few more places need tweaking so it doesn't cause errors in our internal codebase. I'll ping here again once I've done that! |
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 really would like to make another plea for doing the proper refactor (enabling the decorator-based solution) now rather than piling up tech debt.
Highly indented code is less readable.
You could make two decorators, one that uses self.options, the other uses options passed in.
x = 0 # type: Optional[int] | ||
y = None # type: None | ||
|
||
[file mypy.ini] |
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 make sense to add tests with invariant collections such as List
? In non-strict-optional files, should List[Optional[int]]
be compatible with List[int]
and vice versa?
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.
Added!
Just an interesting observation: 6 |
@ilevkivskyi wrote
You can guard against that by adding per-module @ddfisher Can you resolve the conflicts and address the reviews? |
Do I understand correctly that will only work after this PR is merged? Or can I continue working on making mypy strict-optional-clean without waiting for this PR? |
Sorry, yes, that option will only work once this PR is merged. We apologize for the delay, it's been on the back burner since around PyCon, but the plan is to finish the work before July starts. |
(Not yet ready to merge -- there are several errors in internal codebases that still need to be fixed.) |
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 am fine with this landing.
|
||
[file optional.py] | ||
from typing import Optional, List | ||
def f(x: List[int]) -> None: pass |
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.
Didn't you mean to involve f() in some test in this file?
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 I left it for parallel structure with the other tests, but it doesn't actually participate in this one.
This should now be ready to land. It still causes a small number of errors across our two main internal codebases, but the errors are all legitimate (and it's not clear to me why some of them aren't caught currently). |
If there's an issue requesting this feature can you track it down and close it? |
🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉 |
I don't see an issue for this, actually. |
I don't think this is quite 100%, but I think it's close. In particular, suggestions for more tests would be very helpful.
This definitely feels a little hacky, but I'm not convinced there's much of a better way to do it. It currently runs cleanly against the smaller of our internal repos, and with only 6 errors against the larger.