From 47f248709857f59f39950881b67bc3273831e2e9 Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Thu, 10 Aug 2023 21:46:18 +0100 Subject: [PATCH 1/4] Add option to selectively disable --disallow-untyped-calls --- docs/source/command_line.rst | 34 ++++++++++++++++++++++ docs/source/config_file.rst | 32 ++++++++++++++++++++- mypy/checkexpr.py | 22 +++++++++----- mypy/config_parser.py | 18 ++++++++++++ mypy/main.py | 17 ++++++++++- mypy/options.py | 4 +++ test-data/unit/check-flags.test | 51 +++++++++++++++++++++++++++++++++ 7 files changed, 169 insertions(+), 9 deletions(-) diff --git a/docs/source/command_line.rst b/docs/source/command_line.rst index d9de5cd8f9bd3..21981d2fb05eb 100644 --- a/docs/source/command_line.rst +++ b/docs/source/command_line.rst @@ -350,6 +350,40 @@ definitions or calls. This flag reports an error whenever a function with type annotations calls a function defined without annotations. +.. option:: --untyped-call-exception + + This flag allows to selectively disable :option:`--disallow-untyped-calls` + for functions and methods defined in specific packages, modules, or classes. + Note that each exception entry acts as a prefix. For example: + + .. code-block:: python + + # mypy --disallow-untyped-calls --untyped-call-exception=foo --untyped-call-exception=bar.A + from foo import test_foo + from bar import A, B + from baz import test_baz + + test_foo(42) # OK, function comes from module `foo` + test_baz(42) # E: Call to untyped function "test_baz" in typed context + + a: A + b: B + a.meth() # OK, method was defined in class `bar.A` + 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 + + .. 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 9e79ff99937b6..8f39a33cf336c 100644 --- a/docs/source/config_file.rst +++ b/docs/source/config_file.rst @@ -490,7 +490,37 @@ 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_call_exception`, for example: + + .. code-block:: ini + + [mypy] + disallow_untyped_calls = True + untyped_call_exception = some.library + +.. confval:: untyped_call_exception + + :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`. + Note, this option does not support per-file configuration, the exception + list is defined globally for all your code. .. confval:: disallow_untyped_defs diff --git a/mypy/checkexpr.py b/mypy/checkexpr.py index 9e46d9ee39cb8..9c4694730a469 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,21 @@ 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.startswith(p) for p in self.chk.options.untyped_call_exception + ): + 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 47b0bc3acabc0..0ca8facc96510 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_call_exception": 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_call_exception": 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 6173fd6fc1a8d..199eb1394bdec 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-call-exception", + 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_call_exception) + # 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 75343acd38bb1..1322ef13612af 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_call_exception: 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 e21157eae9919..3a1b374560a48 100644 --- a/test-data/unit/check-flags.test +++ b/test-data/unit/check-flags.test @@ -2077,6 +2077,57 @@ 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-call-exception=foo --untyped-call-exception=bar.A +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 + +[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_call_exception = foo, bar.A + [case testPerModuleErrorCodes] # flags: --config-file tmp/mypy.ini import tests.foo From 8055516d2a1d13d86a962d5abf4d84944e204c09 Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Fri, 11 Aug 2023 18:37:06 +0100 Subject: [PATCH 2/4] Address CR --- docs/source/command_line.rst | 29 +++++++++++------------------ docs/source/config_file.rst | 5 +++-- mypy/checkexpr.py | 3 ++- test-data/unit/check-flags.test | 4 ++++ 4 files changed, 20 insertions(+), 21 deletions(-) diff --git a/docs/source/command_line.rst b/docs/source/command_line.rst index 21981d2fb05eb..0b30b959bb58f 100644 --- a/docs/source/command_line.rst +++ b/docs/source/command_line.rst @@ -354,36 +354,29 @@ definitions or calls. This flag allows to selectively disable :option:`--disallow-untyped-calls` for functions and methods defined in specific packages, modules, or classes. - Note that each exception entry acts as a prefix. For example: + Note that each exception entry acts as a prefix. For example (assuming there + are no type annotations for ``numpy`` available): .. code-block:: python - # mypy --disallow-untyped-calls --untyped-call-exception=foo --untyped-call-exception=bar.A - from foo import test_foo - from bar import A, B - from baz import test_baz + # mypy --disallow-untyped-calls + # --untyped-call-exception=numpy.random + # --untyped-call-exception=foo.A + import numpy as np + from foo import A, B - test_foo(42) # OK, function comes from module `foo` - test_baz(42) # E: Call to untyped function "test_baz" in typed context + np.random.gamma(1.0) # OK, function comes from package `numpy.random` + np.fft.fft([1, 2, 3]) # E: Call to untyped function "fft" in typed context - a: A - b: B - a.meth() # OK, method was defined in class `bar.A` - b.meth() # E: Call to untyped function "meth" in typed context + A().meth() # OK, method was defined in class `bar.A` + 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 - - .. 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 8f39a33cf336c..42c2f5d917dd2 100644 --- a/docs/source/config_file.rst +++ b/docs/source/config_file.rst @@ -519,8 +519,9 @@ section of the command line docs. Selectively excludes functions and methods defined in specific packages, modules, and classes from action of :confval:`disallow_untyped_calls`. - Note, this option does not support per-file configuration, the exception - list is defined globally for all your code. + 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 exception list is defined globally for all your code. .. confval:: disallow_untyped_defs diff --git a/mypy/checkexpr.py b/mypy/checkexpr.py index 9c4694730a469..eb7b54a832af3 100644 --- a/mypy/checkexpr.py +++ b/mypy/checkexpr.py @@ -565,7 +565,8 @@ def visit_call_expr_inner(self, e: CallExpr, allow_none_return: bool = False) -> assert object_type is not None fullname = self.method_fullname(object_type, member) if not fullname or not any( - fullname.startswith(p) for p in self.chk.options.untyped_call_exception + fullname == p or fullname.startswith(f"{p}.") + for p in self.chk.options.untyped_call_exception ): self.msg.untyped_function_call(callee_type, e) diff --git a/test-data/unit/check-flags.test b/test-data/unit/check-flags.test index 3a1b374560a48..b645a57132197 100644 --- a/test-data/unit/check-flags.test +++ b/test-data/unit/check-flags.test @@ -2082,9 +2082,11 @@ f(reveal_type(y)) # E: Call to untyped function "f" in typed context \ 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 @@ -2092,6 +2094,8 @@ 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 From 6e20a87d328eefd277e0ed50693db73522e78b0e Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Fri, 11 Aug 2023 20:43:41 +0100 Subject: [PATCH 3/4] Fix module names --- docs/source/command_line.rst | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/docs/source/command_line.rst b/docs/source/command_line.rst index 0b30b959bb58f..95e0be31a056e 100644 --- a/docs/source/command_line.rst +++ b/docs/source/command_line.rst @@ -355,21 +355,22 @@ definitions or calls. This flag allows to selectively disable :option:`--disallow-untyped-calls` for functions and methods defined in specific packages, modules, or classes. Note that each exception entry acts as a prefix. For example (assuming there - are no type annotations for ``numpy`` available): + are no type annotations for ``third_party_lib`` available): .. code-block:: python # mypy --disallow-untyped-calls - # --untyped-call-exception=numpy.random + # --untyped-call-exception=third_party_lib.module_a # --untyped-call-exception=foo.A - import numpy as np - from foo import A, B + from third_party_lib.module_a import some_func + from third_party_lib.module_b import other_func + import foo - np.random.gamma(1.0) # OK, function comes from package `numpy.random` - np.fft.fft([1, 2, 3]) # E: Call to untyped function "fft" in typed context + some_func() # OK, function comes from module `third_party_lib.module_a` + other_func() # E: Call to untyped function "other_func" in typed context - A().meth() # OK, method was defined in class `bar.A` - B().meth() # E: Call to untyped function "meth" 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: From 83df5ddfa33d72074788e3181eaa85a43a4e0712 Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Sun, 13 Aug 2023 11:28:54 +0100 Subject: [PATCH 4/4] Try different name for the flag --- docs/source/command_line.rst | 8 ++++---- docs/source/config_file.rst | 8 ++++---- mypy/checkexpr.py | 2 +- mypy/config_parser.py | 4 ++-- mypy/main.py | 4 ++-- mypy/options.py | 2 +- test-data/unit/check-flags.test | 4 ++-- 7 files changed, 16 insertions(+), 16 deletions(-) diff --git a/docs/source/command_line.rst b/docs/source/command_line.rst index 95e0be31a056e..727d500e2d4df 100644 --- a/docs/source/command_line.rst +++ b/docs/source/command_line.rst @@ -350,18 +350,18 @@ definitions or calls. This flag reports an error whenever a function with type annotations calls a function defined without annotations. -.. option:: --untyped-call-exception +.. 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 exception entry acts as a prefix. For example (assuming there + 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-call-exception=third_party_lib.module_a - # --untyped-call-exception=foo.A + # --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 diff --git a/docs/source/config_file.rst b/docs/source/config_file.rst index 42c2f5d917dd2..c0798bbf03f14 100644 --- a/docs/source/config_file.rst +++ b/docs/source/config_file.rst @@ -505,15 +505,15 @@ section of the command line docs. 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_call_exception`, for example: + :confval:`untyped_calls_exclude`, for example: .. code-block:: ini [mypy] disallow_untyped_calls = True - untyped_call_exception = some.library + untyped_calls_exclude = some.library -.. confval:: untyped_call_exception +.. confval:: untyped_calls_exclude :type: comma-separated list of strings @@ -521,7 +521,7 @@ section of the command line docs. 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 exception list is defined globally for all your code. + 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 eb7b54a832af3..6df64b32493c3 100644 --- a/mypy/checkexpr.py +++ b/mypy/checkexpr.py @@ -566,7 +566,7 @@ def visit_call_expr_inner(self, e: CallExpr, allow_none_return: bool = False) -> 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_call_exception + for p in self.chk.options.untyped_calls_exclude ): self.msg.untyped_function_call(callee_type, e) diff --git a/mypy/config_parser.py b/mypy/config_parser.py index 0ca8facc96510..a84f3594a0d22 100644 --- a/mypy/config_parser.py +++ b/mypy/config_parser.py @@ -178,7 +178,7 @@ 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_call_exception": lambda s: validate_package_allow_list( + "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)], @@ -204,7 +204,7 @@ def split_commas(value: str) -> list[str]: "plugins": try_split, "always_true": try_split, "always_false": try_split, - "untyped_call_exception": lambda s: validate_package_allow_list(try_split(s)), + "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 199eb1394bdec..30f6cfe97455d 100644 --- a/mypy/main.py +++ b/mypy/main.py @@ -681,7 +681,7 @@ def add_invertible_flag( group=untyped_group, ) untyped_group.add_argument( - "--untyped-call-exception", + "--untyped-calls-exclude", metavar="MODULE", action="append", default=[], @@ -1320,7 +1320,7 @@ def set_strict_flags() -> None: % ", ".join(sorted(overlap)) ) - validate_package_allow_list(options.untyped_call_exception) + validate_package_allow_list(options.untyped_calls_exclude) # Process `--enable-error-code` and `--disable-error-code` flags disabled_codes = set(options.disable_error_code) diff --git a/mypy/options.py b/mypy/options.py index 1322ef13612af..9b2e88335b249 100644 --- a/mypy/options.py +++ b/mypy/options.py @@ -138,7 +138,7 @@ def __init__(self) -> None: # Always allow untyped calls for function coming from modules/packages # in this list (each item effectively acts as a prefix match) - self.untyped_call_exception: list[str] = [] + 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 b645a57132197..96f78d81dd168 100644 --- a/test-data/unit/check-flags.test +++ b/test-data/unit/check-flags.test @@ -2078,7 +2078,7 @@ 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-call-exception=foo --untyped-call-exception=bar.A +# 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 @@ -2130,7 +2130,7 @@ def test_baz(x): pass [file mypy.ini] \[mypy] disallow_untyped_calls = True -untyped_call_exception = foo, bar.A +untyped_calls_exclude = foo, bar.A [case testPerModuleErrorCodes] # flags: --config-file tmp/mypy.ini