diff --git a/crates/ty_python_semantic/resources/mdtest/type_qualifiers/initvar.md b/crates/ty_python_semantic/resources/mdtest/type_qualifiers/initvar.md new file mode 100644 index 0000000000..9282782ce4 --- /dev/null +++ b/crates/ty_python_semantic/resources/mdtest/type_qualifiers/initvar.md @@ -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 diff --git a/crates/ty_python_semantic/src/place.rs b/crates/ty_python_semantic/src/place.rs index c2d8edb2e3..7d8c72dcf8 100644 --- a/crates/ty_python_semantic/src/place.rs +++ b/crates/ty_python_semantic/src/place.rs @@ -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 { match self { diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index 8a559667f1..c7e4a4551e 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -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 diff --git a/crates/ty_python_semantic/src/types/class.rs b/crates/ty_python_semantic/src/types/class.rs index 8ad0c5dd29..9a52fed994 100644 --- a/crates/ty_python_semantic/src/types/class.rs +++ b/crates/ty_python_semantic/src/types/class.rs @@ -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>, + + /// 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>, field_policy: CodeGeneratorKind, - ) -> FxOrderMap, Option>)> { + ) -> FxOrderMap> { 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>, - ) -> FxOrderMap, Option>)> { + ) -> FxOrderMap> { 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 diff --git a/crates/ty_python_semantic/src/types/infer.rs b/crates/ty_python_semantic/src/types/infer.rs index d6a7a25081..a1f96104ff 100644 --- a/crates/ty_python_semantic/src/types/infer.rs +++ b/crates/ty_python_semantic/src/types/infer.rs @@ -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(),