From 3ebd792e9f935068d3d5367207dccc96e3e584fd Mon Sep 17 00:00:00 2001 From: Sigve Sebastian Farstad Date: Sun, 24 Aug 2025 00:12:19 +0200 Subject: [PATCH 1/8] 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 https://github.com/python/mypy/issues/12987. --- mypy/build.py | 3 ++ mypy/config_parser.py | 19 +++-------- mypy/main.py | 9 ++++- mypy/options.py | 2 ++ mypy/test/teststubtest.py | 35 ++++++++++++++------ test-data/unit/check-plugin-error-codes.test | 32 ++++++++++++++++++ 6 files changed, 73 insertions(+), 27 deletions(-) create mode 100644 test-data/unit/check-plugin-error-codes.test diff --git a/mypy/build.py b/mypy/build.py index 4f22e0703d97..810116df5e02 100644 --- a/mypy/build.py +++ b/mypy/build.py @@ -504,6 +504,9 @@ def load_plugins( """ custom_plugins, snapshot = load_plugins_from_config(options, errors, stdout) + if options._on_plugins_loaded is not None: + options._on_plugins_loaded() + custom_plugins += extra_plugins default_plugin: Plugin = DefaultPlugin(options) diff --git a/mypy/config_parser.py b/mypy/config_parser.py index 5f08f342241e..2bfd2a1e2eef 100644 --- a/mypy/config_parser.py +++ b/mypy/config_parser.py @@ -8,8 +8,6 @@ import sys from io import StringIO -from mypy.errorcodes import error_codes - if sys.version_info >= (3, 11): import tomllib else: @@ -87,15 +85,6 @@ def complain(x: object, additional_info: str = "") -> Never: complain(v) -def validate_codes(codes: list[str]) -> list[str]: - invalid_codes = set(codes) - set(error_codes.keys()) - if invalid_codes: - raise argparse.ArgumentTypeError( - f"Invalid error code(s): {', '.join(sorted(invalid_codes))}" - ) - return codes - - def validate_package_allow_list(allow_list: list[str]) -> list[str]: for p in allow_list: msg = f"Invalid allow list entry: {p}" @@ -209,8 +198,8 @@ def split_commas(value: str) -> list[str]: [p.strip() for p in split_commas(s)] ), "enable_incomplete_feature": lambda s: [p.strip() for p in split_commas(s)], - "disable_error_code": lambda s: validate_codes([p.strip() for p in split_commas(s)]), - "enable_error_code": lambda s: validate_codes([p.strip() for p in split_commas(s)]), + "disable_error_code": lambda s: [p.strip() for p in split_commas(s)], + "enable_error_code": lambda s: [p.strip() for p in split_commas(s)], "package_root": lambda s: [p.strip() for p in split_commas(s)], "cache_dir": expand_path, "python_executable": expand_path, @@ -234,8 +223,8 @@ def split_commas(value: str) -> list[str]: "always_false": try_split, "untyped_calls_exclude": lambda s: validate_package_allow_list(try_split(s)), "enable_incomplete_feature": try_split, - "disable_error_code": lambda s: validate_codes(try_split(s)), - "enable_error_code": lambda s: validate_codes(try_split(s)), + "disable_error_code": lambda s: try_split(s), + "enable_error_code": lambda s: try_split(s), "package_root": try_split, "exclude": str_or_array_as_list, "packages": try_split, diff --git a/mypy/main.py b/mypy/main.py index 0f70eb41bb14..c0b9c0ea1427 100644 --- a/mypy/main.py +++ b/mypy/main.py @@ -1456,9 +1456,16 @@ def set_strict_flags() -> None: validate_package_allow_list(options.untyped_calls_exclude) validate_package_allow_list(options.deprecated_calls_exclude) - options.process_error_codes(error_callback=parser.error) options.process_incomplete_features(error_callback=parser.error, warning_callback=print) + def on_plugins_loaded() -> None: + # Processing error codes after plugins have loaded since plugins may + # register custom error codes that we don't know about until plugins + # have loaded. + options.process_error_codes(error_callback=parser.error) + + options._on_plugins_loaded = on_plugins_loaded + # Compute absolute path for custom typeshed (if present). if options.custom_typeshed_dir is not None: options.abs_custom_typeshed_dir = os.path.abspath(options.custom_typeshed_dir) diff --git a/mypy/options.py b/mypy/options.py index ad4b26cca095..231c13a348d2 100644 --- a/mypy/options.py +++ b/mypy/options.py @@ -416,6 +416,8 @@ def __init__(self) -> None: # preserving manual tweaks to generated C file) self.mypyc_skip_c_generation = False + self._on_plugins_loaded: Callable[[], None] | None = None + def use_lowercase_names(self) -> bool: warnings.warn( "options.use_lowercase_names() is deprecated and will be removed in a future version", diff --git a/mypy/test/teststubtest.py b/mypy/test/teststubtest.py index 28263e20099d..800f522d90a0 100644 --- a/mypy/test/teststubtest.py +++ b/mypy/test/teststubtest.py @@ -12,6 +12,8 @@ from collections.abc import Iterator from typing import Any, Callable +from pytest import raises + import mypy.stubtest from mypy import build, nodes from mypy.modulefinder import BuildSource @@ -171,7 +173,12 @@ def build_helper(source: str) -> build.BuildResult: def run_stubtest_with_stderr( - stub: str, runtime: str, options: list[str], config_file: str | None = None + stub: str, + runtime: str, + options: list[str], + config_file: str | None = None, + output: io.StringIO | None = None, + outerr: io.StringIO | None = None, ) -> tuple[str, str]: with use_tmp_dir(TEST_MODULE_NAME) as tmp_dir: with open("builtins.pyi", "w") as f: @@ -188,8 +195,8 @@ def run_stubtest_with_stderr( with open(f"{TEST_MODULE_NAME}_config.ini", "w") as f: f.write(config_file) options = options + ["--mypy-config-file", f"{TEST_MODULE_NAME}_config.ini"] - output = io.StringIO() - outerr = io.StringIO() + output = io.StringIO() if output is None else output + outerr = io.StringIO() if outerr is None else outerr with contextlib.redirect_stdout(output), contextlib.redirect_stderr(outerr): test_stubs(parse_options([TEST_MODULE_NAME] + options), use_builtins_fixtures=True) filtered_output = remove_color_code( @@ -2888,14 +2895,20 @@ def test_config_file_error_codes_invalid(self) -> None: runtime = "temp = 5\n" stub = "temp: int\n" config_file = "[mypy]\ndisable_error_code = not-a-valid-name\n" - output, outerr = run_stubtest_with_stderr( - stub=stub, runtime=runtime, options=[], config_file=config_file - ) - assert output == "Success: no issues found in 1 module\n" - assert outerr == ( - "test_module_config.ini: [mypy]: disable_error_code: " - "Invalid error code(s): not-a-valid-name\n" - ) + output = io.StringIO() + outerr = io.StringIO() + with raises(SystemExit): + run_stubtest_with_stderr( + stub=stub, + runtime=runtime, + options=[], + config_file=config_file, + output=output, + outerr=outerr, + ) + + assert output.getvalue() == "error: Invalid error code(s): not-a-valid-name\n" + assert outerr.getvalue() == "" def test_config_file_wrong_incomplete_feature(self) -> None: runtime = "x = 1\n" diff --git a/test-data/unit/check-plugin-error-codes.test b/test-data/unit/check-plugin-error-codes.test new file mode 100644 index 000000000000..95789477977e --- /dev/null +++ b/test-data/unit/check-plugin-error-codes.test @@ -0,0 +1,32 @@ +[case testCustomErrorCodeFromPluginIsTargetable] +# flags: --config-file tmp/mypy.ini --show-error-codes + +def main() -> None: + return +main() # E: Custom error [custom] + +[file mypy.ini] +\[mypy] +plugins=/test-data/unit/plugins/custom_errorcode.py + +[case testCustomErrorCodeCanBeDisabled] +# flags: --config-file tmp/mypy.ini --show-error-codes --disable-error-code=custom + +def main() -> None: + return +main() # no output expected when disabled + +[file mypy.ini] +\[mypy] +plugins=/test-data/unit/plugins/custom_errorcode.py + +[case testCustomErrorCodeCanBeReenabled] +# flags: --config-file tmp/mypy.ini --show-error-codes --disable-error-code=custom --enable-error-code=custom + +def main() -> None: + return +main() # E: Custom error [custom] + +[file mypy.ini] +\[mypy] +plugins=/test-data/unit/plugins/custom_errorcode.py From 9130edfd6e79d6e0cd11890d2b924bd9449af1ff Mon Sep 17 00:00:00 2001 From: John Belmonte Date: Fri, 3 Oct 2025 10:05:19 -0700 Subject: [PATCH 2/8] refactor error callback (#1) --- mypy/main.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/mypy/main.py b/mypy/main.py index c0b9c0ea1427..546643dd47e3 100644 --- a/mypy/main.py +++ b/mypy/main.py @@ -1458,13 +1458,14 @@ def set_strict_flags() -> None: options.process_incomplete_features(error_callback=parser.error, warning_callback=print) - def on_plugins_loaded() -> None: - # Processing error codes after plugins have loaded since plugins may - # register custom error codes that we don't know about until plugins - # have loaded. - options.process_error_codes(error_callback=parser.error) - - options._on_plugins_loaded = on_plugins_loaded + def _bad_error_code_flags(msg: str): + raise CompileError([msg]) + + # Processing error codes after plugins have loaded since plugins may + # register custom error codes that we don't know about until plugins + # have loaded. + options._on_plugins_loaded = \ + lambda: options.process_error_codes(error_callback=_bad_error_code_flags) # Compute absolute path for custom typeshed (if present). if options.custom_typeshed_dir is not None: From 60ab7482ea794eaae900abc04862c759d0ef8e60 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 3 Oct 2025 17:08:22 +0000 Subject: [PATCH 3/8] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- mypy/main.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/mypy/main.py b/mypy/main.py index 546643dd47e3..706e51753430 100644 --- a/mypy/main.py +++ b/mypy/main.py @@ -1464,8 +1464,9 @@ def _bad_error_code_flags(msg: str): # Processing error codes after plugins have loaded since plugins may # register custom error codes that we don't know about until plugins # have loaded. - options._on_plugins_loaded = \ - lambda: options.process_error_codes(error_callback=_bad_error_code_flags) + options._on_plugins_loaded = lambda: options.process_error_codes( + error_callback=_bad_error_code_flags + ) # Compute absolute path for custom typeshed (if present). if options.custom_typeshed_dir is not None: From 53a65f2ff43172ff3776f7d93809362bac392bbb Mon Sep 17 00:00:00 2001 From: John Belmonte Date: Fri, 3 Oct 2025 10:13:50 -0700 Subject: [PATCH 4/8] fix type check --- mypy/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mypy/main.py b/mypy/main.py index 706e51753430..046a9508468e 100644 --- a/mypy/main.py +++ b/mypy/main.py @@ -1458,7 +1458,7 @@ def set_strict_flags() -> None: options.process_incomplete_features(error_callback=parser.error, warning_callback=print) - def _bad_error_code_flags(msg: str): + def _bad_error_code_flags(msg: str) -> None: raise CompileError([msg]) # Processing error codes after plugins have loaded since plugins may From 5d72e39ac41c4aeeed9af7b695c89054fb1e1f77 Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Sat, 4 Oct 2025 02:25:57 +0100 Subject: [PATCH 5/8] Update main.py --- mypy/main.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/mypy/main.py b/mypy/main.py index 046a9508468e..fb5d164c999c 100644 --- a/mypy/main.py +++ b/mypy/main.py @@ -1458,16 +1458,6 @@ def set_strict_flags() -> None: options.process_incomplete_features(error_callback=parser.error, warning_callback=print) - def _bad_error_code_flags(msg: str) -> None: - raise CompileError([msg]) - - # Processing error codes after plugins have loaded since plugins may - # register custom error codes that we don't know about until plugins - # have loaded. - options._on_plugins_loaded = lambda: options.process_error_codes( - error_callback=_bad_error_code_flags - ) - # Compute absolute path for custom typeshed (if present). if options.custom_typeshed_dir is not None: options.abs_custom_typeshed_dir = os.path.abspath(options.custom_typeshed_dir) From 892ad6eb2af992a810864d1e9b1bc2b4d9b1b22f Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Sat, 4 Oct 2025 02:26:48 +0100 Subject: [PATCH 6/8] Update options.py --- mypy/options.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/mypy/options.py b/mypy/options.py index 231c13a348d2..ad4b26cca095 100644 --- a/mypy/options.py +++ b/mypy/options.py @@ -416,8 +416,6 @@ def __init__(self) -> None: # preserving manual tweaks to generated C file) self.mypyc_skip_c_generation = False - self._on_plugins_loaded: Callable[[], None] | None = None - def use_lowercase_names(self) -> bool: warnings.warn( "options.use_lowercase_names() is deprecated and will be removed in a future version", From 5de7da28b455c353ab505c577aca8ca8f362ab5f Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Sat, 4 Oct 2025 02:31:25 +0100 Subject: [PATCH 7/8] Update build.py --- mypy/build.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/mypy/build.py b/mypy/build.py index 810116df5e02..67a5fb5446d8 100644 --- a/mypy/build.py +++ b/mypy/build.py @@ -144,6 +144,10 @@ def __init__(self, manager: BuildManager, graph: Graph) -> None: self.errors: list[str] = [] # Filled in by build if desired +def build_error(msg: str) -> NoReturn: + raise CompileError([f"mypy: error: {msg}"]) + + def build( sources: list[BuildSource], options: Options, @@ -239,6 +243,9 @@ def _build( errors = Errors(options, read_source=lambda path: read_py_file(path, cached_read)) plugin, snapshot = load_plugins(options, errors, stdout, extra_plugins) + # Validate error codes after plugins are loaded. + options.process_error_codes(error_callback=build_error) + # Add catch-all .gitignore to cache dir if we created it cache_dir_existed = os.path.isdir(options.cache_dir) @@ -504,9 +511,6 @@ def load_plugins( """ custom_plugins, snapshot = load_plugins_from_config(options, errors, stdout) - if options._on_plugins_loaded is not None: - options._on_plugins_loaded() - custom_plugins += extra_plugins default_plugin: Plugin = DefaultPlugin(options) From 7691315860c1103b784c56aafbcfb69c181e2e5f Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Sat, 4 Oct 2025 02:39:44 +0100 Subject: [PATCH 8/8] Update cmdline.test --- test-data/unit/cmdline.test | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/test-data/unit/cmdline.test b/test-data/unit/cmdline.test index ff60c24b72a5..35d7b700b161 100644 --- a/test-data/unit/cmdline.test +++ b/test-data/unit/cmdline.test @@ -960,8 +960,6 @@ src/foo/bar.py: note: Common resolutions include: a) adding `__init__.py` somewh [file test.py] x = 1 [out] -usage: mypy [-h] [-v] [-V] [more options; see below] - [-m MODULE] [-p PACKAGE] [-c PROGRAM_TEXT] [files ...] mypy: error: Invalid error code(s): YOLO == Return code: 2 @@ -970,8 +968,6 @@ mypy: error: Invalid error code(s): YOLO [file test.py] x = 1 [out] -usage: mypy [-h] [-v] [-V] [more options; see below] - [-m MODULE] [-p PACKAGE] [-c PROGRAM_TEXT] [files ...] mypy: error: Invalid error code(s): YOLO == Return code: 2 @@ -980,8 +976,6 @@ mypy: error: Invalid error code(s): YOLO [file test.py] x = 1 [out] -usage: mypy [-h] [-v] [-V] [more options; see below] - [-m MODULE] [-p PACKAGE] [-c PROGRAM_TEXT] [files ...] mypy: error: Invalid error code(s): YOLO, YOLO2 == Return code: 2 @@ -990,8 +984,6 @@ mypy: error: Invalid error code(s): YOLO, YOLO2 [file test.py] x = 1 [out] -usage: mypy [-h] [-v] [-V] [more options; see below] - [-m MODULE] [-p PACKAGE] [-c PROGRAM_TEXT] [files ...] mypy: error: Invalid error code(s): YOLO == Return code: 2 @@ -1000,8 +992,6 @@ mypy: error: Invalid error code(s): YOLO [file test.py] x = 1 [out] -usage: mypy [-h] [-v] [-V] [more options; see below] - [-m MODULE] [-p PACKAGE] [-c PROGRAM_TEXT] [files ...] mypy: error: Invalid error code(s): YOLO == Return code: 2