Skip to content

Commit

Permalink
Allow per-module error codes (#13502)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
ilevkivskyi committed Aug 25, 2022
1 parent 17ec3ca commit d68b1c6
Show file tree
Hide file tree
Showing 12 changed files with 212 additions and 34 deletions.
44 changes: 44 additions & 0 deletions docs/source/error_codes.rst
Expand Up @@ -69,3 +69,47 @@ which enables the ``no-untyped-def`` error code.
You can use :option:`--enable-error-code <mypy --enable-error-code>` to
enable specific error codes that don't have a dedicated command-line
flag or config file setting.

Per-module enabling/disabling error codes
-----------------------------------------

You can use :ref:`configuration file <config-file>` sections to enable or
disable specific error codes only in some modules. For example, this ``mypy.ini``
config will enable non-annotated empty containers in tests, while keeping
other parts of code checked in strict mode:

.. code-block:: ini
[mypy]
strict = True
[mypy-tests.*]
allow_untyped_defs = True
allow_untyped_calls = True
disable_error_code = var-annotated, has-type
Note that per-module enabling/disabling acts as override over the global
options. So that you don't need to repeat the error code lists for each
module if you have them in global config section. For example:

.. code-block:: ini
[mypy]
enable_error_code = truthy-bool, ignore-without-code, unused-awaitable
[mypy-extensions.*]
disable_error_code = unused-awaitable
The above config will allow unused awaitables in extension modules, but will
still keep the other two error codes enabled. The overall logic is following:

* Command line and/or config main section set global error codes

* Individual config sections *adjust* them per glob/module

* Inline ``# mypy: ...`` comments can further *adjust* them for a specific
module

So one can e.g. enable some code globally, disable it for all tests in
the corresponding config section, and then re-enable it with an inline
comment in some specific test.
23 changes: 11 additions & 12 deletions mypy/build.py
Expand Up @@ -235,9 +235,8 @@ def _build(
options.show_error_end,
lambda path: read_py_file(path, cached_read),
options.show_absolute_path,
options.enabled_error_codes,
options.disabled_error_codes,
options.many_errors_threshold,
options,
)
plugin, snapshot = load_plugins(options, errors, stdout, extra_plugins)

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

custom_plugins: list[Plugin] = []
errors.set_file(options.config_file, None)
errors.set_file(options.config_file, None, options)
for plugin_path in options.plugins:
func_name = "plugin"
plugin_dir: str | None = None
Expand Down Expand Up @@ -778,7 +777,7 @@ def correct_rel_imp(imp: ImportFrom | ImportAll) -> str:
new_id = file_id + "." + imp.id if imp.id else file_id

if not new_id:
self.errors.set_file(file.path, file.name)
self.errors.set_file(file.path, file.name, self.options)
self.errors.report(
imp.line, 0, "No parent module -- cannot perform relative import", blocker=True
)
Expand Down Expand Up @@ -989,7 +988,7 @@ def write_deps_cache(
error = True

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


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


Expand Down Expand Up @@ -1156,7 +1155,7 @@ def _load_json_file(
result = json.loads(data)
manager.add_stats(data_json_load_time=time.time() - t1)
except json.JSONDecodeError:
manager.errors.set_file(file, None)
manager.errors.set_file(file, None, manager.options)
manager.errors.report(
-1,
-1,
Expand Down Expand Up @@ -2205,7 +2204,7 @@ def parse_inline_configuration(self, source: str) -> None:
if flags:
changes, config_errors = parse_mypy_comments(flags, self.options)
self.options = self.options.apply_changes(changes)
self.manager.errors.set_file(self.xpath, self.id)
self.manager.errors.set_file(self.xpath, self.id, self.options)
for lineno, error in config_errors:
self.manager.errors.report(lineno, 0, error)

Expand Down Expand Up @@ -2717,7 +2716,7 @@ def module_not_found(
errors = manager.errors
save_import_context = errors.import_context()
errors.set_import_context(caller_state.import_context)
errors.set_file(caller_state.xpath, caller_state.id)
errors.set_file(caller_state.xpath, caller_state.id, caller_state.options)
if target == "builtins":
errors.report(
line, 0, "Cannot find 'builtins' module. Typeshed appears broken!", blocker=True
Expand Down Expand Up @@ -2747,7 +2746,7 @@ def skipping_module(
assert caller_state, (id, path)
save_import_context = manager.errors.import_context()
manager.errors.set_import_context(caller_state.import_context)
manager.errors.set_file(caller_state.xpath, caller_state.id)
manager.errors.set_file(caller_state.xpath, caller_state.id, manager.options)
manager.errors.report(line, 0, f'Import of "{id}" ignored', severity="error")
manager.errors.report(
line,
Expand All @@ -2766,7 +2765,7 @@ def skipping_ancestor(manager: BuildManager, id: str, path: str, ancestor_for: S
# But beware, some package may be the ancestor of many modules,
# so we'd need to cache the decision.
manager.errors.set_import_context([])
manager.errors.set_file(ancestor_for.xpath, ancestor_for.id)
manager.errors.set_file(ancestor_for.xpath, ancestor_for.id, manager.options)
manager.errors.report(
-1, -1, f'Ancestor package "{id}" ignored', severity="error", only_once=True
)
Expand Down Expand Up @@ -3000,7 +2999,7 @@ def load_graph(
except ModuleNotFound:
continue
if st.id in graph:
manager.errors.set_file(st.xpath, st.id)
manager.errors.set_file(st.xpath, st.id, manager.options)
manager.errors.report(
-1,
-1,
Expand Down
8 changes: 6 additions & 2 deletions mypy/checker.py
Expand Up @@ -453,7 +453,9 @@ def check_first_pass(self) -> None:
"""
self.recurse_into_functions = True
with state.strict_optional_set(self.options.strict_optional):
self.errors.set_file(self.path, self.tree.fullname, scope=self.tscope)
self.errors.set_file(
self.path, self.tree.fullname, scope=self.tscope, options=self.options
)
with self.tscope.module_scope(self.tree.fullname):
with self.enter_partial_types(), self.binder.top_frame_context():
for d in self.tree.defs:
Expand Down Expand Up @@ -492,7 +494,9 @@ def check_second_pass(
with state.strict_optional_set(self.options.strict_optional):
if not todo and not self.deferred_nodes:
return False
self.errors.set_file(self.path, self.tree.fullname, scope=self.tscope)
self.errors.set_file(
self.path, self.tree.fullname, scope=self.tscope, options=self.options
)
with self.tscope.module_scope(self.tree.fullname):
self.pass_num += 1
if not todo:
Expand Down
27 changes: 23 additions & 4 deletions mypy/config_parser.py
Expand Up @@ -8,6 +8,8 @@
import sys
from io import StringIO

from mypy.errorcodes import error_codes

if sys.version_info >= (3, 11):
import tomllib
else:
Expand Down Expand Up @@ -69,6 +71,15 @@ def try_split(v: str | Sequence[str], split_regex: str = "[,]") -> list[str]:
return [p.strip() for p in 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 expand_path(path: str) -> str:
"""Expand the user home directory and any environment variables contained within
the provided path.
Expand Down Expand Up @@ -147,8 +158,8 @@ def check_follow_imports(choice: str) -> str:
"plugins": lambda s: [p.strip() for p in s.split(",")],
"always_true": lambda s: [p.strip() for p in s.split(",")],
"always_false": lambda s: [p.strip() for p in s.split(",")],
"disable_error_code": lambda s: [p.strip() for p in s.split(",")],
"enable_error_code": lambda s: [p.strip() for p in s.split(",")],
"disable_error_code": lambda s: validate_codes([p.strip() for p in s.split(",")]),
"enable_error_code": lambda s: validate_codes([p.strip() for p in s.split(",")]),
"package_root": lambda s: [p.strip() for p in s.split(",")],
"cache_dir": expand_path,
"python_executable": expand_path,
Expand All @@ -168,8 +179,8 @@ def check_follow_imports(choice: str) -> str:
"plugins": try_split,
"always_true": try_split,
"always_false": try_split,
"disable_error_code": try_split,
"enable_error_code": try_split,
"disable_error_code": lambda s: validate_codes(try_split(s)),
"enable_error_code": lambda s: validate_codes(try_split(s)),
"package_root": try_split,
"exclude": str_or_array_as_list,
}
Expand Down Expand Up @@ -263,6 +274,7 @@ def parse_config_file(
file=stderr,
)
updates = {k: v for k, v in updates.items() if k in PER_MODULE_OPTIONS}

globs = name[5:]
for glob in globs.split(","):
# For backwards compatibility, replace (back)slashes with dots.
Expand Down Expand Up @@ -481,6 +493,13 @@ def parse_section(
if "follow_imports" not in results:
results["follow_imports"] = "error"
results[options_key] = v

# These two flags act as per-module overrides, so store the empty defaults.
if "disable_error_code" not in results:
results["disable_error_code"] = []
if "enable_error_code" not in results:
results["enable_error_code"] = []

return results, report_dirs


Expand Down
22 changes: 15 additions & 7 deletions mypy/errors.py
Expand Up @@ -262,9 +262,8 @@ def __init__(
show_error_end: bool = False,
read_source: Callable[[str], list[str] | None] | None = None,
show_absolute_path: bool = False,
enabled_error_codes: set[ErrorCode] | None = None,
disabled_error_codes: set[ErrorCode] | None = None,
many_errors_threshold: int = -1,
options: Options | None = None,
) -> None:
self.show_error_context = show_error_context
self.show_column_numbers = show_column_numbers
Expand All @@ -276,9 +275,8 @@ def __init__(
assert show_column_numbers, "Inconsistent formatting, must be prevented by argparse"
# We use fscache to read source code when showing snippets.
self.read_source = read_source
self.enabled_error_codes = enabled_error_codes or set()
self.disabled_error_codes = disabled_error_codes or set()
self.many_errors_threshold = many_errors_threshold
self.options = options
self.initialize()

def initialize(self) -> None:
Expand Down Expand Up @@ -313,7 +311,9 @@ def simplify_path(self, file: str) -> str:
file = os.path.normpath(file)
return remove_path_prefix(file, self.ignore_prefix)

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

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

def is_error_code_enabled(self, error_code: ErrorCode) -> bool:
if error_code in self.disabled_error_codes:
if self.options:
current_mod_disabled = self.options.disabled_error_codes
current_mod_enabled = self.options.enabled_error_codes
else:
current_mod_disabled = set()
current_mod_enabled = set()

if error_code in current_mod_disabled:
return False
elif error_code in self.enabled_error_codes:
elif error_code in current_mod_enabled:
return True
else:
return error_code.default_enabled
Expand Down
2 changes: 1 addition & 1 deletion mypy/fastparse.py
Expand Up @@ -263,7 +263,7 @@ def parse(
raise_on_error = True
if options is None:
options = Options()
errors.set_file(fnam, module)
errors.set_file(fnam, module, options=options)
is_stub_file = fnam.endswith(".pyi")
if is_stub_file:
feature_version = defaults.PYTHON3_VERSION[1]
Expand Down
32 changes: 27 additions & 5 deletions mypy/options.py
Expand Up @@ -3,15 +3,13 @@
import pprint
import re
import sys
from typing import TYPE_CHECKING, Any, Callable, Mapping, Pattern
from typing import Any, Callable, Dict, Mapping, Pattern
from typing_extensions import Final

from mypy import defaults
from mypy.errorcodes import ErrorCode, error_codes
from mypy.util import get_class_descriptors, replace_object_state

if TYPE_CHECKING:
from mypy.errorcodes import ErrorCode


class BuildType:
STANDARD: Final = 0
Expand All @@ -27,6 +25,8 @@ class BuildType:
"always_true",
"check_untyped_defs",
"debug_cache",
"disable_error_code",
"disabled_error_codes",
"disallow_any_decorated",
"disallow_any_explicit",
"disallow_any_expr",
Expand All @@ -37,6 +37,8 @@ class BuildType:
"disallow_untyped_calls",
"disallow_untyped_decorators",
"disallow_untyped_defs",
"enable_error_code",
"enabled_error_codes",
"follow_imports",
"follow_imports_for_stubs",
"ignore_errors",
Expand Down Expand Up @@ -347,6 +349,20 @@ def apply_changes(self, changes: dict[str, object]) -> Options:
# This is the only option for which a per-module and a global
# option sometimes beheave differently.
new_options.ignore_missing_imports_per_module = True

# These two act as overrides, so apply them when cloning.
# Similar to global codes enabling overrides disabling, so we start from latter.
new_options.disabled_error_codes = self.disabled_error_codes.copy()
new_options.enabled_error_codes = self.enabled_error_codes.copy()
for code_str in new_options.disable_error_code:
code = error_codes[code_str]
new_options.disabled_error_codes.add(code)
new_options.enabled_error_codes.discard(code)
for code_str in new_options.enable_error_code:
code = error_codes[code_str]
new_options.enabled_error_codes.add(code)
new_options.disabled_error_codes.discard(code)

return new_options

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

def select_options_affecting_cache(self) -> Mapping[str, object]:
return {opt: getattr(self, opt) for opt in OPTIONS_AFFECTING_CACHE}
result: Dict[str, object] = {}
for opt in OPTIONS_AFFECTING_CACHE:
val = getattr(self, opt)
if isinstance(val, set):
val = sorted(val)
result[opt] = val
return result
2 changes: 1 addition & 1 deletion mypy/semanal.py
Expand Up @@ -732,7 +732,7 @@ def file_context(
"""
scope = self.scope
self.options = options
self.errors.set_file(file_node.path, file_node.fullname, scope=scope)
self.errors.set_file(file_node.path, file_node.fullname, scope=scope, options=options)
self.cur_mod_node = file_node
self.cur_mod_id = file_node.fullname
with scope.module_scope(self.cur_mod_id):
Expand Down
2 changes: 1 addition & 1 deletion mypy/semanal_typeargs.py
Expand Up @@ -46,7 +46,7 @@ def __init__(self, errors: Errors, options: Options, is_typeshed_file: bool) ->
self.seen_aliases: set[TypeAliasType] = set()

def visit_mypy_file(self, o: MypyFile) -> None:
self.errors.set_file(o.path, o.fullname, scope=self.scope)
self.errors.set_file(o.path, o.fullname, scope=self.scope, options=self.options)
with self.scope.module_scope(o.fullname):
super().visit_mypy_file(o)

Expand Down

0 comments on commit d68b1c6

Please sign in to comment.