Skip to content

Commit c056317

Browse files
committed
Support error codes from plugins in options
Mypy has options for enabling or disabling specific error codes. These work fine, except that it is not possible to enable or disable error codes from plugins, only mypy's original error codes. The crux of the issue is that mypy validates and rejects unknown error codes passed in the options before it loads plugins and learns about the any error codes that might get registered. There are many ways to solve this. This commit tries to find a pragmatic solution where the relevant options parsing is deferred until after plugin loading. Error code validation in the config parser, where plugins are not loaded yet, is also skipped entirely, since the error code options are re-validated later anyway. This means that this commit introduces a small observable change in behavior when running with invalid error codes specified, as shown in the test test_config_file_error_codes_invalid. This fixes #12987.
1 parent 116b92b commit c056317

File tree

6 files changed

+75
-28
lines changed

6 files changed

+75
-28
lines changed

mypy/build.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -504,6 +504,9 @@ def load_plugins(
504504
"""
505505
custom_plugins, snapshot = load_plugins_from_config(options, errors, stdout)
506506

507+
if options._on_plugins_loaded is not None:
508+
options._on_plugins_loaded()
509+
507510
custom_plugins += extra_plugins
508511

509512
default_plugin: Plugin = DefaultPlugin(options)

mypy/config_parser.py

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,6 @@
88
import sys
99
from io import StringIO
1010

11-
from mypy.errorcodes import error_codes
12-
1311
if sys.version_info >= (3, 11):
1412
import tomllib
1513
else:
@@ -87,15 +85,6 @@ def complain(x: object, additional_info: str = "") -> Never:
8785
complain(v)
8886

8987

90-
def validate_codes(codes: list[str]) -> list[str]:
91-
invalid_codes = set(codes) - set(error_codes.keys())
92-
if invalid_codes:
93-
raise argparse.ArgumentTypeError(
94-
f"Invalid error code(s): {', '.join(sorted(invalid_codes))}"
95-
)
96-
return codes
97-
98-
9988
def validate_package_allow_list(allow_list: list[str]) -> list[str]:
10089
for p in allow_list:
10190
msg = f"Invalid allow list entry: {p}"
@@ -209,8 +198,8 @@ def split_commas(value: str) -> list[str]:
209198
[p.strip() for p in split_commas(s)]
210199
),
211200
"enable_incomplete_feature": lambda s: [p.strip() for p in split_commas(s)],
212-
"disable_error_code": lambda s: validate_codes([p.strip() for p in split_commas(s)]),
213-
"enable_error_code": lambda s: validate_codes([p.strip() for p in split_commas(s)]),
201+
"disable_error_code": lambda s: [p.strip() for p in split_commas(s)],
202+
"enable_error_code": lambda s: [p.strip() for p in split_commas(s)],
214203
"package_root": lambda s: [p.strip() for p in split_commas(s)],
215204
"cache_dir": expand_path,
216205
"python_executable": expand_path,
@@ -234,8 +223,8 @@ def split_commas(value: str) -> list[str]:
234223
"always_false": try_split,
235224
"untyped_calls_exclude": lambda s: validate_package_allow_list(try_split(s)),
236225
"enable_incomplete_feature": try_split,
237-
"disable_error_code": lambda s: validate_codes(try_split(s)),
238-
"enable_error_code": lambda s: validate_codes(try_split(s)),
226+
"disable_error_code": lambda s: try_split(s),
227+
"enable_error_code": lambda s: try_split(s),
239228
"package_root": try_split,
240229
"exclude": str_or_array_as_list,
241230
"packages": try_split,

mypy/main.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1353,7 +1353,8 @@ def process_options(
13531353
If a FileSystemCache is passed in, and package_root options are given,
13541354
call fscache.set_package_root() to set the cache's package root.
13551355
1356-
Returns a tuple of: a list of source files, an Options collected from flags.
1356+
Returns a tuple of: a list of source files, an Options collected from
1357+
flags, and a callback to be called once plugins have loaded.
13571358
"""
13581359
stdout = stdout if stdout is not None else sys.stdout
13591360
stderr = stderr if stderr is not None else sys.stderr
@@ -1456,9 +1457,16 @@ def set_strict_flags() -> None:
14561457
validate_package_allow_list(options.untyped_calls_exclude)
14571458
validate_package_allow_list(options.deprecated_calls_exclude)
14581459

1459-
options.process_error_codes(error_callback=parser.error)
14601460
options.process_incomplete_features(error_callback=parser.error, warning_callback=print)
14611461

1462+
def on_plugins_loaded() -> None:
1463+
# Processing error codes after plugins have loaded since plugins may
1464+
# register custom error codes that we don't know about until plugins
1465+
# have loaded.
1466+
options.process_error_codes(error_callback=parser.error)
1467+
1468+
options._on_plugins_loaded = on_plugins_loaded
1469+
14621470
# Compute absolute path for custom typeshed (if present).
14631471
if options.custom_typeshed_dir is not None:
14641472
options.abs_custom_typeshed_dir = os.path.abspath(options.custom_typeshed_dir)

mypy/options.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,8 @@ def __init__(self) -> None:
416416
# preserving manual tweaks to generated C file)
417417
self.mypyc_skip_c_generation = False
418418

419+
self._on_plugins_loaded: Callable[[], None] | None = None
420+
419421
def use_lowercase_names(self) -> bool:
420422
warnings.warn(
421423
"options.use_lowercase_names() is deprecated and will be removed in a future version",

mypy/test/teststubtest.py

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
from collections.abc import Iterator
1313
from typing import Any, Callable
1414

15+
from pytest import raises
16+
1517
import mypy.stubtest
1618
from mypy import build, nodes
1719
from mypy.modulefinder import BuildSource
@@ -171,7 +173,12 @@ def build_helper(source: str) -> build.BuildResult:
171173

172174

173175
def run_stubtest_with_stderr(
174-
stub: str, runtime: str, options: list[str], config_file: str | None = None
176+
stub: str,
177+
runtime: str,
178+
options: list[str],
179+
config_file: str | None = None,
180+
output: io.StringIO | None = None,
181+
outerr: io.StringIO | None = None,
175182
) -> tuple[str, str]:
176183
with use_tmp_dir(TEST_MODULE_NAME) as tmp_dir:
177184
with open("builtins.pyi", "w") as f:
@@ -188,8 +195,8 @@ def run_stubtest_with_stderr(
188195
with open(f"{TEST_MODULE_NAME}_config.ini", "w") as f:
189196
f.write(config_file)
190197
options = options + ["--mypy-config-file", f"{TEST_MODULE_NAME}_config.ini"]
191-
output = io.StringIO()
192-
outerr = io.StringIO()
198+
output = io.StringIO() if output is None else output
199+
outerr = io.StringIO() if outerr is None else outerr
193200
with contextlib.redirect_stdout(output), contextlib.redirect_stderr(outerr):
194201
test_stubs(parse_options([TEST_MODULE_NAME] + options), use_builtins_fixtures=True)
195202
filtered_output = remove_color_code(
@@ -2888,14 +2895,20 @@ def test_config_file_error_codes_invalid(self) -> None:
28882895
runtime = "temp = 5\n"
28892896
stub = "temp: int\n"
28902897
config_file = "[mypy]\ndisable_error_code = not-a-valid-name\n"
2891-
output, outerr = run_stubtest_with_stderr(
2892-
stub=stub, runtime=runtime, options=[], config_file=config_file
2893-
)
2894-
assert output == "Success: no issues found in 1 module\n"
2895-
assert outerr == (
2896-
"test_module_config.ini: [mypy]: disable_error_code: "
2897-
"Invalid error code(s): not-a-valid-name\n"
2898-
)
2898+
output = io.StringIO()
2899+
outerr = io.StringIO()
2900+
with raises(SystemExit):
2901+
run_stubtest_with_stderr(
2902+
stub=stub,
2903+
runtime=runtime,
2904+
options=[],
2905+
config_file=config_file,
2906+
output=output,
2907+
outerr=outerr,
2908+
)
2909+
2910+
assert output.getvalue() == "error: Invalid error code(s): not-a-valid-name\n"
2911+
assert outerr.getvalue() == ""
28992912

29002913
def test_config_file_wrong_incomplete_feature(self) -> None:
29012914
runtime = "x = 1\n"
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
[case testCustomErrorCodeFromPluginIsTargetable]
2+
# flags: --config-file tmp/mypy.ini --show-error-codes
3+
4+
def main() -> None:
5+
return
6+
main() # E: Custom error [custom]
7+
8+
[file mypy.ini]
9+
\[mypy]
10+
plugins=<ROOT>/test-data/unit/plugins/custom_errorcode.py
11+
12+
[case testCustomErrorCodeCanBeDisabled]
13+
# flags: --config-file tmp/mypy.ini --show-error-codes --disable-error-code=custom
14+
15+
def main() -> None:
16+
return
17+
main() # no output expected when disabled
18+
19+
[file mypy.ini]
20+
\[mypy]
21+
plugins=<ROOT>/test-data/unit/plugins/custom_errorcode.py
22+
23+
[case testCustomErrorCodeCanBeReenabled]
24+
# flags: --config-file tmp/mypy.ini --show-error-codes --disable-error-code=custom --enable-error-code=custom
25+
26+
def main() -> None:
27+
return
28+
main() # E: Custom error [custom]
29+
30+
[file mypy.ini]
31+
\[mypy]
32+
plugins=<ROOT>/test-data/unit/plugins/custom_errorcode.py

0 commit comments

Comments
 (0)