Skip to content

Commit 117b914

Browse files
ilevkivskyiIvan Levkivskyi
and
Ivan Levkivskyi
authored
Add option to selectively disable --disallow-untyped-calls (#15845)
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 <[email protected]>
1 parent 9787a26 commit 117b914

File tree

7 files changed

+169
-9
lines changed

7 files changed

+169
-9
lines changed

docs/source/command_line.rst

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,34 @@ definitions or calls.
350350
This flag reports an error whenever a function with type annotations
351351
calls a function defined without annotations.
352352

353+
.. option:: --untyped-calls-exclude
354+
355+
This flag allows to selectively disable :option:`--disallow-untyped-calls`
356+
for functions and methods defined in specific packages, modules, or classes.
357+
Note that each exclude entry acts as a prefix. For example (assuming there
358+
are no type annotations for ``third_party_lib`` available):
359+
360+
.. code-block:: python
361+
362+
# mypy --disallow-untyped-calls
363+
# --untyped-calls-exclude=third_party_lib.module_a
364+
# --untyped-calls-exclude=foo.A
365+
from third_party_lib.module_a import some_func
366+
from third_party_lib.module_b import other_func
367+
import foo
368+
369+
some_func() # OK, function comes from module `third_party_lib.module_a`
370+
other_func() # E: Call to untyped function "other_func" in typed context
371+
372+
foo.A().meth() # OK, method was defined in class `foo.A`
373+
foo.B().meth() # E: Call to untyped function "meth" in typed context
374+
375+
# file foo.py
376+
class A:
377+
def meth(self): pass
378+
class B:
379+
def meth(self): pass
380+
353381
.. option:: --disallow-untyped-defs
354382

355383
This flag reports an error whenever it encounters a function definition

docs/source/config_file.rst

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -490,7 +490,38 @@ section of the command line docs.
490490
:default: False
491491

492492
Disallows calling functions without type annotations from functions with type
493-
annotations.
493+
annotations. Note that when used in per-module options, it enables/disables
494+
this check **inside** the module(s) specified, not for functions that come
495+
from that module(s), for example config like this:
496+
497+
.. code-block:: ini
498+
499+
[mypy]
500+
disallow_untyped_calls = True
501+
502+
[mypy-some.library.*]
503+
disallow_untyped_calls = False
504+
505+
will disable this check inside ``some.library``, not for your code that
506+
imports ``some.library``. If you want to selectively disable this check for
507+
all your code that imports ``some.library`` you should instead use
508+
:confval:`untyped_calls_exclude`, for example:
509+
510+
.. code-block:: ini
511+
512+
[mypy]
513+
disallow_untyped_calls = True
514+
untyped_calls_exclude = some.library
515+
516+
.. confval:: untyped_calls_exclude
517+
518+
:type: comma-separated list of strings
519+
520+
Selectively excludes functions and methods defined in specific packages,
521+
modules, and classes from action of :confval:`disallow_untyped_calls`.
522+
This also applies to all submodules of packages (i.e. everything inside
523+
a given prefix). Note, this option does not support per-file configuration,
524+
the exclusions list is defined globally for all your code.
494525

495526
.. confval:: disallow_untyped_defs
496527

mypy/checkexpr.py

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -529,13 +529,6 @@ def visit_call_expr_inner(self, e: CallExpr, allow_none_return: bool = False) ->
529529
callee_type = get_proper_type(
530530
self.accept(e.callee, type_context, always_allow_any=True, is_callee=True)
531531
)
532-
if (
533-
self.chk.options.disallow_untyped_calls
534-
and self.chk.in_checked_function()
535-
and isinstance(callee_type, CallableType)
536-
and callee_type.implicit
537-
):
538-
self.msg.untyped_function_call(callee_type, e)
539532

540533
# Figure out the full name of the callee for plugin lookup.
541534
object_type = None
@@ -561,6 +554,22 @@ def visit_call_expr_inner(self, e: CallExpr, allow_none_return: bool = False) ->
561554
):
562555
member = e.callee.name
563556
object_type = self.chk.lookup_type(e.callee.expr)
557+
558+
if (
559+
self.chk.options.disallow_untyped_calls
560+
and self.chk.in_checked_function()
561+
and isinstance(callee_type, CallableType)
562+
and callee_type.implicit
563+
):
564+
if fullname is None and member is not None:
565+
assert object_type is not None
566+
fullname = self.method_fullname(object_type, member)
567+
if not fullname or not any(
568+
fullname == p or fullname.startswith(f"{p}.")
569+
for p in self.chk.options.untyped_calls_exclude
570+
):
571+
self.msg.untyped_function_call(callee_type, e)
572+
564573
ret_type = self.check_call_expr_with_callee_type(
565574
callee_type, e, fullname, object_type, member
566575
)

mypy/config_parser.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,20 @@ def validate_codes(codes: list[str]) -> list[str]:
8181
return codes
8282

8383

84+
def validate_package_allow_list(allow_list: list[str]) -> list[str]:
85+
for p in allow_list:
86+
msg = f"Invalid allow list entry: {p}"
87+
if "*" in p:
88+
raise argparse.ArgumentTypeError(
89+
f"{msg} (entries are already prefixes so must not contain *)"
90+
)
91+
if "\\" in p or "/" in p:
92+
raise argparse.ArgumentTypeError(
93+
f"{msg} (entries must be packages like foo.bar not directories or files)"
94+
)
95+
return allow_list
96+
97+
8498
def expand_path(path: str) -> str:
8599
"""Expand the user home directory and any environment variables contained within
86100
the provided path.
@@ -164,6 +178,9 @@ def split_commas(value: str) -> list[str]:
164178
"plugins": lambda s: [p.strip() for p in split_commas(s)],
165179
"always_true": lambda s: [p.strip() for p in split_commas(s)],
166180
"always_false": lambda s: [p.strip() for p in split_commas(s)],
181+
"untyped_calls_exclude": lambda s: validate_package_allow_list(
182+
[p.strip() for p in split_commas(s)]
183+
),
167184
"enable_incomplete_feature": lambda s: [p.strip() for p in split_commas(s)],
168185
"disable_error_code": lambda s: validate_codes([p.strip() for p in split_commas(s)]),
169186
"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]:
187204
"plugins": try_split,
188205
"always_true": try_split,
189206
"always_false": try_split,
207+
"untyped_calls_exclude": lambda s: validate_package_allow_list(try_split(s)),
190208
"enable_incomplete_feature": try_split,
191209
"disable_error_code": lambda s: validate_codes(try_split(s)),
192210
"enable_error_code": lambda s: validate_codes(try_split(s)),

mypy/main.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,12 @@
1111
from typing import IO, Any, Final, NoReturn, Sequence, TextIO
1212

1313
from mypy import build, defaults, state, util
14-
from mypy.config_parser import get_config_module_names, parse_config_file, parse_version
14+
from mypy.config_parser import (
15+
get_config_module_names,
16+
parse_config_file,
17+
parse_version,
18+
validate_package_allow_list,
19+
)
1520
from mypy.errorcodes import error_codes
1621
from mypy.errors import CompileError
1722
from mypy.find_sources import InvalidSourceList, create_source_list
@@ -675,6 +680,14 @@ def add_invertible_flag(
675680
" from functions with type annotations",
676681
group=untyped_group,
677682
)
683+
untyped_group.add_argument(
684+
"--untyped-calls-exclude",
685+
metavar="MODULE",
686+
action="append",
687+
default=[],
688+
help="Disable --disallow-untyped-calls for functions/methods coming"
689+
" from specific package, module, or class",
690+
)
678691
add_invertible_flag(
679692
"--disallow-untyped-defs",
680693
default=False,
@@ -1307,6 +1320,8 @@ def set_strict_flags() -> None:
13071320
% ", ".join(sorted(overlap))
13081321
)
13091322

1323+
validate_package_allow_list(options.untyped_calls_exclude)
1324+
13101325
# Process `--enable-error-code` and `--disable-error-code` flags
13111326
disabled_codes = set(options.disable_error_code)
13121327
enabled_codes = set(options.enable_error_code)

mypy/options.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,10 @@ def __init__(self) -> None:
136136
# Disallow calling untyped functions from typed ones
137137
self.disallow_untyped_calls = False
138138

139+
# Always allow untyped calls for function coming from modules/packages
140+
# in this list (each item effectively acts as a prefix match)
141+
self.untyped_calls_exclude: list[str] = []
142+
139143
# Disallow defining untyped (or incompletely typed) functions
140144
self.disallow_untyped_defs = False
141145

test-data/unit/check-flags.test

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2077,6 +2077,61 @@ y = 1
20772077
f(reveal_type(y)) # E: Call to untyped function "f" in typed context \
20782078
# N: Revealed type is "builtins.int"
20792079

2080+
[case testDisallowUntypedCallsAllowListFlags]
2081+
# flags: --disallow-untyped-calls --untyped-calls-exclude=foo --untyped-calls-exclude=bar.A
2082+
from foo import test_foo
2083+
from bar import A, B
2084+
from baz import test_baz
2085+
from foobar import bad
2086+
2087+
test_foo(42) # OK
2088+
test_baz(42) # E: Call to untyped function "test_baz" in typed context
2089+
bad(42) # E: Call to untyped function "bad" in typed context
2090+
2091+
a: A
2092+
b: B
2093+
a.meth() # OK
2094+
b.meth() # E: Call to untyped function "meth" in typed context
2095+
[file foo.py]
2096+
def test_foo(x): pass
2097+
[file foobar.py]
2098+
def bad(x): pass
2099+
[file bar.py]
2100+
class A:
2101+
def meth(self): pass
2102+
class B:
2103+
def meth(self): pass
2104+
[file baz.py]
2105+
def test_baz(x): pass
2106+
2107+
[case testDisallowUntypedCallsAllowListConfig]
2108+
# flags: --config-file tmp/mypy.ini
2109+
from foo import test_foo
2110+
from bar import A, B
2111+
from baz import test_baz
2112+
2113+
test_foo(42) # OK
2114+
test_baz(42) # E: Call to untyped function "test_baz" in typed context
2115+
2116+
a: A
2117+
b: B
2118+
a.meth() # OK
2119+
b.meth() # E: Call to untyped function "meth" in typed context
2120+
[file foo.py]
2121+
def test_foo(x): pass
2122+
[file bar.py]
2123+
class A:
2124+
def meth(self): pass
2125+
class B:
2126+
def meth(self): pass
2127+
[file baz.py]
2128+
def test_baz(x): pass
2129+
2130+
[file mypy.ini]
2131+
\[mypy]
2132+
disallow_untyped_calls = True
2133+
untyped_calls_exclude = foo, bar.A
2134+
20802135
[case testPerModuleErrorCodes]
20812136
# flags: --config-file tmp/mypy.ini
20822137
import tests.foo

0 commit comments

Comments
 (0)