[ty] Support frozen dataclasses (#17974)

## Summary
https://github.com/astral-sh/ty/issues/111

This PR adds support for `frozen` dataclasses. It will emit a diagnostic
with a similar message to mypy

Note: This does not include emitting a diagnostic if `__setattr__` or
`__delattr__` are defined on the object as per the
[spec](https://docs.python.org/3/library/dataclasses.html#module-contents)

## Test Plan
mdtest

---------

Co-authored-by: Alex Waygood <Alex.Waygood@Gmail.com>
Co-authored-by: Carl Meyer <carl@astral.sh>
This commit is contained in:
justin 2025-05-22 00:20:34 -04:00 committed by GitHub
parent cb04343b3b
commit 01eeb2f0d6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 153 additions and 52 deletions

View file

@ -369,7 +369,65 @@ To do
### `frozen`
To do
If true (the default is False), assigning to fields will generate a diagnostic.
```py
from dataclasses import dataclass
@dataclass(frozen=True)
class MyFrozenClass:
x: int
frozen_instance = MyFrozenClass(1)
frozen_instance.x = 2 # error: [invalid-assignment]
```
If `__setattr__()` or `__delattr__()` is defined in the class, we should emit a diagnostic.
```py
from dataclasses import dataclass
@dataclass(frozen=True)
class MyFrozenClass:
x: int
# TODO: Emit a diagnostic here
def __setattr__(self, name: str, value: object) -> None: ...
# TODO: Emit a diagnostic here
def __delattr__(self, name: str) -> None: ...
```
This also works for generic dataclasses:
```toml
[environment]
python-version = "3.12"
```
```py
from dataclasses import dataclass
@dataclass(frozen=True)
class MyFrozenGeneric[T]:
x: T
frozen_instance = MyFrozenGeneric[int](1)
frozen_instance.x = 2 # error: [invalid-assignment]
```
When attempting to mutate an unresolved attribute on a frozen dataclass, only `unresolved-attribute`
is emitted:
```py
from dataclasses import dataclass
@dataclass(frozen=True)
class MyFrozenClass: ...
frozen = MyFrozenClass()
frozen.x = 2 # error: [unresolved-attribute]
```
### `match_args`

View file

@ -3077,6 +3077,20 @@ impl<'db> TypeInferenceBuilder<'db> {
| Type::TypeVar(..)
| Type::AlwaysTruthy
| Type::AlwaysFalsy => {
let is_read_only = || {
let dataclass_params = match object_ty {
Type::NominalInstance(instance) => match instance.class {
ClassType::NonGeneric(cls) => cls.dataclass_params(self.db()),
ClassType::Generic(cls) => {
cls.origin(self.db()).dataclass_params(self.db())
}
},
_ => None,
};
dataclass_params.is_some_and(|params| params.contains(DataclassParams::FROZEN))
};
match object_ty.class_member(db, attribute.into()) {
meta_attr @ SymbolAndQualifiers { .. } if meta_attr.is_class_var() => {
if emit_diagnostics {
@ -3096,68 +3110,83 @@ impl<'db> TypeInferenceBuilder<'db> {
symbol: Symbol::Type(meta_attr_ty, meta_attr_boundness),
qualifiers: _,
} => {
let assignable_to_meta_attr = if let Symbol::Type(meta_dunder_set, _) =
meta_attr_ty.class_member(db, "__set__".into()).symbol
{
let successful_call = meta_dunder_set
.try_call(
db,
&CallArgumentTypes::positional([
meta_attr_ty,
object_ty,
value_ty,
]),
)
.is_ok();
if !successful_call && emit_diagnostics {
if is_read_only() {
if emit_diagnostics {
if let Some(builder) =
self.context.report_lint(&INVALID_ASSIGNMENT, target)
{
// TODO: Here, it would be nice to emit an additional diagnostic that explains why the call failed
builder.into_diagnostic(format_args!(
"Invalid assignment to data descriptor attribute \
`{attribute}` on type `{}` with custom `__set__` method",
object_ty.display(db)
"Property `{attribute}` defined in `{ty}` is read-only",
ty = object_ty.display(self.db()),
));
}
}
successful_call
false
} else {
ensure_assignable_to(meta_attr_ty)
};
let assignable_to_instance_attribute = if meta_attr_boundness
== Boundness::PossiblyUnbound
{
let (assignable, boundness) =
if let Symbol::Type(instance_attr_ty, instance_attr_boundness) =
object_ty.instance_member(db, attribute).symbol
{
(
ensure_assignable_to(instance_attr_ty),
instance_attr_boundness,
let assignable_to_meta_attr = if let Symbol::Type(meta_dunder_set, _) =
meta_attr_ty.class_member(db, "__set__".into()).symbol
{
let successful_call = meta_dunder_set
.try_call(
db,
&CallArgumentTypes::positional([
meta_attr_ty,
object_ty,
value_ty,
]),
)
.is_ok();
if !successful_call && emit_diagnostics {
if let Some(builder) =
self.context.report_lint(&INVALID_ASSIGNMENT, target)
{
// TODO: Here, it would be nice to emit an additional diagnostic that explains why the call failed
builder.into_diagnostic(format_args!(
"Invalid assignment to data descriptor attribute \
`{attribute}` on type `{}` with custom `__set__` method",
object_ty.display(db)
));
}
}
successful_call
} else {
ensure_assignable_to(meta_attr_ty)
};
let assignable_to_instance_attribute =
if meta_attr_boundness == Boundness::PossiblyUnbound {
let (assignable, boundness) = if let Symbol::Type(
instance_attr_ty,
instance_attr_boundness,
) =
object_ty.instance_member(db, attribute).symbol
{
(
ensure_assignable_to(instance_attr_ty),
instance_attr_boundness,
)
} else {
(true, Boundness::PossiblyUnbound)
};
if boundness == Boundness::PossiblyUnbound {
report_possibly_unbound_attribute(
&self.context,
target,
attribute,
object_ty,
);
}
assignable
} else {
(true, Boundness::PossiblyUnbound)
true
};
if boundness == Boundness::PossiblyUnbound {
report_possibly_unbound_attribute(
&self.context,
target,
attribute,
object_ty,
);
}
assignable
} else {
true
};
assignable_to_meta_attr && assignable_to_instance_attribute
assignable_to_meta_attr && assignable_to_instance_attribute
}
}
SymbolAndQualifiers {
@ -3176,7 +3205,21 @@ impl<'db> TypeInferenceBuilder<'db> {
);
}
ensure_assignable_to(instance_attr_ty)
if is_read_only() {
if emit_diagnostics {
if let Some(builder) =
self.context.report_lint(&INVALID_ASSIGNMENT, target)
{
builder.into_diagnostic(format_args!(
"Property `{attribute}` defined in `{ty}` is read-only",
ty = object_ty.display(self.db()),
));
}
}
false
} else {
ensure_assignable_to(instance_attr_ty)
}
} else {
let result = object_ty.try_call_dunder_with_policy(
db,