Skip to content

Commit 11449ac

Browse files
Avoid marking InitVar as a typing-only annotation (#9688)
## Summary Given: ```python from dataclasses import InitVar, dataclass @DataClass class C: i: int j: int = None database: InitVar[DatabaseType] = None def __post_init__(self, database): if self.j is None and database is not None: self.j = database.lookup('j') c = C(10, database=my_database) ``` We should avoid marking `InitVar` as typing-only, since it _is_ required by the dataclass at runtime. Note that by default, we _already_ don't flag this, since the `@dataclass` member is needed at runtime too -- so it's only a problem with `strict` mode. Closes #9666.
1 parent 4ccbacd commit 11449ac

7 files changed

+143
-3
lines changed
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
"""Test: avoid marking an `InitVar` as typing-only."""
2+
3+
from __future__ import annotations
4+
5+
from dataclasses import FrozenInstanceError, InitVar, dataclass
6+
from pathlib import Path
7+
8+
9+
@dataclass
10+
class C:
11+
i: int
12+
j: int = None
13+
database: InitVar[Path] = None
14+
15+
err: FrozenInstanceError = None
16+
17+
def __post_init__(self, database):
18+
...

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -721,6 +721,21 @@ where
721721
AnnotationContext::RuntimeEvaluated => {
722722
self.visit_runtime_evaluated_annotation(annotation);
723723
}
724+
AnnotationContext::TypingOnly
725+
if flake8_type_checking::helpers::is_dataclass_meta_annotation(
726+
annotation,
727+
self.semantic(),
728+
) =>
729+
{
730+
if let Expr::Subscript(subscript) = &**annotation {
731+
// Ex) `InitVar[str]`
732+
self.visit_runtime_required_annotation(&subscript.value);
733+
self.visit_annotation(&subscript.slice);
734+
} else {
735+
// Ex) `InitVar`
736+
self.visit_runtime_required_annotation(annotation);
737+
}
738+
}
724739
AnnotationContext::TypingOnly => self.visit_annotation(annotation),
725740
}
726741

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

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@ use anyhow::Result;
22

33
use ruff_diagnostics::Edit;
44
use ruff_python_ast::call_path::from_qualified_name;
5-
use ruff_python_ast::helpers::map_callable;
5+
use ruff_python_ast::helpers::{map_callable, map_subscript};
66
use ruff_python_ast::{self as ast, Decorator, Expr};
77
use ruff_python_codegen::{Generator, Stylist};
88
use ruff_python_semantic::{
9-
analyze, Binding, BindingKind, NodeId, ResolvedReference, SemanticModel,
9+
analyze, Binding, BindingKind, NodeId, ResolvedReference, ScopeKind, SemanticModel,
1010
};
1111
use ruff_source_file::Locator;
1212
use ruff_text_size::Ranged;
@@ -104,6 +104,35 @@ fn runtime_required_decorators(
104104
})
105105
}
106106

107+
/// Returns `true` if an annotation will be inspected at runtime by the `dataclasses` module.
108+
///
109+
/// Specifically, detects whether an annotation is to either `dataclasses.InitVar` or
110+
/// `typing.ClassVar` within a `@dataclass` class definition.
111+
///
112+
/// See: <https://docs.python.org/3/library/dataclasses.html#init-only-variables>
113+
pub(crate) fn is_dataclass_meta_annotation(annotation: &Expr, semantic: &SemanticModel) -> bool {
114+
// Determine whether the assignment is in a `@dataclass` class definition.
115+
if let ScopeKind::Class(class_def) = semantic.current_scope().kind {
116+
if class_def.decorator_list.iter().any(|decorator| {
117+
semantic
118+
.resolve_call_path(map_callable(&decorator.expression))
119+
.is_some_and(|call_path| {
120+
matches!(call_path.as_slice(), ["dataclasses", "dataclass"])
121+
})
122+
}) {
123+
// Determine whether the annotation is `typing.ClassVar` or `dataclasses.InitVar`.
124+
return semantic
125+
.resolve_call_path(map_subscript(annotation))
126+
.is_some_and(|call_path| {
127+
matches!(call_path.as_slice(), ["dataclasses", "InitVar"])
128+
|| semantic.match_typing_call_path(&call_path, "ClassVar")
129+
});
130+
}
131+
}
132+
133+
false
134+
}
135+
107136
/// Returns `true` if a function is registered as a `singledispatch` interface.
108137
///
109138
/// For example, `fun` below is a `singledispatch` interface:

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ mod tests {
3939
#[test_case(Rule::RuntimeStringUnion, Path::new("TCH006_2.py"))]
4040
#[test_case(Rule::TypingOnlyFirstPartyImport, Path::new("TCH001.py"))]
4141
#[test_case(Rule::TypingOnlyStandardLibraryImport, Path::new("TCH003.py"))]
42+
#[test_case(Rule::TypingOnlyStandardLibraryImport, Path::new("init_var.py"))]
4243
#[test_case(Rule::TypingOnlyStandardLibraryImport, Path::new("snapshot.py"))]
4344
#[test_case(Rule::TypingOnlyThirdPartyImport, Path::new("TCH002.py"))]
4445
#[test_case(Rule::TypingOnlyThirdPartyImport, Path::new("quote.py"))]
@@ -75,7 +76,9 @@ mod tests {
7576
}
7677

7778
#[test_case(Rule::TypingOnlyThirdPartyImport, Path::new("strict.py"))]
79+
#[test_case(Rule::TypingOnlyStandardLibraryImport, Path::new("init_var.py"))]
7880
fn strict(rule_code: Rule, path: &Path) -> Result<()> {
81+
let snapshot = format!("strict_{}_{}", rule_code.as_ref(), path.to_string_lossy());
7982
let diagnostics = test_path(
8083
Path::new("flake8_type_checking").join(path).as_path(),
8184
&settings::LinterSettings {
@@ -86,7 +89,7 @@ mod tests {
8689
..settings::LinterSettings::for_rule(rule_code)
8790
},
8891
)?;
89-
assert_messages!(diagnostics);
92+
assert_messages!(snapshot, diagnostics);
9093
Ok(())
9194
}
9295

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
---
2+
source: crates/ruff_linter/src/rules/flake8_type_checking/mod.rs
3+
---
4+
init_var.py:5:25: TCH003 [*] Move standard library import `dataclasses.FrozenInstanceError` into a type-checking block
5+
|
6+
3 | from __future__ import annotations
7+
4 |
8+
5 | from dataclasses import FrozenInstanceError, InitVar, dataclass
9+
| ^^^^^^^^^^^^^^^^^^^ TCH003
10+
6 | from pathlib import Path
11+
|
12+
= help: Move into type-checking block
13+
14+
Unsafe fix
15+
2 2 |
16+
3 3 | from __future__ import annotations
17+
4 4 |
18+
5 |-from dataclasses import FrozenInstanceError, InitVar, dataclass
19+
5 |+from dataclasses import InitVar, dataclass
20+
6 6 | from pathlib import Path
21+
7 |+from typing import TYPE_CHECKING
22+
8 |+
23+
9 |+if TYPE_CHECKING:
24+
10 |+ from dataclasses import FrozenInstanceError
25+
7 11 |
26+
8 12 |
27+
9 13 | @dataclass
28+
29+
init_var.py:6:21: TCH003 [*] Move standard library import `pathlib.Path` into a type-checking block
30+
|
31+
5 | from dataclasses import FrozenInstanceError, InitVar, dataclass
32+
6 | from pathlib import Path
33+
| ^^^^ TCH003
34+
|
35+
= help: Move into type-checking block
36+
37+
Unsafe fix
38+
3 3 | from __future__ import annotations
39+
4 4 |
40+
5 5 | from dataclasses import FrozenInstanceError, InitVar, dataclass
41+
6 |-from pathlib import Path
42+
6 |+from typing import TYPE_CHECKING
43+
7 |+
44+
8 |+if TYPE_CHECKING:
45+
9 |+ from pathlib import Path
46+
7 10 |
47+
8 11 |
48+
9 12 | @dataclass
49+
50+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
source: crates/ruff_linter/src/rules/flake8_type_checking/mod.rs
3+
---
4+
init_var.py:6:21: TCH003 [*] Move standard library import `pathlib.Path` into a type-checking block
5+
|
6+
5 | from dataclasses import FrozenInstanceError, InitVar, dataclass
7+
6 | from pathlib import Path
8+
| ^^^^ TCH003
9+
|
10+
= help: Move into type-checking block
11+
12+
Unsafe fix
13+
3 3 | from __future__ import annotations
14+
4 4 |
15+
5 5 | from dataclasses import FrozenInstanceError, InitVar, dataclass
16+
6 |-from pathlib import Path
17+
6 |+from typing import TYPE_CHECKING
18+
7 |+
19+
8 |+if TYPE_CHECKING:
20+
9 |+ from pathlib import Path
21+
7 10 |
22+
8 11 |
23+
9 12 | @dataclass
24+
25+

0 commit comments

Comments
 (0)