mirror of
https://github.com/astral-sh/ruff.git
synced 2025-11-18 19:41:34 +00:00
[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:
parent
66e9d57797
commit
696d7a5d68
5 changed files with 342 additions and 39 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -2122,18 +2122,25 @@ impl<'db> ClassLiteral<'db> {
|
|||
specialization: Option<Specialization<'db>>,
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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<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.
|
||||
|
|
|
|||
|
|
@ -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<NominalInstanceType<'db>> {
|
||||
match self {
|
||||
Type::NominalInstance(instance_type) => Some(instance_type),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue