Skip to content

Commit 39b4183

Browse files
thejchapsharkdp
andauthored
[ty] synthesize __setattr__ for frozen dataclasses (#19307)
## Summary Synthesize a `__setattr__` method with a return type of `Never` for frozen dataclasses. https://docs.python.org/3/library/dataclasses.html#frozen-instances https://docs.python.org/3/library/dataclasses.html#dataclasses.FrozenInstanceError ### Related astral-sh/ty#111 #17974 (comment) #18347 (comment) ## Test Plan New Markdown tests --------- Co-authored-by: David Peter <[email protected]>
1 parent c7640a4 commit 39b4183

File tree

4 files changed

+144
-107
lines changed

4 files changed

+144
-107
lines changed

crates/ty_python_semantic/resources/mdtest/attributes.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1806,7 +1806,7 @@ class Frozen:
18061806
raise AttributeError("Attributes can not be modified")
18071807

18081808
instance = Frozen()
1809-
instance.non_existing = 2 # error: [invalid-assignment] "Cannot assign to attribute `non_existing` on type `Frozen` whose `__setattr__` method returns `Never`/`NoReturn`"
1809+
instance.non_existing = 2 # error: [invalid-assignment] "Can not assign to unresolved attribute `non_existing` on type `Frozen`"
18101810
instance.existing = 2 # error: [invalid-assignment] "Cannot assign to attribute `existing` on type `Frozen` whose `__setattr__` method returns `Never`/`NoReturn`"
18111811
```
18121812

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

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -415,8 +415,7 @@ frozen_instance = MyFrozenGeneric[int](1)
415415
frozen_instance.x = 2 # error: [invalid-assignment]
416416
```
417417

418-
When attempting to mutate an unresolved attribute on a frozen dataclass, only `unresolved-attribute`
419-
is emitted:
418+
Attempting to mutate an unresolved attribute on a frozen dataclass:
420419

421420
```py
422421
from dataclasses import dataclass
@@ -425,7 +424,39 @@ from dataclasses import dataclass
425424
class MyFrozenClass: ...
426425

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

431462
### `match_args`

crates/ty_python_semantic/src/types/class.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1600,6 +1600,25 @@ impl<'db> ClassLiteral<'db> {
16001600
.place
16011601
.ignore_possibly_unbound()
16021602
}
1603+
(CodeGeneratorKind::DataclassLike, "__setattr__") => {
1604+
if has_dataclass_param(DataclassParams::FROZEN) {
1605+
let signature = Signature::new(
1606+
Parameters::new([
1607+
Parameter::positional_or_keyword(Name::new_static("self"))
1608+
.with_annotated_type(Type::instance(
1609+
db,
1610+
self.apply_optional_specialization(db, specialization),
1611+
)),
1612+
Parameter::positional_or_keyword(Name::new_static("name")),
1613+
Parameter::positional_or_keyword(Name::new_static("value")),
1614+
]),
1615+
Some(Type::Never),
1616+
);
1617+
1618+
return Some(CallableType::function_like(db, signature));
1619+
}
1620+
None
1621+
}
16031622
_ => None,
16041623
}
16051624
}

crates/ty_python_semantic/src/types/infer.rs

Lines changed: 90 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -3446,20 +3446,6 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
34463446
| Type::AlwaysTruthy
34473447
| Type::AlwaysFalsy
34483448
| Type::TypeIs(_) => {
3449-
let is_read_only = || {
3450-
let dataclass_params = match object_ty {
3451-
Type::NominalInstance(instance) => match instance.class {
3452-
ClassType::NonGeneric(cls) => cls.dataclass_params(self.db()),
3453-
ClassType::Generic(cls) => {
3454-
cls.origin(self.db()).dataclass_params(self.db())
3455-
}
3456-
},
3457-
_ => None,
3458-
};
3459-
3460-
dataclass_params.is_some_and(|params| params.contains(DataclassParams::FROZEN))
3461-
};
3462-
34633449
// First, try to call the `__setattr__` dunder method. If this is present/defined, overrides
34643450
// assigning the attributed by the normal mechanism.
34653451
let setattr_dunder_call_result = object_ty.try_call_dunder_with_policy(
@@ -3476,11 +3462,41 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
34763462
if let Some(builder) =
34773463
self.context.report_lint(&INVALID_ASSIGNMENT, target)
34783464
{
3479-
builder.into_diagnostic(format_args!(
3480-
"Cannot assign to attribute `{attribute}` on type `{}` \
3481-
whose `__setattr__` method returns `Never`/`NoReturn`",
3482-
object_ty.display(db)
3483-
));
3465+
let is_setattr_synthesized = match object_ty
3466+
.class_member_with_policy(
3467+
db,
3468+
"__setattr__".into(),
3469+
MemberLookupPolicy::MRO_NO_OBJECT_FALLBACK,
3470+
) {
3471+
PlaceAndQualifiers {
3472+
place: Place::Type(attr_ty, _),
3473+
qualifiers: _,
3474+
} => attr_ty.is_callable_type(),
3475+
_ => false,
3476+
};
3477+
3478+
let member_exists =
3479+
!object_ty.member(db, attribute).place.is_unbound();
3480+
3481+
let msg = if !member_exists {
3482+
format!(
3483+
"Can not assign to unresolved attribute `{attribute}` on type `{}`",
3484+
object_ty.display(db)
3485+
)
3486+
} else if is_setattr_synthesized {
3487+
format!(
3488+
"Property `{attribute}` defined in `{}` is read-only",
3489+
object_ty.display(db)
3490+
)
3491+
} else {
3492+
format!(
3493+
"Cannot assign to attribute `{attribute}` on type `{}` \
3494+
whose `__setattr__` method returns `Never`/`NoReturn`",
3495+
object_ty.display(db)
3496+
)
3497+
};
3498+
3499+
builder.into_diagnostic(msg);
34843500
}
34853501
}
34863502
false
@@ -3530,85 +3546,71 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
35303546
place: Place::Type(meta_attr_ty, meta_attr_boundness),
35313547
qualifiers: _,
35323548
} => {
3533-
if is_read_only() {
3534-
if emit_diagnostics {
3535-
if let Some(builder) =
3536-
self.context.report_lint(&INVALID_ASSIGNMENT, target)
3537-
{
3538-
builder.into_diagnostic(format_args!(
3539-
"Property `{attribute}` defined in `{ty}` is read-only",
3540-
ty = object_ty.display(self.db()),
3541-
));
3542-
}
3543-
}
3544-
false
3545-
} else {
3546-
let assignable_to_meta_attr =
3547-
if let Place::Type(meta_dunder_set, _) =
3548-
meta_attr_ty.class_member(db, "__set__".into()).place
3549-
{
3550-
let successful_call = meta_dunder_set
3551-
.try_call(
3552-
db,
3553-
&CallArguments::positional([
3554-
meta_attr_ty,
3555-
object_ty,
3556-
value_ty,
3557-
]),
3558-
)
3559-
.is_ok();
3560-
3561-
if !successful_call && emit_diagnostics {
3562-
if let Some(builder) = self
3563-
.context
3564-
.report_lint(&INVALID_ASSIGNMENT, target)
3565-
{
3566-
// TODO: Here, it would be nice to emit an additional diagnostic that explains why the call failed
3567-
builder.into_diagnostic(format_args!(
3549+
let assignable_to_meta_attr =
3550+
if let Place::Type(meta_dunder_set, _) =
3551+
meta_attr_ty.class_member(db, "__set__".into()).place
3552+
{
3553+
let successful_call = meta_dunder_set
3554+
.try_call(
3555+
db,
3556+
&CallArguments::positional([
3557+
meta_attr_ty,
3558+
object_ty,
3559+
value_ty,
3560+
]),
3561+
)
3562+
.is_ok();
3563+
3564+
if !successful_call && emit_diagnostics {
3565+
if let Some(builder) = self
3566+
.context
3567+
.report_lint(&INVALID_ASSIGNMENT, target)
3568+
{
3569+
// TODO: Here, it would be nice to emit an additional diagnostic that explains why the call failed
3570+
builder.into_diagnostic(format_args!(
35683571
"Invalid assignment to data descriptor attribute \
35693572
`{attribute}` on type `{}` with custom `__set__` method",
35703573
object_ty.display(db)
35713574
));
3572-
}
35733575
}
3576+
}
35743577

3575-
successful_call
3578+
successful_call
3579+
} else {
3580+
ensure_assignable_to(meta_attr_ty)
3581+
};
3582+
3583+
let assignable_to_instance_attribute =
3584+
if meta_attr_boundness == Boundness::PossiblyUnbound {
3585+
let (assignable, boundness) = if let Place::Type(
3586+
instance_attr_ty,
3587+
instance_attr_boundness,
3588+
) =
3589+
object_ty.instance_member(db, attribute).place
3590+
{
3591+
(
3592+
ensure_assignable_to(instance_attr_ty),
3593+
instance_attr_boundness,
3594+
)
35763595
} else {
3577-
ensure_assignable_to(meta_attr_ty)
3596+
(true, Boundness::PossiblyUnbound)
35783597
};
35793598

3580-
let assignable_to_instance_attribute =
3581-
if meta_attr_boundness == Boundness::PossiblyUnbound {
3582-
let (assignable, boundness) = if let Place::Type(
3583-
instance_attr_ty,
3584-
instance_attr_boundness,
3585-
) =
3586-
object_ty.instance_member(db, attribute).place
3587-
{
3588-
(
3589-
ensure_assignable_to(instance_attr_ty),
3590-
instance_attr_boundness,
3591-
)
3592-
} else {
3593-
(true, Boundness::PossiblyUnbound)
3594-
};
3595-
3596-
if boundness == Boundness::PossiblyUnbound {
3597-
report_possibly_unbound_attribute(
3598-
&self.context,
3599-
target,
3600-
attribute,
3601-
object_ty,
3602-
);
3603-
}
3599+
if boundness == Boundness::PossiblyUnbound {
3600+
report_possibly_unbound_attribute(
3601+
&self.context,
3602+
target,
3603+
attribute,
3604+
object_ty,
3605+
);
3606+
}
36043607

3605-
assignable
3606-
} else {
3607-
true
3608-
};
3608+
assignable
3609+
} else {
3610+
true
3611+
};
36093612

3610-
assignable_to_meta_attr && assignable_to_instance_attribute
3611-
}
3613+
assignable_to_meta_attr && assignable_to_instance_attribute
36123614
}
36133615

36143616
PlaceAndQualifiers {
@@ -3627,22 +3629,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
36273629
);
36283630
}
36293631

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

0 commit comments

Comments
 (0)