diff --git a/docs/source/command_line.rst b/docs/source/command_line.rst index d9de5cd8f9bd..727d500e2d4d 100644 --- a/docs/source/command_line.rst +++ b/docs/source/command_line.rst @@ -350,6 +350,34 @@ definitions or calls. This flag reports an error whenever a function with type annotations calls a function defined without annotations. +.. option:: --untyped-calls-exclude + + This flag allows to selectively disable :option:`--disallow-untyped-calls` + for functions and methods defined in specific packages, modules, or classes. + Note that each exclude entry acts as a prefix. For example (assuming there + are no type annotations for ``third_party_lib`` available): + + .. code-block:: python + + # mypy --disallow-untyped-calls + # --untyped-calls-exclude=third_party_lib.module_a + # --untyped-calls-exclude=foo.A + from third_party_lib.module_a import some_func + from third_party_lib.module_b import other_func + import foo + + some_func() # OK, function comes from module `third_party_lib.module_a` + other_func() # E: Call to untyped function "other_func" in typed context + + foo.A().meth() # OK, method was defined in class `foo.A` + foo.B().meth() # E: Call to untyped function "meth" in typed context + + # file foo.py + class A: + def meth(self): pass + class B: + def meth(self): pass + .. option:: --disallow-untyped-defs This flag reports an error whenever it encounters a function definition diff --git a/docs/source/config_file.rst b/docs/source/config_file.rst index 9e79ff99937b..c0798bbf03f1 100644 --- a/docs/source/config_file.rst +++ b/docs/source/config_file.rst @@ -490,7 +490,38 @@ section of the command line docs. :default: False Disallows calling functions without type annotations from functions with type - annotations. + annotations. Note that when used in per-module options, it enables/disables + this check **inside** the module(s) specified, not for functions that come + from that module(s), for example config like this: + + .. code-block:: ini + + [mypy] + disallow_untyped_calls = True + + [mypy-some.library.*] + disallow_untyped_calls = False + + will disable this check inside ``some.library``, not for your code that + imports ``some.library``. If you want to selectively disable this check for + all your code that imports ``some.library`` you should instead use + :confval:`untyped_calls_exclude`, for example: + + .. code-block:: ini + + [mypy] + disallow_untyped_calls = True + untyped_calls_exclude = some.library + +.. confval:: untyped_calls_exclude + + :type: comma-separated list of strings + + Selectively excludes functions and methods defined in specific packages, + modules, and classes from action of :confval:`disallow_untyped_calls`. + This also applies to all submodules of packages (i.e. everything inside + a given prefix). Note, this option does not support per-file configuration, + the exclusions list is defined globally for all your code. .. confval:: disallow_untyped_defs diff --git a/mypy/checkexpr.py b/mypy/checkexpr.py index 9e46d9ee39cb..6df64b32493c 100644 --- a/mypy/checkexpr.py +++ b/mypy/checkexpr.py @@ -529,13 +529,6 @@ def visit_call_expr_inner(self, e: CallExpr, allow_none_return: bool = False) -> callee_type = get_proper_type( self.accept(e.callee, type_context, always_allow_any=True, is_callee=True) ) - if ( - self.chk.options.disallow_untyped_calls - and self.chk.in_checked_function() - and isinstance(callee_type, CallableType) - and callee_type.implicit - ): - self.msg.untyped_function_call(callee_type, e) # Figure out the full name of the callee for plugin lookup. object_type = None @@ -561,6 +554,22 @@ def visit_call_expr_inner(self, e: CallExpr, allow_none_return: bool = False) -> ): member = e.callee.name object_type = self.chk.lookup_type(e.callee.expr) + + if ( + self.chk.options.disallow_untyped_calls + and self.chk.in_checked_function() + and isinstance(callee_type, CallableType) + and callee_type.implicit + ): + if fullname is None and member is not None: + assert object_type is not None + fullname = self.method_fullname(object_type, member) + if not fullname or not any( + fullname == p or fullname.startswith(f"{p}.") + for p in self.chk.options.untyped_calls_exclude + ): + self.msg.untyped_function_call(callee_type, e) + ret_type = self.check_call_expr_with_callee_type( callee_type, e, fullname, object_type, member ) diff --git a/mypy/config_parser.py b/mypy/config_parser.py index 47b0bc3acabc..a84f3594a0d2 100644 --- a/mypy/config_parser.py +++ b/mypy/config_parser.py @@ -81,6 +81,20 @@ def validate_codes(codes: list[str]) -> list[str]: return codes +def validate_package_allow_list(allow_list: list[str]) -> list[str]: + for p in allow_list: + msg = f"Invalid allow list entry: {p}" + if "*" in p: + raise argparse.ArgumentTypeError( + f"{msg} (entries are already prefixes so must not contain *)" + ) + if "\\" in p or "/" in p: + raise argparse.ArgumentTypeError( + f"{msg} (entries must be packages like foo.bar not directories or files)" + ) + return allow_list + + def expand_path(path: str) -> str: """Expand the user home directory and any environment variables contained within the provided path. @@ -164,6 +178,9 @@ def split_commas(value: str) -> list[str]: "plugins": lambda s: [p.strip() for p in split_commas(s)], "always_true": lambda s: [p.strip() for p in split_commas(s)], "always_false": lambda s: [p.strip() for p in split_commas(s)], + "untyped_calls_exclude": lambda s: validate_package_allow_list( + [p.strip() for p in split_commas(s)] + ), "enable_incomplete_feature": lambda s: [p.strip() for p in split_commas(s)], "disable_error_code": lambda s: validate_codes([p.strip() for p in split_commas(s)]), "enable_error_code": lambda s: validate_codes([p.strip() for p in split_commas(s)]), @@ -187,6 +204,7 @@ def split_commas(value: str) -> list[str]: "plugins": try_split, "always_true": try_split, "always_false": try_split, + "untyped_calls_exclude": lambda s: validate_package_allow_list(try_split(s)), "enable_incomplete_feature": try_split, "disable_error_code": lambda s: validate_codes(try_split(s)), "enable_error_code": lambda s: validate_codes(try_split(s)), diff --git a/mypy/main.py b/mypy/main.py index 6173fd6fc1a8..30f6cfe97455 100644 --- a/mypy/main.py +++ b/mypy/main.py @@ -11,7 +11,12 @@ from typing import IO, Any, Final, NoReturn, Sequence, TextIO from mypy import build, defaults, state, util -from mypy.config_parser import get_config_module_names, parse_config_file, parse_version +from mypy.config_parser import ( + get_config_module_names, + parse_config_file, + parse_version, + validate_package_allow_list, +) from mypy.errorcodes import error_codes from mypy.errors import CompileError from mypy.find_sources import InvalidSourceList, create_source_list @@ -675,6 +680,14 @@ def add_invertible_flag( " from functions with type annotations", group=untyped_group, ) + untyped_group.add_argument( + "--untyped-calls-exclude", + metavar="MODULE", + action="append", + default=[], + help="Disable --disallow-untyped-calls for functions/methods coming" + " from specific package, module, or class", + ) add_invertible_flag( "--disallow-untyped-defs", default=False, @@ -1307,6 +1320,8 @@ def set_strict_flags() -> None: % ", ".join(sorted(overlap)) ) + validate_package_allow_list(options.untyped_calls_exclude) + # Process `--enable-error-code` and `--disable-error-code` flags disabled_codes = set(options.disable_error_code) enabled_codes = set(options.enable_error_code) diff --git a/mypy/options.py b/mypy/options.py index 75343acd38bb..9b2e88335b24 100644 --- a/mypy/options.py +++ b/mypy/options.py @@ -136,6 +136,10 @@ def __init__(self) -> None: # Disallow calling untyped functions from typed ones self.disallow_untyped_calls = False + # Always allow untyped calls for function coming from modules/packages + # in this list (each item effectively acts as a prefix match) + self.untyped_calls_exclude: list[str] = [] + # Disallow defining untyped (or incompletely typed) functions self.disallow_untyped_defs = False diff --git a/test-data/unit/check-flags.test b/test-data/unit/check-flags.test index e21157eae991..96f78d81dd16 100644 --- a/test-data/unit/check-flags.test +++ b/test-data/unit/check-flags.test @@ -2077,6 +2077,61 @@ y = 1 f(reveal_type(y)) # E: Call to untyped function "f" in typed context \ # N: Revealed type is "builtins.int" +[case testDisallowUntypedCallsAllowListFlags] +# flags: --disallow-untyped-calls --untyped-calls-exclude=foo --untyped-calls-exclude=bar.A +from foo import test_foo +from bar import A, B +from baz import test_baz +from foobar import bad + +test_foo(42) # OK +test_baz(42) # E: Call to untyped function "test_baz" in typed context +bad(42) # E: Call to untyped function "bad" in typed context + +a: A +b: B +a.meth() # OK +b.meth() # E: Call to untyped function "meth" in typed context +[file foo.py] +def test_foo(x): pass +[file foobar.py] +def bad(x): pass +[file bar.py] +class A: + def meth(self): pass +class B: + def meth(self): pass +[file baz.py] +def test_baz(x): pass + +[case testDisallowUntypedCallsAllowListConfig] +# flags: --config-file tmp/mypy.ini +from foo import test_foo +from bar import A, B +from baz import test_baz + +test_foo(42) # OK +test_baz(42) # E: Call to untyped function "test_baz" in typed context + +a: A +b: B +a.meth() # OK +b.meth() # E: Call to untyped function "meth" in typed context +[file foo.py] +def test_foo(x): pass +[file bar.py] +class A: + def meth(self): pass +class B: + def meth(self): pass +[file baz.py] +def test_baz(x): pass + +[file mypy.ini] +\[mypy] +disallow_untyped_calls = True +untyped_calls_exclude = foo, bar.A + [case testPerModuleErrorCodes] # flags: --config-file tmp/mypy.ini import tests.foo