Skip to content

Commit 4a9499c

Browse files
committed
Add a rule to detect string members in runtime-evaluated unions
1 parent 85b27a9 commit 4a9499c

8 files changed

Lines changed: 165 additions & 2 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from typing import TypeVar
2+
3+
4+
x: "int" | str # TCH006
5+
x: ("int" | str) | "bool" # TCH006
6+
7+
8+
def func():
9+
x: "int" | str # OK
10+
11+
12+
z: list[str, str | "int"] = [] # TCH006
13+
14+
type A = Value["int" | str] # OK
15+
16+
OldS = TypeVar('OldS', int | 'str', str) # TCH006

‎crates/ruff_linter/src/checkers/ast/analyze/expression.rs‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,9 @@ use crate::rules::{
1515
flake8_comprehensions, flake8_datetimez, flake8_debugger, flake8_django,
1616
flake8_future_annotations, flake8_gettext, flake8_implicit_str_concat, flake8_logging,
1717
flake8_logging_format, flake8_pie, flake8_print, flake8_pyi, flake8_pytest_style, flake8_self,
18-
flake8_simplify, flake8_tidy_imports, flake8_trio, flake8_use_pathlib, flynt, numpy,
19-
pandas_vet, pep8_naming, pycodestyle, pyflakes, pygrep_hooks, pylint, pyupgrade, refurb, ruff,
18+
flake8_simplify, flake8_tidy_imports, flake8_trio, flake8_type_checking, flake8_use_pathlib,
19+
flynt, numpy, pandas_vet, pep8_naming, pycodestyle, pyflakes, pygrep_hooks, pylint, pyupgrade,
20+
refurb, ruff,
2021
};
2122
use crate::settings::types::PythonVersion;
2223

@@ -1170,6 +1171,9 @@ pub(crate) fn expression(expr: &Expr, checker: &mut Checker) {
11701171
if checker.enabled(Rule::UnnecessaryTypeUnion) {
11711172
flake8_pyi::rules::unnecessary_type_union(checker, expr);
11721173
}
1174+
if checker.enabled(Rule::RuntimeStringUnion) {
1175+
flake8_type_checking::rules::runtime_string_union(checker, expr);
1176+
}
11731177
}
11741178
}
11751179
Expr::UnaryOp(

‎crates/ruff_linter/src/codes.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -807,6 +807,7 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> {
807807
(Flake8TypeChecking, "003") => (RuleGroup::Stable, rules::flake8_type_checking::rules::TypingOnlyStandardLibraryImport),
808808
(Flake8TypeChecking, "004") => (RuleGroup::Stable, rules::flake8_type_checking::rules::RuntimeImportInTypeCheckingBlock),
809809
(Flake8TypeChecking, "005") => (RuleGroup::Stable, rules::flake8_type_checking::rules::EmptyTypeCheckingBlock),
810+
(Flake8TypeChecking, "006") => (RuleGroup::Preview, rules::flake8_type_checking::rules::RuntimeStringUnion),
810811

811812
// tryceratops
812813
(Tryceratops, "002") => (RuleGroup::Stable, rules::tryceratops::rules::RaiseVanillaClass),

‎crates/ruff_linter/src/rules/flake8_type_checking/mod.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ mod tests {
3535
#[test_case(Rule::RuntimeImportInTypeCheckingBlock, Path::new("TCH004_8.py"))]
3636
#[test_case(Rule::RuntimeImportInTypeCheckingBlock, Path::new("TCH004_9.py"))]
3737
#[test_case(Rule::RuntimeImportInTypeCheckingBlock, Path::new("quote.py"))]
38+
#[test_case(Rule::RuntimeStringUnion, Path::new("TCH006.py"))]
3839
#[test_case(Rule::TypingOnlyFirstPartyImport, Path::new("TCH001.py"))]
3940
#[test_case(Rule::TypingOnlyStandardLibraryImport, Path::new("TCH003.py"))]
4041
#[test_case(Rule::TypingOnlyStandardLibraryImport, Path::new("snapshot.py"))]
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
pub(crate) use empty_type_checking_block::*;
22
pub(crate) use runtime_import_in_type_checking_block::*;
3+
pub(crate) use runtime_string_union::*;
34
pub(crate) use typing_only_runtime_import::*;
45

56
mod empty_type_checking_block;
67
mod runtime_import_in_type_checking_block;
8+
mod runtime_string_union;
79
mod typing_only_runtime_import;
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
use ruff_diagnostics::{Diagnostic, Violation};
2+
use ruff_macros::{derive_message_formats, violation};
3+
use ruff_python_ast as ast;
4+
use ruff_python_ast::{Expr, Operator};
5+
use ruff_text_size::Ranged;
6+
7+
use crate::checkers::ast::Checker;
8+
9+
/// ## What it does
10+
/// Checks for the presence of string literals in `X | Y`-style union types.
11+
///
12+
/// ## Why is this bad?
13+
/// [PEP 604] introduced a new syntax for union type annotations based on the
14+
/// `|` operator.
15+
///
16+
/// While Python's type annotations can typically be wrapped in strings to
17+
/// avoid runtime evaluation, the use of a string member within an `X | Y`-style
18+
/// union type will cause a runtime error.
19+
///
20+
/// Instead, remove the quotes, wrap the _entire_ union in quotes, or use
21+
/// `from __future__ import annotations` to disable runtime evaluation of
22+
/// annotations entirely.
23+
///
24+
/// ## Example
25+
/// ```python
26+
/// var: str | "int"
27+
/// ```
28+
///
29+
/// Use instead:
30+
/// ```python
31+
/// var: str | int
32+
/// ```
33+
///
34+
/// Or, extend the quotes to include the entire union:
35+
/// ```python
36+
/// var: "str | int"
37+
/// ```
38+
///
39+
/// ## References
40+
/// - [PEP 535](https://peps.python.org/pep-0563/)
41+
/// - [PEP 604](https://peps.python.org/pep-0604/)
42+
///
43+
/// [PEP 604]: https://peps.python.org/pep-0604/
44+
#[violation]
45+
pub struct RuntimeStringUnion;
46+
47+
impl Violation for RuntimeStringUnion {
48+
#[derive_message_formats]
49+
fn message(&self) -> String {
50+
format!("Invalid string member in `X | Y`-style union type")
51+
}
52+
}
53+
54+
/// TCH006
55+
pub(crate) fn runtime_string_union(checker: &mut Checker, expr: &Expr) {
56+
if !checker.semantic().in_type_definition() {
57+
return;
58+
}
59+
60+
if !checker.semantic().execution_context().is_runtime() {
61+
return;
62+
}
63+
64+
// Search for strings within the binary operator.
65+
let mut strings = Vec::new();
66+
traverse_op(expr, &mut strings);
67+
68+
for string in strings {
69+
checker
70+
.diagnostics
71+
.push(Diagnostic::new(RuntimeStringUnion, string.range()));
72+
}
73+
}
74+
75+
/// Collect all string members in possibly-nested binary `|` expressions.
76+
fn traverse_op<'a>(expr: &'a Expr, strings: &mut Vec<&'a Expr>) {
77+
match expr {
78+
Expr::StringLiteral(_) => {
79+
strings.push(expr);
80+
}
81+
Expr::BytesLiteral(_) => {
82+
strings.push(expr);
83+
}
84+
Expr::BinOp(ast::ExprBinOp {
85+
left,
86+
right,
87+
op: Operator::BitOr,
88+
..
89+
}) => {
90+
traverse_op(left, strings);
91+
traverse_op(right, strings);
92+
}
93+
_ => {}
94+
}
95+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
source: crates/ruff_linter/src/rules/flake8_type_checking/mod.rs
3+
---
4+
TCH006.py:3:4: TCH006 Invalid string member in `X | Y`-style union type
5+
|
6+
1 | from typing import TypeVar
7+
2 |
8+
3 | x: "int" | str # TCH006
9+
| ^^^^^ TCH006
10+
4 | x: ("int" | str) | "bool" # TCH006
11+
|
12+
13+
TCH006.py:4:5: TCH006 Invalid string member in `X | Y`-style union type
14+
|
15+
3 | x: "int" | str # TCH006
16+
4 | x: ("int" | str) | "bool" # TCH006
17+
| ^^^^^ TCH006
18+
|
19+
20+
TCH006.py:4:20: TCH006 Invalid string member in `X | Y`-style union type
21+
|
22+
3 | x: "int" | str # TCH006
23+
4 | x: ("int" | str) | "bool" # TCH006
24+
| ^^^^^^ TCH006
25+
|
26+
27+
TCH006.py:11:20: TCH006 Invalid string member in `X | Y`-style union type
28+
|
29+
11 | z: list[str, str | "int"] = [] # TCH006
30+
| ^^^^^ TCH006
31+
12 |
32+
13 | type A = Value["int" | str] # OK
33+
|
34+
35+
TCH006.py:15:30: TCH006 Invalid string member in `X | Y`-style union type
36+
|
37+
13 | type A = Value["int" | str] # OK
38+
14 |
39+
15 | OldS = TypeVar('OldS', int | 'str', str)
40+
| ^^^^^ TCH006
41+
|
42+
43+

‎ruff.schema.json‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)