Skip to content

Commit 7b3ad9d

Browse files
ddfishergvanrossum
authored andcommitted
Add option to limit strict Optional errors to whitelist of globs (#2060)
1 parent 6aeb7a5 commit 7b3ad9d

File tree

7 files changed

+129
-18
lines changed

7 files changed

+129
-18
lines changed

docs/source/command_line.rst

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,18 @@ Here are some more useful flags:
227227
use of ``None`` values -- they are valid everywhere. See :ref:`strict_optional` for
228228
more about this feature.
229229

230+
- ``--strict-optional-whitelist`` attempts to suppress strict Optional-related
231+
errors in non-whitelisted files. Takes an arbitrary number of globs as the
232+
whitelist. This option is intended to be used to incrementally roll out
233+
``--strict-optional`` to a large codebase that already has mypy annotations.
234+
However, this flag comes with some significant caveats. It does not suppress
235+
all errors caused by turning on ``--strict-optional``, only most of them, so
236+
there may still be a bit of upfront work to be done before it can be used in
237+
CI. It will also suppress some errors that would be caught in a
238+
non-strict-Optional run. Therefore, when using this flag, you should also
239+
re-check your code without ``--strict-optional`` to ensure new type errors
240+
are not introduced.
241+
230242
- ``--disallow-untyped-defs`` reports an error whenever it encounters
231243
a function definition without type annotations.
232244

mypy/checker.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import itertools
44
import contextlib
5+
import fnmatch
56
import os
67
import os.path
78

@@ -114,7 +115,10 @@ class TypeChecker(NodeVisitor[Type]):
114115
# Have we deferred the current function? If yes, don't infer additional
115116
# types during this pass within the function.
116117
current_node_deferred = False
118+
# Is this file a typeshed stub?
117119
is_typeshed_stub = False
120+
# Should strict Optional-related errors be suppressed in this file?
121+
suppress_none_errors = False
118122
options = None # type: Options
119123

120124
def __init__(self, errors: Errors, modules: Dict[str, MypyFile], options: Options) -> None:
@@ -148,6 +152,12 @@ def visit_file(self, file_node: MypyFile, path: str) -> None:
148152
self.weak_opts = file_node.weak_opts
149153
self.enter_partial_types()
150154
self.is_typeshed_stub = self.errors.is_typeshed_file(path)
155+
if self.options.strict_optional_whitelist is None:
156+
self.suppress_none_errors = False
157+
else:
158+
self.suppress_none_errors = not any(fnmatch.fnmatch(path, pattern)
159+
for pattern
160+
in self.options.strict_optional_whitelist)
151161

152162
for d in file_node.defs:
153163
self.accept(d)
@@ -2125,6 +2135,8 @@ def check_subtype(self, subtype: Type, supertype: Type, context: Context,
21252135
if self.is_unusable_type(subtype):
21262136
self.msg.does_not_return_value(subtype, context)
21272137
else:
2138+
if self.should_suppress_optional_error([subtype]):
2139+
return False
21282140
extra_info = [] # type: List[str]
21292141
if subtype_label is not None or supertype_label is not None:
21302142
subtype_str, supertype_str = self.msg.format_distinctly(subtype, supertype)
@@ -2137,6 +2149,17 @@ def check_subtype(self, subtype: Type, supertype: Type, context: Context,
21372149
self.fail(msg, context)
21382150
return False
21392151

2152+
def contains_none(self, t: Type):
2153+
return (
2154+
isinstance(t, NoneTyp) or
2155+
(isinstance(t, UnionType) and any(self.contains_none(ut) for ut in t.items)) or
2156+
(isinstance(t, TupleType) and any(self.contains_none(tt) for tt in t.items)) or
2157+
(isinstance(t, Instance) and t.args and any(self.contains_none(it) for it in t.args))
2158+
)
2159+
2160+
def should_suppress_optional_error(self, related_types: List[Type]) -> bool:
2161+
return self.suppress_none_errors and any(self.contains_none(t) for t in related_types)
2162+
21402163
def named_type(self, name: str) -> Instance:
21412164
"""Return an instance type with type given by the name and no
21422165
type arguments. For example, named_type('builtins.object')

mypy/checkexpr.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -289,7 +289,7 @@ def check_call(self, callee: Type, args: List[Node],
289289
elif isinstance(callee, Instance):
290290
call_function = analyze_member_access('__call__', callee, context,
291291
False, False, False, self.named_type,
292-
self.not_ready_callback, self.msg)
292+
self.not_ready_callback, self.msg, chk=self.chk)
293293
return self.check_call(call_function, args, arg_kinds, context, arg_names,
294294
callable_node, arg_messages)
295295
elif isinstance(callee, TypeVarType):
@@ -709,6 +709,8 @@ def check_arg(self, caller_type: Type, original_caller_type: Type,
709709
elif isinstance(caller_type, DeletedType):
710710
messages.deleted_as_rvalue(caller_type, context)
711711
elif not is_subtype(caller_type, callee_type):
712+
if self.chk.should_suppress_optional_error([caller_type, callee_type]):
713+
return
712714
messages.incompatible_argument(n, m, callee, original_caller_type,
713715
caller_kind, context)
714716

@@ -757,7 +759,8 @@ def overload_call_target(self, arg_types: List[Type], arg_kinds: List[int],
757759
match.append(typ)
758760
best_match = max(best_match, similarity)
759761
if not match:
760-
messages.no_variant_matches_arguments(overload, arg_types, context)
762+
if not self.chk.should_suppress_optional_error(arg_types):
763+
messages.no_variant_matches_arguments(overload, arg_types, context)
761764
return AnyType()
762765
else:
763766
if len(match) == 1:
@@ -871,7 +874,8 @@ def analyze_ordinary_member_access(self, e: MemberExpr,
871874
# This is a reference to a non-module attribute.
872875
return analyze_member_access(e.name, self.accept(e.expr), e,
873876
is_lvalue, False, False,
874-
self.named_type, self.not_ready_callback, self.msg)
877+
self.named_type, self.not_ready_callback, self.msg,
878+
chk=self.chk)
875879

876880
def analyze_external_member_access(self, member: str, base_type: Type,
877881
context: Context) -> Type:
@@ -880,7 +884,8 @@ def analyze_external_member_access(self, member: str, base_type: Type,
880884
"""
881885
# TODO remove; no private definitions in mypy
882886
return analyze_member_access(member, base_type, context, False, False, False,
883-
self.named_type, self.not_ready_callback, self.msg)
887+
self.named_type, self.not_ready_callback, self.msg,
888+
chk=self.chk)
884889

885890
def visit_int_expr(self, e: IntExpr) -> Type:
886891
"""Type check an integer literal (trivial)."""
@@ -1032,7 +1037,8 @@ def check_op_local(self, method: str, base_type: Type, arg: Node,
10321037
Return tuple (result type, inferred operator method type).
10331038
"""
10341039
method_type = analyze_member_access(method, base_type, context, False, False, True,
1035-
self.named_type, self.not_ready_callback, local_errors)
1040+
self.named_type, self.not_ready_callback, local_errors,
1041+
chk=self.chk)
10361042
return self.check_call(method_type, [arg], [nodes.ARG_POS],
10371043
context, arg_messages=local_errors)
10381044

@@ -1555,7 +1561,7 @@ def analyze_super(self, e: SuperExpr, is_lvalue: bool) -> Type:
15551561
return analyze_member_access(e.name, self_type(e.info), e,
15561562
is_lvalue, True, False,
15571563
self.named_type, self.not_ready_callback,
1558-
self.msg, base)
1564+
self.msg, base, chk=self.chk)
15591565
else:
15601566
# Invalid super. This has been reported by the semantic analyzer.
15611567
return AnyType()

mypy/checkmember.py

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
from mypy.semanal import self_type
1818
from mypy import messages
1919
from mypy import subtypes
20+
if False: # import for forward declaration only
21+
import mypy.checker
2022

2123

2224
def analyze_member_access(name: str,
@@ -29,7 +31,8 @@ def analyze_member_access(name: str,
2931
not_ready_callback: Callable[[str, Context], None],
3032
msg: MessageBuilder,
3133
override_info: TypeInfo = None,
32-
report_type: Type = None) -> Type:
34+
report_type: Type = None,
35+
chk: 'mypy.checker.TypeChecker' = None) -> Type:
3336
"""Analyse attribute access.
3437
3538
This is a general operation that supports various different variations:
@@ -74,27 +77,30 @@ def analyze_member_access(name: str,
7477
return analyze_member_var_access(name, typ, info, node,
7578
is_lvalue, is_super, builtin_type,
7679
not_ready_callback, msg,
77-
report_type=report_type)
80+
report_type=report_type, chk=chk)
7881
elif isinstance(typ, AnyType):
7982
# The base object has dynamic type.
8083
return AnyType()
8184
elif isinstance(typ, NoneTyp):
85+
if chk and chk.should_suppress_optional_error([typ]):
86+
return AnyType()
8287
# The only attribute NoneType has are those it inherits from object
8388
return analyze_member_access(name, builtin_type('builtins.object'), node, is_lvalue,
8489
is_super, is_operator, builtin_type, not_ready_callback, msg,
85-
report_type=report_type)
90+
report_type=report_type, chk=chk)
8691
elif isinstance(typ, UnionType):
8792
# The base object has dynamic type.
8893
msg.disable_type_names += 1
8994
results = [analyze_member_access(name, subtype, node, is_lvalue, is_super,
90-
is_operator, builtin_type, not_ready_callback, msg)
95+
is_operator, builtin_type, not_ready_callback, msg,
96+
chk=chk)
9197
for subtype in typ.items]
9298
msg.disable_type_names -= 1
9399
return UnionType.make_simplified_union(results)
94100
elif isinstance(typ, TupleType):
95101
# Actually look up from the fallback instance type.
96102
return analyze_member_access(name, typ.fallback, node, is_lvalue, is_super,
97-
is_operator, builtin_type, not_ready_callback, msg)
103+
is_operator, builtin_type, not_ready_callback, msg, chk=chk)
98104
elif isinstance(typ, FunctionLike) and typ.is_type_obj():
99105
# Class attribute.
100106
# TODO super?
@@ -123,18 +129,18 @@ def analyze_member_access(name: str,
123129
# Look up from the 'type' type.
124130
return analyze_member_access(name, typ.fallback, node, is_lvalue, is_super,
125131
is_operator, builtin_type, not_ready_callback, msg,
126-
report_type=report_type)
132+
report_type=report_type, chk=chk)
127133
else:
128134
assert False, 'Unexpected type {}'.format(repr(ret_type))
129135
elif isinstance(typ, FunctionLike):
130136
# Look up from the 'function' type.
131137
return analyze_member_access(name, typ.fallback, node, is_lvalue, is_super,
132138
is_operator, builtin_type, not_ready_callback, msg,
133-
report_type=report_type)
139+
report_type=report_type, chk=chk)
134140
elif isinstance(typ, TypeVarType):
135141
return analyze_member_access(name, typ.upper_bound, node, is_lvalue, is_super,
136142
is_operator, builtin_type, not_ready_callback, msg,
137-
report_type=report_type)
143+
report_type=report_type, chk=chk)
138144
elif isinstance(typ, DeletedType):
139145
msg.deleted_as_rvalue(typ, node)
140146
return AnyType()
@@ -155,7 +161,10 @@ def analyze_member_access(name: str,
155161
fallback = builtin_type('builtins.type')
156162
return analyze_member_access(name, fallback, node, is_lvalue, is_super,
157163
is_operator, builtin_type, not_ready_callback, msg,
158-
report_type=report_type)
164+
report_type=report_type, chk=chk)
165+
166+
if chk and chk.should_suppress_optional_error([typ]):
167+
return AnyType()
159168
return msg.has_no_attr(report_type, name, node)
160169

161170

@@ -164,7 +173,8 @@ def analyze_member_var_access(name: str, itype: Instance, info: TypeInfo,
164173
builtin_type: Callable[[str], Instance],
165174
not_ready_callback: Callable[[str, Context], None],
166175
msg: MessageBuilder,
167-
report_type: Type = None) -> Type:
176+
report_type: Type = None,
177+
chk: 'mypy.checker.TypeChecker' = None) -> Type:
168178
"""Analyse attribute access that does not target a method.
169179
170180
This is logically part of analyze_member_access and the arguments are
@@ -200,6 +210,8 @@ def analyze_member_var_access(name: str, itype: Instance, info: TypeInfo,
200210
msg.undefined_in_superclass(name, node)
201211
return AnyType()
202212
else:
213+
if chk and chk.should_suppress_optional_error([itype]):
214+
return AnyType()
203215
return msg.has_no_attr(report_type or itype, name, node)
204216

205217

mypy/main.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,11 @@ def process_options(args: List[str],
174174
parser.add_argument('--strict-optional', action='store_true',
175175
dest='special-opts:strict_optional',
176176
help="enable experimental strict Optional checks")
177+
parser.add_argument('--strict-optional-whitelist', metavar='GLOB', nargs='*',
178+
help="suppress strict Optional errors in all but the provided files "
179+
"(experimental -- read documentation before using!). "
180+
"Implies --strict-optional. Has the undesirable side-effect of "
181+
"suppressing other errors in non-whitelisted files.")
177182
parser.add_argument('--pdb', action='store_true', help="invoke pdb on fatal error")
178183
parser.add_argument('--show-traceback', '--tb', action='store_true',
179184
help="show traceback on fatal error")
@@ -268,7 +273,7 @@ def process_options(args: List[str],
268273
parser.error("May only specify one of: module, package, files, or command.")
269274

270275
# Set build flags.
271-
if special_opts.strict_optional:
276+
if special_opts.strict_optional or options.strict_optional_whitelist is not None:
272277
experiments.STRICT_OPTIONAL = True
273278

274279
# Set reports.

mypy/options.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from mypy import defaults
22
import pprint
33
import sys
4-
from typing import Any, Optional, Tuple
4+
from typing import Any, Optional, Tuple, List
55

66

77
class BuildType:
@@ -40,6 +40,10 @@ def __init__(self) -> None:
4040

4141
# Warn about unused '# type: ignore' comments
4242
self.warn_unused_ignores = False
43+
44+
# Files in which to allow strict-Optional related errors
45+
self.strict_optional_whitelist = None # type: Optional[List[str]]
46+
4347
# -- development options --
4448
self.verbosity = 0 # More verbose messages (for troubleshooting)
4549
self.pdb = False

test-data/unit/check-optional.test

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,3 +379,52 @@ reveal_type(None if bool() else 0) # E: Revealed type is 'Union[builtins.int, b
379379
[case testListWithNone]
380380
reveal_type([0, None, 0]) # E: Revealed type is 'builtins.list[Union[builtins.int, builtins.None]]'
381381
[builtins fixtures/list.pyi]
382+
383+
[case testOptionalWhitelistSuppressesOptionalErrors]
384+
# flags: --strict-optional-whitelist
385+
import a
386+
import b
387+
[file a.py]
388+
from typing import Optional
389+
x = None # type: Optional[str]
390+
x + "foo"
391+
392+
[file b.py]
393+
from typing import Optional
394+
x = None # type: Optional[int]
395+
x + 1
396+
397+
[case testOptionalWhitelistPermitsOtherErrors]
398+
# flags: --strict-optional-whitelist
399+
import a
400+
import b
401+
[file a.py]
402+
from typing import Optional
403+
x = None # type: Optional[str]
404+
x + "foo"
405+
406+
[file b.py]
407+
from typing import Optional
408+
x = None # type: Optional[int]
409+
x + 1
410+
1 + "foo"
411+
[out]
412+
main:3: note: In module imported here:
413+
tmp/b.py:4: error: Unsupported operand types for + ("int" and "str")
414+
415+
[case testOptionalWhitelistPermitsWhitelistedFiles]
416+
# flags: --strict-optional-whitelist **/a.py
417+
import a
418+
import b
419+
[file a.py]
420+
from typing import Optional
421+
x = None # type: Optional[str]
422+
x + "foo"
423+
424+
[file b.py]
425+
from typing import Optional
426+
x = None # type: Optional[int]
427+
x + 1
428+
[out]
429+
main:2: note: In module imported here:
430+
tmp/a.py:3: error: Unsupported left operand type for + (some union)

0 commit comments

Comments
 (0)