Skip to content

Commit 1c7ea69

Browse files
authored
[flake8-type-checking] Fix TC003 false positive with future-annotations (#21125)
Summary -- Fixes #21121 by upgrading `RuntimeEvaluated` annotations like `dataclasses.KW_ONLY` to `RuntimeRequired`. We already had special handling for `TypingOnly` annotations in this context but not `RuntimeEvaluated`. Combining that with the `future-annotations` setting, which allowed ignoring the `RuntimeEvaluated` flag, led to the reported bug where we would try to move `KW_ONLY` into a `TYPE_CHECKING` block. Test Plan -- A new test based on the issue
1 parent 9bacd19 commit 1c7ea69

File tree

8 files changed

+139
-0
lines changed

8 files changed

+139
-0
lines changed

crates/ruff_linter/resources/test/fixtures/flake8_type_checking/TC003.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,14 @@ def f():
1414
import os
1515

1616
print(os)
17+
18+
19+
# regression test for https://github.com/astral-sh/ruff/issues/21121
20+
from dataclasses import KW_ONLY, dataclass
21+
22+
23+
@dataclass
24+
class DataClass:
25+
a: int
26+
_: KW_ONLY # should be an exception to TC003, even with future-annotations
27+
b: int
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
"""
2+
Regression test for an ecosystem hit on
3+
https://github.com/astral-sh/ruff/pull/21125.
4+
5+
We should mark all of the components of special dataclass annotations as
6+
runtime-required, not just the first layer.
7+
"""
8+
9+
from dataclasses import dataclass
10+
from typing import ClassVar, Optional
11+
12+
13+
@dataclass(frozen=True)
14+
class EmptyCell:
15+
_singleton: ClassVar[Optional["EmptyCell"]] = None
16+
# the behavior of _singleton above should match a non-ClassVar
17+
_doubleton: "EmptyCell"

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1400,6 +1400,14 @@ impl<'a> Visitor<'a> for Checker<'a> {
14001400
AnnotationContext::RuntimeRequired => {
14011401
self.visit_runtime_required_annotation(annotation);
14021402
}
1403+
AnnotationContext::RuntimeEvaluated
1404+
if flake8_type_checking::helpers::is_dataclass_meta_annotation(
1405+
annotation,
1406+
self.semantic(),
1407+
) =>
1408+
{
1409+
self.visit_runtime_required_annotation(annotation);
1410+
}
14031411
AnnotationContext::RuntimeEvaluated => {
14041412
self.visit_runtime_evaluated_annotation(annotation);
14051413
}

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,26 @@ mod tests {
9898
Ok(())
9999
}
100100

101+
#[test_case(Rule::TypingOnlyStandardLibraryImport, Path::new("TC003.py"))]
102+
fn add_future_import_dataclass_kw_only_py313(rule: Rule, path: &Path) -> Result<()> {
103+
let snapshot = format!(
104+
"add_future_import_kw_only__{}_{}",
105+
rule.noqa_code(),
106+
path.to_string_lossy()
107+
);
108+
let diagnostics = test_path(
109+
Path::new("flake8_type_checking").join(path).as_path(),
110+
&settings::LinterSettings {
111+
future_annotations: true,
112+
// The issue in #21121 also didn't trigger on Python 3.14
113+
unresolved_target_version: PythonVersion::PY313.into(),
114+
..settings::LinterSettings::for_rule(rule)
115+
},
116+
)?;
117+
assert_diagnostics!(snapshot, diagnostics);
118+
Ok(())
119+
}
120+
101121
// we test these rules as a pair, since they're opposites of one another
102122
// so we want to make sure their fixes are not going around in circles.
103123
#[test_case(Rule::UnquotedTypeAlias, Path::new("TC007.py"))]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
source: crates/ruff_linter/src/rules/flake8_type_checking/mod.rs
3+
---
4+
TC003 [*] Move standard library import `os` into a type-checking block
5+
--> TC003.py:8:12
6+
|
7+
7 | def f():
8+
8 | import os
9+
| ^^
10+
9 |
11+
10 | x: os
12+
|
13+
help: Move into type-checking block
14+
2 |
15+
3 | For typing-only import detection tests, see `TC002.py`.
16+
4 | """
17+
5 + from typing import TYPE_CHECKING
18+
6 +
19+
7 + if TYPE_CHECKING:
20+
8 + import os
21+
9 |
22+
10 |
23+
11 | def f():
24+
- import os
25+
12 |
26+
13 | x: os
27+
14 |
28+
note: This is an unsafe fix and may change runtime behavior

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ mod tests {
6464
#[test_case(Rule::QuotedAnnotation, Path::new("UP037_0.py"))]
6565
#[test_case(Rule::QuotedAnnotation, Path::new("UP037_1.py"))]
6666
#[test_case(Rule::QuotedAnnotation, Path::new("UP037_2.pyi"))]
67+
#[test_case(Rule::QuotedAnnotation, Path::new("UP037_3.py"))]
6768
#[test_case(Rule::RedundantOpenModes, Path::new("UP015.py"))]
6869
#[test_case(Rule::RedundantOpenModes, Path::new("UP015_1.py"))]
6970
#[test_case(Rule::ReplaceStdoutStderr, Path::new("UP022.py"))]
@@ -156,6 +157,20 @@ mod tests {
156157
Ok(())
157158
}
158159

160+
#[test_case(Rule::QuotedAnnotation, Path::new("UP037_3.py"))]
161+
fn rules_py313(rule_code: Rule, path: &Path) -> Result<()> {
162+
let snapshot = format!("rules_py313__{}", path.to_string_lossy());
163+
let diagnostics = test_path(
164+
Path::new("pyupgrade").join(path).as_path(),
165+
&settings::LinterSettings {
166+
unresolved_target_version: PythonVersion::PY313.into(),
167+
..settings::LinterSettings::for_rule(rule_code)
168+
},
169+
)?;
170+
assert_diagnostics!(snapshot, diagnostics);
171+
Ok(())
172+
}
173+
159174
#[test_case(Rule::NonPEP695TypeAlias, Path::new("UP040.py"))]
160175
#[test_case(Rule::NonPEP695TypeAlias, Path::new("UP040.pyi"))]
161176
#[test_case(Rule::NonPEP695GenericClass, Path::new("UP046_0.py"))]
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
source: crates/ruff_linter/src/rules/pyupgrade/mod.rs
3+
---
4+
UP037 [*] Remove quotes from type annotation
5+
--> UP037_3.py:15:35
6+
|
7+
13 | @dataclass(frozen=True)
8+
14 | class EmptyCell:
9+
15 | _singleton: ClassVar[Optional["EmptyCell"]] = None
10+
| ^^^^^^^^^^^
11+
16 | # the behavior of _singleton above should match a non-ClassVar
12+
17 | _doubleton: "EmptyCell"
13+
|
14+
help: Remove quotes
15+
12 |
16+
13 | @dataclass(frozen=True)
17+
14 | class EmptyCell:
18+
- _singleton: ClassVar[Optional["EmptyCell"]] = None
19+
15 + _singleton: ClassVar[Optional[EmptyCell]] = None
20+
16 | # the behavior of _singleton above should match a non-ClassVar
21+
17 | _doubleton: "EmptyCell"
22+
23+
UP037 [*] Remove quotes from type annotation
24+
--> UP037_3.py:17:17
25+
|
26+
15 | _singleton: ClassVar[Optional["EmptyCell"]] = None
27+
16 | # the behavior of _singleton above should match a non-ClassVar
28+
17 | _doubleton: "EmptyCell"
29+
| ^^^^^^^^^^^
30+
|
31+
help: Remove quotes
32+
14 | class EmptyCell:
33+
15 | _singleton: ClassVar[Optional["EmptyCell"]] = None
34+
16 | # the behavior of _singleton above should match a non-ClassVar
35+
- _doubleton: "EmptyCell"
36+
17 + _doubleton: EmptyCell
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
source: crates/ruff_linter/src/rules/pyupgrade/mod.rs
3+
---
4+

0 commit comments

Comments
 (0)