Skip to content

Commit 242ab00

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 fixes #12987.
1 parent 116b92b commit 242ab00

File tree

9 files changed

+110
-32
lines changed

9 files changed

+110
-32
lines changed

mypy/__main__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ def console_entry() -> None:
2222
os.dup2(devnull, sys.stdout.fileno())
2323
sys.exit(2)
2424
except KeyboardInterrupt:
25-
_, options = process_options(args=sys.argv[1:])
25+
_, options, _ = process_options(args=sys.argv[1:])
2626
if options.show_traceback:
2727
sys.stdout.write(traceback.format_exc())
2828
formatter = FancyFormatter(sys.stdout, sys.stderr, False)

mypy/build.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ def build(
153153
stdout: TextIO | None = None,
154154
stderr: TextIO | None = None,
155155
extra_plugins: Sequence[Plugin] | None = None,
156+
on_plugins_loaded: Callable[[], None] | None = None,
156157
) -> BuildResult:
157158
"""Analyze a program.
158159
@@ -192,7 +193,7 @@ def default_flush_errors(
192193

193194
try:
194195
result = _build(
195-
sources, options, alt_lib_path, flush_errors, fscache, stdout, stderr, extra_plugins
196+
sources, options, alt_lib_path, flush_errors, fscache, stdout, stderr, extra_plugins, on_plugins_loaded=on_plugins_loaded
196197
)
197198
result.errors = messages
198199
return result
@@ -216,6 +217,7 @@ def _build(
216217
stdout: TextIO,
217218
stderr: TextIO,
218219
extra_plugins: Sequence[Plugin],
220+
on_plugins_loaded: Callable[[], None] | None = None
219221
) -> BuildResult:
220222
if platform.python_implementation() == "CPython":
221223
# Run gc less frequently, as otherwise we can spent a large fraction of
@@ -239,6 +241,8 @@ def _build(
239241
errors = Errors(options, read_source=lambda path: read_py_file(path, cached_read))
240242
plugin, snapshot = load_plugins(options, errors, stdout, extra_plugins)
241243

244+
on_plugins_loaded()
245+
242246
# Add catch-all .gitignore to cache dir if we created it
243247
cache_dir_existed = os.path.isdir(options.cache_dir)
244248

mypy/config_parser.py

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -87,15 +87,6 @@ def complain(x: object, additional_info: str = "") -> Never:
8787
complain(v)
8888

8989

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-
9990
def validate_package_allow_list(allow_list: list[str]) -> list[str]:
10091
for p in allow_list:
10192
msg = f"Invalid allow list entry: {p}"
@@ -209,8 +200,8 @@ def split_commas(value: str) -> list[str]:
209200
[p.strip() for p in split_commas(s)]
210201
),
211202
"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)]),
203+
"disable_error_code": lambda s: [p.strip() for p in split_commas(s)],
204+
"enable_error_code": lambda s: [p.strip() for p in split_commas(s)],
214205
"package_root": lambda s: [p.strip() for p in split_commas(s)],
215206
"cache_dir": expand_path,
216207
"python_executable": expand_path,
@@ -234,8 +225,8 @@ def split_commas(value: str) -> list[str]:
234225
"always_false": try_split,
235226
"untyped_calls_exclude": lambda s: validate_package_allow_list(try_split(s)),
236227
"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)),
228+
"disable_error_code": lambda s: try_split(s),
229+
"enable_error_code": lambda s: try_split(s),
239230
"package_root": try_split,
240231
"exclude": str_or_array_as_list,
241232
"packages": try_split,

mypy/dmypy_server.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ def daemonize(
136136

137137

138138
def process_start_options(flags: list[str], allow_sources: bool) -> Options:
139-
_, options = mypy.main.process_options(
139+
_, options, _ = mypy.main.process_options(
140140
["-i"] + flags, require_targets=False, server_options=True
141141
)
142142
if options.report_dirs:
@@ -326,7 +326,7 @@ def cmd_run(
326326
# capture stderr so the client can report it
327327
with redirect_stderr(stderr):
328328
with redirect_stdout(stdout):
329-
sources, options = mypy.main.process_options(
329+
sources, options, _ = mypy.main.process_options(
330330
["-i"] + list(args),
331331
require_targets=True,
332332
server_options=True,

mypy/main.py

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from collections.abc import Sequence
1212
from gettext import gettext
1313
from io import TextIOWrapper
14-
from typing import IO, TYPE_CHECKING, Any, Final, NoReturn, TextIO
14+
from typing import IO, TYPE_CHECKING, Any, Callable, Final, NoReturn, TextIO
1515

1616
from mypy import build, defaults, state, util
1717
from mypy.config_parser import (
@@ -86,7 +86,7 @@ def main(
8686
stdout.reconfigure(errors="backslashreplace")
8787

8888
fscache = FileSystemCache()
89-
sources, options = process_options(args, stdout=stdout, stderr=stderr, fscache=fscache)
89+
sources, options, on_plugins_loaded = process_options(args, stdout=stdout, stderr=stderr, fscache=fscache)
9090
if clean_exit:
9191
options.fast_exit = False
9292

@@ -124,7 +124,7 @@ def main(
124124
install_types(formatter, options, non_interactive=options.non_interactive)
125125
return
126126

127-
res, messages, blockers = run_build(sources, options, fscache, t0, stdout, stderr)
127+
res, messages, blockers = run_build(sources, options, fscache, t0, stdout, stderr, on_plugins_loaded=on_plugins_loaded)
128128

129129
if options.non_interactive:
130130
missing_pkgs = read_types_packages_to_install(options.cache_dir, after_run=True)
@@ -182,6 +182,7 @@ def run_build(
182182
t0: float,
183183
stdout: TextIO,
184184
stderr: TextIO,
185+
on_plugins_loaded: Callable[[], None] | None = None
185186
) -> tuple[build.BuildResult | None, list[str], bool]:
186187
formatter = util.FancyFormatter(
187188
stdout, stderr, options.hide_error_codes, hide_success=bool(options.output)
@@ -208,7 +209,7 @@ def flush_errors(filename: str | None, new_messages: list[str], serious: bool) -
208209
try:
209210
# Keep a dummy reference (res) for memory profiling afterwards, as otherwise
210211
# the result could be freed.
211-
res = build.build(sources, options, None, flush_errors, fscache, stdout, stderr)
212+
res = build.build(sources, options, None, flush_errors, fscache, stdout, stderr, on_plugins_loaded=on_plugins_loaded)
212213
except CompileError as e:
213214
blockers = True
214215
if not e.use_stdout:
@@ -1347,13 +1348,14 @@ def process_options(
13471348
fscache: FileSystemCache | None = None,
13481349
program: str = "mypy",
13491350
header: str = HEADER,
1350-
) -> tuple[list[BuildSource], Options]:
1351+
) -> tuple[list[BuildSource], Options, Callable[[], None]]:
13511352
"""Parse command line arguments.
13521353
13531354
If a FileSystemCache is passed in, and package_root options are given,
13541355
call fscache.set_package_root() to set the cache's package root.
13551356
1356-
Returns a tuple of: a list of source files, an Options collected from flags.
1357+
Returns a tuple of: a list of source files, an Options collected from
1358+
flags, and a callback to be called once plugins have loaded.
13571359
"""
13581360
stdout = stdout if stdout is not None else sys.stdout
13591361
stderr = stderr if stderr is not None else sys.stderr
@@ -1456,9 +1458,14 @@ def set_strict_flags() -> None:
14561458
validate_package_allow_list(options.untyped_calls_exclude)
14571459
validate_package_allow_list(options.deprecated_calls_exclude)
14581460

1459-
options.process_error_codes(error_callback=parser.error)
14601461
options.process_incomplete_features(error_callback=parser.error, warning_callback=print)
14611462

1463+
def on_plugins_loaded() -> None:
1464+
# Processing error codes after plugins have loaded since plugins may
1465+
# register custom error codes that we don't know about until plugins
1466+
# have loaded.
1467+
options.process_error_codes(error_callback=parser.error)
1468+
14621469
# Compute absolute path for custom typeshed (if present).
14631470
if options.custom_typeshed_dir is not None:
14641471
options.abs_custom_typeshed_dir = os.path.abspath(options.custom_typeshed_dir)
@@ -1550,7 +1557,7 @@ def set_strict_flags() -> None:
15501557
# exceptions of different types.
15511558
except InvalidSourceList as e2:
15521559
fail(str(e2), stderr, options)
1553-
return targets, options
1560+
return targets, options, on_plugins_loaded
15541561

15551562

15561563
def process_package_roots(

mypy/test/helpers.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -343,7 +343,7 @@ def parse_options(
343343
if flags:
344344
flag_list = flags.group(1).split()
345345
flag_list.append("--no-site-packages") # the tests shouldn't need an installed Python
346-
targets, options = process_options(flag_list, require_targets=False)
346+
targets, options, _ = process_options(flag_list, require_targets=False)
347347
if targets:
348348
# TODO: support specifying targets via the flags pragma
349349
raise RuntimeError("Specifying targets via the flags pragma is not supported.")

mypy/test/testargs.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
class ArgSuite(Suite):
1919
def test_coherence(self) -> None:
2020
options = Options()
21-
_, parsed_options = process_options([], require_targets=False)
21+
_, parsed_options, _ = process_options([], require_targets=False)
2222
# FIX: test this too. Requires changing working dir to avoid finding 'setup.cfg'
2323
options.config_file = parsed_options.config_file
2424
assert_equal(options.snapshot(), parsed_options.snapshot())
@@ -31,12 +31,12 @@ def test_executable_inference(self) -> None:
3131

3232
# test inference given one (infer the other)
3333
matching_version = base + [f"--python-version={sys_ver_str}"]
34-
_, options = process_options(matching_version)
34+
_, options, _ = process_options(matching_version)
3535
assert options.python_version == sys.version_info[:2]
3636
assert options.python_executable == sys.executable
3737

3838
matching_version = base + [f"--python-executable={sys.executable}"]
39-
_, options = process_options(matching_version)
39+
_, options, _ = process_options(matching_version)
4040
assert options.python_version == sys.version_info[:2]
4141
assert options.python_executable == sys.executable
4242

@@ -45,13 +45,13 @@ def test_executable_inference(self) -> None:
4545
f"--python-version={sys_ver_str}",
4646
f"--python-executable={sys.executable}",
4747
]
48-
_, options = process_options(matching_version)
48+
_, options, _ = process_options(matching_version)
4949
assert options.python_version == sys.version_info[:2]
5050
assert options.python_executable == sys.executable
5151

5252
# test that --no-site-packages will disable executable inference
5353
matching_version = base + [f"--python-version={sys_ver_str}", "--no-site-packages"]
54-
_, options = process_options(matching_version)
54+
_, options, _ = process_options(matching_version)
5555
assert options.python_version == sys.version_info[:2]
5656
assert options.python_executable is None
5757

mypyc/build.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ def get_mypy_config(
126126
fscache: FileSystemCache | None,
127127
) -> tuple[list[BuildSource], list[BuildSource], Options]:
128128
"""Construct mypy BuildSources and Options from file and options lists"""
129-
all_sources, options = process_options(mypy_options, fscache=fscache)
129+
all_sources, options, _ = process_options(mypy_options, fscache=fscache)
130130
if only_compile_paths is not None:
131131
paths_set = set(only_compile_paths)
132132
mypyc_sources = [s for s in all_sources if s.path in paths_set]
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
[case test_custom_error_code_from_plugin_is_targetable]
2+
# flags: --show-error-codes
3+
# mypy.ini: [mypy]
4+
# mypy.ini: plugins = my_dummy_plugin
5+
6+
[file my_dummy_plugin.py]
7+
from __future__ import annotations
8+
from mypy.plugin import Plugin, FunctionContext
9+
from mypy.errorcodes import ErrorCode
10+
11+
DUMMY_PLUGIN_ERROR = ErrorCode("dummy-plugin-error", "Dummy plugin error", "Plugin")
12+
13+
class DummyPlugin(Plugin):
14+
def get_function_hook(self, fullname: str):
15+
return lambda ctx: ctx.api.fail("this is a plugin error", ctx.context, code=DUMMY_PLUGIN_ERROR)
16+
17+
def plugin(version: str): return DummyPlugin
18+
19+
[file main.py]
20+
def fn(x: int) -> int: ...
21+
fn("x")
22+
23+
[out]
24+
main.py:2: error: this is a plugin error [dummy-plugin-error]
25+
Found 1 error in 1 file (checked 1 source file)
26+
27+
[case test_custom_error_code_can_be_disabled]
28+
# flags: --show-error-codes --disable-error-code=dummy-plugin-error
29+
# mypy.ini: [mypy]
30+
# mypy.ini: plugins = my_dummy_plugin
31+
32+
[file my_dummy_plugin.py]
33+
from __future__ import annotations
34+
from mypy.plugin import Plugin, FunctionContext
35+
from mypy.errorcodes import ErrorCode
36+
37+
DUMMY_PLUGIN_ERROR = ErrorCode("dummy-plugin-error", "Dummy plugin error", "Plugin")
38+
39+
class DummyPlugin(Plugin):
40+
def get_function_hook(self, fullname: str):
41+
return lambda ctx: ctx.api.fail("this is a plugin error", ctx.context, code=DUMMY_PLUGIN_ERROR)
42+
43+
def plugin(version: str): return DummyPlugin
44+
45+
[file main.py]
46+
def fn(x: int) -> int: ...
47+
fn("x") # no output expected when disabled
48+
49+
[out]
50+
Success: no issues found in 1 source file
51+
52+
[case test_custom_error_code_can_be_reenabled]
53+
# flags: --show-error-codes --disable-error-code=dummy-plugin-error --enable-error-code=dummy-plugin-error
54+
# mypy.ini: [mypy]
55+
# mypy.ini: plugins = my_dummy_plugin
56+
57+
[file my_dummy_plugin.py]
58+
from __future__ import annotations
59+
from mypy.plugin import Plugin, FunctionContext
60+
from mypy.errorcodes import ErrorCode
61+
62+
DUMMY_PLUGIN_ERROR = ErrorCode("dummy-plugin-error", "Dummy plugin error", "Plugin")
63+
64+
class DummyPlugin(Plugin):
65+
def get_function_hook(self, fullname: str):
66+
return lambda ctx: ctx.api.fail("this is a plugin error", ctx.context, code=DUMMY_PLUGIN_ERROR)
67+
68+
def plugin(version: str): return DummyPlugin
69+
70+
[file main.py]
71+
def fn(x: int) -> int: ...
72+
fn("x")
73+
74+
[out]
75+
main.py:2: error: this is a plugin error [dummy-plugin-error]
76+
Found 1 error in 1 file (checked 1 source file)

0 commit comments

Comments
 (0)