diff --git a/docs/source/error_codes.rst b/docs/source/error_codes.rst index bed73abc379f..ccbe81a157b7 100644 --- a/docs/source/error_codes.rst +++ b/docs/source/error_codes.rst @@ -69,3 +69,47 @@ which enables the ``no-untyped-def`` error code. You can use :option:`--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 ` 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. diff --git a/mypy/build.py b/mypy/build.py index 559e9e10daf6..0941f6d6943c 100644 --- a/mypy/build.py +++ b/mypy/build.py @@ -236,9 +236,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) @@ -422,7 +421,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 @@ -773,7 +772,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 ) @@ -984,7 +983,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) @@ -1048,7 +1047,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) @@ -1151,7 +1150,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, @@ -2200,7 +2199,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) @@ -2711,7 +2710,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 @@ -2741,7 +2740,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, @@ -2760,7 +2759,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 ) @@ -2994,7 +2993,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, diff --git a/mypy/checker.py b/mypy/checker.py index 670a08a8e2be..306726d093b1 100644 --- a/mypy/checker.py +++ b/mypy/checker.py @@ -451,7 +451,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: @@ -490,7 +492,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: diff --git a/mypy/config_parser.py b/mypy/config_parser.py index 55cc0fea3720..ce8bfb585c9e 100644 --- a/mypy/config_parser.py +++ b/mypy/config_parser.py @@ -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: @@ -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. @@ -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, @@ -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, } @@ -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. @@ -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 diff --git a/mypy/errors.py b/mypy/errors.py index 32315330759f..00f715a0c4d6 100644 --- a/mypy/errors.py +++ b/mypy/errors.py @@ -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 @@ -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: @@ -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 @@ -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 @@ -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 diff --git a/mypy/fastparse.py b/mypy/fastparse.py index ad7bf2ddf06b..901c2057e7cb 100644 --- a/mypy/fastparse.py +++ b/mypy/fastparse.py @@ -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] diff --git a/mypy/options.py b/mypy/options.py index ec99bc016495..1d4a6ec929df 100644 --- a/mypy/options.py +++ b/mypy/options.py @@ -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 @@ -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", @@ -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", @@ -349,6 +351,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: @@ -448,4 +464,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 diff --git a/mypy/semanal.py b/mypy/semanal.py index baffbec5dc05..78ad7691574e 100644 --- a/mypy/semanal.py +++ b/mypy/semanal.py @@ -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): diff --git a/mypy/semanal_typeargs.py b/mypy/semanal_typeargs.py index f988014cdd02..161775ce8fd9 100644 --- a/mypy/semanal_typeargs.py +++ b/mypy/semanal_typeargs.py @@ -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) diff --git a/mypyc/codegen/emitmodule.py b/mypyc/codegen/emitmodule.py index 005c0f764e9a..492cbb6afd37 100644 --- a/mypyc/codegen/emitmodule.py +++ b/mypyc/codegen/emitmodule.py @@ -419,7 +419,9 @@ def compile_modules_to_c( # Sometimes when we call back into mypy, there might be errors. # We don't want to crash when that happens. - result.manager.errors.set_file("", module=None, scope=None) + result.manager.errors.set_file( + "", module=None, scope=None, options=result.manager.options + ) modules = compile_modules_to_ir(result, mapper, compiler_options, errors) ctext = compile_ir_to_c(groups, modules, result, mapper, compiler_options) diff --git a/test-data/unit/check-flags.test b/test-data/unit/check-flags.test index 5b5d49c80708..11229465eac4 100644 --- a/test-data/unit/check-flags.test +++ b/test-data/unit/check-flags.test @@ -2053,3 +2053,41 @@ def f(x): y = 1 f(reveal_type(y)) # E: Call to untyped function "f" in typed context \ # N: Revealed type is "builtins.int" + +[case testPerModuleErrorCodes] +# flags: --config-file tmp/mypy.ini +import tests.foo +import bar +[file bar.py] +x = [] # E: Need type annotation for "x" (hint: "x: List[] = ...") +[file tests/__init__.py] +[file tests/foo.py] +x = [] # OK +[file mypy.ini] +\[mypy] +strict = True + +\[mypy-tests.*] +allow_untyped_defs = True +allow_untyped_calls = True +disable_error_code = var-annotated + +[case testPerModuleErrorCodesOverride] +# flags: --config-file tmp/mypy.ini +import tests.foo +import bar +[file bar.py] +def foo() -> int: ... +if foo: ... # E: Function "Callable[[], int]" could always be true in boolean context +42 + "no" # type: ignore # E: "type: ignore" comment without error code (consider "type: ignore[operator]" instead) +[file tests/__init__.py] +[file tests/foo.py] +def foo() -> int: ... +if foo: ... # E: Function "Callable[[], int]" could always be true in boolean context +42 + "no" # type: ignore +[file mypy.ini] +\[mypy] +enable_error_code = ignore-without-code, truthy-bool + +\[mypy-tests.*] +disable_error_code = ignore-without-code diff --git a/test-data/unit/check-inline-config.test b/test-data/unit/check-inline-config.test index 578d8eff7ff8..3c318d89789a 100644 --- a/test-data/unit/check-inline-config.test +++ b/test-data/unit/check-inline-config.test @@ -162,3 +162,45 @@ main:1: error: Unrecognized option: skip_file = True # mypy: strict [out] main:1: error: Setting "strict" not supported in inline configuration: specify it in a configuration file instead, or set individual inline flags (see "mypy -h" for the list of flags enabled in strict mode) + +[case testInlineErrorCodes] +# flags: --strict-optional +# mypy: enable-error-code="ignore-without-code,truthy-bool" + +def foo() -> int: ... +if foo: ... # E: Function "Callable[[], int]" could always be true in boolean context +42 + "no" # type: ignore # E: "type: ignore" comment without error code (consider "type: ignore[operator]" instead) + +[case testInlineErrorCodesOverrideConfig] +# flags: --strict-optional --config-file tmp/mypy.ini +import foo +import tests.bar +import tests.baz +[file foo.py] +# mypy: disable-error-code="truthy-bool" + +def foo() -> int: ... +if foo: ... +42 + "no" # type: ignore # E: "type: ignore" comment without error code (consider "type: ignore[operator]" instead) + +[file tests/__init__.py] +[file tests/bar.py] +# mypy: enable-error-code="ignore-without-code" + +def foo() -> int: ... +if foo: ... # E: Function "Callable[[], int]" could always be true in boolean context +42 + "no" # type: ignore # E: "type: ignore" comment without error code (consider "type: ignore[operator]" instead) + +[file tests/baz.py] +# mypy: disable-error-code="truthy-bool" + +def foo() -> int: ... +if foo: ... +42 + "no" # type: ignore + +[file mypy.ini] +\[mypy] +enable_error_code = ignore-without-code, truthy-bool + +\[mypy-tests.*] +disable_error_code = ignore-without-code