Skip to content

Commit 40a53aa

Browse files
lggruspePierre-Sassoulas
authored andcommitted
Fix #3299 false positives with names in string literal type annotations (#7400)
Don't emit 'unused-variable' or 'unused-import' on names in string literal type annotations (#3299) Don't treat strings inside typing.Literal as names
1 parent d476a8b commit 40a53aa

12 files changed

+167
-1
lines changed
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Fix false positive for ``unused-variable`` and ``unused-import`` when a name is only used in a string literal type annotation.
2+
3+
Closes #3299

pylint/checkers/utils.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1891,6 +1891,28 @@ def in_type_checking_block(node: nodes.NodeNG) -> bool:
18911891
return False
18921892

18931893

1894+
def is_typing_literal(node: nodes.NodeNG) -> bool:
1895+
"""Check if a node refers to typing.Literal."""
1896+
if isinstance(node, nodes.Name):
1897+
try:
1898+
import_from = node.lookup(node.name)[1][0]
1899+
except IndexError:
1900+
return False
1901+
if isinstance(import_from, nodes.ImportFrom):
1902+
return (
1903+
import_from.modname == "typing"
1904+
and import_from.real_name(node.name) == "Literal"
1905+
)
1906+
elif isinstance(node, nodes.Attribute):
1907+
inferred_module = safe_infer(node.expr)
1908+
return (
1909+
isinstance(inferred_module, nodes.Module)
1910+
and inferred_module.name == "typing"
1911+
and node.attrname == "Literal"
1912+
)
1913+
return False
1914+
1915+
18941916
@lru_cache()
18951917
def in_for_else_branch(parent: nodes.NodeNG, stmt: nodes.Statement) -> bool:
18961918
"""Returns True if stmt is inside the else branch for a parent For stmt."""

pylint/checkers/variables.py

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from typing import TYPE_CHECKING, Any, NamedTuple
2020

2121
import astroid
22-
from astroid import nodes
22+
from astroid import extract_node, nodes
2323
from astroid.typing import InferenceResult
2424

2525
from pylint.checkers import BaseChecker, utils
@@ -2341,6 +2341,10 @@ def _check_is_unused(
23412341
if name in comprehension_target_names:
23422342
return
23432343

2344+
# Ignore names in string literal type annotation.
2345+
if name in self._type_annotation_names:
2346+
return
2347+
23442348
argnames = node.argnames()
23452349
# Care about functions with unknown argument (builtins)
23462350
if name in argnames:
@@ -2904,6 +2908,41 @@ def _check_potential_index_error(
29042908
)
29052909
return
29062910

2911+
@utils.only_required_for_messages(
2912+
"unused-import",
2913+
"unused-variable",
2914+
)
2915+
def visit_const(self, node: nodes.Const) -> None:
2916+
"""Take note of names that appear inside string literal type annotations
2917+
unless the string is a parameter to typing.Literal.
2918+
"""
2919+
if node.pytype() != "builtins.str":
2920+
return
2921+
if not utils.is_node_in_type_annotation_context(node):
2922+
return
2923+
if not node.value.isidentifier():
2924+
try:
2925+
annotation = extract_node(node.value)
2926+
self._store_type_annotation_node(annotation)
2927+
except ValueError:
2928+
# e.g. node.value is white space
2929+
return
2930+
except astroid.AstroidSyntaxError:
2931+
# e.g. "?" or ":" in typing.Literal["?", ":"]
2932+
return
2933+
2934+
# Check if parent's or grandparent's first child is typing.Literal
2935+
parent = node.parent
2936+
if isinstance(parent, nodes.Tuple):
2937+
parent = parent.parent
2938+
2939+
if isinstance(parent, nodes.Subscript):
2940+
origin = next(parent.get_children(), None)
2941+
if origin is not None and utils.is_typing_literal(origin):
2942+
return
2943+
2944+
self._type_annotation_names.append(node.value)
2945+
29072946

29082947
def register(linter: PyLinter) -> None:
29092948
linter.register_checker(VariablesChecker(linter))

tests/checkers/unittest_utils.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -489,3 +489,29 @@ def visit_assname(self, node: nodes.NodeNG) -> None:
489489
records[0].message.args[0]
490490
== "utils.check_messages will be removed in favour of calling utils.only_required_for_messages in pylint 3.0"
491491
)
492+
493+
494+
def test_is_typing_literal() -> None:
495+
code = astroid.extract_node(
496+
"""
497+
from typing import Literal as Lit, Set as Literal
498+
import typing as t
499+
500+
Literal #@
501+
Lit #@
502+
t.Literal #@
503+
"""
504+
)
505+
506+
assert not utils.is_typing_literal(code[0])
507+
assert utils.is_typing_literal(code[1])
508+
assert utils.is_typing_literal(code[2])
509+
510+
code = astroid.extract_node(
511+
"""
512+
Literal #@
513+
typing.Literal #@
514+
"""
515+
)
516+
assert not utils.is_typing_literal(code[0])
517+
assert not utils.is_typing_literal(code[1])
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
"""Test if pylint sees names inside string literal type annotations. #3299"""
2+
# pylint: disable=too-few-public-methods
3+
4+
from argparse import ArgumentParser, Namespace
5+
import os
6+
from os import PathLike
7+
from pathlib import Path
8+
from typing import NoReturn, Set
9+
10+
# unused-import shouldn't be emitted for Path
11+
example1: Set["Path"] = set()
12+
13+
def example2(_: "ArgumentParser") -> "NoReturn":
14+
"""unused-import shouldn't be emitted for ArgumentParser or NoReturn."""
15+
while True:
16+
pass
17+
18+
def example3(_: "os.PathLike[str]") -> None:
19+
"""unused-import shouldn't be emitted for os."""
20+
21+
def example4(_: "PathLike[str]") -> None:
22+
"""unused-import shouldn't be emitted for PathLike."""
23+
24+
class Class:
25+
"""unused-import shouldn't be emitted for Namespace"""
26+
cls: "Namespace"
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# pylint: disable=missing-docstring
2+
3+
from typing import TypeAlias
4+
5+
def unused_variable_should_not_be_emitted():
6+
"""unused-variable shouldn't be emitted for Example."""
7+
Example: TypeAlias = int
8+
result: set["Example"] = set()
9+
return result
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
[testoptions]
2+
min_pyver=3.10
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# pylint: disable=missing-docstring
2+
3+
from argparse import ArgumentParser # [unused-import]
4+
from argparse import Namespace # [unused-import]
5+
from typing import Literal as Lit
6+
import typing as t
7+
8+
# str inside Literal shouldn't be treated as names
9+
example1: t.Literal["ArgumentParser", Lit["Namespace", "ArgumentParser"]]
10+
11+
12+
def unused_variable_example():
13+
hello = "hello" # [unused-variable]
14+
world = "world" # [unused-variable]
15+
example2: Lit["hello", "world"] = "hello"
16+
return example2
17+
18+
19+
# pylint shouldn't crash with the following strings in a type annotation context
20+
example3: Lit["", " ", "?"] = "?"
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
[testoptions]
2+
min_pyver=3.8
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
unused-import:3:0:3:35::Unused ArgumentParser imported from argparse:UNDEFINED
2+
unused-import:4:0:4:30::Unused Namespace imported from argparse:UNDEFINED
3+
unused-variable:13:4:13:9:unused_variable_example:Unused variable 'hello':UNDEFINED
4+
unused-variable:14:4:14:9:unused_variable_example:Unused variable 'world':UNDEFINED
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# pylint: disable=missing-docstring
2+
3+
import graphlib
4+
from graphlib import TopologicalSorter
5+
6+
def example(
7+
sorter1: "graphlib.TopologicalSorter[int]",
8+
sorter2: "TopologicalSorter[str]",
9+
) -> None:
10+
"""unused-import shouldn't be emitted for graphlib or TopologicalSorter."""
11+
print(sorter1, sorter2)
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
[testoptions]
2+
min_pyver=3.9

0 commit comments

Comments
 (0)