Skip to content

[ty] Preserve frozen-dataclass setter delegation - #27217

Merged
charliermarsh merged 2 commits into
mainfrom
charlie/fix-frozen-dataclass-setattr-delegation
Jul 28, 2026
Merged

[ty] Preserve frozen-dataclass setter delegation#27217
charliermarsh merged 2 commits into
mainfrom
charlie/fix-frozen-dataclass-setattr-delegation

Conversation

@charliermarsh

Copy link
Copy Markdown
Member

Summary

Prior to this change, we modeled a frozen dataclass's inherited __setattr__ as an overload that rejects known frozen fields and returns None for every other attribute. That catch-all loses the actual setter that Python calls after the frozen base.

For example, this assignment must be rejected because the next base's setter never returns:

from dataclasses import dataclass
from typing import Never

@dataclass(frozen=True)
class Frozen:
    x: int = 1

class RejectsAssignment:
    y: int = 1

    def __setattr__(self, name: str, value: object) -> Never:
        raise AttributeError(name)

class Child(Frozen, RejectsAssignment): ...

Child().y = 2

Previously, we accepted it. We also rejected valid assignments when a later setter explicitly accepts a value for an otherwise read-only property.

We now collect fields from every reachable frozen base, distinguish frozen fields from attributes that are delegated through super(), and validate delegated assignments against the actual next setter. This preserves setter argument checking and declared attribute types, handles both generic syntaxes, excludes InitVar arguments, and keeps the existing behavior for slotted frozen dataclasses.

This is a prerequisite for #27001: it fixes the existing assignment behavior and establishes the shared frozen-method dispatch that the deletion change can reuse without expanding its scope.

@astral-sh-bot astral-sh-bot Bot added the ty Multi-file analysis & type inference label Jul 27, 2026
@astral-sh-bot

astral-sh-bot Bot commented Jul 27, 2026

Copy link
Copy Markdown

Typing conformance results

No changes detected ✅

Current numbers
The percentage of diagnostics emitted that were expected errors held steady at 96.84%. The percentage of expected errors that received a diagnostic held steady at 92.11%. The number of fully passing files held steady at 99/133.

@astral-sh-bot

astral-sh-bot Bot commented Jul 27, 2026

Copy link
Copy Markdown

Memory usage report

Summary

Project Old New Diff Outcome
prefect 452.29MB 452.41MB +0.03% (132.46kB)
sphinx 166.03MB 166.05MB +0.01% (22.65kB)
trio 70.16MB 70.17MB +0.01% (4.42kB)
flake8 28.77MB 28.77MB +0.00% (1008.00B)

Significant changes

Click to expand detailed breakdown

prefect

Name Old New Diff Outcome
infer_scope_types_impl 30.32MB 30.45MB +0.42% (128.93kB)
infer_statement_types_impl 832.64kB 834.25kB +0.19% (1.62kB)
infer_expression_types_impl 39.95MB 39.95MB +0.00% (984.00B)
infer_definition_types 50.61MB 50.61MB +0.00% (592.00B)
analyze_non_terminal_call 1.65MB 1.65MB +0.02% (368.00B)
StaticClassLiteral<'db>::implicit_attribute_inner_ 820.31kB 820.32kB +0.00% (8.00B)
infer_expression_type_impl 230.14kB 230.15kB +0.00% (8.00B)

sphinx

Name Old New Diff Outcome
infer_scope_types_impl 8.04MB 8.06MB +0.27% (21.92kB)
infer_expression_types_impl 15.25MB 15.25MB +0.00% (368.00B)
infer_statement_types_impl 500.40kB 500.59kB +0.04% (192.00B)
infer_definition_types 13.64MB 13.64MB +0.00% (184.00B)

trio

Name Old New Diff Outcome
infer_scope_types_impl 2.40MB 2.40MB +0.18% (4.39kB)
infer_statement_types_impl 34.39kB 34.42kB +0.09% (32.00B)

flake8

Name Old New Diff Outcome
infer_scope_types_impl 511.15kB 512.13kB +0.19% (1008.00B)

@astral-sh-bot

astral-sh-bot Bot commented Jul 27, 2026

Copy link
Copy Markdown

ecosystem-analyzer results

No diagnostic changes detected ✅

Flaky changes detected. This PR summary excludes flaky changes; see the HTML report for details.

Full report with detailed diff (timing results)

@charliermarsh
charliermarsh requested a review from carljm July 27, 2026 15:59
@charliermarsh
charliermarsh marked this pull request as ready for review July 27, 2026 15:59
@charliermarsh
charliermarsh requested a review from a team as a code owner July 27, 2026 15:59
ExplicitAttributeWriteRequirement::Descriptor { .. }
) =>
{
return true;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A successful delegated __setattr__ does not establish that a descriptor was bypassed: the later setter can call super().__setattr__, which still invokes property.__set__ or the target data descriptor.

from dataclasses import dataclass

@dataclass(frozen=True)
class Frozen:
    x: int = 1

class ReadOnlyBase:
    @property
    def y(self) -> int:
        return 1

    def __setattr__(self, name: str, value: object) -> None:
        super().__setattr__(name, value)

class Child(Frozen, ReadOnlyBase):
    pass

Child().y = 1  # raises AttributeError; main and all other type checkers reject, this PR accepts it

I also reproduced the same false negative with a mistyped property setter, a property setter returning Never, and a custom descriptor whose __set__ returns Never. I think we need to retain descriptor validation whenever the delegated setter can forward through super(), and add mdtests for read-only, typed, and terminal property/descriptor setters.

}

impl<'db> FrozenDataclassDispatch<'db> {
/// Returns `object` for a frozen field or `super(frozen_base, object)` for a non-field.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we have a little more clarification here on the purpose of this method -- why it behaves the way it does? Maybe with an example?

/// Fields protected by reachable frozen-dataclass methods.
struct InheritedFrozenDataclassFields<'db> {
names: Box<[Name]>,
last_frozen_base: StaticClassLiteral<'db>,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we need some documentation on what this field means

// The Salsa heap is tracked separately.
impl get_size2::GetSize for StaticClassLiteral<'_> {}

/// The outcome of dispatching a frozen-dataclass method on a subclass instance.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe we could refer to the doc comment on inherited_frozen_dataclass_dispatch for more clarity on what this is all about

Comment on lines +1915 to +1917
/// For a non-field, CPython delegates past each generated frozen method. Preserving the final
/// frozen base lets assignment validation perform the same `super()` lookup without hiding a
/// later method or an attribute's descriptor.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we could expand this a bit, with examples -- what does "for a non-field" mean, what does "delegates past each generated frozen method" mean.

@charliermarsh
charliermarsh force-pushed the charlie/fix-frozen-dataclass-setattr-delegation branch from 0b2c74a to 1ca0a23 Compare July 28, 2026 13:46
@charliermarsh
charliermarsh merged commit b6457d5 into main Jul 28, 2026
63 checks passed
@charliermarsh
charliermarsh deleted the charlie/fix-frozen-dataclass-setattr-delegation branch July 28, 2026 14:00
charliermarsh added a commit that referenced this pull request Jul 28, 2026
## Summary

Stacked on #27217.

Prior to this change, we rejected assignments to fields inherited from a
frozen dataclass but allowed the equivalent deletion through an ordinary
subclass, even though it raises `FrozenInstanceError` at runtime:

```python
from dataclasses import dataclass


@DataClass(frozen=True)
class Frozen:
    x: int = 1


class Child(Frozen): ...


del Child().x
```

We now synthesize frozen `__delattr__` for both frozen dataclasses and
their ordinary subclasses, reuse the frozen-method dispatch from #27217,
and continue through `super()` for non-field deletions. This preserves
descriptor and later-MRO checks, supports multiple frozen bases and
generic specializations, excludes `InitVar`, and retains the existing
Python 3.12 behavior for slotted frozen dataclasses.

The generated signatures also make direct and intermediate method
overrides consistent with frozen `__setattr__`.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ty Multi-file analysis & type inference

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants