mirror of
https://github.com/astral-sh/ruff.git
synced 2025-08-10 13:48:18 +00:00
[ty] synthesize __setattr__ for frozen dataclasses (#19307)
Some checks are pending
CI / Determine changes (push) Waiting to run
CI / cargo fmt (push) Waiting to run
CI / cargo clippy (push) Blocked by required conditions
CI / cargo test (linux) (push) Blocked by required conditions
CI / cargo test (linux, release) (push) Blocked by required conditions
CI / cargo test (windows) (push) Blocked by required conditions
CI / cargo test (wasm) (push) Blocked by required conditions
CI / cargo build (release) (push) Waiting to run
CI / cargo build (msrv) (push) Blocked by required conditions
CI / cargo fuzz build (push) Blocked by required conditions
CI / fuzz parser (push) Blocked by required conditions
CI / test scripts (push) Blocked by required conditions
CI / ecosystem (push) Blocked by required conditions
CI / Fuzz for new ty panics (push) Blocked by required conditions
CI / cargo shear (push) Blocked by required conditions
CI / python package (push) Waiting to run
CI / pre-commit (push) Waiting to run
CI / mkdocs (push) Waiting to run
CI / formatter instabilities and black similarity (push) Blocked by required conditions
CI / test ruff-lsp (push) Blocked by required conditions
CI / check playground (push) Blocked by required conditions
CI / benchmarks-instrumented (push) Blocked by required conditions
CI / benchmarks-walltime (push) Blocked by required conditions
[ty Playground] Release / publish (push) Waiting to run
Some checks are pending
CI / Determine changes (push) Waiting to run
CI / cargo fmt (push) Waiting to run
CI / cargo clippy (push) Blocked by required conditions
CI / cargo test (linux) (push) Blocked by required conditions
CI / cargo test (linux, release) (push) Blocked by required conditions
CI / cargo test (windows) (push) Blocked by required conditions
CI / cargo test (wasm) (push) Blocked by required conditions
CI / cargo build (release) (push) Waiting to run
CI / cargo build (msrv) (push) Blocked by required conditions
CI / cargo fuzz build (push) Blocked by required conditions
CI / fuzz parser (push) Blocked by required conditions
CI / test scripts (push) Blocked by required conditions
CI / ecosystem (push) Blocked by required conditions
CI / Fuzz for new ty panics (push) Blocked by required conditions
CI / cargo shear (push) Blocked by required conditions
CI / python package (push) Waiting to run
CI / pre-commit (push) Waiting to run
CI / mkdocs (push) Waiting to run
CI / formatter instabilities and black similarity (push) Blocked by required conditions
CI / test ruff-lsp (push) Blocked by required conditions
CI / check playground (push) Blocked by required conditions
CI / benchmarks-instrumented (push) Blocked by required conditions
CI / benchmarks-walltime (push) Blocked by required conditions
[ty Playground] Release / publish (push) Waiting to run
## 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 https://github.com/astral-sh/ty/issues/111 https://github.com/astral-sh/ruff/pull/17974#discussion_r2108527106 https://github.com/astral-sh/ruff/pull/18347#discussion_r2128174665 ## Test Plan New Markdown tests --------- Co-authored-by: David Peter <mail@david-peter.de>
This commit is contained in:
parent
c7640a433e
commit
39b41838f3
4 changed files with 143 additions and 106 deletions
|
@ -1806,7 +1806,7 @@ class Frozen:
|
||||||
raise AttributeError("Attributes can not be modified")
|
raise AttributeError("Attributes can not be modified")
|
||||||
|
|
||||||
instance = Frozen()
|
instance = Frozen()
|
||||||
instance.non_existing = 2 # error: [invalid-assignment] "Cannot assign to attribute `non_existing` on type `Frozen` whose `__setattr__` method returns `Never`/`NoReturn`"
|
instance.non_existing = 2 # error: [invalid-assignment] "Can not assign to unresolved attribute `non_existing` on type `Frozen`"
|
||||||
instance.existing = 2 # error: [invalid-assignment] "Cannot assign to attribute `existing` on type `Frozen` whose `__setattr__` method returns `Never`/`NoReturn`"
|
instance.existing = 2 # error: [invalid-assignment] "Cannot assign to attribute `existing` on type `Frozen` whose `__setattr__` method returns `Never`/`NoReturn`"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
@ -415,8 +415,7 @@ frozen_instance = MyFrozenGeneric[int](1)
|
||||||
frozen_instance.x = 2 # error: [invalid-assignment]
|
frozen_instance.x = 2 # error: [invalid-assignment]
|
||||||
```
|
```
|
||||||
|
|
||||||
When attempting to mutate an unresolved attribute on a frozen dataclass, only `unresolved-attribute`
|
Attempting to mutate an unresolved attribute on a frozen dataclass:
|
||||||
is emitted:
|
|
||||||
|
|
||||||
```py
|
```py
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
@ -425,7 +424,39 @@ from dataclasses import dataclass
|
||||||
class MyFrozenClass: ...
|
class MyFrozenClass: ...
|
||||||
|
|
||||||
frozen = MyFrozenClass()
|
frozen = MyFrozenClass()
|
||||||
frozen.x = 2 # error: [unresolved-attribute]
|
frozen.x = 2 # error: [invalid-assignment] "Can not assign to unresolved attribute `x` on type `MyFrozenClass`"
|
||||||
|
```
|
||||||
|
|
||||||
|
A diagnostic is also emitted if a frozen dataclass is inherited, and an attempt is made to mutate an
|
||||||
|
attribute in the child class:
|
||||||
|
|
||||||
|
```py
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MyFrozenClass:
|
||||||
|
x: int = 1
|
||||||
|
|
||||||
|
class MyFrozenChildClass(MyFrozenClass): ...
|
||||||
|
|
||||||
|
frozen = MyFrozenChildClass()
|
||||||
|
frozen.x = 2 # error: [invalid-assignment]
|
||||||
|
```
|
||||||
|
|
||||||
|
The same diagnostic is emitted if a frozen dataclass is inherited, and an attempt is made to delete
|
||||||
|
an attribute:
|
||||||
|
|
||||||
|
```py
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MyFrozenClass:
|
||||||
|
x: int = 1
|
||||||
|
|
||||||
|
class MyFrozenChildClass(MyFrozenClass): ...
|
||||||
|
|
||||||
|
frozen = MyFrozenChildClass()
|
||||||
|
del frozen.x # TODO this should emit an [invalid-assignment]
|
||||||
```
|
```
|
||||||
|
|
||||||
### `match_args`
|
### `match_args`
|
||||||
|
|
|
@ -1600,6 +1600,25 @@ impl<'db> ClassLiteral<'db> {
|
||||||
.place
|
.place
|
||||||
.ignore_possibly_unbound()
|
.ignore_possibly_unbound()
|
||||||
}
|
}
|
||||||
|
(CodeGeneratorKind::DataclassLike, "__setattr__") => {
|
||||||
|
if has_dataclass_param(DataclassParams::FROZEN) {
|
||||||
|
let signature = Signature::new(
|
||||||
|
Parameters::new([
|
||||||
|
Parameter::positional_or_keyword(Name::new_static("self"))
|
||||||
|
.with_annotated_type(Type::instance(
|
||||||
|
db,
|
||||||
|
self.apply_optional_specialization(db, specialization),
|
||||||
|
)),
|
||||||
|
Parameter::positional_or_keyword(Name::new_static("name")),
|
||||||
|
Parameter::positional_or_keyword(Name::new_static("value")),
|
||||||
|
]),
|
||||||
|
Some(Type::Never),
|
||||||
|
);
|
||||||
|
|
||||||
|
return Some(CallableType::function_like(db, signature));
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3446,20 +3446,6 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
|
||||||
| Type::AlwaysTruthy
|
| Type::AlwaysTruthy
|
||||||
| Type::AlwaysFalsy
|
| Type::AlwaysFalsy
|
||||||
| Type::TypeIs(_) => {
|
| Type::TypeIs(_) => {
|
||||||
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))
|
|
||||||
};
|
|
||||||
|
|
||||||
// First, try to call the `__setattr__` dunder method. If this is present/defined, overrides
|
// First, try to call the `__setattr__` dunder method. If this is present/defined, overrides
|
||||||
// assigning the attributed by the normal mechanism.
|
// assigning the attributed by the normal mechanism.
|
||||||
let setattr_dunder_call_result = object_ty.try_call_dunder_with_policy(
|
let setattr_dunder_call_result = object_ty.try_call_dunder_with_policy(
|
||||||
|
@ -3476,11 +3462,41 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
|
||||||
if let Some(builder) =
|
if let Some(builder) =
|
||||||
self.context.report_lint(&INVALID_ASSIGNMENT, target)
|
self.context.report_lint(&INVALID_ASSIGNMENT, target)
|
||||||
{
|
{
|
||||||
builder.into_diagnostic(format_args!(
|
let is_setattr_synthesized = match object_ty
|
||||||
"Cannot assign to attribute `{attribute}` on type `{}` \
|
.class_member_with_policy(
|
||||||
whose `__setattr__` method returns `Never`/`NoReturn`",
|
db,
|
||||||
object_ty.display(db)
|
"__setattr__".into(),
|
||||||
));
|
MemberLookupPolicy::MRO_NO_OBJECT_FALLBACK,
|
||||||
|
) {
|
||||||
|
PlaceAndQualifiers {
|
||||||
|
place: Place::Type(attr_ty, _),
|
||||||
|
qualifiers: _,
|
||||||
|
} => attr_ty.is_callable_type(),
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let member_exists =
|
||||||
|
!object_ty.member(db, attribute).place.is_unbound();
|
||||||
|
|
||||||
|
let msg = if !member_exists {
|
||||||
|
format!(
|
||||||
|
"Can not assign to unresolved attribute `{attribute}` on type `{}`",
|
||||||
|
object_ty.display(db)
|
||||||
|
)
|
||||||
|
} else if is_setattr_synthesized {
|
||||||
|
format!(
|
||||||
|
"Property `{attribute}` defined in `{}` is read-only",
|
||||||
|
object_ty.display(db)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"Cannot assign to attribute `{attribute}` on type `{}` \
|
||||||
|
whose `__setattr__` method returns `Never`/`NoReturn`",
|
||||||
|
object_ty.display(db)
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
builder.into_diagnostic(msg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
false
|
false
|
||||||
|
@ -3530,85 +3546,71 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
|
||||||
place: Place::Type(meta_attr_ty, meta_attr_boundness),
|
place: Place::Type(meta_attr_ty, meta_attr_boundness),
|
||||||
qualifiers: _,
|
qualifiers: _,
|
||||||
} => {
|
} => {
|
||||||
if is_read_only() {
|
let assignable_to_meta_attr =
|
||||||
if emit_diagnostics {
|
if let Place::Type(meta_dunder_set, _) =
|
||||||
if let Some(builder) =
|
meta_attr_ty.class_member(db, "__set__".into()).place
|
||||||
self.context.report_lint(&INVALID_ASSIGNMENT, target)
|
{
|
||||||
{
|
let successful_call = meta_dunder_set
|
||||||
builder.into_diagnostic(format_args!(
|
.try_call(
|
||||||
"Property `{attribute}` defined in `{ty}` is read-only",
|
db,
|
||||||
ty = object_ty.display(self.db()),
|
&CallArguments::positional([
|
||||||
));
|
meta_attr_ty,
|
||||||
}
|
object_ty,
|
||||||
}
|
value_ty,
|
||||||
false
|
]),
|
||||||
} else {
|
)
|
||||||
let assignable_to_meta_attr =
|
.is_ok();
|
||||||
if let Place::Type(meta_dunder_set, _) =
|
|
||||||
meta_attr_ty.class_member(db, "__set__".into()).place
|
|
||||||
{
|
|
||||||
let successful_call = meta_dunder_set
|
|
||||||
.try_call(
|
|
||||||
db,
|
|
||||||
&CallArguments::positional([
|
|
||||||
meta_attr_ty,
|
|
||||||
object_ty,
|
|
||||||
value_ty,
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
.is_ok();
|
|
||||||
|
|
||||||
if !successful_call && emit_diagnostics {
|
if !successful_call && emit_diagnostics {
|
||||||
if let Some(builder) = self
|
if let Some(builder) = self
|
||||||
.context
|
.context
|
||||||
.report_lint(&INVALID_ASSIGNMENT, target)
|
.report_lint(&INVALID_ASSIGNMENT, target)
|
||||||
{
|
{
|
||||||
// TODO: Here, it would be nice to emit an additional diagnostic that explains why the call failed
|
// TODO: Here, it would be nice to emit an additional diagnostic that explains why the call failed
|
||||||
builder.into_diagnostic(format_args!(
|
builder.into_diagnostic(format_args!(
|
||||||
"Invalid assignment to data descriptor attribute \
|
"Invalid assignment to data descriptor attribute \
|
||||||
`{attribute}` on type `{}` with custom `__set__` method",
|
`{attribute}` on type `{}` with custom `__set__` method",
|
||||||
object_ty.display(db)
|
object_ty.display(db)
|
||||||
));
|
));
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
successful_call
|
successful_call
|
||||||
} else {
|
} else {
|
||||||
ensure_assignable_to(meta_attr_ty)
|
ensure_assignable_to(meta_attr_ty)
|
||||||
};
|
};
|
||||||
|
|
||||||
let assignable_to_instance_attribute =
|
let assignable_to_instance_attribute =
|
||||||
if meta_attr_boundness == Boundness::PossiblyUnbound {
|
if meta_attr_boundness == Boundness::PossiblyUnbound {
|
||||||
let (assignable, boundness) = if let Place::Type(
|
let (assignable, boundness) = if let Place::Type(
|
||||||
instance_attr_ty,
|
instance_attr_ty,
|
||||||
|
instance_attr_boundness,
|
||||||
|
) =
|
||||||
|
object_ty.instance_member(db, attribute).place
|
||||||
|
{
|
||||||
|
(
|
||||||
|
ensure_assignable_to(instance_attr_ty),
|
||||||
instance_attr_boundness,
|
instance_attr_boundness,
|
||||||
) =
|
)
|
||||||
object_ty.instance_member(db, attribute).place
|
|
||||||
{
|
|
||||||
(
|
|
||||||
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 {
|
} else {
|
||||||
true
|
(true, Boundness::PossiblyUnbound)
|
||||||
};
|
};
|
||||||
|
|
||||||
assignable_to_meta_attr && assignable_to_instance_attribute
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
PlaceAndQualifiers {
|
PlaceAndQualifiers {
|
||||||
|
@ -3627,22 +3629,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if is_read_only() {
|
ensure_assignable_to(instance_attr_ty)
|
||||||
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 {
|
} else {
|
||||||
if emit_diagnostics {
|
if emit_diagnostics {
|
||||||
if let Some(builder) =
|
if let Some(builder) =
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue