Skip to content

Commit dd1a59e

Browse files
committed
[ty] synthesize __setattr__ and __delattr__ for frozen dataclasses
1 parent dca594f commit dd1a59e

File tree

3 files changed

+106
-98
lines changed

3 files changed

+106
-98
lines changed

crates/ty_python_semantic/resources/mdtest/dataclasses/dataclasses.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -428,6 +428,38 @@ frozen = MyFrozenClass()
428428
frozen.x = 2 # error: [unresolved-attribute]
429429
```
430430

431+
A diagnostic is also emitted if a frozen dataclass is inherited, and an attempt is made to mutate an
432+
attribute in the child class:
433+
434+
```py
435+
from dataclasses import dataclass
436+
437+
@dataclass(frozen=True)
438+
class MyFrozenClass:
439+
x: int = 1
440+
441+
class MyFrozenChildClass(MyFrozenClass): ...
442+
443+
frozen = MyFrozenChildClass()
444+
frozen.x = 2 # error: [invalid-assignment]
445+
```
446+
447+
The same diagnostic is emitted if a frozen dataclass is inherited, and an attempt is made to delete
448+
an attribute:
449+
450+
```py
451+
from dataclasses import dataclass
452+
453+
@dataclass(frozen=True)
454+
class MyFrozenClass:
455+
x: int = 1
456+
457+
class MyFrozenChildClass(MyFrozenClass): ...
458+
459+
frozen = MyFrozenChildClass()
460+
del frozen.x # TODO this should emit an [invalid-assignment]
461+
```
462+
431463
### `match_args`
432464

433465
To do

crates/ty_python_semantic/src/types/class.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1581,6 +1581,25 @@ impl<'db> ClassLiteral<'db> {
15811581

15821582
Some(CallableType::function_like(db, signature))
15831583
}
1584+
(CodeGeneratorKind::DataclassLike, "__setattr__") => {
1585+
if has_dataclass_param(DataclassParams::FROZEN) {
1586+
let signature = Signature::new(
1587+
Parameters::new([
1588+
Parameter::positional_or_keyword(Name::new_static("self"))
1589+
.with_annotated_type(Type::instance(
1590+
db,
1591+
self.apply_optional_specialization(db, specialization),
1592+
)),
1593+
Parameter::positional_or_keyword(Name::new_static("name")),
1594+
Parameter::positional_or_keyword(Name::new_static("value")),
1595+
]),
1596+
Some(Type::Never),
1597+
);
1598+
1599+
return Some(CallableType::function_like(db, signature));
1600+
}
1601+
None
1602+
}
15841603
(CodeGeneratorKind::NamedTuple, name) if name != "__init__" => {
15851604
KnownClass::NamedTupleFallback
15861605
.to_class_literal(db)

crates/ty_python_semantic/src/types/infer.rs

Lines changed: 55 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -3442,20 +3442,6 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
34423442
| Type::AlwaysTruthy
34433443
| Type::AlwaysFalsy
34443444
| Type::TypeIs(_) => {
3445-
let is_read_only = || {
3446-
let dataclass_params = match object_ty {
3447-
Type::NominalInstance(instance) => match instance.class {
3448-
ClassType::NonGeneric(cls) => cls.dataclass_params(self.db()),
3449-
ClassType::Generic(cls) => {
3450-
cls.origin(self.db()).dataclass_params(self.db())
3451-
}
3452-
},
3453-
_ => None,
3454-
};
3455-
3456-
dataclass_params.is_some_and(|params| params.contains(DataclassParams::FROZEN))
3457-
};
3458-
34593445
// First, try to call the `__setattr__` dunder method. If this is present/defined, overrides
34603446
// assigning the attributed by the normal mechanism.
34613447
let setattr_dunder_call_result = object_ty.try_call_dunder_with_policy(
@@ -3529,85 +3515,71 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
35293515
place: Place::Type(meta_attr_ty, meta_attr_boundness),
35303516
qualifiers: _,
35313517
} => {
3532-
if is_read_only() {
3533-
if emit_diagnostics {
3534-
if let Some(builder) =
3535-
self.context.report_lint(&INVALID_ASSIGNMENT, target)
3536-
{
3537-
builder.into_diagnostic(format_args!(
3538-
"Property `{attribute}` defined in `{ty}` is read-only",
3539-
ty = object_ty.display(self.db()),
3540-
));
3541-
}
3542-
}
3543-
false
3544-
} else {
3545-
let assignable_to_meta_attr =
3546-
if let Place::Type(meta_dunder_set, _) =
3547-
meta_attr_ty.class_member(db, "__set__".into()).place
3548-
{
3549-
let successful_call = meta_dunder_set
3550-
.try_call(
3551-
db,
3552-
&CallArgumentTypes::positional([
3553-
meta_attr_ty,
3554-
object_ty,
3555-
value_ty,
3556-
]),
3557-
)
3558-
.is_ok();
3559-
3560-
if !successful_call && emit_diagnostics {
3561-
if let Some(builder) = self
3562-
.context
3563-
.report_lint(&INVALID_ASSIGNMENT, target)
3564-
{
3565-
// TODO: Here, it would be nice to emit an additional diagnostic that explains why the call failed
3566-
builder.into_diagnostic(format_args!(
3518+
let assignable_to_meta_attr =
3519+
if let Place::Type(meta_dunder_set, _) =
3520+
meta_attr_ty.class_member(db, "__set__".into()).place
3521+
{
3522+
let successful_call = meta_dunder_set
3523+
.try_call(
3524+
db,
3525+
&CallArgumentTypes::positional([
3526+
meta_attr_ty,
3527+
object_ty,
3528+
value_ty,
3529+
]),
3530+
)
3531+
.is_ok();
3532+
3533+
if !successful_call && emit_diagnostics {
3534+
if let Some(builder) = self
3535+
.context
3536+
.report_lint(&INVALID_ASSIGNMENT, target)
3537+
{
3538+
// TODO: Here, it would be nice to emit an additional diagnostic that explains why the call failed
3539+
builder.into_diagnostic(format_args!(
35673540
"Invalid assignment to data descriptor attribute \
35683541
`{attribute}` on type `{}` with custom `__set__` method",
35693542
object_ty.display(db)
35703543
));
3571-
}
35723544
}
3545+
}
35733546

3574-
successful_call
3547+
successful_call
3548+
} else {
3549+
ensure_assignable_to(meta_attr_ty)
3550+
};
3551+
3552+
let assignable_to_instance_attribute =
3553+
if meta_attr_boundness == Boundness::PossiblyUnbound {
3554+
let (assignable, boundness) = if let Place::Type(
3555+
instance_attr_ty,
3556+
instance_attr_boundness,
3557+
) =
3558+
object_ty.instance_member(db, attribute).place
3559+
{
3560+
(
3561+
ensure_assignable_to(instance_attr_ty),
3562+
instance_attr_boundness,
3563+
)
35753564
} else {
3576-
ensure_assignable_to(meta_attr_ty)
3565+
(true, Boundness::PossiblyUnbound)
35773566
};
35783567

3579-
let assignable_to_instance_attribute =
3580-
if meta_attr_boundness == Boundness::PossiblyUnbound {
3581-
let (assignable, boundness) = if let Place::Type(
3582-
instance_attr_ty,
3583-
instance_attr_boundness,
3584-
) =
3585-
object_ty.instance_member(db, attribute).place
3586-
{
3587-
(
3588-
ensure_assignable_to(instance_attr_ty),
3589-
instance_attr_boundness,
3590-
)
3591-
} else {
3592-
(true, Boundness::PossiblyUnbound)
3593-
};
3594-
3595-
if boundness == Boundness::PossiblyUnbound {
3596-
report_possibly_unbound_attribute(
3597-
&self.context,
3598-
target,
3599-
attribute,
3600-
object_ty,
3601-
);
3602-
}
3568+
if boundness == Boundness::PossiblyUnbound {
3569+
report_possibly_unbound_attribute(
3570+
&self.context,
3571+
target,
3572+
attribute,
3573+
object_ty,
3574+
);
3575+
}
36033576

3604-
assignable
3605-
} else {
3606-
true
3607-
};
3577+
assignable
3578+
} else {
3579+
true
3580+
};
36083581

3609-
assignable_to_meta_attr && assignable_to_instance_attribute
3610-
}
3582+
assignable_to_meta_attr && assignable_to_instance_attribute
36113583
}
36123584

36133585
PlaceAndQualifiers {
@@ -3626,22 +3598,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
36263598
);
36273599
}
36283600

3629-
if is_read_only() {
3630-
if emit_diagnostics {
3631-
if let Some(builder) = self
3632-
.context
3633-
.report_lint(&INVALID_ASSIGNMENT, target)
3634-
{
3635-
builder.into_diagnostic(format_args!(
3636-
"Property `{attribute}` defined in `{ty}` is read-only",
3637-
ty = object_ty.display(self.db()),
3638-
));
3639-
}
3640-
}
3641-
false
3642-
} else {
3643-
ensure_assignable_to(instance_attr_ty)
3644-
}
3601+
ensure_assignable_to(instance_attr_ty)
36453602
} else {
36463603
if emit_diagnostics {
36473604
if let Some(builder) =

0 commit comments

Comments
 (0)