Skip to content

Commit 01eeb2f

Browse files
thejchapAlexWaygoodcarljm
authored
[ty] Support frozen dataclasses (#17974)
## Summary astral-sh/ty#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 <[email protected]> Co-authored-by: Carl Meyer <[email protected]>
1 parent cb04343 commit 01eeb2f

File tree

2 files changed

+152
-51
lines changed

2 files changed

+152
-51
lines changed

crates/ty_python_semantic/resources/mdtest/dataclasses.md

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -369,7 +369,65 @@ To do
369369

370370
### `frozen`
371371

372-
To do
372+
If true (the default is False), assigning to fields will generate a diagnostic.
373+
374+
```py
375+
from dataclasses import dataclass
376+
377+
@dataclass(frozen=True)
378+
class MyFrozenClass:
379+
x: int
380+
381+
frozen_instance = MyFrozenClass(1)
382+
frozen_instance.x = 2 # error: [invalid-assignment]
383+
```
384+
385+
If `__setattr__()` or `__delattr__()` is defined in the class, we should emit a diagnostic.
386+
387+
```py
388+
from dataclasses import dataclass
389+
390+
@dataclass(frozen=True)
391+
class MyFrozenClass:
392+
x: int
393+
394+
# TODO: Emit a diagnostic here
395+
def __setattr__(self, name: str, value: object) -> None: ...
396+
397+
# TODO: Emit a diagnostic here
398+
def __delattr__(self, name: str) -> None: ...
399+
```
400+
401+
This also works for generic dataclasses:
402+
403+
```toml
404+
[environment]
405+
python-version = "3.12"
406+
```
407+
408+
```py
409+
from dataclasses import dataclass
410+
411+
@dataclass(frozen=True)
412+
class MyFrozenGeneric[T]:
413+
x: T
414+
415+
frozen_instance = MyFrozenGeneric[int](1)
416+
frozen_instance.x = 2 # error: [invalid-assignment]
417+
```
418+
419+
When attempting to mutate an unresolved attribute on a frozen dataclass, only `unresolved-attribute`
420+
is emitted:
421+
422+
```py
423+
from dataclasses import dataclass
424+
425+
@dataclass(frozen=True)
426+
class MyFrozenClass: ...
427+
428+
frozen = MyFrozenClass()
429+
frozen.x = 2 # error: [unresolved-attribute]
430+
```
373431

374432
### `match_args`
375433

crates/ty_python_semantic/src/types/infer.rs

Lines changed: 93 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -3077,6 +3077,20 @@ impl<'db> TypeInferenceBuilder<'db> {
30773077
| Type::TypeVar(..)
30783078
| Type::AlwaysTruthy
30793079
| Type::AlwaysFalsy => {
3080+
let is_read_only = || {
3081+
let dataclass_params = match object_ty {
3082+
Type::NominalInstance(instance) => match instance.class {
3083+
ClassType::NonGeneric(cls) => cls.dataclass_params(self.db()),
3084+
ClassType::Generic(cls) => {
3085+
cls.origin(self.db()).dataclass_params(self.db())
3086+
}
3087+
},
3088+
_ => None,
3089+
};
3090+
3091+
dataclass_params.is_some_and(|params| params.contains(DataclassParams::FROZEN))
3092+
};
3093+
30803094
match object_ty.class_member(db, attribute.into()) {
30813095
meta_attr @ SymbolAndQualifiers { .. } if meta_attr.is_class_var() => {
30823096
if emit_diagnostics {
@@ -3096,68 +3110,83 @@ impl<'db> TypeInferenceBuilder<'db> {
30963110
symbol: Symbol::Type(meta_attr_ty, meta_attr_boundness),
30973111
qualifiers: _,
30983112
} => {
3099-
let assignable_to_meta_attr = if let Symbol::Type(meta_dunder_set, _) =
3100-
meta_attr_ty.class_member(db, "__set__".into()).symbol
3101-
{
3102-
let successful_call = meta_dunder_set
3103-
.try_call(
3104-
db,
3105-
&CallArgumentTypes::positional([
3106-
meta_attr_ty,
3107-
object_ty,
3108-
value_ty,
3109-
]),
3110-
)
3111-
.is_ok();
3112-
3113-
if !successful_call && emit_diagnostics {
3113+
if is_read_only() {
3114+
if emit_diagnostics {
31143115
if let Some(builder) =
31153116
self.context.report_lint(&INVALID_ASSIGNMENT, target)
31163117
{
3117-
// TODO: Here, it would be nice to emit an additional diagnostic that explains why the call failed
31183118
builder.into_diagnostic(format_args!(
3119-
"Invalid assignment to data descriptor attribute \
3120-
`{attribute}` on type `{}` with custom `__set__` method",
3121-
object_ty.display(db)
3119+
"Property `{attribute}` defined in `{ty}` is read-only",
3120+
ty = object_ty.display(self.db()),
31223121
));
31233122
}
31243123
}
3125-
3126-
successful_call
3124+
false
31273125
} else {
3128-
ensure_assignable_to(meta_attr_ty)
3129-
};
3126+
let assignable_to_meta_attr = if let Symbol::Type(meta_dunder_set, _) =
3127+
meta_attr_ty.class_member(db, "__set__".into()).symbol
3128+
{
3129+
let successful_call = meta_dunder_set
3130+
.try_call(
3131+
db,
3132+
&CallArgumentTypes::positional([
3133+
meta_attr_ty,
3134+
object_ty,
3135+
value_ty,
3136+
]),
3137+
)
3138+
.is_ok();
31303139

3131-
let assignable_to_instance_attribute = if meta_attr_boundness
3132-
== Boundness::PossiblyUnbound
3133-
{
3134-
let (assignable, boundness) =
3135-
if let Symbol::Type(instance_attr_ty, instance_attr_boundness) =
3136-
object_ty.instance_member(db, attribute).symbol
3137-
{
3138-
(
3139-
ensure_assignable_to(instance_attr_ty),
3140+
if !successful_call && emit_diagnostics {
3141+
if let Some(builder) =
3142+
self.context.report_lint(&INVALID_ASSIGNMENT, target)
3143+
{
3144+
// TODO: Here, it would be nice to emit an additional diagnostic that explains why the call failed
3145+
builder.into_diagnostic(format_args!(
3146+
"Invalid assignment to data descriptor attribute \
3147+
`{attribute}` on type `{}` with custom `__set__` method",
3148+
object_ty.display(db)
3149+
));
3150+
}
3151+
}
3152+
3153+
successful_call
3154+
} else {
3155+
ensure_assignable_to(meta_attr_ty)
3156+
};
3157+
3158+
let assignable_to_instance_attribute =
3159+
if meta_attr_boundness == Boundness::PossiblyUnbound {
3160+
let (assignable, boundness) = if let Symbol::Type(
3161+
instance_attr_ty,
31403162
instance_attr_boundness,
3141-
)
3142-
} else {
3143-
(true, Boundness::PossiblyUnbound)
3144-
};
3163+
) =
3164+
object_ty.instance_member(db, attribute).symbol
3165+
{
3166+
(
3167+
ensure_assignable_to(instance_attr_ty),
3168+
instance_attr_boundness,
3169+
)
3170+
} else {
3171+
(true, Boundness::PossiblyUnbound)
3172+
};
31453173

3146-
if boundness == Boundness::PossiblyUnbound {
3147-
report_possibly_unbound_attribute(
3148-
&self.context,
3149-
target,
3150-
attribute,
3151-
object_ty,
3152-
);
3153-
}
3174+
if boundness == Boundness::PossiblyUnbound {
3175+
report_possibly_unbound_attribute(
3176+
&self.context,
3177+
target,
3178+
attribute,
3179+
object_ty,
3180+
);
3181+
}
31543182

3155-
assignable
3156-
} else {
3157-
true
3158-
};
3183+
assignable
3184+
} else {
3185+
true
3186+
};
31593187

3160-
assignable_to_meta_attr && assignable_to_instance_attribute
3188+
assignable_to_meta_attr && assignable_to_instance_attribute
3189+
}
31613190
}
31623191

31633192
SymbolAndQualifiers {
@@ -3176,7 +3205,21 @@ impl<'db> TypeInferenceBuilder<'db> {
31763205
);
31773206
}
31783207

3179-
ensure_assignable_to(instance_attr_ty)
3208+
if is_read_only() {
3209+
if emit_diagnostics {
3210+
if let Some(builder) =
3211+
self.context.report_lint(&INVALID_ASSIGNMENT, target)
3212+
{
3213+
builder.into_diagnostic(format_args!(
3214+
"Property `{attribute}` defined in `{ty}` is read-only",
3215+
ty = object_ty.display(self.db()),
3216+
));
3217+
}
3218+
}
3219+
false
3220+
} else {
3221+
ensure_assignable_to(instance_attr_ty)
3222+
}
31803223
} else {
31813224
let result = object_ty.try_call_dunder_with_policy(
31823225
db,

0 commit comments

Comments
 (0)