Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow per-module error codes #13502

Merged
merged 8 commits into from Aug 25, 2022
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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.
5 changes: 2 additions & 3 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 @@ -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, options=caller_state.options)
if target == "builtins":
errors.report(
line, 0, "Cannot find 'builtins' module. Typeshed appears broken!", blocker=True
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
26 changes: 22 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,13 @@ def parse_config_file(
file=stderr,
)
updates = {k: v for k, v in updates.items() if k in PER_MODULE_OPTIONS}

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

globs = name[5:]
for glob in globs.split(","):
# For backwards compatibility, replace (back)slashes with dots.
Expand Down
27 changes: 20 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,13 @@ 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,
scope: Scope | None = None,
options: Options | None = None,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Should this have a default value? Having it be required would make sure we get all call sites

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, OK, makes sense.

) -> 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 +328,8 @@ def set_file(self, file: str, module: str | None, scope: Scope | None = None) ->
self.file = file
self.target_module = module
self.scope = scope
if options:
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 +592,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
38 changes: 38 additions & 0 deletions test-data/unit/check-flags.test
Expand Up @@ -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
9 changes: 9 additions & 0 deletions test-data/unit/check-inline-config.test
Expand Up @@ -162,3 +162,12 @@ 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)