[flake8-type-checking] Fix TC003 false positive with future-annotations (#21125)
Some checks are pending
CI / Determine changes (push) Waiting to run
CI / cargo fmt (push) Waiting to run
CI / cargo clippy (push) Blocked by required conditions
CI / cargo test (linux) (push) Blocked by required conditions
CI / cargo test (linux, release) (push) Blocked by required conditions
CI / cargo test (${{ github.repository == 'astral-sh/ruff' && 'depot-windows-2022-16' || 'windows-latest' }}) (push) Blocked by required conditions
CI / cargo test (macos-latest) (push) Blocked by required conditions
CI / cargo test (wasm) (push) Blocked by required conditions
CI / cargo build (msrv) (push) Blocked by required conditions
CI / cargo fuzz build (push) Blocked by required conditions
CI / fuzz parser (push) Blocked by required conditions
CI / test scripts (push) Blocked by required conditions
CI / ecosystem (push) Blocked by required conditions
CI / Fuzz for new ty panics (push) Blocked by required conditions
CI / cargo shear (push) Blocked by required conditions
CI / ty completion evaluation (push) Blocked by required conditions
CI / python package (push) Waiting to run
CI / pre-commit (push) Waiting to run
CI / mkdocs (push) Waiting to run
CI / formatter instabilities and black similarity (push) Blocked by required conditions
CI / test ruff-lsp (push) Blocked by required conditions
CI / check playground (push) Blocked by required conditions
CI / benchmarks instrumented (ruff) (push) Blocked by required conditions
CI / benchmarks instrumented (ty) (push) Blocked by required conditions
CI / benchmarks walltime (medium|multithreaded) (push) Blocked by required conditions
CI / benchmarks walltime (small|large) (push) Blocked by required conditions
[ty Playground] Release / publish (push) Waiting to run

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
This commit is contained in:
Brent Westbrook 2025-10-30 14:14:29 -04:00 committed by GitHub
parent 9bacd19c5a
commit 1c7ea690a8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 139 additions and 0 deletions

View file

@ -14,3 +14,14 @@ def f():
import os
print(os)
# regression test for https://github.com/astral-sh/ruff/issues/21121
from dataclasses import KW_ONLY, dataclass
@dataclass
class DataClass:
a: int
_: KW_ONLY # should be an exception to TC003, even with future-annotations
b: int

View file

@ -0,0 +1,17 @@
"""
Regression test for an ecosystem hit on
https://github.com/astral-sh/ruff/pull/21125.
We should mark all of the components of special dataclass annotations as
runtime-required, not just the first layer.
"""
from dataclasses import dataclass
from typing import ClassVar, Optional
@dataclass(frozen=True)
class EmptyCell:
_singleton: ClassVar[Optional["EmptyCell"]] = None
# the behavior of _singleton above should match a non-ClassVar
_doubleton: "EmptyCell"

View file

@ -1400,6 +1400,14 @@ impl<'a> Visitor<'a> for Checker<'a> {
AnnotationContext::RuntimeRequired => {
self.visit_runtime_required_annotation(annotation);
}
AnnotationContext::RuntimeEvaluated
if flake8_type_checking::helpers::is_dataclass_meta_annotation(
annotation,
self.semantic(),
) =>
{
self.visit_runtime_required_annotation(annotation);
}
AnnotationContext::RuntimeEvaluated => {
self.visit_runtime_evaluated_annotation(annotation);
}

View file

@ -98,6 +98,26 @@ mod tests {
Ok(())
}
#[test_case(Rule::TypingOnlyStandardLibraryImport, Path::new("TC003.py"))]
fn add_future_import_dataclass_kw_only_py313(rule: Rule, path: &Path) -> Result<()> {
let snapshot = format!(
"add_future_import_kw_only__{}_{}",
rule.noqa_code(),
path.to_string_lossy()
);
let diagnostics = test_path(
Path::new("flake8_type_checking").join(path).as_path(),
&settings::LinterSettings {
future_annotations: true,
// The issue in #21121 also didn't trigger on Python 3.14
unresolved_target_version: PythonVersion::PY313.into(),
..settings::LinterSettings::for_rule(rule)
},
)?;
assert_diagnostics!(snapshot, diagnostics);
Ok(())
}
// we test these rules as a pair, since they're opposites of one another
// so we want to make sure their fixes are not going around in circles.
#[test_case(Rule::UnquotedTypeAlias, Path::new("TC007.py"))]

View file

@ -0,0 +1,28 @@
---
source: crates/ruff_linter/src/rules/flake8_type_checking/mod.rs
---
TC003 [*] Move standard library import `os` into a type-checking block
--> TC003.py:8:12
|
7 | def f():
8 | import os
| ^^
9 |
10 | x: os
|
help: Move into type-checking block
2 |
3 | For typing-only import detection tests, see `TC002.py`.
4 | """
5 + from typing import TYPE_CHECKING
6 +
7 + if TYPE_CHECKING:
8 + import os
9 |
10 |
11 | def f():
- import os
12 |
13 | x: os
14 |
note: This is an unsafe fix and may change runtime behavior

View file

@ -64,6 +64,7 @@ mod tests {
#[test_case(Rule::QuotedAnnotation, Path::new("UP037_0.py"))]
#[test_case(Rule::QuotedAnnotation, Path::new("UP037_1.py"))]
#[test_case(Rule::QuotedAnnotation, Path::new("UP037_2.pyi"))]
#[test_case(Rule::QuotedAnnotation, Path::new("UP037_3.py"))]
#[test_case(Rule::RedundantOpenModes, Path::new("UP015.py"))]
#[test_case(Rule::RedundantOpenModes, Path::new("UP015_1.py"))]
#[test_case(Rule::ReplaceStdoutStderr, Path::new("UP022.py"))]
@ -156,6 +157,20 @@ mod tests {
Ok(())
}
#[test_case(Rule::QuotedAnnotation, Path::new("UP037_3.py"))]
fn rules_py313(rule_code: Rule, path: &Path) -> Result<()> {
let snapshot = format!("rules_py313__{}", path.to_string_lossy());
let diagnostics = test_path(
Path::new("pyupgrade").join(path).as_path(),
&settings::LinterSettings {
unresolved_target_version: PythonVersion::PY313.into(),
..settings::LinterSettings::for_rule(rule_code)
},
)?;
assert_diagnostics!(snapshot, diagnostics);
Ok(())
}
#[test_case(Rule::NonPEP695TypeAlias, Path::new("UP040.py"))]
#[test_case(Rule::NonPEP695TypeAlias, Path::new("UP040.pyi"))]
#[test_case(Rule::NonPEP695GenericClass, Path::new("UP046_0.py"))]

View file

@ -0,0 +1,36 @@
---
source: crates/ruff_linter/src/rules/pyupgrade/mod.rs
---
UP037 [*] Remove quotes from type annotation
--> UP037_3.py:15:35
|
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"
|
help: Remove quotes
12 |
13 | @dataclass(frozen=True)
14 | class EmptyCell:
- _singleton: ClassVar[Optional["EmptyCell"]] = None
15 + _singleton: ClassVar[Optional[EmptyCell]] = None
16 | # the behavior of _singleton above should match a non-ClassVar
17 | _doubleton: "EmptyCell"
UP037 [*] Remove quotes from type annotation
--> UP037_3.py:17:17
|
15 | _singleton: ClassVar[Optional["EmptyCell"]] = None
16 | # the behavior of _singleton above should match a non-ClassVar
17 | _doubleton: "EmptyCell"
| ^^^^^^^^^^^
|
help: Remove quotes
14 | class EmptyCell:
15 | _singleton: ClassVar[Optional["EmptyCell"]] = None
16 | # the behavior of _singleton above should match a non-ClassVar
- _doubleton: "EmptyCell"
17 + _doubleton: EmptyCell

View file

@ -0,0 +1,4 @@
---
source: crates/ruff_linter/src/rules/pyupgrade/mod.rs
---