Skip to content

Commit ccf886e

Browse files
authored
Merge pull request #184 from snok/invalid-string-literal-in-binop
feat: Add TC010 which detects invalid use of string literals with |
2 parents d4a7162 + 594ab51 commit ccf886e

File tree

6 files changed

+128
-5
lines changed

6 files changed

+128
-5
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ And depending on which error code range you've opted into, it will tell you
7474
| TC007 | Type alias needs to be made into a string literal |
7575
| TC008 | Type alias does not need to be a string literal |
7676
| TC009 | Move declaration out of type-checking block. Variable is used for more than type hinting. |
77+
| TC010 | Operands for | cannot be a string literal |
7778

7879
## Choosing how to handle forward references
7980

flake8_type_checking/checker.py

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
ATTRIBUTE_PROPERTY,
2121
ATTRS_DECORATORS,
2222
ATTRS_IMPORTS,
23+
BINOP_OPERAND_PROPERTY,
2324
NAME_RE,
2425
TC001,
2526
TC002,
@@ -30,6 +31,7 @@
3031
TC007,
3132
TC008,
3233
TC009,
34+
TC010,
3335
TC100,
3436
TC101,
3537
TC200,
@@ -86,6 +88,8 @@ def visit(self, node: ast.AST) -> None:
8688
if isinstance(node, ast.BinOp):
8789
if not isinstance(node.op, ast.BitOr):
8890
return
91+
setattr(node.left, BINOP_OPERAND_PROPERTY, True)
92+
setattr(node.right, BINOP_OPERAND_PROPERTY, True)
8993
self.visit(node.left)
9094
self.visit(node.right)
9195
elif (py38 and isinstance(node, Index)) or isinstance(node, ast.Attribute):
@@ -818,6 +822,9 @@ def __init__(self) -> None:
818822
#: All type annotations in the file, with quotes around them
819823
self.wrapped_annotations: list[WrappedAnnotation] = []
820824

825+
#: All the invalid uses of string literals inside ast.BinOp
826+
self.invalid_binop_literals: list[ast.Constant] = []
827+
821828
def visit(
822829
self, node: ast.AST, scope: Scope | None = None, type: Literal['annotation', 'alias', 'new-alias'] | None = None
823830
) -> None:
@@ -836,13 +843,17 @@ def visit_annotation_name(self, node: ast.Name) -> None:
836843
)
837844

838845
def visit_annotation_string(self, node: ast.Constant) -> None:
839-
"""Register wrapped annotation."""
846+
"""Register wrapped annotation and invalid binop literals."""
840847
setattr(node, ANNOTATION_PROPERTY, True)
841-
self.wrapped_annotations.append(
842-
WrappedAnnotation(
843-
node.lineno, node.col_offset, node.value, set(NAME_RE.findall(node.value)), self.scope, self.type
848+
# we don't want to register them as both so we don't emit redundant errors
849+
if getattr(node, BINOP_OPERAND_PROPERTY, False):
850+
self.invalid_binop_literals.append(node)
851+
else:
852+
self.wrapped_annotations.append(
853+
WrappedAnnotation(
854+
node.lineno, node.col_offset, node.value, set(NAME_RE.findall(node.value)), self.scope, self.type
855+
)
844856
)
845-
)
846857

847858

848859
class ImportVisitor(
@@ -958,6 +969,11 @@ def wrapped_annotations(self) -> list[WrappedAnnotation]:
958969
"""All type annotations in the file, with quotes around them."""
959970
return self.annotation_visitor.wrapped_annotations
960971

972+
@property
973+
def invalid_binop_literals(self) -> list[ast.Constant]:
974+
"""All invalid uses of binop literals."""
975+
return self.annotation_visitor.invalid_binop_literals
976+
961977
@property
962978
def typing_module_name(self) -> str:
963979
"""
@@ -1800,6 +1816,8 @@ def __init__(self, node: ast.Module, options: Optional[Namespace]) -> None:
18001816
self.empty_type_checking_blocks,
18011817
# TC006
18021818
self.unquoted_type_in_cast,
1819+
# TC010
1820+
self.invalid_string_literal_in_binop,
18031821
# TC100, TC200, TC007
18041822
self.missing_quotes_or_futures_import,
18051823
# TC101
@@ -1900,6 +1918,11 @@ def unquoted_type_in_cast(self) -> Flake8Generator:
19001918
for lineno, col_offset, annotation in self.visitor.unquoted_types_in_casts:
19011919
yield lineno, col_offset, TC006.format(annotation=annotation), None
19021920

1921+
def invalid_string_literal_in_binop(self) -> Flake8Generator:
1922+
"""TC010."""
1923+
for node in self.visitor.invalid_binop_literals:
1924+
yield node.lineno, node.col_offset, TC010, None
1925+
19031926
def missing_quotes_or_futures_import(self) -> Flake8Generator:
19041927
"""TC100, TC200 and TC007."""
19051928
encountered_missing_quotes = False

flake8_type_checking/constants.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
ATTRIBUTE_PROPERTY = '_flake8-type-checking__parent'
88
ANNOTATION_PROPERTY = '_flake8-type-checking__is_annotation'
9+
BINOP_OPERAND_PROPERTY = '_flake8-type-checking__is_binop_operand'
910

1011
NAME_RE = re.compile(r'(?<![\'".])\b[A-Za-z_]\w*(?![\'"])')
1112

@@ -43,6 +44,7 @@
4344
TC007 = "TC007 Type alias '{alias}' needs to be made into a string literal"
4445
TC008 = "TC008 Type alias '{alias}' does not need to be a string literal"
4546
TC009 = "TC009 Move declaration '{name}' out of type-checking block. Variable is used for more than type hinting."
47+
TC010 = 'TC010 Operands for | cannot be a string literal'
4648
TC100 = "TC100 Add 'from __future__ import annotations' import"
4749
TC101 = "TC101 Annotation '{annotation}' does not need to be a string literal"
4850
TC200 = "TC200 Annotation '{annotation}' needs to be made into a string literal"

tests/test_tc008.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
examples = [
1717
('', set()),
1818
("x: TypeAlias = 'int'", {'1:15 ' + TC008.format(alias='int')}),
19+
# this should emit a TC010 instead
20+
("x: TypeAlias = 'int' | None", set()),
1921
# this used to emit an error before fixing #164 if we wanted to handle
2022
# this case once again we could add a whitelist of subscriptable types
2123
("x: TypeAlias = 'Dict[int]'", set()),

tests/test_tc010.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
"""
2+
This file tests the TC010 error:
3+
4+
>> Operands for | cannot be a string literal
5+
6+
"""
7+
8+
import sys
9+
import textwrap
10+
11+
import pytest
12+
13+
from flake8_type_checking.constants import TC010
14+
from tests.conftest import _get_error
15+
16+
examples = [
17+
# No error
18+
('', set()),
19+
('x: int | None', set()),
20+
# Used in type annotation at runtime
21+
(
22+
'x: "int" | None',
23+
{'1:3 ' + TC010},
24+
),
25+
(
26+
'x: int | "None"',
27+
{'1:9 ' + TC010},
28+
),
29+
(
30+
'x: "int" | "None"',
31+
{'1:3 ' + TC010, '1:11 ' + TC010},
32+
),
33+
(
34+
'def foo(x: int | "str" | None) -> None: ...',
35+
{'1:17 ' + TC010},
36+
),
37+
(
38+
'def foo(x: int) -> int | "None": ...',
39+
{'1:25 ' + TC010},
40+
),
41+
# Used in implicit type alias at runtime (can't detect)
42+
(
43+
'x = "int" | None',
44+
set(),
45+
),
46+
# Used in explicit type alias at runtime
47+
(
48+
'x: TypeAlias = "int" | None',
49+
{'1:15 ' + TC010},
50+
),
51+
# Used in type annotations at type checking time
52+
# We could have chosen not to emit an error inside if TYPE_CHECKING blocks
53+
# however it is plausible that type checkers will be able to detect this
54+
# case at some point and then it might become an error, so it's better
55+
# to have cleaned up those annotations by then
56+
(
57+
textwrap.dedent("""
58+
if TYPE_CHECKING:
59+
x: "int" | None
60+
y: int | "None"
61+
z: "int" | "None"
62+
63+
Foo: TypeAlias = "int" | None
64+
Bar = "int" | None
65+
66+
def foo(x: int | "str" | None) -> int | "None":
67+
pass
68+
"""),
69+
{
70+
'3:7 ' + TC010,
71+
'4:13 ' + TC010,
72+
'5:7 ' + TC010,
73+
'5:15 ' + TC010,
74+
'7:21 ' + TC010,
75+
'10:21 ' + TC010,
76+
'10:44 ' + TC010,
77+
},
78+
),
79+
]
80+
81+
if sys.version_info >= (3, 12):
82+
# PEP695 tests
83+
examples += [
84+
(
85+
'type x = "int" | None',
86+
{'1:9 ' + TC010},
87+
),
88+
]
89+
90+
91+
@pytest.mark.parametrize(('example', 'expected'), examples)
92+
def test_TC010_errors(example, expected):
93+
assert _get_error(example, error_code_filter='TC010') == expected

tests/test_tc201.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
# this case once again we could add a whitelist of subscriptable types
1919
("x: 'Dict[int]'", set()),
2020
("from typing import Dict\nx: 'Dict'", {'2:3 ' + TC201.format(annotation='Dict')}),
21+
# this should emit a TC010 instead
22+
("from typing import Dict\nx: 'Dict' | None", set()),
2123
("from __future__ import annotations\nx: 'int'", {'2:3 ' + TC201.format(annotation='int')}),
2224
("if TYPE_CHECKING:\n\tfrom typing import Dict\nx: 'Dict'", set()),
2325
("if TYPE_CHECKING:\n\tfrom typing import Dict\nx: 'Dict[int]'", set()),

0 commit comments

Comments
 (0)