Skip to content

Commit 6e90c48

Browse files
nickdrozdPierre-Sassoulas
authored andcommitted
Add new check: unguarded-typing-import
1 parent 6674505 commit 6e90c48

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 = [
@@ -1386,7 +1416,8 @@ def leave_module(self, node: nodes.Module) -> None:
13861416
assert len(self._to_consume) == 1
13871417

13881418
self._check_metaclasses(node)
1389-
not_consumed = self._to_consume.pop().to_consume
1419+
consumer = self._to_consume.pop()
1420+
not_consumed = consumer.to_consume
13901421
# attempt to check for __all__ if defined
13911422
if "__all__" in node.locals:
13921423
self._check_all(node, not_consumed)
@@ -1398,7 +1429,7 @@ def leave_module(self, node: nodes.Module) -> None:
13981429
if not self.linter.config.init_import and node.package:
13991430
return
14001431

1401-
self._check_imports(not_consumed)
1432+
self._check_imports(not_consumed, consumer.consumed_as_type)
14021433
self._type_annotation_names = []
14031434

14041435
def visit_classdef(self, node: nodes.ClassDef) -> None:
@@ -1702,7 +1733,11 @@ def _undefined_and_used_before_checker(
17021733
# They will have already had a chance to emit used-before-assignment.
17031734
# We check here instead of before every single return in _check_consumer()
17041735
nodes_to_consume += current_consumer.consumed_uncertain[node.name]
1705-
current_consumer.mark_as_consumed(node.name, nodes_to_consume)
1736+
current_consumer.mark_as_consumed(
1737+
node.name,
1738+
nodes_to_consume,
1739+
consumed_as_type=is_node_in_type_annotation_context(node),
1740+
)
17061741
if action is VariableVisitConsumerAction.CONTINUE:
17071742
continue
17081743
if action is VariableVisitConsumerAction.RETURN:
@@ -3236,7 +3271,11 @@ def _check_globals(self, not_consumed: Consumption) -> None:
32363271
self.add_message("unused-variable", args=(name,), node=node)
32373272

32383273
# pylint: disable = too-many-branches
3239-
def _check_imports(self, not_consumed: Consumption) -> None:
3274+
def _check_imports(
3275+
self,
3276+
not_consumed: Consumption,
3277+
consumed_as_type: Consumption,
3278+
) -> None:
32403279
local_names = _fix_dot_imports(not_consumed)
32413280
checked = set()
32423281
unused_wildcard_imports: defaultdict[
@@ -3324,8 +3363,26 @@ def _check_imports(self, not_consumed: Consumption) -> None:
33243363
self.add_message(
33253364
"unused-wildcard-import", args=(arg_string, module[0]), node=module[1]
33263365
)
3366+
3367+
self._check_type_imports(consumed_as_type)
3368+
33273369
del self._to_consume
33283370

3371+
def _check_type_imports(
3372+
self,
3373+
consumed_as_type: dict[str, list[nodes.NodeNG]],
3374+
) -> None:
3375+
for name, import_node in _fix_dot_imports(consumed_as_type):
3376+
if import_node.names[0][0] == "*":
3377+
continue
3378+
3379+
if not in_type_checking_block(import_node):
3380+
self.add_message(
3381+
"unguarded-typing-import",
3382+
args=name,
3383+
node=import_node,
3384+
)
3385+
33293386
def _check_metaclasses(self, node: nodes.Module | nodes.FunctionDef) -> None:
33303387
"""Update consumption analysis for metaclasses."""
33313388
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)