Skip to content

Commit

Permalink
Add option to selectively disable --disallow-untyped-calls (#15845)
Browse files Browse the repository at this point in the history
Fixes #10757 

It is surprisingly one of the most upvoted issues. Also it looks quite
easy to implement, so why not. Note I also try to improve docs for
per-module logic for `disallow_untyped_calls`, as there is currently
some confusion.

---------

Co-authored-by: Ivan Levkivskyi <ilevkivskyi@hopper.com>
  • Loading branch information
ilevkivskyi and Ivan Levkivskyi committed Aug 13, 2023
1 parent 9787a26 commit 117b914
Show file tree
Hide file tree
Showing 7 changed files with 169 additions and 9 deletions.
28 changes: 28 additions & 0 deletions docs/source/command_line.rst
Expand Up @@ -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
Expand Down
33 changes: 32 additions & 1 deletion docs/source/config_file.rst
Expand Up @@ -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

Expand Down
23 changes: 16 additions & 7 deletions mypy/checkexpr.py
Expand Up @@ -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
Expand All @@ -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
)
Expand Down
18 changes: 18 additions & 0 deletions mypy/config_parser.py
Expand Up @@ -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.
Expand Down Expand Up @@ -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)]),
Expand All @@ -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)),
Expand Down
17 changes: 16 additions & 1 deletion mypy/main.py
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions mypy/options.py
Expand Up @@ -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

Expand Down
55 changes: 55 additions & 0 deletions test-data/unit/check-flags.test
Expand Up @@ -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
Expand Down

0 comments on commit 117b914

Please sign in to comment.