Skip to content

Commit ea1649f

Browse files
nickdrozdPierre-Sassoulas
authored andcommitted
Add new check: unguarded-typing-import
1 parent d94194b commit ea1649f

File tree

4 files changed

+92
-11
lines changed

4 files changed

+92
-11
lines changed

pylint/checkers/variables.py

+68-11
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from pylint.checkers.utils import (
2525
in_type_checking_block,
2626
is_module_ignored,
27+
is_node_in_type_annotation_context,
2728
is_postponed_evaluation_enabled,
2829
is_sys_guard,
2930
overridden_method,
@@ -434,6 +435,14 @@ def _has_locals_call_after_node(stmt: nodes.NodeNG, scope: nodes.FunctionDef) ->
434435
"Used when an imported module or variable is not used from a "
435436
"`'from X import *'` style import.",
436437
),
438+
"R0615": (
439+
"`%s` used only for typechecking but imported outside of a typechecking block",
440+
"unguarded-typing-import",
441+
"Used when an import is used only for typechecking but imported outside of a typechecking block.",
442+
{
443+
"default_enabled": False,
444+
},
445+
),
437446
"W0621": (
438447
"Redefining name %r from outer scope (line %s)",
439448
"redefined-outer-name",
@@ -507,6 +516,7 @@ class NamesConsumer:
507516

508517
to_consume: Consumption
509518
consumed: Consumption
519+
consumed_as_type: Consumption
510520
consumed_uncertain: Consumption
511521
"""Retrieves nodes filtered out by get_next_to_consume() that may not
512522
have executed.
@@ -523,6 +533,7 @@ def __init__(self, node: nodes.NodeNG, scope_type: str):
523533

524534
self.to_consume = copy.copy(node.locals)
525535
self.consumed = {}
536+
self.consumed_as_type = {}
526537
self.consumed_uncertain = defaultdict(list)
527538

528539
self.names_under_always_false_test: set[str] = set()
@@ -531,30 +542,46 @@ def __init__(self, node: nodes.NodeNG, scope_type: str):
531542
def __repr__(self) -> str:
532543
_to_consumes = [f"{k}->{v}" for k, v in self.to_consume.items()]
533544
_consumed = [f"{k}->{v}" for k, v in self.consumed.items()]
545+
_consumed_as_type = [f"{k}->{v}" for k, v in self.consumed_as_type.items()]
534546
_consumed_uncertain = [f"{k}->{v}" for k, v in self.consumed_uncertain.items()]
535547
to_consumes = ", ".join(_to_consumes)
536548
consumed = ", ".join(_consumed)
549+
consumed_as_type = ", ".join(_consumed_as_type)
537550
consumed_uncertain = ", ".join(_consumed_uncertain)
538551
return f"""
539552
to_consume : {to_consumes}
540553
consumed : {consumed}
554+
consumed_as_type : {consumed_as_type}
541555
consumed_uncertain: {consumed_uncertain}
542556
scope_type : {self.scope_type}
543557
"""
544558

545-
def mark_as_consumed(self, name: str, consumed_nodes: list[nodes.NodeNG]) -> None:
559+
def mark_as_consumed(
560+
self,
561+
name: str,
562+
consumed_nodes: list[nodes.NodeNG],
563+
consumed_as_type: bool = False,
564+
) -> None:
546565
"""Mark the given nodes as consumed for the name.
547566
548567
If all of the nodes for the name were consumed, delete the name from
549568
the to_consume dictionary
550569
"""
551-
unconsumed = [n for n in self.to_consume[name] if n not in set(consumed_nodes)]
552-
self.consumed[name] = consumed_nodes
570+
consumed = self.consumed_as_type if consumed_as_type else self.consumed
571+
consumed[name] = consumed_nodes
553572

554-
if unconsumed:
555-
self.to_consume[name] = unconsumed
556-
else:
557-
del self.to_consume[name]
573+
if name in self.to_consume:
574+
unconsumed = [
575+
n for n in self.to_consume[name] if n not in set(consumed_nodes)
576+
]
577+
578+
if unconsumed:
579+
self.to_consume[name] = unconsumed
580+
else:
581+
del self.to_consume[name]
582+
583+
if not consumed_as_type and name in self.consumed_as_type:
584+
del self.consumed_as_type[name]
558585

559586
def get_next_to_consume(self, node: nodes.Name) -> list[nodes.NodeNG] | None:
560587
"""Return a list of the nodes that define `node` from this scope.
@@ -594,6 +621,9 @@ def get_next_to_consume(self, node: nodes.Name) -> list[nodes.NodeNG] | None:
594621
if VariablesChecker._comprehension_between_frame_and_node(node):
595622
return found_nodes
596623

624+
if found_nodes is None:
625+
found_nodes = self.consumed_as_type.get(name)
626+
597627
# Filter out assignments in ExceptHandlers that node is not contained in
598628
if found_nodes:
599629
found_nodes = [
@@ -1384,7 +1414,8 @@ def leave_module(self, node: nodes.Module) -> None:
13841414
assert len(self._to_consume) == 1
13851415

13861416
self._check_metaclasses(node)
1387-
not_consumed = self._to_consume.pop().to_consume
1417+
consumer = self._to_consume.pop()
1418+
not_consumed = consumer.to_consume
13881419
# attempt to check for __all__ if defined
13891420
if "__all__" in node.locals:
13901421
self._check_all(node, not_consumed)
@@ -1396,7 +1427,7 @@ def leave_module(self, node: nodes.Module) -> None:
13961427
if not self.linter.config.init_import and node.package:
13971428
return
13981429

1399-
self._check_imports(not_consumed)
1430+
self._check_imports(not_consumed, consumer.consumed_as_type)
14001431
self._type_annotation_names = []
14011432

14021433
def visit_classdef(self, node: nodes.ClassDef) -> None:
@@ -1700,7 +1731,11 @@ def _undefined_and_used_before_checker(
17001731
# They will have already had a chance to emit used-before-assignment.
17011732
# We check here instead of before every single return in _check_consumer()
17021733
nodes_to_consume += current_consumer.consumed_uncertain[node.name]
1703-
current_consumer.mark_as_consumed(node.name, nodes_to_consume)
1734+
current_consumer.mark_as_consumed(
1735+
node.name,
1736+
nodes_to_consume,
1737+
consumed_as_type=is_node_in_type_annotation_context(node),
1738+
)
17041739
if action is VariableVisitConsumerAction.CONTINUE:
17051740
continue
17061741
if action is VariableVisitConsumerAction.RETURN:
@@ -3218,7 +3253,11 @@ def _check_globals(self, not_consumed: Consumption) -> None:
32183253
self.add_message("unused-variable", args=(name,), node=node)
32193254

32203255
# pylint: disable = too-many-branches
3221-
def _check_imports(self, not_consumed: Consumption) -> None:
3256+
def _check_imports(
3257+
self,
3258+
not_consumed: Consumption,
3259+
consumed_as_type: Consumption,
3260+
) -> None:
32223261
local_names = _fix_dot_imports(not_consumed)
32233262
checked = set()
32243263
unused_wildcard_imports: defaultdict[
@@ -3306,8 +3345,26 @@ def _check_imports(self, not_consumed: Consumption) -> None:
33063345
self.add_message(
33073346
"unused-wildcard-import", args=(arg_string, module[0]), node=module[1]
33083347
)
3348+
3349+
self._check_type_imports(consumed_as_type)
3350+
33093351
del self._to_consume
33103352

3353+
def _check_type_imports(
3354+
self,
3355+
consumed_as_type: dict[str, list[nodes.NodeNG]],
3356+
) -> None:
3357+
for name, import_node in _fix_dot_imports(consumed_as_type):
3358+
if import_node.names[0][0] == "*":
3359+
continue
3360+
3361+
if not in_type_checking_block(import_node):
3362+
self.add_message(
3363+
"unguarded-typing-import",
3364+
args=name,
3365+
node=import_node,
3366+
)
3367+
33113368
def _check_metaclasses(self, node: nodes.Module | nodes.FunctionDef) -> None:
33123369
"""Update consumption analysis for metaclasses."""
33133370
consumed: list[tuple[Consumption, str]] = []
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# pylint: disable = import-error, missing-module-docstring, missing-function-docstring, missing-class-docstring, too-few-public-methods,
2+
3+
from mod import A # [unguarded-typing-import]
4+
from mod import B
5+
6+
def f(_: A):
7+
pass
8+
9+
def g(x: B):
10+
assert isinstance(x, B)
11+
12+
class C:
13+
pass
14+
15+
class D:
16+
c: C
17+
18+
def h(self):
19+
# --> BUG <--
20+
# pylint: disable = undefined-variable
21+
return [C() for _ in self.c]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
[MESSAGES CONTROL]
2+
enable = unguarded-typing-import
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
unguarded-typing-import:3:0:3:17::`A` used only for typechecking but imported outside of a typechecking block:UNDEFINED

0 commit comments

Comments
 (0)