mirror of https://github.com/astral-sh/ruff
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 https://github.com/astral-sh/ruff/issues/9666.
This commit is contained in:
parent
4ccbacd44b
commit
11449acfd9
|
|
@ -0,0 +1,18 @@
|
||||||
|
"""Test: avoid marking an `InitVar` as typing-only."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import FrozenInstanceError, InitVar, dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class C:
|
||||||
|
i: int
|
||||||
|
j: int = None
|
||||||
|
database: InitVar[Path] = None
|
||||||
|
|
||||||
|
err: FrozenInstanceError = None
|
||||||
|
|
||||||
|
def __post_init__(self, database):
|
||||||
|
...
|
||||||
|
|
@ -721,6 +721,21 @@ where
|
||||||
AnnotationContext::RuntimeEvaluated => {
|
AnnotationContext::RuntimeEvaluated => {
|
||||||
self.visit_runtime_evaluated_annotation(annotation);
|
self.visit_runtime_evaluated_annotation(annotation);
|
||||||
}
|
}
|
||||||
|
AnnotationContext::TypingOnly
|
||||||
|
if flake8_type_checking::helpers::is_dataclass_meta_annotation(
|
||||||
|
annotation,
|
||||||
|
self.semantic(),
|
||||||
|
) =>
|
||||||
|
{
|
||||||
|
if let Expr::Subscript(subscript) = &**annotation {
|
||||||
|
// Ex) `InitVar[str]`
|
||||||
|
self.visit_runtime_required_annotation(&subscript.value);
|
||||||
|
self.visit_annotation(&subscript.slice);
|
||||||
|
} else {
|
||||||
|
// Ex) `InitVar`
|
||||||
|
self.visit_runtime_required_annotation(annotation);
|
||||||
|
}
|
||||||
|
}
|
||||||
AnnotationContext::TypingOnly => self.visit_annotation(annotation),
|
AnnotationContext::TypingOnly => self.visit_annotation(annotation),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,11 @@ use anyhow::Result;
|
||||||
|
|
||||||
use ruff_diagnostics::Edit;
|
use ruff_diagnostics::Edit;
|
||||||
use ruff_python_ast::call_path::from_qualified_name;
|
use ruff_python_ast::call_path::from_qualified_name;
|
||||||
use ruff_python_ast::helpers::map_callable;
|
use ruff_python_ast::helpers::{map_callable, map_subscript};
|
||||||
use ruff_python_ast::{self as ast, Decorator, Expr};
|
use ruff_python_ast::{self as ast, Decorator, Expr};
|
||||||
use ruff_python_codegen::{Generator, Stylist};
|
use ruff_python_codegen::{Generator, Stylist};
|
||||||
use ruff_python_semantic::{
|
use ruff_python_semantic::{
|
||||||
analyze, Binding, BindingKind, NodeId, ResolvedReference, SemanticModel,
|
analyze, Binding, BindingKind, NodeId, ResolvedReference, ScopeKind, SemanticModel,
|
||||||
};
|
};
|
||||||
use ruff_source_file::Locator;
|
use ruff_source_file::Locator;
|
||||||
use ruff_text_size::Ranged;
|
use ruff_text_size::Ranged;
|
||||||
|
|
@ -104,6 +104,35 @@ fn runtime_required_decorators(
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns `true` if an annotation will be inspected at runtime by the `dataclasses` module.
|
||||||
|
///
|
||||||
|
/// Specifically, detects whether an annotation is to either `dataclasses.InitVar` or
|
||||||
|
/// `typing.ClassVar` within a `@dataclass` class definition.
|
||||||
|
///
|
||||||
|
/// See: <https://docs.python.org/3/library/dataclasses.html#init-only-variables>
|
||||||
|
pub(crate) fn is_dataclass_meta_annotation(annotation: &Expr, semantic: &SemanticModel) -> bool {
|
||||||
|
// Determine whether the assignment is in a `@dataclass` class definition.
|
||||||
|
if let ScopeKind::Class(class_def) = semantic.current_scope().kind {
|
||||||
|
if class_def.decorator_list.iter().any(|decorator| {
|
||||||
|
semantic
|
||||||
|
.resolve_call_path(map_callable(&decorator.expression))
|
||||||
|
.is_some_and(|call_path| {
|
||||||
|
matches!(call_path.as_slice(), ["dataclasses", "dataclass"])
|
||||||
|
})
|
||||||
|
}) {
|
||||||
|
// Determine whether the annotation is `typing.ClassVar` or `dataclasses.InitVar`.
|
||||||
|
return semantic
|
||||||
|
.resolve_call_path(map_subscript(annotation))
|
||||||
|
.is_some_and(|call_path| {
|
||||||
|
matches!(call_path.as_slice(), ["dataclasses", "InitVar"])
|
||||||
|
|| semantic.match_typing_call_path(&call_path, "ClassVar")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns `true` if a function is registered as a `singledispatch` interface.
|
/// Returns `true` if a function is registered as a `singledispatch` interface.
|
||||||
///
|
///
|
||||||
/// For example, `fun` below is a `singledispatch` interface:
|
/// For example, `fun` below is a `singledispatch` interface:
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ mod tests {
|
||||||
#[test_case(Rule::RuntimeStringUnion, Path::new("TCH006_2.py"))]
|
#[test_case(Rule::RuntimeStringUnion, Path::new("TCH006_2.py"))]
|
||||||
#[test_case(Rule::TypingOnlyFirstPartyImport, Path::new("TCH001.py"))]
|
#[test_case(Rule::TypingOnlyFirstPartyImport, Path::new("TCH001.py"))]
|
||||||
#[test_case(Rule::TypingOnlyStandardLibraryImport, Path::new("TCH003.py"))]
|
#[test_case(Rule::TypingOnlyStandardLibraryImport, Path::new("TCH003.py"))]
|
||||||
|
#[test_case(Rule::TypingOnlyStandardLibraryImport, Path::new("init_var.py"))]
|
||||||
#[test_case(Rule::TypingOnlyStandardLibraryImport, Path::new("snapshot.py"))]
|
#[test_case(Rule::TypingOnlyStandardLibraryImport, Path::new("snapshot.py"))]
|
||||||
#[test_case(Rule::TypingOnlyThirdPartyImport, Path::new("TCH002.py"))]
|
#[test_case(Rule::TypingOnlyThirdPartyImport, Path::new("TCH002.py"))]
|
||||||
#[test_case(Rule::TypingOnlyThirdPartyImport, Path::new("quote.py"))]
|
#[test_case(Rule::TypingOnlyThirdPartyImport, Path::new("quote.py"))]
|
||||||
|
|
@ -75,7 +76,9 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test_case(Rule::TypingOnlyThirdPartyImport, Path::new("strict.py"))]
|
#[test_case(Rule::TypingOnlyThirdPartyImport, Path::new("strict.py"))]
|
||||||
|
#[test_case(Rule::TypingOnlyStandardLibraryImport, Path::new("init_var.py"))]
|
||||||
fn strict(rule_code: Rule, path: &Path) -> Result<()> {
|
fn strict(rule_code: Rule, path: &Path) -> Result<()> {
|
||||||
|
let snapshot = format!("strict_{}_{}", rule_code.as_ref(), path.to_string_lossy());
|
||||||
let diagnostics = test_path(
|
let diagnostics = test_path(
|
||||||
Path::new("flake8_type_checking").join(path).as_path(),
|
Path::new("flake8_type_checking").join(path).as_path(),
|
||||||
&settings::LinterSettings {
|
&settings::LinterSettings {
|
||||||
|
|
@ -86,7 +89,7 @@ mod tests {
|
||||||
..settings::LinterSettings::for_rule(rule_code)
|
..settings::LinterSettings::for_rule(rule_code)
|
||||||
},
|
},
|
||||||
)?;
|
)?;
|
||||||
assert_messages!(diagnostics);
|
assert_messages!(snapshot, diagnostics);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
---
|
||||||
|
source: crates/ruff_linter/src/rules/flake8_type_checking/mod.rs
|
||||||
|
---
|
||||||
|
init_var.py:5:25: TCH003 [*] Move standard library import `dataclasses.FrozenInstanceError` into a type-checking block
|
||||||
|
|
|
||||||
|
3 | from __future__ import annotations
|
||||||
|
4 |
|
||||||
|
5 | from dataclasses import FrozenInstanceError, InitVar, dataclass
|
||||||
|
| ^^^^^^^^^^^^^^^^^^^ TCH003
|
||||||
|
6 | from pathlib import Path
|
||||||
|
|
|
||||||
|
= help: Move into type-checking block
|
||||||
|
|
||||||
|
ℹ Unsafe fix
|
||||||
|
2 2 |
|
||||||
|
3 3 | from __future__ import annotations
|
||||||
|
4 4 |
|
||||||
|
5 |-from dataclasses import FrozenInstanceError, InitVar, dataclass
|
||||||
|
5 |+from dataclasses import InitVar, dataclass
|
||||||
|
6 6 | from pathlib import Path
|
||||||
|
7 |+from typing import TYPE_CHECKING
|
||||||
|
8 |+
|
||||||
|
9 |+if TYPE_CHECKING:
|
||||||
|
10 |+ from dataclasses import FrozenInstanceError
|
||||||
|
7 11 |
|
||||||
|
8 12 |
|
||||||
|
9 13 | @dataclass
|
||||||
|
|
||||||
|
init_var.py:6:21: TCH003 [*] Move standard library import `pathlib.Path` into a type-checking block
|
||||||
|
|
|
||||||
|
5 | from dataclasses import FrozenInstanceError, InitVar, dataclass
|
||||||
|
6 | from pathlib import Path
|
||||||
|
| ^^^^ TCH003
|
||||||
|
|
|
||||||
|
= help: Move into type-checking block
|
||||||
|
|
||||||
|
ℹ Unsafe fix
|
||||||
|
3 3 | from __future__ import annotations
|
||||||
|
4 4 |
|
||||||
|
5 5 | from dataclasses import FrozenInstanceError, InitVar, dataclass
|
||||||
|
6 |-from pathlib import Path
|
||||||
|
6 |+from typing import TYPE_CHECKING
|
||||||
|
7 |+
|
||||||
|
8 |+if TYPE_CHECKING:
|
||||||
|
9 |+ from pathlib import Path
|
||||||
|
7 10 |
|
||||||
|
8 11 |
|
||||||
|
9 12 | @dataclass
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
---
|
||||||
|
source: crates/ruff_linter/src/rules/flake8_type_checking/mod.rs
|
||||||
|
---
|
||||||
|
init_var.py:6:21: TCH003 [*] Move standard library import `pathlib.Path` into a type-checking block
|
||||||
|
|
|
||||||
|
5 | from dataclasses import FrozenInstanceError, InitVar, dataclass
|
||||||
|
6 | from pathlib import Path
|
||||||
|
| ^^^^ TCH003
|
||||||
|
|
|
||||||
|
= help: Move into type-checking block
|
||||||
|
|
||||||
|
ℹ Unsafe fix
|
||||||
|
3 3 | from __future__ import annotations
|
||||||
|
4 4 |
|
||||||
|
5 5 | from dataclasses import FrozenInstanceError, InitVar, dataclass
|
||||||
|
6 |-from pathlib import Path
|
||||||
|
6 |+from typing import TYPE_CHECKING
|
||||||
|
7 |+
|
||||||
|
8 |+if TYPE_CHECKING:
|
||||||
|
9 |+ from pathlib import Path
|
||||||
|
7 10 |
|
||||||
|
8 11 |
|
||||||
|
9 12 | @dataclass
|
||||||
|
|
||||||
|
|
||||||
Loading…
Reference in New Issue