Skip to content

Commit fce898e

Browse files
authored
Add new extension TypingChecker (#4382)
1 parent c91f7f9 commit fce898e

23 files changed

+804
-1
lines changed

ChangeLog

+5
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,11 @@ Release date: Undefined
5757

5858
* Update ``astroid`` to 2.5.4
5959

60+
* Add new extension ``TypingChecker``. This optional checker can detect the use of deprecated typing aliases
61+
and can suggest the use of the alternative union syntax where possible.
62+
(For example, 'typing.Dict' can be replaced by 'dict', and 'typing.Unions' by '|', etc.)
63+
Make sure to check the config options if you plan on using it!
64+
6065

6166
What's New in Pylint 2.7.5?
6267
===========================

doc/whatsnew/2.8.rst

+6
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ New checkers
2323

2424
* Add ``consider-using-min-max-builtin`` check for if statement which could be replaced by Python builtin min or max.
2525

26+
* Add new extension ``TypingChecker``. This optional checker can detect the use of deprecated typing aliases
27+
and can suggest the use of the alternative union syntax where possible.
28+
(For example, 'typing.Dict' can be replaced by 'dict', and 'typing.Unions' by '|', etc.)
29+
Make sure to check the config options if you plan on using it!
30+
31+
2632
Other Changes
2733
=============
2834

pylint/config/option.py

+12
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,15 @@ def _multiple_choices_validating_option(opt, name, value):
6363
return _multiple_choice_validator(opt.choices, name, value)
6464

6565

66+
def _py_version_validator(_, name, value):
67+
if not isinstance(value, tuple):
68+
try:
69+
value = tuple(int(val) for val in value.split("."))
70+
except (ValueError, AttributeError):
71+
raise optparse.OptionValueError(f"Invalid format for {name}") from None
72+
return value
73+
74+
6675
VALIDATORS = {
6776
"string": utils._unquote,
6877
"int": int,
@@ -76,6 +85,7 @@ def _multiple_choices_validating_option(opt, name, value):
7685
opt["choices"], name, value
7786
),
7887
"non_empty_string": _non_empty_string_validator,
88+
"py_version": _py_version_validator,
7989
}
8090

8191

@@ -114,6 +124,7 @@ class Option(optparse.Option):
114124
"yn",
115125
"multiple_choice",
116126
"non_empty_string",
127+
"py_version",
117128
)
118129
ATTRS = optparse.Option.ATTRS + ["hide", "level"]
119130
TYPE_CHECKER = copy.copy(optparse.Option.TYPE_CHECKER)
@@ -123,6 +134,7 @@ class Option(optparse.Option):
123134
TYPE_CHECKER["yn"] = _yn_validator
124135
TYPE_CHECKER["multiple_choice"] = _multiple_choices_validating_option
125136
TYPE_CHECKER["non_empty_string"] = _non_empty_string_validator
137+
TYPE_CHECKER["py_version"] = _py_version_validator
126138

127139
def __init__(self, *opts, **attrs):
128140
optparse.Option.__init__(self, *opts, **attrs)

pylint/extensions/typing.py

+315
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,315 @@
1+
from functools import lru_cache
2+
from typing import Dict, List, NamedTuple, Set, Union
3+
4+
import astroid
5+
import astroid.bases
6+
import astroid.node_classes
7+
8+
from pylint.checkers import BaseChecker
9+
from pylint.checkers.utils import (
10+
check_messages,
11+
is_node_in_type_annotation_context,
12+
safe_infer,
13+
)
14+
from pylint.interfaces import IAstroidChecker
15+
from pylint.lint import PyLinter
16+
17+
18+
class TypingAlias(NamedTuple):
19+
name: str
20+
name_collision: bool
21+
22+
23+
DEPRECATED_TYPING_ALIASES: Dict[str, TypingAlias] = {
24+
"typing.Tuple": TypingAlias("tuple", False),
25+
"typing.List": TypingAlias("list", False),
26+
"typing.Dict": TypingAlias("dict", False),
27+
"typing.Set": TypingAlias("set", False),
28+
"typing.FrozenSet": TypingAlias("frozenset", False),
29+
"typing.Type": TypingAlias("type", False),
30+
"typing.Deque": TypingAlias("collections.deque", True),
31+
"typing.DefaultDict": TypingAlias("collections.defaultdict", True),
32+
"typing.OrderedDict": TypingAlias("collections.OrderedDict", True),
33+
"typing.Counter": TypingAlias("collections.Counter", True),
34+
"typing.ChainMap": TypingAlias("collections.ChainMap", True),
35+
"typing.Awaitable": TypingAlias("collections.abc.Awaitable", True),
36+
"typing.Coroutine": TypingAlias("collections.abc.Coroutine", True),
37+
"typing.AsyncIterable": TypingAlias("collections.abc.AsyncIterable", True),
38+
"typing.AsyncIterator": TypingAlias("collections.abc.AsyncIterator", True),
39+
"typing.AsyncGenerator": TypingAlias("collections.abc.AsyncGenerator", True),
40+
"typing.Iterable": TypingAlias("collections.abc.Iterable", True),
41+
"typing.Iterator": TypingAlias("collections.abc.Iterator", True),
42+
"typing.Generator": TypingAlias("collections.abc.Generator", True),
43+
"typing.Reversible": TypingAlias("collections.abc.Reversible", True),
44+
"typing.Container": TypingAlias("collections.abc.Container", True),
45+
"typing.Collection": TypingAlias("collections.abc.Collection", True),
46+
"typing.Callable": TypingAlias("collections.abc.Callable", True),
47+
"typing.AbstractSet": TypingAlias("collections.abc.Set", False),
48+
"typing.MutableSet": TypingAlias("collections.abc.MutableSet", True),
49+
"typing.Mapping": TypingAlias("collections.abc.Mapping", True),
50+
"typing.MutableMapping": TypingAlias("collections.abc.MutableMapping", True),
51+
"typing.Sequence": TypingAlias("collections.abc.Sequence", True),
52+
"typing.MutableSequence": TypingAlias("collections.abc.MutableSequence", True),
53+
"typing.ByteString": TypingAlias("collections.abc.ByteString", True),
54+
"typing.MappingView": TypingAlias("collections.abc.MappingView", True),
55+
"typing.KeysView": TypingAlias("collections.abc.KeysView", True),
56+
"typing.ItemsView": TypingAlias("collections.abc.ItemsView", True),
57+
"typing.ValuesView": TypingAlias("collections.abc.ValuesView", True),
58+
"typing.ContextManager": TypingAlias("contextlib.AbstractContextManager", False),
59+
"typing.AsyncContextManager": TypingAlias(
60+
"contextlib.AbstractAsyncContextManager", False
61+
),
62+
"typing.Pattern": TypingAlias("re.Pattern", True),
63+
"typing.Match": TypingAlias("re.Match", True),
64+
"typing.Hashable": TypingAlias("collections.abc.Hashable", True),
65+
"typing.Sized": TypingAlias("collections.abc.Sized", True),
66+
}
67+
68+
ALIAS_NAMES = frozenset(key.split(".")[1] for key in DEPRECATED_TYPING_ALIASES)
69+
UNION_NAMES = ("Optional", "Union")
70+
71+
72+
class DeprecatedTypingAliasMsg(NamedTuple):
73+
node: Union[astroid.Name, astroid.Attribute]
74+
qname: str
75+
alias: str
76+
parent_subscript: bool
77+
78+
79+
class TypingChecker(BaseChecker):
80+
"""Find issue specifically related to type annotations."""
81+
82+
__implements__ = (IAstroidChecker,)
83+
84+
name = "typing"
85+
priority = -1
86+
msgs = {
87+
"W6001": (
88+
"'%s' is deprecated, use '%s' instead",
89+
"deprecated-typing-alias",
90+
"Emitted when a deprecated typing alias is used.",
91+
),
92+
"R6002": (
93+
"'%s' will be deprecated with PY39, consider using '%s' instead%s",
94+
"consider-using-alias",
95+
"Only emitted if 'runtime-typing=no' and a deprecated "
96+
"typing alias is used in a type annotation context in "
97+
"Python 3.7 or 3.8.",
98+
),
99+
"R6003": (
100+
"Consider using alternative Union syntax instead of '%s'%s",
101+
"consider-alternative-union-syntax",
102+
"Emitted when 'typing.Union' or 'typing.Optional' is used "
103+
"instead of the alternative Union syntax 'int | None'.",
104+
),
105+
}
106+
options = (
107+
(
108+
"py-version",
109+
{
110+
"default": (3, 7),
111+
"type": "py_version",
112+
"metavar": "<py_version>",
113+
"help": (
114+
"Min Python version to use for typing related checks, "
115+
"e.g. ``3.7``. This should be equal to the min supported Python "
116+
"version of the project."
117+
),
118+
},
119+
),
120+
(
121+
"runtime-typing",
122+
{
123+
"default": True,
124+
"type": "yn",
125+
"metavar": "<y_or_n>",
126+
"help": (
127+
"Set to ``no`` if the app / libary does NOT need to "
128+
"support runtime introspection of type "
129+
"annotations. Only applies to Python version "
130+
"3.7 - 3.9"
131+
),
132+
},
133+
),
134+
)
135+
136+
def __init__(self, linter: PyLinter) -> None:
137+
"""Initialize checker instance."""
138+
super().__init__(linter=linter)
139+
self._alias_name_collisions: Set[str] = set()
140+
self._consider_using_alias_msgs: List[DeprecatedTypingAliasMsg] = []
141+
142+
@lru_cache()
143+
def _py37_plus(self) -> bool:
144+
return self.config.py_version >= (3, 7)
145+
146+
@lru_cache()
147+
def _py39_plus(self) -> bool:
148+
return self.config.py_version >= (3, 9)
149+
150+
@lru_cache()
151+
def _py310_plus(self) -> bool:
152+
return self.config.py_version >= (3, 10)
153+
154+
@lru_cache()
155+
def _should_check_typing_alias(self) -> bool:
156+
"""The use of type aliases (PEP 585) requires Python 3.9
157+
or Python 3.7+ with postponed evaluation.
158+
"""
159+
return (
160+
self._py39_plus()
161+
or self._py37_plus()
162+
and self.config.runtime_typing is False
163+
)
164+
165+
@lru_cache()
166+
def _should_check_alternative_union_syntax(self) -> bool:
167+
"""The use of alternative union syntax (PEP 604) requires Python 3.10
168+
or Python 3.7+ with postponed evaluation.
169+
"""
170+
return (
171+
self._py310_plus()
172+
or self._py37_plus()
173+
and self.config.runtime_typing is False
174+
)
175+
176+
def _msg_postponed_eval_hint(self, node) -> str:
177+
"""Message hint if postponed evaluation isn't enabled."""
178+
if self._py310_plus() or "annotations" in node.root().future_imports:
179+
return ""
180+
return ". Add 'from __future__ import annotations' as well"
181+
182+
@check_messages(
183+
"deprecated-typing-alias",
184+
"consider-using-alias",
185+
"consider-alternative-union-syntax",
186+
)
187+
def visit_name(self, node: astroid.Name) -> None:
188+
if self._should_check_typing_alias() and node.name in ALIAS_NAMES:
189+
self._check_for_typing_alias(node)
190+
if self._should_check_alternative_union_syntax() and node.name in UNION_NAMES:
191+
self._check_for_alternative_union_syntax(node, node.name)
192+
193+
@check_messages(
194+
"deprecated-typing-alias",
195+
"consider-using-alias",
196+
"consider-alternative-union-syntax",
197+
)
198+
def visit_attribute(self, node: astroid.Attribute):
199+
if self._should_check_typing_alias() and node.attrname in ALIAS_NAMES:
200+
self._check_for_typing_alias(node)
201+
if (
202+
self._should_check_alternative_union_syntax()
203+
and node.attrname in UNION_NAMES
204+
):
205+
self._check_for_alternative_union_syntax(node, node.attrname)
206+
207+
def _check_for_alternative_union_syntax(
208+
self,
209+
node: Union[astroid.Name, astroid.Attribute],
210+
name: str,
211+
) -> None:
212+
"""Check if alternative union syntax could be used.
213+
214+
Requires
215+
- Python 3.10
216+
- OR: Python 3.7+ with postponed evaluation in
217+
a type annotation context
218+
"""
219+
inferred = safe_infer(node)
220+
if not (
221+
isinstance(inferred, astroid.FunctionDef)
222+
and inferred.qname()
223+
in (
224+
"typing.Optional",
225+
"typing.Union",
226+
)
227+
or isinstance(inferred, astroid.bases.Instance)
228+
and inferred.qname() == "typing._SpecialForm"
229+
):
230+
return
231+
if not (self._py310_plus() or is_node_in_type_annotation_context(node)):
232+
return
233+
self.add_message(
234+
"consider-alternative-union-syntax",
235+
node=node,
236+
args=(name, self._msg_postponed_eval_hint(node)),
237+
)
238+
239+
def _check_for_typing_alias(
240+
self,
241+
node: Union[astroid.Name, astroid.Attribute],
242+
) -> None:
243+
"""Check if typing alias is depecated or could be replaced.
244+
245+
Requires
246+
- Python 3.9
247+
- OR: Python 3.7+ with postponed evaluation in
248+
a type annotation context
249+
250+
For Python 3.7+: Only emitt message if change doesn't create
251+
any name collisions, only ever used in a type annotation
252+
context, and can safely be replaced.
253+
"""
254+
inferred = safe_infer(node)
255+
if not isinstance(inferred, astroid.ClassDef):
256+
return
257+
alias = DEPRECATED_TYPING_ALIASES.get(inferred.qname(), None)
258+
if alias is None:
259+
return
260+
261+
if self._py39_plus():
262+
self.add_message(
263+
"deprecated-typing-alias",
264+
node=node,
265+
args=(inferred.qname(), alias.name),
266+
)
267+
return
268+
269+
# For PY37+, check for type annotation context first
270+
if not is_node_in_type_annotation_context(node) and isinstance(
271+
node.parent, astroid.Subscript
272+
):
273+
if alias.name_collision is True:
274+
self._alias_name_collisions.add(inferred.qname())
275+
return
276+
self._consider_using_alias_msgs.append(
277+
DeprecatedTypingAliasMsg(
278+
node,
279+
inferred.qname(),
280+
alias.name,
281+
isinstance(node.parent, astroid.Subscript),
282+
)
283+
)
284+
285+
@check_messages("consider-using-alias")
286+
def leave_module(self, node: astroid.Module) -> None:
287+
"""After parsing of module is complete, add messages for
288+
'consider-using-alias' check. Make sure results are safe
289+
to recommend / collision free.
290+
"""
291+
if self._py37_plus() and not self._py39_plus():
292+
msg_future_import = self._msg_postponed_eval_hint(node)
293+
while True:
294+
try:
295+
msg = self._consider_using_alias_msgs.pop(0)
296+
except IndexError:
297+
break
298+
if msg.qname in self._alias_name_collisions:
299+
continue
300+
self.add_message(
301+
"consider-using-alias",
302+
node=msg.node,
303+
args=(
304+
msg.qname,
305+
msg.alias,
306+
msg_future_import if msg.parent_subscript else "",
307+
),
308+
)
309+
# Clear all module cache variables
310+
self._alias_name_collisions.clear()
311+
self._consider_using_alias_msgs.clear()
312+
313+
314+
def register(linter: PyLinter) -> None:
315+
linter.register_checker(TypingChecker(linter))

pylint/utils/utils.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,9 @@ def _comment(string):
245245

246246
def _format_option_value(optdict, value):
247247
"""return the user input's value from a 'compiled' value"""
248-
if isinstance(value, (list, tuple)):
248+
if optdict.get("type", None) == "py_version":
249+
value = ".".join(str(item) for item in value)
250+
elif isinstance(value, (list, tuple)):
249251
value = ",".join(_format_option_value(optdict, item) for item in value)
250252
elif isinstance(value, dict):
251253
value = ",".join(f"{k}:{v}" for k, v in value.items())

0 commit comments

Comments
 (0)