[ty] Support dataclasses.InitVar (#19527)

## Summary

I saw that this creates a lot of false positives in the ecosystem, and
it seemed to be relatively easy to add basic support for this.

Some preliminary work on this was done by @InSyncWithFoo — thank you.

part of https://github.com/astral-sh/ty/issues/111

## Ecosystem analysis

The results look good.

## Test Plan

New Markdown tests

---------

Co-authored-by: InSync <insyncwithfoo@gmail.com>
Co-authored-by: Alex Waygood <Alex.Waygood@Gmail.com>
This commit is contained in:
David Peter 2025-07-24 16:33:33 +02:00 committed by GitHub
parent 1079975b35
commit dc6be457b5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 336 additions and 57 deletions

View file

@ -0,0 +1,150 @@
# `dataclasses.InitVar`
From the Python documentation on [`dataclasses.InitVar`]:
If a field is an `InitVar`, it is considered a pseudo-field called an init-only field. As it is not
a true field, it is not returned by the module-level `fields()` function. Init-only fields are added
as parameters to the generated `__init__()` method, and are passed to the optional `__post_init__()`
method. They are not otherwise used by dataclasses.
## Basic
Consider the following dataclass example where the `db` attribute is annotated with `InitVar`:
```py
from dataclasses import InitVar, dataclass
class Database: ...
@dataclass(order=True)
class Person:
db: InitVar[Database]
name: str
age: int
```
We can see in the signature of `__init__` that `db` is included as an argument:
```py
reveal_type(Person.__init__) # revealed: (self: Person, db: Database, name: str, age: int) -> None
```
However, when we create an instance of this dataclass, the `db` attribute is not accessible:
```py
db = Database()
alice = Person(db, "Alice", 30)
alice.db # error: [unresolved-attribute]
```
The `db` attribute is also not accessible on the class itself:
```py
Person.db # error: [unresolved-attribute]
```
Other fields can still be accessed normally:
```py
reveal_type(alice.name) # revealed: str
reveal_type(alice.age) # revealed: int
```
## `InitVar` with default value
An `InitVar` can also have a default value. In this case, the attribute *is* accessible on the class
and on instances:
```py
from dataclasses import InitVar, dataclass
@dataclass
class Person:
name: str
age: int
metadata: InitVar[str] = "default"
reveal_type(Person.__init__) # revealed: (self: Person, name: str, age: int, metadata: str = Literal["default"]) -> None
alice = Person("Alice", 30)
bob = Person("Bob", 25, "custom metadata")
reveal_type(bob.metadata) # revealed: str
reveal_type(Person.metadata) # revealed: str
```
## Overwritten `InitVar`
We do not emit an error if an `InitVar` attribute is later overwritten on the instance. In that
case, we also allow the attribute to be accessed:
```py
from dataclasses import InitVar, dataclass
@dataclass
class Person:
name: str
metadata: InitVar[str]
def __post_init__(self, metadata: str) -> None:
self.metadata = f"Person with name {self.name}"
alice = Person("Alice", "metadata that will be overwritten")
reveal_type(alice.metadata) # revealed: str
```
## Error cases
### Syntax
`InitVar` can only be used with a single argument:
```py
from dataclasses import InitVar, dataclass
@dataclass
class Wrong:
x: InitVar[int, str] # error: [invalid-type-form] "Type qualifier `InitVar` expected exactly 1 argument, got 2"
```
A bare `InitVar` is not allowed according to the [type annotation grammar]:
```py
@dataclass
class AlsoWrong:
x: InitVar # error: [invalid-type-form] "`InitVar` may not be used without a type argument"
```
### Outside of dataclasses
`InitVar` annotations are not allowed outside of dataclass attribute annotations:
```py
from dataclasses import InitVar, dataclass
# error: [invalid-type-form] "`InitVar` annotations are only allowed in class-body scopes"
x: InitVar[int] = 1
def f(x: InitVar[int]) -> None: # error: [invalid-type-form] "`InitVar` is not allowed in function parameter annotations"
pass
def g() -> InitVar[int]: # error: [invalid-type-form] "`InitVar` is not allowed in function return type annotations"
return 1
class C:
# TODO: this would ideally be an error
x: InitVar[int]
@dataclass
class D:
def __init__(self) -> None:
self.x: InitVar[int] = 1 # error: [invalid-type-form] "`InitVar` annotations are not allowed for non-name targets"
```
[type annotation grammar]: https://typing.python.org/en/latest/spec/annotations.html#type-and-annotation-expressions
[`dataclasses.initvar`]: https://docs.python.org/3/library/dataclasses.html#dataclasses.InitVar

View file

@ -244,7 +244,7 @@ pub(crate) fn class_symbol<'db>(
ConsideredDefinitions::EndOfScope,
);
if !place_and_quals.place.is_unbound() {
if !place_and_quals.place.is_unbound() && !place_and_quals.is_init_var() {
// Trust the declared type if we see a class-level declaration
return place_and_quals;
}
@ -524,6 +524,11 @@ impl<'db> PlaceAndQualifiers<'db> {
self.qualifiers.contains(TypeQualifiers::CLASS_VAR)
}
/// Returns `true` if the place has a `InitVar` type qualifier.
pub(crate) fn is_init_var(&self) -> bool {
self.qualifiers.contains(TypeQualifiers::INIT_VAR)
}
/// Returns `Some(…)` if the place is qualified with `typing.Final` without a specified type.
pub(crate) fn is_bare_final(&self) -> Option<TypeQualifiers> {
match self {

View file

@ -6137,11 +6137,29 @@ bitflags! {
const CLASS_VAR = 1 << 0;
/// `typing.Final`
const FINAL = 1 << 1;
/// `dataclasses.InitVar`
const INIT_VAR = 1 << 2;
}
}
impl get_size2::GetSize for TypeQualifiers {}
impl TypeQualifiers {
/// Get the name of a qualifier. Note that this only works
///
/// Panics if more than a single bit is set.
fn name(self) -> &'static str {
match self {
Self::CLASS_VAR => "ClassVar",
Self::FINAL => "Final",
Self::INIT_VAR => "InitVar",
_ => {
unreachable!("Only a single bit should be set when calling `TypeQualifiers::name`")
}
}
}
}
/// When inferring the type of an annotation expression, we can also encounter type qualifiers
/// such as `ClassVar` or `Final`. These do not affect the inferred type itself, but rather
/// control how a particular place can be accessed or modified. This struct holds a type and

View file

@ -881,6 +881,20 @@ impl MethodDecorator {
}
}
/// Metadata regarding a dataclass field/attribute.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct DataclassField<'db> {
/// The declared type of the field
pub(crate) field_ty: Type<'db>,
/// The type of the default value for this field
pub(crate) default_ty: Option<Type<'db>>,
/// Whether or not this field is "init-only". If this is true, it only appears in the
/// `__init__` signature, but is not accessible as a real field
pub(crate) init_only: bool,
}
/// Representation of a class definition statement in the AST: either a non-generic class, or a
/// generic class that has not been specialized.
///
@ -1580,10 +1594,16 @@ impl<'db> ClassLiteral<'db> {
let signature_from_fields = |mut parameters: Vec<_>| {
let mut kw_only_field_seen = false;
for (name, (mut attr_ty, mut default_ty)) in
self.fields(db, specialization, field_policy)
for (
field_name,
DataclassField {
mut field_ty,
mut default_ty,
init_only: _,
},
) in self.fields(db, specialization, field_policy)
{
if attr_ty
if field_ty
.into_nominal_instance()
.is_some_and(|instance| instance.class.is_known(db, KnownClass::KwOnly))
{
@ -1594,7 +1614,7 @@ impl<'db> ClassLiteral<'db> {
continue;
}
let dunder_set = attr_ty.class_member(db, "__set__".into());
let dunder_set = field_ty.class_member(db, "__set__".into());
if let Place::Type(dunder_set, Boundness::Bound) = dunder_set.place {
// The descriptor handling below is guarded by this not-dynamic check, because
// dynamic types like `Any` are valid (data) descriptors: since they have all
@ -1623,7 +1643,7 @@ impl<'db> ClassLiteral<'db> {
}
}
}
attr_ty = value_types.build();
field_ty = value_types.build();
// The default value of the attribute is *not* determined by the right hand side
// of the class-body assignment. Instead, the runtime invokes `__get__` on the
@ -1640,11 +1660,11 @@ impl<'db> ClassLiteral<'db> {
}
let mut parameter = if kw_only_field_seen {
Parameter::keyword_only(name)
Parameter::keyword_only(field_name)
} else {
Parameter::positional_or_keyword(name)
Parameter::positional_or_keyword(field_name)
}
.with_annotated_type(attr_ty);
.with_annotated_type(field_ty);
if let Some(default_ty) = default_ty {
parameter = parameter.with_default_type(default_ty);
@ -1746,7 +1766,7 @@ impl<'db> ClassLiteral<'db> {
db: &'db dyn Db,
specialization: Option<Specialization<'db>>,
field_policy: CodeGeneratorKind,
) -> FxOrderMap<Name, (Type<'db>, Option<Type<'db>>)> {
) -> FxOrderMap<Name, DataclassField<'db>> {
if field_policy == CodeGeneratorKind::NamedTuple {
// NamedTuples do not allow multiple inheritance, so it is sufficient to enumerate the
// fields of this class only.
@ -1793,7 +1813,7 @@ impl<'db> ClassLiteral<'db> {
self,
db: &'db dyn Db,
specialization: Option<Specialization<'db>>,
) -> FxOrderMap<Name, (Type<'db>, Option<Type<'db>>)> {
) -> FxOrderMap<Name, DataclassField<'db>> {
let mut attributes = FxOrderMap::default();
let class_body_scope = self.body_scope(db);
@ -1835,11 +1855,12 @@ impl<'db> ClassLiteral<'db> {
attributes.insert(
place_expr.expect_name().clone(),
(
attr_ty.apply_optional_specialization(db, specialization),
default_ty
DataclassField {
field_ty: attr_ty.apply_optional_specialization(db, specialization),
default_ty: default_ty
.map(|ty| ty.apply_optional_specialization(db, specialization)),
),
init_only: attr.is_init_var(),
},
);
}
}
@ -2254,6 +2275,17 @@ impl<'db> ClassLiteral<'db> {
declared = Place::Unbound;
}
if qualifiers.contains(TypeQualifiers::INIT_VAR) {
// We ignore `InitVar` declarations on the class body, unless that attribute is overwritten
// by an implicit assignment in a method
if Self::implicit_attribute(db, body_scope, name, MethodDecorator::None)
.place
.is_unbound()
{
return Place::Unbound.into();
}
}
// The attribute is declared in the class body.
let bindings = use_def.end_of_scope_bindings(place_id);
@ -2592,6 +2624,7 @@ pub enum KnownClass {
// dataclasses
Field,
KwOnly,
InitVar,
// _typeshed._type_checker_internals
NamedTupleFallback,
}
@ -2686,6 +2719,7 @@ impl KnownClass {
| Self::Deprecated
| Self::Field
| Self::KwOnly
| Self::InitVar
| Self::NamedTupleFallback => Truthiness::Ambiguous,
}
}
@ -2744,6 +2778,7 @@ impl KnownClass {
| Self::EllipsisType
| Self::NotImplementedType
| Self::KwOnly
| Self::InitVar
| Self::VersionInfo
| Self::Bool
| Self::NoneType => false,
@ -2843,6 +2878,7 @@ impl KnownClass {
| KnownClass::NotImplementedType
| KnownClass::Field
| KnownClass::KwOnly
| KnownClass::InitVar
| KnownClass::NamedTupleFallback => false,
}
}
@ -2925,6 +2961,7 @@ impl KnownClass {
| Self::UnionType
| Self::Field
| Self::KwOnly
| Self::InitVar
| Self::NamedTupleFallback => false,
}
}
@ -3016,6 +3053,7 @@ impl KnownClass {
Self::NotImplementedType => "_NotImplementedType",
Self::Field => "Field",
Self::KwOnly => "KW_ONLY",
Self::InitVar => "InitVar",
Self::NamedTupleFallback => "NamedTupleFallback",
}
}
@ -3269,8 +3307,7 @@ impl KnownClass {
| Self::DefaultDict
| Self::Deque
| Self::OrderedDict => KnownModule::Collections,
Self::Field => KnownModule::Dataclasses,
Self::KwOnly => KnownModule::Dataclasses,
Self::Field | Self::KwOnly | Self::InitVar => KnownModule::Dataclasses,
Self::NamedTupleFallback => KnownModule::TypeCheckerInternals,
}
}
@ -3342,6 +3379,7 @@ impl KnownClass {
| Self::NewType
| Self::Field
| Self::KwOnly
| Self::InitVar
| Self::Iterable
| Self::Iterator
| Self::NamedTupleFallback => false,
@ -3417,6 +3455,7 @@ impl KnownClass {
| Self::NewType
| Self::Field
| Self::KwOnly
| Self::InitVar
| Self::Iterable
| Self::Iterator
| Self::NamedTupleFallback => false,
@ -3504,6 +3543,7 @@ impl KnownClass {
"_NotImplementedType" => Self::NotImplementedType,
"Field" => Self::Field,
"KW_ONLY" => Self::KwOnly,
"InitVar" => Self::InitVar,
"NamedTupleFallback" => Self::NamedTupleFallback,
_ => return None,
};
@ -3566,6 +3606,7 @@ impl KnownClass {
| Self::WrapperDescriptorType
| Self::Field
| Self::KwOnly
| Self::InitVar
| Self::NamedTupleFallback => module == self.canonical_module(db),
Self::NoneType => matches!(module, KnownModule::Typeshed | KnownModule::Types),
Self::SpecialForm

View file

@ -88,7 +88,7 @@ use crate::semantic_index::{
ApplicableConstraints, EagerSnapshotResult, SemanticIndex, place_table, semantic_index,
};
use crate::types::call::{Binding, Bindings, CallArguments, CallError};
use crate::types::class::{CodeGeneratorKind, MetaclassErrorKind, SliceLiteral};
use crate::types::class::{CodeGeneratorKind, DataclassField, MetaclassErrorKind, SliceLiteral};
use crate::types::diagnostic::{
self, CALL_NON_CALLABLE, CONFLICTING_DECLARATIONS, CONFLICTING_METACLASS,
CYCLIC_CLASS_DEFINITION, DIVISION_BY_ZERO, DUPLICATE_KW_ONLY, INCONSISTENT_MRO,
@ -1365,8 +1365,10 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
let specialization = None;
let mut kw_only_field_names = vec![];
for (name, (attr_ty, _)) in class.fields(self.db(), specialization, field_policy) {
let Some(instance) = attr_ty.into_nominal_instance() else {
for (name, DataclassField { field_ty, .. }) in
class.fields(self.db(), specialization, field_policy)
{
let Some(instance) = field_ty.into_nominal_instance() else {
continue;
};
@ -2651,18 +2653,21 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
if let Some(returns) = returns {
let annotated = self.infer_annotation_expression(returns, deferred_expression_state);
if annotated.qualifiers.contains(TypeQualifiers::FINAL) {
if let Some(builder) = self.context.report_lint(&INVALID_TYPE_FORM, returns) {
builder.into_diagnostic(
"`Final` is not allowed in function return type annotations",
);
}
}
if annotated.qualifiers.contains(TypeQualifiers::CLASS_VAR) {
if let Some(builder) = self.context.report_lint(&INVALID_TYPE_FORM, returns) {
builder.into_diagnostic(
"`ClassVar` is not allowed in function return type annotations",
);
if !annotated.qualifiers.is_empty() {
for qualifier in [
TypeQualifiers::FINAL,
TypeQualifiers::CLASS_VAR,
TypeQualifiers::INIT_VAR,
] {
if annotated.qualifiers.contains(qualifier) {
if let Some(builder) = self.context.report_lint(&INVALID_TYPE_FORM, returns)
{
builder.into_diagnostic(format!(
"`{name}` is not allowed in function return type annotations",
name = qualifier.name()
));
}
}
}
}
}
@ -2704,18 +2709,22 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
);
if let Some(qualifiers) = annotated.map(|annotated| annotated.qualifiers) {
if qualifiers.contains(TypeQualifiers::FINAL) {
if let Some(builder) = self.context.report_lint(&INVALID_TYPE_FORM, parameter) {
builder.into_diagnostic(
"`Final` is not allowed in function parameter annotations",
);
}
}
if qualifiers.contains(TypeQualifiers::CLASS_VAR) {
if let Some(builder) = self.context.report_lint(&INVALID_TYPE_FORM, parameter) {
builder.into_diagnostic(
"`ClassVar` is not allowed in function parameter annotations",
);
if !qualifiers.is_empty() {
for qualifier in [
TypeQualifiers::FINAL,
TypeQualifiers::CLASS_VAR,
TypeQualifiers::INIT_VAR,
] {
if qualifiers.contains(qualifier) {
if let Some(builder) =
self.context.report_lint(&INVALID_TYPE_FORM, parameter)
{
builder.into_diagnostic(format!(
"`{name}` is not allowed in function parameter annotations",
name = qualifier.name()
));
}
}
}
}
}
@ -4264,14 +4273,19 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
let annotated =
self.infer_annotation_expression(annotation, DeferredExpressionState::None);
if annotated.qualifiers.contains(TypeQualifiers::CLASS_VAR) {
if let Some(builder) = self
.context
.report_lint(&INVALID_TYPE_FORM, annotation.as_ref())
{
builder.into_diagnostic(
"`ClassVar` annotations are not allowed for non-name targets",
);
if !annotated.qualifiers.is_empty() {
for qualifier in [TypeQualifiers::CLASS_VAR, TypeQualifiers::INIT_VAR] {
if annotated.qualifiers.contains(qualifier) {
if let Some(builder) = self
.context
.report_lint(&INVALID_TYPE_FORM, annotation.as_ref())
{
builder.into_diagnostic(format_args!(
"`{name}` annotations are not allowed for non-name targets",
name = qualifier.name()
));
}
}
}
}
@ -4306,14 +4320,21 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
DeferredExpressionState::from(self.defer_annotations()),
);
if declared.qualifiers.contains(TypeQualifiers::CLASS_VAR) {
if !declared.qualifiers.is_empty() {
let current_scope_id = self.scope().file_scope_id(self.db());
let current_scope = self.index.scope(current_scope_id);
if current_scope.kind() != ScopeKind::Class {
if let Some(builder) = self.context.report_lint(&INVALID_TYPE_FORM, annotation) {
builder.into_diagnostic(
"`ClassVar` annotations are only allowed in class-body scopes",
);
for qualifier in [TypeQualifiers::CLASS_VAR, TypeQualifiers::INIT_VAR] {
if declared.qualifiers.contains(qualifier) {
if let Some(builder) =
self.context.report_lint(&INVALID_TYPE_FORM, annotation)
{
builder.into_diagnostic(format_args!(
"`{name}` annotations are only allowed in class-body scopes",
name = qualifier.name()
));
}
}
}
}
}
@ -9064,6 +9085,18 @@ impl<'db> TypeInferenceBuilder<'db, '_> {
Type::SpecialForm(SpecialFormType::Final) => {
TypeAndQualifiers::new(Type::unknown(), TypeQualifiers::FINAL)
}
Type::ClassLiteral(class)
if class.is_known(self.db(), KnownClass::InitVar) =>
{
if let Some(builder) =
self.context.report_lint(&INVALID_TYPE_FORM, annotation)
{
builder.into_diagnostic(
"`InitVar` may not be used without a type argument",
);
}
TypeAndQualifiers::new(Type::unknown(), TypeQualifiers::INIT_VAR)
}
_ => name_expr_ty
.in_type_expression(self.db(), self.scope())
.unwrap_or_else(|error| {
@ -9134,6 +9167,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> {
let type_and_qualifiers = if num_arguments == 1 {
let mut type_and_qualifiers =
self.infer_annotation_expression_impl(slice);
match type_qualifier {
SpecialFormType::ClassVar => {
type_and_qualifiers.add_qualifier(TypeQualifiers::CLASS_VAR);
@ -9163,6 +9197,37 @@ impl<'db> TypeInferenceBuilder<'db, '_> {
}
type_and_qualifiers
}
Type::ClassLiteral(class) if class.is_known(self.db(), KnownClass::InitVar) => {
let arguments = if let ast::Expr::Tuple(tuple) = slice {
&*tuple.elts
} else {
std::slice::from_ref(slice)
};
let num_arguments = arguments.len();
let type_and_qualifiers = if num_arguments == 1 {
let mut type_and_qualifiers =
self.infer_annotation_expression_impl(slice);
type_and_qualifiers.add_qualifier(TypeQualifiers::INIT_VAR);
type_and_qualifiers
} else {
for element in arguments {
self.infer_annotation_expression_impl(element);
}
if let Some(builder) =
self.context.report_lint(&INVALID_TYPE_FORM, subscript)
{
builder.into_diagnostic(format_args!(
"Type qualifier `InitVar` expected exactly 1 argument, \
got {num_arguments}",
));
}
Type::unknown().into()
};
if slice.is_tuple_expr() {
self.store_expression_type(slice, type_and_qualifiers.inner_type());
}
type_and_qualifiers
}
_ => self
.infer_subscript_type_expression_no_store(subscript, slice, value_ty)
.into(),