Skip to content

Commit bc0685b

Browse files
committed
[flake8_pyi] Fix PYI041 not resolving string annotations
1 parent 9218bf7 commit bc0685b

File tree

5 files changed

+505
-3
lines changed

5 files changed

+505
-3
lines changed
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
from typing import (
2+
TYPE_CHECKING,
3+
Union,
4+
)
5+
6+
from typing_extensions import (
7+
TypeAlias,
8+
)
9+
10+
TA0: TypeAlias = int
11+
TA1: TypeAlias = "int | float | bool"
12+
TA2: TypeAlias = "Union[int, float, bool]"
13+
14+
15+
def good1(arg: "int") -> "int | bool":
16+
...
17+
18+
19+
def good2(arg: "int", arg2: "int | bool") -> "None":
20+
...
21+
22+
23+
def f0(arg1: "float | int") -> "None":
24+
...
25+
26+
27+
def f1(arg1: "float", *, arg2: "float | list[str] | type[bool] | complex") -> "None":
28+
...
29+
30+
31+
def f2(arg1: "int", /, arg2: "int | int | float") -> "None":
32+
...
33+
34+
35+
def f3(arg1: "int", *args: "Union[int | int | float]") -> "None":
36+
...
37+
38+
39+
async def f4(**kwargs: "int | int | float") -> "None":
40+
...
41+
42+
43+
def f5(arg1: "int", *args: "Union[int, int, float]") -> "None":
44+
...
45+
46+
47+
def f6(arg1: "int", *args: "Union[Union[int, int, float]]") -> "None":
48+
...
49+
50+
51+
def f7(arg1: "int", *args: "Union[Union[Union[int, int, float]]]") -> "None":
52+
...
53+
54+
55+
def f8(arg1: "int", *args: "Union[Union[Union[int | int | float]]]") -> "None":
56+
...
57+
58+
59+
def f9(
60+
arg: """Union[ # comment
61+
float, # another
62+
complex, int]"""
63+
) -> "None":
64+
...
65+
66+
def f10(
67+
arg: """
68+
int | # comment
69+
float | # another
70+
complex
71+
"""
72+
) -> "None":
73+
...
74+
75+
76+
class Foo:
77+
def good(self, arg: "int") -> "None":
78+
...
79+
80+
def bad(self, arg: "int | float | complex") -> "None":
81+
...
82+
83+
def bad2(self, arg: "int | Union[float, complex]") -> "None":
84+
...
85+
86+
def bad3(self, arg: "Union[Union[float, complex], int]") -> "None":
87+
...
88+
89+
def bad4(self, arg: "Union[float | complex, int]") -> "None":
90+
...
91+
92+
def bad5(self, arg: "int | (float | complex)") -> "None":
93+
...
94+
95+
96+
# https://github.com/astral-sh/ruff/issues/18298
97+
# fix must not yield runtime `None | None | ...` (TypeError)
98+
class Issue18298:
99+
def f1(self, arg: "None | int | None | float" = None) -> "None": # PYI041 - no fix
100+
pass
101+
102+
if TYPE_CHECKING:
103+
104+
def f2(self, arg: "None | int | None | float" = None) -> "None": ... # PYI041 - with fix
105+
106+
else:
107+
108+
def f2(self, arg=None) -> "None":
109+
pass
110+
111+
def f3(self, arg: "None | float | None | int | None" = None) -> "None": # PYI041 - with fix
112+
pass

crates/ruff_linter/src/checkers/ast/mod.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -538,6 +538,26 @@ impl<'a> Checker<'a> {
538538
}
539539
}
540540

541+
/// Apply a mapper function to an annotation expression,
542+
/// abstracting over the fact that the annotation expression might be "stringized".
543+
///
544+
/// A stringized annotation is one enclosed in string quotes:
545+
/// `foo: "typing.Any"` means the same thing to a type checker as `foo: typing.Any`.
546+
pub(crate) fn map_maybe_stringized_annotation<T>(
547+
&self,
548+
expr: &ast::Expr,
549+
map_fn: impl FnOnce(&ast::Expr) -> T,
550+
) -> T {
551+
if let ast::Expr::StringLiteral(string_annotation) = expr {
552+
let Some(parsed_annotation) = self.parse_type_annotation(string_annotation).ok() else {
553+
return map_fn(expr);
554+
};
555+
map_fn(parsed_annotation.expression())
556+
} else {
557+
map_fn(expr)
558+
}
559+
}
560+
541561
/// Push `diagnostic` if the checker is not in a `@no_type_check` context.
542562
pub(crate) fn report_type_diagnostic<T: Violation>(&self, kind: T, range: TextRange) {
543563
if !self.semantic.in_no_type_check() {

crates/ruff_linter/src/rules/flake8_pyi/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ mod tests {
7676
#[test_case(Rule::RedundantNumericUnion, Path::new("PYI041_1.py"))]
7777
#[test_case(Rule::RedundantNumericUnion, Path::new("PYI041_1.pyi"))]
7878
#[test_case(Rule::RedundantNumericUnion, Path::new("PYI041_2.py"))]
79+
#[test_case(Rule::RedundantNumericUnion, Path::new("PYI041_3.py"))]
7980
#[test_case(Rule::SnakeCaseTypeAlias, Path::new("PYI042.py"))]
8081
#[test_case(Rule::SnakeCaseTypeAlias, Path::new("PYI042.pyi"))]
8182
#[test_case(Rule::StrOrReprDefinedInStub, Path::new("PYI029.py"))]

crates/ruff_linter/src/rules/flake8_pyi/rules/redundant_numeric_union.rs

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,11 +80,13 @@ impl Violation for RedundantNumericUnion {
8080
/// PYI041
8181
pub(crate) fn redundant_numeric_union(checker: &Checker, parameters: &Parameters) {
8282
for annotation in parameters.iter().filter_map(AnyParameterRef::annotation) {
83-
check_annotation(checker, annotation);
83+
checker.map_maybe_stringized_annotation(annotation, |resolved_annotation| {
84+
check_annotation(checker, resolved_annotation, annotation);
85+
});
8486
}
8587
}
8688

87-
fn check_annotation<'a>(checker: &Checker, annotation: &'a Expr) {
89+
fn check_annotation<'a>(checker: &Checker, annotation: &'a Expr, unresolved_annotation: &'a Expr) {
8890
let mut numeric_flags = NumericFlags::empty();
8991

9092
let mut find_numeric_type = |expr: &Expr, _parent: &Expr| {
@@ -141,8 +143,14 @@ fn check_annotation<'a>(checker: &Checker, annotation: &'a Expr) {
141143
return;
142144
}
143145

146+
let string_annotation = unresolved_annotation
147+
.as_string_literal_expr()
148+
.map(|str| str.value.to_str());
149+
144150
// Mark [`Fix`] as unsafe when comments are in range.
145-
let applicability = if checker.comment_ranges().intersects(annotation.range()) {
151+
let applicability = if string_annotation.is_some_and(|s| s.contains('#'))
152+
|| checker.comment_ranges().intersects(annotation.range())
153+
{
146154
Applicability::Unsafe
147155
} else {
148156
Applicability::Safe

0 commit comments

Comments
 (0)