[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
This commit is contained in:
David Peter 2025-11-14 11:31:20 +01:00 committed by GitHub
parent 66e9d57797
commit 696d7a5d68
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 342 additions and 39 deletions

View file

@ -461,7 +461,51 @@ del frozen.x # TODO this should emit an [invalid-assignment]
### `match_args` ### `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` ### `kw_only`
@ -623,7 +667,18 @@ reveal_type(B.__slots__) # revealed: tuple[Literal["x"], Literal["y"]]
### `weakref_slot` ### `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 ## `Final` fields

View file

@ -548,13 +548,20 @@ static_assert(not has_member(c, "dynamic_attr"))
### Dataclasses ### 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 ```py
from ty_extensions import has_member, static_assert from ty_extensions import has_member, static_assert
from dataclasses import dataclass from dataclasses import dataclass
@dataclass(order=True) @dataclass
class Person: class Person:
age: int age: int
name: str name: str
@ -562,13 +569,177 @@ class Person:
static_assert(has_member(Person, "name")) static_assert(has_member(Person, "name"))
static_assert(has_member(Person, "age")) 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`: # These are always available, since they are also defined on `object`:
static_assert(has_member(Person, "__init__")) static_assert(has_member(Person, "__init__"))
static_assert(has_member(Person, "__repr__")) static_assert(has_member(Person, "__repr__"))
static_assert(has_member(Person, "__eq__")) static_assert(has_member(Person, "__eq__"))
static_assert(has_member(Person, "__ne__"))
# TODO: this should ideally be available: # There are not available, unless `order=True` is set:
static_assert(has_member(Person, "__lt__")) # error: [static-assert-error] 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 ### Attributes not available at runtime

View file

@ -2122,18 +2122,25 @@ impl<'db> ClassLiteral<'db> {
specialization: Option<Specialization<'db>>, specialization: Option<Specialization<'db>>,
name: &str, name: &str,
) -> Member<'db> { ) -> Member<'db> {
if name == "__dataclass_fields__" && self.dataclass_params(db).is_some() { if self.dataclass_params(db).is_some() {
// Make this class look like a subclass of the `DataClassInstance` protocol if name == "__dataclass_fields__" {
return Member { // Make this class look like a subclass of the `DataClassInstance` protocol
inner: Place::declared(KnownClass::Dict.to_specialized_instance( return Member {
db, inner: Place::declared(KnownClass::Dict.to_specialized_instance(
[ db,
KnownClass::Str.to_instance(db), [
KnownClass::Field.to_specialized_instance(db, [Type::any()]), KnownClass::Str.to_instance(db),
], KnownClass::Field.to_specialized_instance(db, [Type::any()]),
)) ],
.with_qualifiers(TypeQualifiers::CLASS_VAR), ))
}; .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) { if CodeGeneratorKind::NamedTuple.matches(db, self, specialization) {
@ -2368,6 +2375,39 @@ impl<'db> ClassLiteral<'db> {
Some(CallableType::function_like(db, signature)) 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__" => { (CodeGeneratorKind::NamedTuple, name) if name != "__init__" => {
KnownClass::NamedTupleFallback KnownClass::NamedTupleFallback
.to_class_literal(db) .to_class_literal(db)

View file

@ -11,6 +11,7 @@ use crate::semantic_index::{
attribute_scopes, global_scope, place_table, semantic_index, use_def_map, attribute_scopes, global_scope, place_table, semantic_index, use_def_map,
}; };
use crate::types::call::{CallArguments, MatchedArgument}; use crate::types::call::{CallArguments, MatchedArgument};
use crate::types::generics::Specialization;
use crate::types::signatures::Signature; use crate::types::signatures::Signature;
use crate::types::{CallDunderError, UnionType}; use crate::types::{CallDunderError, UnionType};
use crate::types::{ use crate::types::{
@ -28,6 +29,23 @@ use rustc_hash::FxHashSet;
pub use resolve_definition::{ImportAliasResolution, ResolvedDefinition, map_stub_definition}; pub use resolve_definition::{ImportAliasResolution, ResolvedDefinition, map_stub_definition};
use resolve_definition::{find_symbol_in_scope, resolve_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>( pub(crate) fn all_declarations_and_bindings<'db>(
db: &'db dyn Db, db: &'db dyn Db,
scope_id: ScopeId<'db>, scope_id: ScopeId<'db>,
@ -119,13 +137,9 @@ impl<'db> AllMembers<'db> {
), ),
Type::NominalInstance(instance) => { 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); self.extend_with_instance_members(db, ty, class_literal);
self.extend_with_synthetic_members(db, ty, class_literal, specialization);
// 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));
}
} }
Type::NewTypeInstance(newtype) => { Type::NewTypeInstance(newtype) => {
@ -146,10 +160,7 @@ impl<'db> AllMembers<'db> {
Type::ClassLiteral(class_literal) => { Type::ClassLiteral(class_literal) => {
self.extend_with_class_members(db, ty, class_literal); self.extend_with_class_members(db, ty, class_literal);
self.extend_with_synthetic_members(db, ty, class_literal, None);
if CodeGeneratorKind::NamedTuple.matches(db, class_literal, None) {
self.extend_with_type(db, KnownClass::NamedTupleFallback.to_class_literal(db));
}
if let Type::ClassLiteral(meta_class_literal) = ty.to_meta_type(db) { if let Type::ClassLiteral(meta_class_literal) = ty.to_meta_type(db) {
self.extend_with_class_members(db, ty, meta_class_literal); self.extend_with_class_members(db, ty, meta_class_literal);
@ -158,23 +169,15 @@ impl<'db> AllMembers<'db> {
Type::GenericAlias(generic_alias) => { Type::GenericAlias(generic_alias) => {
let class_literal = generic_alias.origin(db); 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_class_members(db, ty, class_literal);
self.extend_with_synthetic_members(db, ty, class_literal, None);
} }
Type::SubclassOf(subclass_of_type) => { Type::SubclassOf(subclass_of_type) => {
if let Some(class_type) = subclass_of_type.subclass_of().into_class() { 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); self.extend_with_class_members(db, ty, class_literal);
self.extend_with_synthetic_members(db, ty, class_literal, specialization);
if CodeGeneratorKind::NamedTuple.matches(db, class_literal, None) {
self.extend_with_type(
db,
KnownClass::NamedTupleFallback.to_class_literal(db),
);
}
} }
} }
@ -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<Specialization<'db>>,
) {
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. /// A member of a type with an optional definition.

View file

@ -88,6 +88,10 @@ impl<'db> Type<'db> {
Type::NominalInstance(NominalInstanceType(NominalInstanceInner::ExactTuple(tuple))) 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<NominalInstanceType<'db>> { pub(crate) const fn as_nominal_instance(self) -> Option<NominalInstanceType<'db>> {
match self { match self {
Type::NominalInstance(instance_type) => Some(instance_type), Type::NominalInstance(instance_type) => Some(instance_type),