Skip to content

Commit 47f2487

Browse files
author
Ivan Levkivskyi
committed
Add option to selectively disable --disallow-untyped-calls
1 parent d0d63b4 commit 47f2487

File tree

7 files changed

+169
-9
lines changed

7 files changed

+169
-9
lines changed

docs/source/command_line.rst

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,40 @@ 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-call-exception
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 exception entry acts as a prefix. For example:
358+
359+
.. code-block:: python
360+
361+
# mypy --disallow-untyped-calls --untyped-call-exception=foo --untyped-call-exception=bar.A
362+
from foo import test_foo
363+
from bar import A, B
364+
from baz import test_baz
365+
366+
test_foo(42) # OK, function comes from module `foo`
367+
test_baz(42) # E: Call to untyped function "test_baz" in typed context
368+
369+
a: A
370+
b: B
371+
a.meth() # OK, method was defined in class `bar.A`
372+
b.meth() # E: Call to untyped function "meth" in typed context
373+
374+
# file foo.py
375+
def test_foo(x): pass
376+
377+
# file bar.py
378+
class A:
379+
def meth(self): pass
380+
class B:
381+
def meth(self): pass
382+
383+
# file baz.py
384+
def test_baz(x): pass
385+
386+
353387
.. option:: --disallow-untyped-defs
354388

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

docs/source/config_file.rst

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -490,7 +490,37 @@ 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_call_exception`, for example:
509+
510+
.. code-block:: ini
511+
512+
[mypy]
513+
disallow_untyped_calls = True
514+
untyped_call_exception = some.library
515+
516+
.. confval:: untyped_call_exception
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+
Note, this option does not support per-file configuration, the exception
523+
list is defined globally for all your code.
494524

495525
.. confval:: disallow_untyped_defs
496526

mypy/checkexpr.py

Lines changed: 15 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,21 @@ 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.startswith(p) for p in self.chk.options.untyped_call_exception
569+
):
570+
self.msg.untyped_function_call(callee_type, e)
571+
564572
ret_type = self.check_call_expr_with_callee_type(
565573
callee_type, e, fullname, object_type, member
566574
)

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_call_exception": 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_call_exception": 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-call-exception",
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_call_exception)
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_call_exception: 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: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2077,6 +2077,57 @@ 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-call-exception=foo --untyped-call-exception=bar.A
2082+
from foo import test_foo
2083+
from bar import A, B
2084+
from baz import test_baz
2085+
2086+
test_foo(42) # OK
2087+
test_baz(42) # E: Call to untyped function "test_baz" in typed context
2088+
2089+
a: A
2090+
b: B
2091+
a.meth() # OK
2092+
b.meth() # E: Call to untyped function "meth" in typed context
2093+
[file foo.py]
2094+
def test_foo(x): pass
2095+
[file bar.py]
2096+
class A:
2097+
def meth(self): pass
2098+
class B:
2099+
def meth(self): pass
2100+
[file baz.py]
2101+
def test_baz(x): pass
2102+
2103+
[case testDisallowUntypedCallsAllowListConfig]
2104+
# flags: --config-file tmp/mypy.ini
2105+
from foo import test_foo
2106+
from bar import A, B
2107+
from baz import test_baz
2108+
2109+
test_foo(42) # OK
2110+
test_baz(42) # E: Call to untyped function "test_baz" in typed context
2111+
2112+
a: A
2113+
b: B
2114+
a.meth() # OK
2115+
b.meth() # E: Call to untyped function "meth" in typed context
2116+
[file foo.py]
2117+
def test_foo(x): pass
2118+
[file bar.py]
2119+
class A:
2120+
def meth(self): pass
2121+
class B:
2122+
def meth(self): pass
2123+
[file baz.py]
2124+
def test_baz(x): pass
2125+
2126+
[file mypy.ini]
2127+
\[mypy]
2128+
disallow_untyped_calls = True
2129+
untyped_call_exception = foo, bar.A
2130+
20802131
[case testPerModuleErrorCodes]
20812132
# flags: --config-file tmp/mypy.ini
20822133
import tests.foo

0 commit comments

Comments
 (0)