Repository: python/mypy (Python)

Issue:
Add support for per-module enabling/disabling of error codes
**Feature**

#9172 implemented global error code disabling/enabling as discussed in #8975. It seems like it was always the intention to consider per-module configuration (https://github.com/python/mypy/issues/8975#issuecomment-641167533), but we don't currently have an open issue for it.

**Pitch**

My personal use case is for tests. Typing tests is often just a chore, but it's nice to have whatever type safety you can get for free. I'd want to be able to do:
```
[mypy]
strict = True

[mypy-tests.*]
allow_untyped_defs = True
allow_untyped_calls = True
disable_error_codes = ["var-annotated"]
```
(For this use case, I think there's actually a fair argument to be made that we should suppress var-annotated aka "needs type annotation" errors when checking untyped defs. If this strikes you as reasonable, I can open another issue. Edit: see #8558 for this)



Hints:
Seems reasonable.

You have a typo, the option is actually called `disable_error_code`. This triggered surprise in me, because at first I thought you were talking about `show_error_codes`. Is it too late to rename that flag to `ignore_error_code`? I guess a problem with that is that the inverse would be `dont-ignore-error-code`, which is kind of awkward.
Second this request. For me, I'd really like to disable `func-returns-value` in my tests but keep it active in source
This sounds like a useful feature. Another potential use case would be migrating legacy code -- you might initially ignore some error codes in code that wasn't originally written with type checking in mind, but you'd want to enable all error codes in new files.
>  Another potential use case would be migrating legacy code 

Exactly this.  I wrote a tool called [mypy-runner](https://pypi.org/project/mypy-runner/) for filtering errors using error codes (first using my own error codes, then the official ones once that feature arrived) as well regexes.  I'd love to not have to use my tool at all!  One of the big downsides of the runner-based approach is that there's a major performance penalty:  dmypy rechecks files with errors (regardless of modified timestamps), and from mypy's perspective all those files still have errors, so reruns of dmypy on our very large codebase are slow.  

Just ran into a use case for this! I'm migrating our (large) codebase to mypy-protobuf 2.0 - and it requires some backward incompatible changes to enums in pyi stubs. Ideally, I'm able to do this in many smaller diffs.

However, if a migrated module tries to import from a non-migrated module, it hits a `name-defined` error.

Ideally I can disable that error specifically - and reenable it later when the non-migrated module gets migrated.

Will be using ignore_errors for now - but it seems like an excessive hammer
This would be useful to turn off override errors when using fixtures in tests, like:

```python
class A:
     @pytest.fixture
     def b():
        pass

     @pytest.fixture
     def a(b):
          pass


class B(A):
     @pytest.fixture
     def c():
        pass

     @pytest.fixture
     def a(b, c):
          pass
```

Patch:
diff --git a/mypy/build.py b/mypy/build.py
--- a/mypy/build.py
+++ b/mypy/build.py
@@ -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)
 
@@ -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
@@ -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
                 )
@@ -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)
 
 
@@ -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)
 
 
@@ -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,
@@ -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)
 
@@ -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
@@ -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,
@@ -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
     )
@@ -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,
diff --git a/mypy/checker.py b/mypy/checker.py
--- a/mypy/checker.py
+++ b/mypy/checker.py
@@ -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:
@@ -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:
diff --git a/mypy/config_parser.py b/mypy/config_parser.py
--- 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
--- 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
--- 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
--- 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",
@@ -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:
@@ -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
diff --git a/mypy/semanal.py b/mypy/semanal.py
--- 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
--- 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
--- 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("<mypyc>", module=None, scope=None)
+    result.manager.errors.set_file(
+        "<mypyc>", 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)


Test patch:
diff --git a/test-data/unit/check-flags.test b/test-data/unit/check-flags.test
--- 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[<type>] = ...")
+[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
--- 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