[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

@ -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(),