Skip to content

Commit 1f80611

Browse files
ilevkivskyiIvan Levkivskyi
authored and
Ivan Levkivskyi
committed
Allow per-module error codes (#13502)
Fixes #9440 This is a bit non-trivial because I decided to make per-module code act as overrides over main section error codes. This looks more natural no me, rather that putting an adjusted list in each section. I also fix the inline `# mypy: ...` comment error codes, that are currently just ignored. The logic is naturally like this: * Command line and/or config main section set global codes * Config sections _adjust_ them per glob/module * Inline comments adjust them again So one can e.g. enable code globally, disable it for all tests in config, and then re-enable locally by an inline comment.
1 parent 9dc1f1a commit 1f80611

12 files changed

+212
-34
lines changed

docs/source/error_codes.rst

+44
Original file line numberDiff line numberDiff line change
@@ -69,3 +69,47 @@ which enables the ``no-untyped-def`` error code.
6969
You can use :option:`--enable-error-code <mypy --enable-error-code>` to
7070
enable specific error codes that don't have a dedicated command-line
7171
flag or config file setting.
72+
73+
Per-module enabling/disabling error codes
74+
-----------------------------------------
75+
76+
You can use :ref:`configuration file <config-file>` sections to enable or
77+
disable specific error codes only in some modules. For example, this ``mypy.ini``
78+
config will enable non-annotated empty containers in tests, while keeping
79+
other parts of code checked in strict mode:
80+
81+
.. code-block:: ini
82+
83+
[mypy]
84+
strict = True
85+
86+
[mypy-tests.*]
87+
allow_untyped_defs = True
88+
allow_untyped_calls = True
89+
disable_error_code = var-annotated, has-type
90+
91+
Note that per-module enabling/disabling acts as override over the global
92+
options. So that you don't need to repeat the error code lists for each
93+
module if you have them in global config section. For example:
94+
95+
.. code-block:: ini
96+
97+
[mypy]
98+
enable_error_code = truthy-bool, ignore-without-code, unused-awaitable
99+
100+
[mypy-extensions.*]
101+
disable_error_code = unused-awaitable
102+
103+
The above config will allow unused awaitables in extension modules, but will
104+
still keep the other two error codes enabled. The overall logic is following:
105+
106+
* Command line and/or config main section set global error codes
107+
108+
* Individual config sections *adjust* them per glob/module
109+
110+
* Inline ``# mypy: ...`` comments can further *adjust* them for a specific
111+
module
112+
113+
So one can e.g. enable some code globally, disable it for all tests in
114+
the corresponding config section, and then re-enable it with an inline
115+
comment in some specific test.

mypy/build.py

+11-12
Original file line numberDiff line numberDiff line change
@@ -236,9 +236,8 @@ def _build(
236236
options.show_error_end,
237237
lambda path: read_py_file(path, cached_read),
238238
options.show_absolute_path,
239-
options.enabled_error_codes,
240-
options.disabled_error_codes,
241239
options.many_errors_threshold,
240+
options,
242241
)
243242
plugin, snapshot = load_plugins(options, errors, stdout, extra_plugins)
244243

@@ -422,7 +421,7 @@ def plugin_error(message: str) -> NoReturn:
422421
errors.raise_error(use_stdout=False)
423422

424423
custom_plugins: list[Plugin] = []
425-
errors.set_file(options.config_file, None)
424+
errors.set_file(options.config_file, None, options)
426425
for plugin_path in options.plugins:
427426
func_name = "plugin"
428427
plugin_dir: str | None = None
@@ -773,7 +772,7 @@ def correct_rel_imp(imp: ImportFrom | ImportAll) -> str:
773772
new_id = file_id + "." + imp.id if imp.id else file_id
774773

775774
if not new_id:
776-
self.errors.set_file(file.path, file.name)
775+
self.errors.set_file(file.path, file.name, self.options)
777776
self.errors.report(
778777
imp.line, 0, "No parent module -- cannot perform relative import", blocker=True
779778
)
@@ -984,7 +983,7 @@ def write_deps_cache(
984983
error = True
985984

986985
if error:
987-
manager.errors.set_file(_cache_dir_prefix(manager.options), None)
986+
manager.errors.set_file(_cache_dir_prefix(manager.options), None, manager.options)
988987
manager.errors.report(0, 0, "Error writing fine-grained dependencies cache", blocker=True)
989988

990989

@@ -1048,7 +1047,7 @@ def generate_deps_for_cache(manager: BuildManager, graph: Graph) -> dict[str, di
10481047
def write_plugins_snapshot(manager: BuildManager) -> None:
10491048
"""Write snapshot of versions and hashes of currently active plugins."""
10501049
if not manager.metastore.write(PLUGIN_SNAPSHOT_FILE, json.dumps(manager.plugins_snapshot)):
1051-
manager.errors.set_file(_cache_dir_prefix(manager.options), None)
1050+
manager.errors.set_file(_cache_dir_prefix(manager.options), None, manager.options)
10521051
manager.errors.report(0, 0, "Error writing plugins snapshot", blocker=True)
10531052

10541053

@@ -1151,7 +1150,7 @@ def _load_json_file(
11511150
result = json.loads(data)
11521151
manager.add_stats(data_json_load_time=time.time() - t1)
11531152
except json.JSONDecodeError:
1154-
manager.errors.set_file(file, None)
1153+
manager.errors.set_file(file, None, manager.options)
11551154
manager.errors.report(
11561155
-1,
11571156
-1,
@@ -2200,7 +2199,7 @@ def parse_inline_configuration(self, source: str) -> None:
22002199
if flags:
22012200
changes, config_errors = parse_mypy_comments(flags, self.options)
22022201
self.options = self.options.apply_changes(changes)
2203-
self.manager.errors.set_file(self.xpath, self.id)
2202+
self.manager.errors.set_file(self.xpath, self.id, self.options)
22042203
for lineno, error in config_errors:
22052204
self.manager.errors.report(lineno, 0, error)
22062205

@@ -2711,7 +2710,7 @@ def module_not_found(
27112710
errors = manager.errors
27122711
save_import_context = errors.import_context()
27132712
errors.set_import_context(caller_state.import_context)
2714-
errors.set_file(caller_state.xpath, caller_state.id)
2713+
errors.set_file(caller_state.xpath, caller_state.id, caller_state.options)
27152714
if target == "builtins":
27162715
errors.report(
27172716
line, 0, "Cannot find 'builtins' module. Typeshed appears broken!", blocker=True
@@ -2741,7 +2740,7 @@ def skipping_module(
27412740
assert caller_state, (id, path)
27422741
save_import_context = manager.errors.import_context()
27432742
manager.errors.set_import_context(caller_state.import_context)
2744-
manager.errors.set_file(caller_state.xpath, caller_state.id)
2743+
manager.errors.set_file(caller_state.xpath, caller_state.id, manager.options)
27452744
manager.errors.report(line, 0, f'Import of "{id}" ignored', severity="error")
27462745
manager.errors.report(
27472746
line,
@@ -2760,7 +2759,7 @@ def skipping_ancestor(manager: BuildManager, id: str, path: str, ancestor_for: S
27602759
# But beware, some package may be the ancestor of many modules,
27612760
# so we'd need to cache the decision.
27622761
manager.errors.set_import_context([])
2763-
manager.errors.set_file(ancestor_for.xpath, ancestor_for.id)
2762+
manager.errors.set_file(ancestor_for.xpath, ancestor_for.id, manager.options)
27642763
manager.errors.report(
27652764
-1, -1, f'Ancestor package "{id}" ignored', severity="error", only_once=True
27662765
)
@@ -2994,7 +2993,7 @@ def load_graph(
29942993
except ModuleNotFound:
29952994
continue
29962995
if st.id in graph:
2997-
manager.errors.set_file(st.xpath, st.id)
2996+
manager.errors.set_file(st.xpath, st.id, manager.options)
29982997
manager.errors.report(
29992998
-1,
30002999
-1,

mypy/checker.py

+6-2
Original file line numberDiff line numberDiff line change
@@ -451,7 +451,9 @@ def check_first_pass(self) -> None:
451451
"""
452452
self.recurse_into_functions = True
453453
with state.strict_optional_set(self.options.strict_optional):
454-
self.errors.set_file(self.path, self.tree.fullname, scope=self.tscope)
454+
self.errors.set_file(
455+
self.path, self.tree.fullname, scope=self.tscope, options=self.options
456+
)
455457
with self.tscope.module_scope(self.tree.fullname):
456458
with self.enter_partial_types(), self.binder.top_frame_context():
457459
for d in self.tree.defs:
@@ -490,7 +492,9 @@ def check_second_pass(
490492
with state.strict_optional_set(self.options.strict_optional):
491493
if not todo and not self.deferred_nodes:
492494
return False
493-
self.errors.set_file(self.path, self.tree.fullname, scope=self.tscope)
495+
self.errors.set_file(
496+
self.path, self.tree.fullname, scope=self.tscope, options=self.options
497+
)
494498
with self.tscope.module_scope(self.tree.fullname):
495499
self.pass_num += 1
496500
if not todo:

mypy/config_parser.py

+23-4
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
import sys
99
from io import StringIO
1010

11+
from mypy.errorcodes import error_codes
12+
1113
if sys.version_info >= (3, 11):
1214
import tomllib
1315
else:
@@ -69,6 +71,15 @@ def try_split(v: str | Sequence[str], split_regex: str = "[,]") -> list[str]:
6971
return [p.strip() for p in v]
7072

7173

74+
def validate_codes(codes: list[str]) -> list[str]:
75+
invalid_codes = set(codes) - set(error_codes.keys())
76+
if invalid_codes:
77+
raise argparse.ArgumentTypeError(
78+
f"Invalid error code(s): {', '.join(sorted(invalid_codes))}"
79+
)
80+
return codes
81+
82+
7283
def expand_path(path: str) -> str:
7384
"""Expand the user home directory and any environment variables contained within
7485
the provided path.
@@ -147,8 +158,8 @@ def check_follow_imports(choice: str) -> str:
147158
"plugins": lambda s: [p.strip() for p in s.split(",")],
148159
"always_true": lambda s: [p.strip() for p in s.split(",")],
149160
"always_false": lambda s: [p.strip() for p in s.split(",")],
150-
"disable_error_code": lambda s: [p.strip() for p in s.split(",")],
151-
"enable_error_code": lambda s: [p.strip() for p in s.split(",")],
161+
"disable_error_code": lambda s: validate_codes([p.strip() for p in s.split(",")]),
162+
"enable_error_code": lambda s: validate_codes([p.strip() for p in s.split(",")]),
152163
"package_root": lambda s: [p.strip() for p in s.split(",")],
153164
"cache_dir": expand_path,
154165
"python_executable": expand_path,
@@ -168,8 +179,8 @@ def check_follow_imports(choice: str) -> str:
168179
"plugins": try_split,
169180
"always_true": try_split,
170181
"always_false": try_split,
171-
"disable_error_code": try_split,
172-
"enable_error_code": try_split,
182+
"disable_error_code": lambda s: validate_codes(try_split(s)),
183+
"enable_error_code": lambda s: validate_codes(try_split(s)),
173184
"package_root": try_split,
174185
"exclude": str_or_array_as_list,
175186
}
@@ -263,6 +274,7 @@ def parse_config_file(
263274
file=stderr,
264275
)
265276
updates = {k: v for k, v in updates.items() if k in PER_MODULE_OPTIONS}
277+
266278
globs = name[5:]
267279
for glob in globs.split(","):
268280
# For backwards compatibility, replace (back)slashes with dots.
@@ -481,6 +493,13 @@ def parse_section(
481493
if "follow_imports" not in results:
482494
results["follow_imports"] = "error"
483495
results[options_key] = v
496+
497+
# These two flags act as per-module overrides, so store the empty defaults.
498+
if "disable_error_code" not in results:
499+
results["disable_error_code"] = []
500+
if "enable_error_code" not in results:
501+
results["enable_error_code"] = []
502+
484503
return results, report_dirs
485504

486505

mypy/errors.py

+15-7
Original file line numberDiff line numberDiff line change
@@ -262,9 +262,8 @@ def __init__(
262262
show_error_end: bool = False,
263263
read_source: Callable[[str], list[str] | None] | None = None,
264264
show_absolute_path: bool = False,
265-
enabled_error_codes: set[ErrorCode] | None = None,
266-
disabled_error_codes: set[ErrorCode] | None = None,
267265
many_errors_threshold: int = -1,
266+
options: Options | None = None,
268267
) -> None:
269268
self.show_error_context = show_error_context
270269
self.show_column_numbers = show_column_numbers
@@ -276,9 +275,8 @@ def __init__(
276275
assert show_column_numbers, "Inconsistent formatting, must be prevented by argparse"
277276
# We use fscache to read source code when showing snippets.
278277
self.read_source = read_source
279-
self.enabled_error_codes = enabled_error_codes or set()
280-
self.disabled_error_codes = disabled_error_codes or set()
281278
self.many_errors_threshold = many_errors_threshold
279+
self.options = options
282280
self.initialize()
283281

284282
def initialize(self) -> None:
@@ -313,7 +311,9 @@ def simplify_path(self, file: str) -> str:
313311
file = os.path.normpath(file)
314312
return remove_path_prefix(file, self.ignore_prefix)
315313

316-
def set_file(self, file: str, module: str | None, scope: Scope | None = None) -> None:
314+
def set_file(
315+
self, file: str, module: str | None, options: Options, scope: Scope | None = None
316+
) -> None:
317317
"""Set the path and module id of the current file."""
318318
# The path will be simplified later, in render_messages. That way
319319
# * 'file' is always a key that uniquely identifies a source file
@@ -324,6 +324,7 @@ def set_file(self, file: str, module: str | None, scope: Scope | None = None) ->
324324
self.file = file
325325
self.target_module = module
326326
self.scope = scope
327+
self.options = options
327328

328329
def set_file_ignored_lines(
329330
self, file: str, ignored_lines: dict[int, list[str]], ignore_all: bool = False
@@ -586,9 +587,16 @@ def is_ignored_error(self, line: int, info: ErrorInfo, ignores: dict[int, list[s
586587
return False
587588

588589
def is_error_code_enabled(self, error_code: ErrorCode) -> bool:
589-
if error_code in self.disabled_error_codes:
590+
if self.options:
591+
current_mod_disabled = self.options.disabled_error_codes
592+
current_mod_enabled = self.options.enabled_error_codes
593+
else:
594+
current_mod_disabled = set()
595+
current_mod_enabled = set()
596+
597+
if error_code in current_mod_disabled:
590598
return False
591-
elif error_code in self.enabled_error_codes:
599+
elif error_code in current_mod_enabled:
592600
return True
593601
else:
594602
return error_code.default_enabled

mypy/fastparse.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,7 @@ def parse(
263263
raise_on_error = True
264264
if options is None:
265265
options = Options()
266-
errors.set_file(fnam, module)
266+
errors.set_file(fnam, module, options=options)
267267
is_stub_file = fnam.endswith(".pyi")
268268
if is_stub_file:
269269
feature_version = defaults.PYTHON3_VERSION[1]

mypy/options.py

+27-5
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,13 @@
33
import pprint
44
import re
55
import sys
6-
from typing import TYPE_CHECKING, Any, Callable, Mapping, Pattern
6+
from typing import Any, Callable, Dict, Mapping, Pattern
77
from typing_extensions import Final
88

99
from mypy import defaults
10+
from mypy.errorcodes import ErrorCode, error_codes
1011
from mypy.util import get_class_descriptors, replace_object_state
1112

12-
if TYPE_CHECKING:
13-
from mypy.errorcodes import ErrorCode
14-
1513

1614
class BuildType:
1715
STANDARD: Final = 0
@@ -27,6 +25,8 @@ class BuildType:
2725
"always_true",
2826
"check_untyped_defs",
2927
"debug_cache",
28+
"disable_error_code",
29+
"disabled_error_codes",
3030
"disallow_any_decorated",
3131
"disallow_any_explicit",
3232
"disallow_any_expr",
@@ -37,6 +37,8 @@ class BuildType:
3737
"disallow_untyped_calls",
3838
"disallow_untyped_decorators",
3939
"disallow_untyped_defs",
40+
"enable_error_code",
41+
"enabled_error_codes",
4042
"follow_imports",
4143
"follow_imports_for_stubs",
4244
"ignore_errors",
@@ -349,6 +351,20 @@ def apply_changes(self, changes: dict[str, object]) -> Options:
349351
# This is the only option for which a per-module and a global
350352
# option sometimes beheave differently.
351353
new_options.ignore_missing_imports_per_module = True
354+
355+
# These two act as overrides, so apply them when cloning.
356+
# Similar to global codes enabling overrides disabling, so we start from latter.
357+
new_options.disabled_error_codes = self.disabled_error_codes.copy()
358+
new_options.enabled_error_codes = self.enabled_error_codes.copy()
359+
for code_str in new_options.disable_error_code:
360+
code = error_codes[code_str]
361+
new_options.disabled_error_codes.add(code)
362+
new_options.enabled_error_codes.discard(code)
363+
for code_str in new_options.enable_error_code:
364+
code = error_codes[code_str]
365+
new_options.enabled_error_codes.add(code)
366+
new_options.disabled_error_codes.discard(code)
367+
352368
return new_options
353369

354370
def build_per_module_cache(self) -> None:
@@ -448,4 +464,10 @@ def compile_glob(self, s: str) -> Pattern[str]:
448464
return re.compile(expr + "\\Z")
449465

450466
def select_options_affecting_cache(self) -> Mapping[str, object]:
451-
return {opt: getattr(self, opt) for opt in OPTIONS_AFFECTING_CACHE}
467+
result: Dict[str, object] = {}
468+
for opt in OPTIONS_AFFECTING_CACHE:
469+
val = getattr(self, opt)
470+
if isinstance(val, set):
471+
val = sorted(val)
472+
result[opt] = val
473+
return result

mypy/semanal.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -732,7 +732,7 @@ def file_context(
732732
"""
733733
scope = self.scope
734734
self.options = options
735-
self.errors.set_file(file_node.path, file_node.fullname, scope=scope)
735+
self.errors.set_file(file_node.path, file_node.fullname, scope=scope, options=options)
736736
self.cur_mod_node = file_node
737737
self.cur_mod_id = file_node.fullname
738738
with scope.module_scope(self.cur_mod_id):

mypy/semanal_typeargs.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ def __init__(self, errors: Errors, options: Options, is_typeshed_file: bool) ->
4646
self.seen_aliases: set[TypeAliasType] = set()
4747

4848
def visit_mypy_file(self, o: MypyFile) -> None:
49-
self.errors.set_file(o.path, o.fullname, scope=self.scope)
49+
self.errors.set_file(o.path, o.fullname, scope=self.scope, options=self.options)
5050
with self.scope.module_scope(o.fullname):
5151
super().visit_mypy_file(o)
5252

0 commit comments

Comments
 (0)