Skip to content

Commit 6d30e12

Browse files
authored
Add broken Callable check (#5891)
1 parent 1faf365 commit 6d30e12

11 files changed

+213
-7
lines changed

ChangeLog

+4
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,10 @@ Release date: TBA
406406
if ``py-version`` is set to Python ``3.7.1`` or below.
407407
https://bugs.python.org/issue34921
408408

409+
* Added new check ``broken-collections-callable`` to detect broken uses of ``collections.abc.Callable``
410+
if ``py-version`` is set to Python ``3.9.1`` or below.
411+
https://bugs.python.org/issue42965
412+
409413
* The ``testutils`` for unittests now accept ``end_lineno`` and ``end_column``. Tests
410414
without these will trigger a ``DeprecationWarning``.
411415

doc/whatsnew/2.13.rst

+4
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,10 @@ Extensions
8181
if ``py-version`` is set to Python ``3.7.1`` or below.
8282
https://bugs.python.org/issue34921
8383

84+
* Added new check ``broken-collections-callable`` to detect broken uses of ``collections.abc.Callable``
85+
if ``py-version`` is set to Python ``3.9.1`` or below.
86+
https://bugs.python.org/issue42965
87+
8488
* ``DocstringParameterChecker``
8589

8690
* Fixed incorrect classification of Numpy-style docstring as Google-style docstring for

pylint/extensions/typing.py

+97-7
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ class DeprecatedTypingAliasMsg(NamedTuple):
8181
node: Union[nodes.Name, nodes.Attribute]
8282
qname: str
8383
alias: str
84-
parent_subscript: bool
84+
parent_subscript: bool = False
8585

8686

8787
class TypingChecker(BaseChecker):
@@ -118,6 +118,14 @@ class TypingChecker(BaseChecker):
118118
"use string annotation instead. E.g. "
119119
"``Callable[..., 'NoReturn']``. https://bugs.python.org/issue34921",
120120
),
121+
"E6005": (
122+
"'collections.abc.Callable' inside Optional and Union is broken in "
123+
"3.9.0 / 3.9.1 (use 'typing.Callable' instead)",
124+
"broken-collections-callable",
125+
"``collections.abc.Callable`` inside Optional and Union is broken in "
126+
"Python 3.9.0 and 3.9.1. Use ``typing.Callable`` for these cases instead. "
127+
"https://bugs.python.org/issue42965",
128+
),
121129
}
122130
options = (
123131
(
@@ -152,7 +160,9 @@ class TypingChecker(BaseChecker):
152160
def __init__(self, linter: "PyLinter") -> None:
153161
"""Initialize checker instance."""
154162
super().__init__(linter=linter)
163+
self._found_broken_callable_location: bool = False
155164
self._alias_name_collisions: Set[str] = set()
165+
self._deprecated_typing_alias_msgs: List[DeprecatedTypingAliasMsg] = []
156166
self._consider_using_alias_msgs: List[DeprecatedTypingAliasMsg] = []
157167

158168
def open(self) -> None:
@@ -169,8 +179,9 @@ def open(self) -> None:
169179
)
170180

171181
self._should_check_noreturn = py_version < (3, 7, 2)
182+
self._should_check_callable = py_version < (3, 9, 2)
172183

173-
def _msg_postponed_eval_hint(self, node) -> str:
184+
def _msg_postponed_eval_hint(self, node: nodes.NodeNG) -> str:
174185
"""Message hint if postponed evaluation isn't enabled."""
175186
if self._py310_plus or "annotations" in node.root().future_imports:
176187
return ""
@@ -181,6 +192,7 @@ def _msg_postponed_eval_hint(self, node) -> str:
181192
"consider-using-alias",
182193
"consider-alternative-union-syntax",
183194
"broken-noreturn",
195+
"broken-collections-callable",
184196
)
185197
def visit_name(self, node: nodes.Name) -> None:
186198
if self._should_check_typing_alias and node.name in ALIAS_NAMES:
@@ -189,12 +201,15 @@ def visit_name(self, node: nodes.Name) -> None:
189201
self._check_for_alternative_union_syntax(node, node.name)
190202
if self._should_check_noreturn and node.name == "NoReturn":
191203
self._check_broken_noreturn(node)
204+
if self._should_check_callable and node.name == "Callable":
205+
self._check_broken_callable(node)
192206

193207
@check_messages(
194208
"deprecated-typing-alias",
195209
"consider-using-alias",
196210
"consider-alternative-union-syntax",
197211
"broken-noreturn",
212+
"broken-collections-callable",
198213
)
199214
def visit_attribute(self, node: nodes.Attribute) -> None:
200215
if self._should_check_typing_alias and node.attrname in ALIAS_NAMES:
@@ -203,6 +218,8 @@ def visit_attribute(self, node: nodes.Attribute) -> None:
203218
self._check_for_alternative_union_syntax(node, node.attrname)
204219
if self._should_check_noreturn and node.attrname == "NoReturn":
205220
self._check_broken_noreturn(node)
221+
if self._should_check_callable and node.attrname == "Callable":
222+
self._check_broken_callable(node)
206223

207224
def _check_for_alternative_union_syntax(
208225
self,
@@ -255,10 +272,16 @@ def _check_for_typing_alias(
255272
return
256273

257274
if self._py39_plus:
258-
self.add_message(
259-
"deprecated-typing-alias",
260-
node=node,
261-
args=(inferred.qname(), alias.name),
275+
if inferred.qname() == "typing.Callable" and self._broken_callable_location(
276+
node
277+
):
278+
self._found_broken_callable_location = True
279+
self._deprecated_typing_alias_msgs.append(
280+
DeprecatedTypingAliasMsg(
281+
node,
282+
inferred.qname(),
283+
alias.name,
284+
)
262285
)
263286
return
264287

@@ -284,7 +307,20 @@ def leave_module(self, node: nodes.Module) -> None:
284307
'consider-using-alias' check. Make sure results are safe
285308
to recommend / collision free.
286309
"""
287-
if self._py37_plus and not self._py39_plus:
310+
if self._py39_plus:
311+
for msg in self._deprecated_typing_alias_msgs:
312+
if (
313+
self._found_broken_callable_location
314+
and msg.qname == "typing.Callable"
315+
):
316+
continue
317+
self.add_message(
318+
"deprecated-typing-alias",
319+
node=msg.node,
320+
args=(msg.qname, msg.alias),
321+
)
322+
323+
elif self._py37_plus:
288324
msg_future_import = self._msg_postponed_eval_hint(node)
289325
for msg in self._consider_using_alias_msgs:
290326
if msg.qname in self._alias_name_collisions:
@@ -298,7 +334,10 @@ def leave_module(self, node: nodes.Module) -> None:
298334
msg_future_import if msg.parent_subscript else "",
299335
),
300336
)
337+
301338
# Clear all module cache variables
339+
self._found_broken_callable_location = False
340+
self._deprecated_typing_alias_msgs.clear()
302341
self._alias_name_collisions.clear()
303342
self._consider_using_alias_msgs.clear()
304343

@@ -328,6 +367,57 @@ def _check_broken_noreturn(self, node: Union[nodes.Name, nodes.Attribute]) -> No
328367
self.add_message("broken-noreturn", node=node, confidence=INFERENCE)
329368
break
330369

370+
def _check_broken_callable(self, node: Union[nodes.Name, nodes.Attribute]) -> None:
371+
"""Check for 'collections.abc.Callable' inside Optional and Union."""
372+
inferred = safe_infer(node)
373+
if not (
374+
isinstance(inferred, nodes.ClassDef)
375+
and inferred.qname() == "_collections_abc.Callable"
376+
and self._broken_callable_location(node)
377+
):
378+
return
379+
380+
self.add_message("broken-collections-callable", node=node, confidence=INFERENCE)
381+
382+
def _broken_callable_location( # pylint: disable=no-self-use
383+
self, node: Union[nodes.Name, nodes.Attribute]
384+
) -> bool:
385+
"""Check if node would be a broken location for collections.abc.Callable."""
386+
if is_postponed_evaluation_enabled(node) and is_node_in_type_annotation_context(
387+
node
388+
):
389+
return False
390+
391+
# Check first Callable arg is a list of arguments -> Callable[[int], None]
392+
if not (
393+
isinstance(node.parent, nodes.Subscript)
394+
and isinstance(node.parent.slice, nodes.Tuple)
395+
and len(node.parent.slice.elts) == 2
396+
and isinstance(node.parent.slice.elts[0], nodes.List)
397+
):
398+
return False
399+
400+
# Check nested inside Optional or Union
401+
parent_subscript = node.parent.parent
402+
if isinstance(parent_subscript, nodes.BaseContainer):
403+
parent_subscript = parent_subscript.parent
404+
if not (
405+
isinstance(parent_subscript, nodes.Subscript)
406+
and isinstance(parent_subscript.value, (nodes.Name, nodes.Attribute))
407+
):
408+
return False
409+
410+
inferred_parent = safe_infer(parent_subscript.value)
411+
if not (
412+
isinstance(inferred_parent, nodes.FunctionDef)
413+
and inferred_parent.qname() in {"typing.Optional", "typing.Union"}
414+
or isinstance(inferred_parent, astroid.bases.Instance)
415+
and inferred_parent.qname() == "typing._SpecialForm"
416+
):
417+
return False
418+
419+
return True
420+
331421

332422
def register(linter: "PyLinter") -> None:
333423
linter.register_checker(TypingChecker(linter))
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
"""
2+
'collections.abc.Callable' is broken inside Optional and Union types for Python 3.9.0
3+
https://bugs.python.org/issue42965
4+
5+
Use 'typing.Callable' instead.
6+
"""
7+
# pylint: disable=missing-docstring,unsubscriptable-object
8+
import collections.abc
9+
from collections.abc import Callable
10+
from typing import Optional, Union
11+
12+
Alias1 = Optional[Callable[[int], None]] # [broken-collections-callable]
13+
Alias2 = Union[Callable[[int], None], None] # [broken-collections-callable]
14+
15+
Alias3 = Optional[Callable[..., None]]
16+
Alias4 = Union[Callable[..., None], None]
17+
Alias5 = list[Callable[..., None]]
18+
Alias6 = Callable[[int], None]
19+
20+
21+
def func1() -> Optional[Callable[[int], None]]: # [broken-collections-callable]
22+
...
23+
24+
def func2() -> Optional["Callable[[int], None]"]:
25+
...
26+
27+
def func3() -> Union[collections.abc.Callable[[int], None], None]: # [broken-collections-callable]
28+
...
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
[master]
2+
py-version=3.9
3+
load-plugins=pylint.extensions.typing
4+
5+
[testoptions]
6+
min_pyver=3.7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
broken-collections-callable:12:18:12:26::'collections.abc.Callable' inside Optional and Union is broken in 3.9.0 / 3.9.1 (use 'typing.Callable' instead):INFERENCE
2+
broken-collections-callable:13:15:13:23::'collections.abc.Callable' inside Optional and Union is broken in 3.9.0 / 3.9.1 (use 'typing.Callable' instead):INFERENCE
3+
broken-collections-callable:21:24:21:32:func1:'collections.abc.Callable' inside Optional and Union is broken in 3.9.0 / 3.9.1 (use 'typing.Callable' instead):INFERENCE
4+
broken-collections-callable:27:21:27:45:func3:'collections.abc.Callable' inside Optional and Union is broken in 3.9.0 / 3.9.1 (use 'typing.Callable' instead):INFERENCE
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
"""
2+
'collections.abc.Callable' is broken inside Optional and Union types for Python 3.9.0
3+
https://bugs.python.org/issue42965
4+
5+
Use 'typing.Callable' instead.
6+
7+
Don't emit 'deprecated-typing-alias' for 'Callable' if at least one replacement
8+
would create broken instances.
9+
"""
10+
# pylint: disable=missing-docstring,unsubscriptable-object
11+
from typing import Callable, Optional, Union
12+
13+
Alias1 = Optional[Callable[[int], None]]
14+
Alias2 = Union[Callable[[int], None], None]
15+
16+
Alias3 = Optional[Callable[..., None]]
17+
Alias4 = Union[Callable[..., None], None]
18+
Alias5 = list[Callable[[int], None]]
19+
Alias6 = Callable[[int], None]
20+
21+
22+
def func1() -> Optional[Callable[[int], None]]:
23+
...
24+
25+
def func2() -> Optional["Callable[[int], None]"]:
26+
...
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
[master]
2+
py-version=3.9
3+
load-plugins=pylint.extensions.typing
4+
5+
[testoptions]
6+
min_pyver=3.7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
"""
2+
'collections.abc.Callable' is broken inside Optional and Union types for Python 3.9.0
3+
https://bugs.python.org/issue42965
4+
5+
Use 'typing.Callable' instead.
6+
"""
7+
# pylint: disable=missing-docstring,unsubscriptable-object
8+
from __future__ import annotations
9+
10+
import collections.abc
11+
from collections.abc import Callable
12+
from typing import Optional, Union
13+
14+
Alias1 = Optional[Callable[[int], None]] # [broken-collections-callable]
15+
Alias2 = Union[Callable[[int], None], None] # [broken-collections-callable]
16+
17+
Alias3 = Optional[Callable[..., None]]
18+
Alias4 = Union[Callable[..., None], None]
19+
Alias5 = list[Callable[[int], None]]
20+
Alias6 = Callable[[int], None]
21+
22+
23+
def func1() -> Optional[Callable[[int], None]]:
24+
...
25+
26+
def func2() -> Optional["Callable[[int], None]"]:
27+
...
28+
29+
def func3() -> Union[collections.abc.Callable[[int], None], None]:
30+
...
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
[master]
2+
py-version=3.9
3+
load-plugins=pylint.extensions.typing
4+
5+
[testoptions]
6+
min_pyver=3.7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
broken-collections-callable:14:18:14:26::'collections.abc.Callable' inside Optional and Union is broken in 3.9.0 / 3.9.1 (use 'typing.Callable' instead):INFERENCE
2+
broken-collections-callable:15:15:15:23::'collections.abc.Callable' inside Optional and Union is broken in 3.9.0 / 3.9.1 (use 'typing.Callable' instead):INFERENCE

0 commit comments

Comments
 (0)