Skip to content

Commit d68b1c6

Browse files
authored
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 17ec3ca commit d68b1c6

12 files changed

+212
-34
lines changed

docs/source/error_codes.rst

Lines changed: 44 additions & 0 deletions
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

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -235,9 +235,8 @@ def _build(
235235
options.show_error_end,
236236
lambda path: read_py_file(path, cached_read),
237237
options.show_absolute_path,
238-
options.enabled_error_codes,
239-
options.disabled_error_codes,
240238
options.many_errors_threshold,
239+
options,
241240
)
242241
plugin, snapshot = load_plugins(options, errors, stdout, extra_plugins)
243242

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

423422
custom_plugins: list[Plugin] = []
424-
errors.set_file(options.config_file, None)
423+
errors.set_file(options.config_file, None, options)
425424
for plugin_path in options.plugins:
426425
func_name = "plugin"
427426
plugin_dir: str | None = None
@@ -778,7 +777,7 @@ def correct_rel_imp(imp: ImportFrom | ImportAll) -> str:
778777
new_id = file_id + "." + imp.id if imp.id else file_id
779778

780779
if not new_id:
781-
self.errors.set_file(file.path, file.name)
780+
self.errors.set_file(file.path, file.name, self.options)
782781
self.errors.report(
783782
imp.line, 0, "No parent module -- cannot perform relative import", blocker=True
784783
)
@@ -989,7 +988,7 @@ def write_deps_cache(
989988
error = True
990989

991990
if error:
992-
manager.errors.set_file(_cache_dir_prefix(manager.options), None)
991+
manager.errors.set_file(_cache_dir_prefix(manager.options), None, manager.options)
993992
manager.errors.report(0, 0, "Error writing fine-grained dependencies cache", blocker=True)
994993

995994

@@ -1053,7 +1052,7 @@ def generate_deps_for_cache(manager: BuildManager, graph: Graph) -> dict[str, di
10531052
def write_plugins_snapshot(manager: BuildManager) -> None:
10541053
"""Write snapshot of versions and hashes of currently active plugins."""
10551054
if not manager.metastore.write(PLUGIN_SNAPSHOT_FILE, json.dumps(manager.plugins_snapshot)):
1056-
manager.errors.set_file(_cache_dir_prefix(manager.options), None)
1055+
manager.errors.set_file(_cache_dir_prefix(manager.options), None, manager.options)
10571056
manager.errors.report(0, 0, "Error writing plugins snapshot", blocker=True)
10581057

10591058

@@ -1156,7 +1155,7 @@ def _load_json_file(
11561155
result = json.loads(data)
11571156
manager.add_stats(data_json_load_time=time.time() - t1)
11581157
except json.JSONDecodeError:
1159-
manager.errors.set_file(file, None)
1158+
manager.errors.set_file(file, None, manager.options)
11601159
manager.errors.report(
11611160
-1,
11621161
-1,
@@ -2205,7 +2204,7 @@ def parse_inline_configuration(self, source: str) -> None:
22052204
if flags:
22062205
changes, config_errors = parse_mypy_comments(flags, self.options)
22072206
self.options = self.options.apply_changes(changes)
2208-
self.manager.errors.set_file(self.xpath, self.id)
2207+
self.manager.errors.set_file(self.xpath, self.id, self.options)
22092208
for lineno, error in config_errors:
22102209
self.manager.errors.report(lineno, 0, error)
22112210

@@ -2717,7 +2716,7 @@ def module_not_found(
27172716
errors = manager.errors
27182717
save_import_context = errors.import_context()
27192718
errors.set_import_context(caller_state.import_context)
2720-
errors.set_file(caller_state.xpath, caller_state.id)
2719+
errors.set_file(caller_state.xpath, caller_state.id, caller_state.options)
27212720
if target == "builtins":
27222721
errors.report(
27232722
line, 0, "Cannot find 'builtins' module. Typeshed appears broken!", blocker=True
@@ -2747,7 +2746,7 @@ def skipping_module(
27472746
assert caller_state, (id, path)
27482747
save_import_context = manager.errors.import_context()
27492748
manager.errors.set_import_context(caller_state.import_context)
2750-
manager.errors.set_file(caller_state.xpath, caller_state.id)
2749+
manager.errors.set_file(caller_state.xpath, caller_state.id, manager.options)
27512750
manager.errors.report(line, 0, f'Import of "{id}" ignored', severity="error")
27522751
manager.errors.report(
27532752
line,
@@ -2766,7 +2765,7 @@ def skipping_ancestor(manager: BuildManager, id: str, path: str, ancestor_for: S
27662765
# But beware, some package may be the ancestor of many modules,
27672766
# so we'd need to cache the decision.
27682767
manager.errors.set_import_context([])
2769-
manager.errors.set_file(ancestor_for.xpath, ancestor_for.id)
2768+
manager.errors.set_file(ancestor_for.xpath, ancestor_for.id, manager.options)
27702769
manager.errors.report(
27712770
-1, -1, f'Ancestor package "{id}" ignored', severity="error", only_once=True
27722771
)
@@ -3000,7 +2999,7 @@ def load_graph(
30002999
except ModuleNotFound:
30013000
continue
30023001
if st.id in graph:
3003-
manager.errors.set_file(st.xpath, st.id)
3002+
manager.errors.set_file(st.xpath, st.id, manager.options)
30043003
manager.errors.report(
30053004
-1,
30063005
-1,

mypy/checker.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -453,7 +453,9 @@ def check_first_pass(self) -> None:
453453
"""
454454
self.recurse_into_functions = True
455455
with state.strict_optional_set(self.options.strict_optional):
456-
self.errors.set_file(self.path, self.tree.fullname, scope=self.tscope)
456+
self.errors.set_file(
457+
self.path, self.tree.fullname, scope=self.tscope, options=self.options
458+
)
457459
with self.tscope.module_scope(self.tree.fullname):
458460
with self.enter_partial_types(), self.binder.top_frame_context():
459461
for d in self.tree.defs:
@@ -492,7 +494,9 @@ def check_second_pass(
492494
with state.strict_optional_set(self.options.strict_optional):
493495
if not todo and not self.deferred_nodes:
494496
return False
495-
self.errors.set_file(self.path, self.tree.fullname, scope=self.tscope)
497+
self.errors.set_file(
498+
self.path, self.tree.fullname, scope=self.tscope, options=self.options
499+
)
496500
with self.tscope.module_scope(self.tree.fullname):
497501
self.pass_num += 1
498502
if not todo:

mypy/config_parser.py

Lines changed: 23 additions & 4 deletions
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

Lines changed: 15 additions & 7 deletions
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

Lines changed: 1 addition & 1 deletion
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

Lines changed: 27 additions & 5 deletions
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",
@@ -347,6 +349,20 @@ def apply_changes(self, changes: dict[str, object]) -> Options:
347349
# This is the only option for which a per-module and a global
348350
# option sometimes beheave differently.
349351
new_options.ignore_missing_imports_per_module = True
352+
353+
# These two act as overrides, so apply them when cloning.
354+
# Similar to global codes enabling overrides disabling, so we start from latter.
355+
new_options.disabled_error_codes = self.disabled_error_codes.copy()
356+
new_options.enabled_error_codes = self.enabled_error_codes.copy()
357+
for code_str in new_options.disable_error_code:
358+
code = error_codes[code_str]
359+
new_options.disabled_error_codes.add(code)
360+
new_options.enabled_error_codes.discard(code)
361+
for code_str in new_options.enable_error_code:
362+
code = error_codes[code_str]
363+
new_options.enabled_error_codes.add(code)
364+
new_options.disabled_error_codes.discard(code)
365+
350366
return new_options
351367

352368
def build_per_module_cache(self) -> None:
@@ -446,4 +462,10 @@ def compile_glob(self, s: str) -> Pattern[str]:
446462
return re.compile(expr + "\\Z")
447463

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

mypy/semanal.py

Lines changed: 1 addition & 1 deletion
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

Lines changed: 1 addition & 1 deletion
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)