From 696d7a5d6802e6e9148361840675478cf2d54d63 Mon Sep 17 00:00:00 2001 From: David Peter Date: Fri, 14 Nov 2025 11:31:20 +0100 Subject: [PATCH] [ty] Add synthetic members to completions on dataclasses (#21446) ## Summary Add synthetic members to completions on dataclasses and dataclass instances. Also, while we're at it, add support for `__weakref__` and `__match_args__`. closes https://github.com/astral-sh/ty/issues/1542 ## Test Plan New Markdown tests --- .../mdtest/dataclasses/dataclasses.md | 59 +++++- .../mdtest/ide_support/all_members.md | 179 +++++++++++++++++- crates/ty_python_semantic/src/types/class.rs | 64 +++++-- .../src/types/ide_support.rs | 75 ++++++-- .../ty_python_semantic/src/types/instance.rs | 4 + 5 files changed, 342 insertions(+), 39 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/dataclasses/dataclasses.md b/crates/ty_python_semantic/resources/mdtest/dataclasses/dataclasses.md index 8548085302..906e79c7b9 100644 --- a/crates/ty_python_semantic/resources/mdtest/dataclasses/dataclasses.md +++ b/crates/ty_python_semantic/resources/mdtest/dataclasses/dataclasses.md @@ -461,7 +461,51 @@ del frozen.x # TODO this should emit an [invalid-assignment] ### `match_args` -To do +If `match_args` is set to `True` (the default), the `__match_args__` attribute is a tuple created +from the list of non keyword-only parameters to the synthesized `__init__` method (even if +`__init__` is not actually generated). + +```py +from dataclasses import dataclass, field + +@dataclass +class WithMatchArgs: + normal_a: str + normal_b: int + kw_only: int = field(kw_only=True) + +reveal_type(WithMatchArgs.__match_args__) # revealed: tuple[Literal["normal_a"], Literal["normal_b"]] + +@dataclass(kw_only=True) +class KwOnlyDefaultMatchArgs: + normal_a: str = field(kw_only=False) + normal_b: int = field(kw_only=False) + kw_only: int + +reveal_type(KwOnlyDefaultMatchArgs.__match_args__) # revealed: tuple[Literal["normal_a"], Literal["normal_b"]] + +@dataclass(match_args=True) +class ExplicitMatchArgs: + normal: str + +reveal_type(ExplicitMatchArgs.__match_args__) # revealed: tuple[Literal["normal"]] + +@dataclass +class Empty: ... + +reveal_type(Empty.__match_args__) # revealed: tuple[()] +``` + +When `match_args` is explicitly set to `False`, the `__match_args__` attribute is not available: + +```py +@dataclass(match_args=False) +class NoMatchArgs: + x: int + y: str + +NoMatchArgs.__match_args__ # error: [unresolved-attribute] +``` ### `kw_only` @@ -623,7 +667,18 @@ reveal_type(B.__slots__) # revealed: tuple[Literal["x"], Literal["y"]] ### `weakref_slot` -To do +When a dataclass is defined with `weakref_slot=True`, the `__weakref__` attribute is generated. For +now, we do not attempt to infer a more precise type for it. + +```py +from dataclasses import dataclass + +@dataclass(slots=True, weakref_slot=True) +class C: + x: int + +reveal_type(C.__weakref__) # revealed: Any | None +``` ## `Final` fields diff --git a/crates/ty_python_semantic/resources/mdtest/ide_support/all_members.md b/crates/ty_python_semantic/resources/mdtest/ide_support/all_members.md index e8c19625ca..0ef6eda4a0 100644 --- a/crates/ty_python_semantic/resources/mdtest/ide_support/all_members.md +++ b/crates/ty_python_semantic/resources/mdtest/ide_support/all_members.md @@ -548,13 +548,20 @@ static_assert(not has_member(c, "dynamic_attr")) ### Dataclasses -So far, we do not include synthetic members of dataclasses. +#### Basic + +For dataclasses, we make sure to include all synthesized members: + +```toml +[environment] +python-version = "3.9" +``` ```py from ty_extensions import has_member, static_assert from dataclasses import dataclass -@dataclass(order=True) +@dataclass class Person: age: int name: str @@ -562,13 +569,177 @@ class Person: static_assert(has_member(Person, "name")) static_assert(has_member(Person, "age")) +static_assert(has_member(Person, "__dataclass_fields__")) +static_assert(has_member(Person, "__dataclass_params__")) + # These are always available, since they are also defined on `object`: static_assert(has_member(Person, "__init__")) static_assert(has_member(Person, "__repr__")) static_assert(has_member(Person, "__eq__")) +static_assert(has_member(Person, "__ne__")) -# TODO: this should ideally be available: -static_assert(has_member(Person, "__lt__")) # error: [static-assert-error] +# There are not available, unless `order=True` is set: +static_assert(not has_member(Person, "__lt__")) +static_assert(not has_member(Person, "__le__")) +static_assert(not has_member(Person, "__gt__")) +static_assert(not has_member(Person, "__ge__")) + +# These are not available, unless `slots=True`, `weakref_slot=True` are set: +static_assert(not has_member(Person, "__slots__")) +static_assert(not has_member(Person, "__weakref__")) + +# Not available before Python 3.13: +static_assert(not has_member(Person, "__replace__")) +``` + +The same behavior applies to instances of dataclasses: + +```py +def _(person: Person): + static_assert(has_member(person, "name")) + static_assert(has_member(person, "age")) + + static_assert(has_member(person, "__dataclass_fields__")) + static_assert(has_member(person, "__dataclass_params__")) + + static_assert(has_member(person, "__init__")) + static_assert(has_member(person, "__repr__")) + static_assert(has_member(person, "__eq__")) + static_assert(has_member(person, "__ne__")) + + static_assert(not has_member(person, "__lt__")) + static_assert(not has_member(person, "__le__")) + static_assert(not has_member(person, "__gt__")) + static_assert(not has_member(person, "__ge__")) + + static_assert(not has_member(person, "__slots__")) + + static_assert(not has_member(person, "__replace__")) +``` + +#### `__init__`, `__repr__` and `__eq__` + +`__init__`, `__repr__` and `__eq__` are always available (via `object`), even when `init=False`, +`repr=False` and `eq=False` are set: + +```py +from ty_extensions import has_member, static_assert +from dataclasses import dataclass + +@dataclass(init=False, repr=False, eq=False) +class C: + x: int + +static_assert(has_member(C, "__init__")) +static_assert(has_member(C, "__repr__")) +static_assert(has_member(C, "__eq__")) +static_assert(has_member(C, "__ne__")) +static_assert(has_member(C(), "__init__")) +static_assert(has_member(C(), "__repr__")) +static_assert(has_member(C(), "__eq__")) +static_assert(has_member(C(), "__ne__")) +``` + +#### `order=True` + +When `order=True` is set, comparison dunder methods become available: + +```py +from ty_extensions import has_member, static_assert +from dataclasses import dataclass + +@dataclass(order=True) +class C: + x: int + +static_assert(has_member(C, "__lt__")) +static_assert(has_member(C, "__le__")) +static_assert(has_member(C, "__gt__")) +static_assert(has_member(C, "__ge__")) + +def _(c: C): + static_assert(has_member(c, "__lt__")) + static_assert(has_member(c, "__le__")) + static_assert(has_member(c, "__gt__")) + static_assert(has_member(c, "__ge__")) +``` + +#### `slots=True` + +When `slots=True`, the corresponding dunder attribute becomes available: + +```py +from ty_extensions import has_member, static_assert +from dataclasses import dataclass + +@dataclass(slots=True) +class C: + x: int + +static_assert(has_member(C, "__slots__")) +static_assert(has_member(C(1), "__slots__")) +``` + +#### `weakref_slot=True` + +When `weakref_slot=True`, the corresponding dunder attribute becomes available: + +```py +from ty_extensions import has_member, static_assert +from dataclasses import dataclass + +@dataclass(slots=True, weakref_slot=True) +class C: + x: int + +static_assert(has_member(C, "__weakref__")) +static_assert(has_member(C(1), "__weakref__")) +``` + +#### `__replace__` in Python 3.13+ + +Since Python 3.13, dataclasses have a `__replace__` method: + +```toml +[environment] +python-version = "3.13" +``` + +```py +from ty_extensions import has_member, static_assert +from dataclasses import dataclass + +@dataclass +class C: + x: int + +static_assert(has_member(C, "__replace__")) + +def _(c: C): + static_assert(has_member(c, "__replace__")) +``` + +#### `__match_args__` + +Since Python 3.10, dataclasses have a `__match_args__` attribute: + +```toml +[environment] +python-version = "3.10" +``` + +```py +from ty_extensions import has_member, static_assert +from dataclasses import dataclass + +@dataclass +class C: + x: int + +static_assert(has_member(C, "__match_args__")) + +def _(c: C): + static_assert(has_member(c, "__match_args__")) ``` ### Attributes not available at runtime diff --git a/crates/ty_python_semantic/src/types/class.rs b/crates/ty_python_semantic/src/types/class.rs index d939dabb01..809c5e7259 100644 --- a/crates/ty_python_semantic/src/types/class.rs +++ b/crates/ty_python_semantic/src/types/class.rs @@ -2122,18 +2122,25 @@ impl<'db> ClassLiteral<'db> { specialization: Option>, name: &str, ) -> Member<'db> { - if name == "__dataclass_fields__" && self.dataclass_params(db).is_some() { - // Make this class look like a subclass of the `DataClassInstance` protocol - return Member { - inner: Place::declared(KnownClass::Dict.to_specialized_instance( - db, - [ - KnownClass::Str.to_instance(db), - KnownClass::Field.to_specialized_instance(db, [Type::any()]), - ], - )) - .with_qualifiers(TypeQualifiers::CLASS_VAR), - }; + if self.dataclass_params(db).is_some() { + if name == "__dataclass_fields__" { + // Make this class look like a subclass of the `DataClassInstance` protocol + return Member { + inner: Place::declared(KnownClass::Dict.to_specialized_instance( + db, + [ + KnownClass::Str.to_instance(db), + KnownClass::Field.to_specialized_instance(db, [Type::any()]), + ], + )) + .with_qualifiers(TypeQualifiers::CLASS_VAR), + }; + } else if name == "__dataclass_params__" { + // There is no typeshed class for this. For now, we model it as `Any`. + return Member { + inner: Place::declared(Type::any()).with_qualifiers(TypeQualifiers::CLASS_VAR), + }; + } } if CodeGeneratorKind::NamedTuple.matches(db, self, specialization) { @@ -2368,6 +2375,39 @@ impl<'db> ClassLiteral<'db> { Some(CallableType::function_like(db, signature)) } + (CodeGeneratorKind::DataclassLike(_), "__match_args__") + if Program::get(db).python_version(db) >= PythonVersion::PY310 => + { + if !has_dataclass_param(DataclassFlags::MATCH_ARGS) { + return None; + } + + let kw_only_default = has_dataclass_param(DataclassFlags::KW_ONLY); + + let fields = self.fields(db, specialization, field_policy); + let match_args = fields + .iter() + .filter(|(_, field)| { + if let FieldKind::Dataclass { init, kw_only, .. } = &field.kind { + *init && !kw_only.unwrap_or(kw_only_default) + } else { + false + } + }) + .map(|(name, _)| Type::string_literal(db, name)); + Some(Type::heterogeneous_tuple(db, match_args)) + } + (CodeGeneratorKind::DataclassLike(_), "__weakref__") => { + if !has_dataclass_param(DataclassFlags::WEAKREF_SLOT) + || !has_dataclass_param(DataclassFlags::SLOTS) + { + return None; + } + + // This could probably be `weakref | None`, but it does not seem important enough to + // model it precisely. + Some(UnionType::from_elements(db, [Type::any(), Type::none(db)])) + } (CodeGeneratorKind::NamedTuple, name) if name != "__init__" => { KnownClass::NamedTupleFallback .to_class_literal(db) diff --git a/crates/ty_python_semantic/src/types/ide_support.rs b/crates/ty_python_semantic/src/types/ide_support.rs index 475c3017c7..d57869825a 100644 --- a/crates/ty_python_semantic/src/types/ide_support.rs +++ b/crates/ty_python_semantic/src/types/ide_support.rs @@ -11,6 +11,7 @@ use crate::semantic_index::{ attribute_scopes, global_scope, place_table, semantic_index, use_def_map, }; use crate::types::call::{CallArguments, MatchedArgument}; +use crate::types::generics::Specialization; use crate::types::signatures::Signature; use crate::types::{CallDunderError, UnionType}; use crate::types::{ @@ -28,6 +29,23 @@ use rustc_hash::FxHashSet; pub use resolve_definition::{ImportAliasResolution, ResolvedDefinition, map_stub_definition}; use resolve_definition::{find_symbol_in_scope, resolve_definition}; +// `__init__`, `__repr__`, `__eq__`, `__ne__` and `__hash__` are always included via `object`, +// so we don't need to list them here. +const SYNTHETIC_DATACLASS_ATTRIBUTES: &[&str] = &[ + "__lt__", + "__le__", + "__gt__", + "__ge__", + "__replace__", + "__setattr__", + "__delattr__", + "__slots__", + "__weakref__", + "__match_args__", + "__dataclass_fields__", + "__dataclass_params__", +]; + pub(crate) fn all_declarations_and_bindings<'db>( db: &'db dyn Db, scope_id: ScopeId<'db>, @@ -119,13 +137,9 @@ impl<'db> AllMembers<'db> { ), Type::NominalInstance(instance) => { - let class_literal = instance.class_literal(db); + let (class_literal, specialization) = instance.class(db).class_literal(db); self.extend_with_instance_members(db, ty, class_literal); - - // If this is a NamedTuple instance, include members from NamedTupleFallback - if CodeGeneratorKind::NamedTuple.matches(db, class_literal, None) { - self.extend_with_type(db, KnownClass::NamedTupleFallback.to_class_literal(db)); - } + self.extend_with_synthetic_members(db, ty, class_literal, specialization); } Type::NewTypeInstance(newtype) => { @@ -146,10 +160,7 @@ impl<'db> AllMembers<'db> { Type::ClassLiteral(class_literal) => { self.extend_with_class_members(db, ty, class_literal); - - if CodeGeneratorKind::NamedTuple.matches(db, class_literal, None) { - self.extend_with_type(db, KnownClass::NamedTupleFallback.to_class_literal(db)); - } + self.extend_with_synthetic_members(db, ty, class_literal, None); if let Type::ClassLiteral(meta_class_literal) = ty.to_meta_type(db) { self.extend_with_class_members(db, ty, meta_class_literal); @@ -158,23 +169,15 @@ impl<'db> AllMembers<'db> { Type::GenericAlias(generic_alias) => { let class_literal = generic_alias.origin(db); - if CodeGeneratorKind::NamedTuple.matches(db, class_literal, None) { - self.extend_with_type(db, KnownClass::NamedTupleFallback.to_class_literal(db)); - } self.extend_with_class_members(db, ty, class_literal); + self.extend_with_synthetic_members(db, ty, class_literal, None); } Type::SubclassOf(subclass_of_type) => { if let Some(class_type) = subclass_of_type.subclass_of().into_class() { - let class_literal = class_type.class_literal(db).0; + let (class_literal, specialization) = class_type.class_literal(db); self.extend_with_class_members(db, ty, class_literal); - - if CodeGeneratorKind::NamedTuple.matches(db, class_literal, None) { - self.extend_with_type( - db, - KnownClass::NamedTupleFallback.to_class_literal(db), - ); - } + self.extend_with_synthetic_members(db, ty, class_literal, specialization); } } @@ -414,6 +417,36 @@ impl<'db> AllMembers<'db> { } } } + + fn extend_with_synthetic_members( + &mut self, + db: &'db dyn Db, + ty: Type<'db>, + class_literal: ClassLiteral<'db>, + specialization: Option>, + ) { + match CodeGeneratorKind::from_class(db, class_literal, specialization) { + Some(CodeGeneratorKind::NamedTuple) => { + if ty.is_nominal_instance() { + self.extend_with_type(db, KnownClass::NamedTupleFallback.to_instance(db)); + } else { + self.extend_with_type(db, KnownClass::NamedTupleFallback.to_class_literal(db)); + } + } + Some(CodeGeneratorKind::TypedDict) => {} + Some(CodeGeneratorKind::DataclassLike(_)) => { + for attr in SYNTHETIC_DATACLASS_ATTRIBUTES { + if let Place::Defined(synthetic_member, _, _) = ty.member(db, attr).place { + self.members.insert(Member { + name: Name::from(*attr), + ty: synthetic_member, + }); + } + } + } + None => {} + } + } } /// A member of a type with an optional definition. diff --git a/crates/ty_python_semantic/src/types/instance.rs b/crates/ty_python_semantic/src/types/instance.rs index 8c5adc9e0d..8f0719fbc6 100644 --- a/crates/ty_python_semantic/src/types/instance.rs +++ b/crates/ty_python_semantic/src/types/instance.rs @@ -88,6 +88,10 @@ impl<'db> Type<'db> { Type::NominalInstance(NominalInstanceType(NominalInstanceInner::ExactTuple(tuple))) } + pub(crate) const fn is_nominal_instance(self) -> bool { + matches!(self, Type::NominalInstance(_)) + } + pub(crate) const fn as_nominal_instance(self) -> Option> { match self { Type::NominalInstance(instance_type) => Some(instance_type),