Skip to content

Commit d78d4ee

Browse files
committed
Add new check: unguarded-typing-import
1 parent 0d7b0d7 commit d78d4ee

File tree

4 files changed

+92
-11
lines changed

4 files changed

+92
-11
lines changed

pylint/checkers/variables.py

Lines changed: 68 additions & 11 deletions
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,
@@ -409,6 +410,14 @@ def _has_locals_call_after_node(stmt: nodes.NodeNG, scope: nodes.FunctionDef) ->
409410
"Used when an imported module or variable is not used from a "
410411
"`'from X import *'` style import.",
411412
),
413+
"R0615": (
414+
"`%s` used only for typechecking but imported outside of a typechecking block",
415+
"unguarded-typing-import",
416+
"Used when an import is used only for typechecking but imported outside of a typechecking block.",
417+
{
418+
"default_enabled": False,
419+
},
420+
),
412421
"W0621": (
413422
"Redefining name %r from outer scope (line %s)",
414423
"redefined-outer-name",
@@ -482,6 +491,7 @@ class NamesConsumer:
482491

483492
to_consume: Consumption
484493
consumed: Consumption
494+
consumed_as_type: Consumption
485495
consumed_uncertain: Consumption
486496
"""Retrieves nodes filtered out by get_next_to_consume() that may not
487497
have executed.
@@ -498,6 +508,7 @@ def __init__(self, node: nodes.NodeNG, scope_type: str):
498508

499509
self.to_consume = copy.copy(node.locals)
500510
self.consumed = {}
511+
self.consumed_as_type = {}
501512
self.consumed_uncertain = defaultdict(list)
502513

503514
self.names_under_always_false_test: set[str] = set()
@@ -506,30 +517,46 @@ def __init__(self, node: nodes.NodeNG, scope_type: str):
506517
def __repr__(self) -> str:
507518
_to_consumes = [f"{k}->{v}" for k, v in self.to_consume.items()]
508519
_consumed = [f"{k}->{v}" for k, v in self.consumed.items()]
520+
_consumed_as_type = [f"{k}->{v}" for k, v in self.consumed_as_type.items()]
509521
_consumed_uncertain = [f"{k}->{v}" for k, v in self.consumed_uncertain.items()]
510522
to_consumes = ", ".join(_to_consumes)
511523
consumed = ", ".join(_consumed)
524+
consumed_as_type = ", ".join(_consumed_as_type)
512525
consumed_uncertain = ", ".join(_consumed_uncertain)
513526
return f"""
514527
to_consume : {to_consumes}
515528
consumed : {consumed}
529+
consumed_as_type : {consumed_as_type}
516530
consumed_uncertain: {consumed_uncertain}
517531
scope_type : {self.scope_type}
518532
"""
519533

520-
def mark_as_consumed(self, name: str, consumed_nodes: list[nodes.NodeNG]) -> None:
534+
def mark_as_consumed(
535+
self,
536+
name: str,
537+
consumed_nodes: list[nodes.NodeNG],
538+
consumed_as_type: bool = False,
539+
) -> None:
521540
"""Mark the given nodes as consumed for the name.
522541
523542
If all of the nodes for the name were consumed, delete the name from
524543
the to_consume dictionary
525544
"""
526-
unconsumed = [n for n in self.to_consume[name] if n not in set(consumed_nodes)]
527-
self.consumed[name] = consumed_nodes
545+
consumed = self.consumed_as_type if consumed_as_type else self.consumed
546+
consumed[name] = consumed_nodes
528547

529-
if unconsumed:
530-
self.to_consume[name] = unconsumed
531-
else:
532-
del self.to_consume[name]
548+
if name in self.to_consume:
549+
unconsumed = [
550+
n for n in self.to_consume[name] if n not in set(consumed_nodes)
551+
]
552+
553+
if unconsumed:
554+
self.to_consume[name] = unconsumed
555+
else:
556+
del self.to_consume[name]
557+
558+
if not consumed_as_type and name in self.consumed_as_type:
559+
del self.consumed_as_type[name]
533560

534561
def get_next_to_consume(self, node: nodes.Name) -> list[nodes.NodeNG] | None:
535562
"""Return a list of the nodes that define `node` from this scope.
@@ -572,6 +599,9 @@ def get_next_to_consume(self, node: nodes.Name) -> list[nodes.NodeNG] | None:
572599
if VariablesChecker._comprehension_between_frame_and_node(node):
573600
return found_nodes
574601

602+
if found_nodes is None:
603+
found_nodes = self.consumed_as_type.get(name)
604+
575605
# Filter out assignments in ExceptHandlers that node is not contained in
576606
if found_nodes:
577607
found_nodes = [
@@ -1356,7 +1386,8 @@ def leave_module(self, node: nodes.Module) -> None:
13561386
assert len(self._to_consume) == 1
13571387

13581388
self._check_metaclasses(node)
1359-
not_consumed = self._to_consume.pop().to_consume
1389+
consumer = self._to_consume.pop()
1390+
not_consumed = consumer.to_consume
13601391
# attempt to check for __all__ if defined
13611392
if "__all__" in node.locals:
13621393
self._check_all(node, not_consumed)
@@ -1368,7 +1399,7 @@ def leave_module(self, node: nodes.Module) -> None:
13681399
if not self.linter.config.init_import and node.package:
13691400
return
13701401

1371-
self._check_imports(not_consumed)
1402+
self._check_imports(not_consumed, consumer.consumed_as_type)
13721403
self._type_annotation_names = []
13731404

13741405
def visit_classdef(self, node: nodes.ClassDef) -> None:
@@ -1672,7 +1703,11 @@ def _undefined_and_used_before_checker(
16721703
# They will have already had a chance to emit used-before-assignment.
16731704
# We check here instead of before every single return in _check_consumer()
16741705
nodes_to_consume += current_consumer.consumed_uncertain[node.name]
1675-
current_consumer.mark_as_consumed(node.name, nodes_to_consume)
1706+
current_consumer.mark_as_consumed(
1707+
node.name,
1708+
nodes_to_consume,
1709+
consumed_as_type=is_node_in_type_annotation_context(node),
1710+
)
16761711
if action is VariableVisitConsumerAction.CONTINUE:
16771712
continue
16781713
if action is VariableVisitConsumerAction.RETURN:
@@ -3163,7 +3198,11 @@ def _check_globals(self, not_consumed: Consumption) -> None:
31633198
self.add_message("unused-variable", args=(name,), node=node)
31643199

31653200
# pylint: disable = too-many-branches
3166-
def _check_imports(self, not_consumed: Consumption) -> None:
3201+
def _check_imports(
3202+
self,
3203+
not_consumed: Consumption,
3204+
consumed_as_type: Consumption,
3205+
) -> None:
31673206
local_names = _fix_dot_imports(not_consumed)
31683207
checked = set()
31693208
unused_wildcard_imports: defaultdict[
@@ -3251,8 +3290,26 @@ def _check_imports(self, not_consumed: Consumption) -> None:
32513290
self.add_message(
32523291
"unused-wildcard-import", args=(arg_string, module[0]), node=module[1]
32533292
)
3293+
3294+
self._check_type_imports(consumed_as_type)
3295+
32543296
del self._to_consume
32553297

3298+
def _check_type_imports(
3299+
self,
3300+
consumed_as_type: dict[str, list[nodes.NodeNG]],
3301+
) -> None:
3302+
for name, import_node in _fix_dot_imports(consumed_as_type):
3303+
if import_node.names[0][0] == "*":
3304+
continue
3305+
3306+
if not in_type_checking_block(import_node):
3307+
self.add_message(
3308+
"unguarded-typing-import",
3309+
args=name,
3310+
node=import_node,
3311+
)
3312+
32563313
def _check_metaclasses(self, node: nodes.Module | nodes.FunctionDef) -> None:
32573314
"""Update consumption analysis for metaclasses."""
32583315
consumed: list[tuple[Consumption, str]] = []
Lines changed: 21 additions & 0 deletions
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]
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
[MESSAGES CONTROL]
2+
enable = unguarded-typing-import
Lines changed: 1 addition & 0 deletions
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)