Thanks to visit codestin.com
Credit goes to github.com

Skip to content

[ty] Allow known non-field writes on frozen dataclass subclasses#25087

Merged
charliermarsh merged 2 commits into
mainfrom
charlie/frozen-dataclass-subclass-attrs
May 18, 2026
Merged

[ty] Allow known non-field writes on frozen dataclass subclasses#25087
charliermarsh merged 2 commits into
mainfrom
charlie/frozen-dataclass-subclass-attrs

Conversation

@charliermarsh

@charliermarsh charliermarsh commented May 10, 2026

Copy link
Copy Markdown
Member

Summary

A frozen=True dataclass rejects writes to all declared and undeclared attributes; however, a non-frozen subclass of a frozen=True dataclass only rejects writes to the attributes declared on the dataclass.

This PR synthesizes a set of __setattr__ overloads for such subclasses to narrow the scope of the "frozen" behavior to only those attributes defined by the dataclass, matching the behavior used in Mypy and Pyright (and at runtime).

For example, child.y = 2 is now accepted:

from dataclasses import dataclass

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

class Child(Frozen):
    y: int

child = Child()
child.x = 2  # runtime: FrozenInstanceError
child.y = 2  # runtime: OK

Closes astral-sh/ty#3434.

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

astral-sh-bot Bot commented May 10, 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 89.36%. The percentage of expected errors that received a diagnostic held steady at 85.49%. The number of fully passing files held steady at 88/134.

@astral-sh-bot

astral-sh-bot Bot commented May 10, 2026

Copy link
Copy Markdown

Memory usage report

Summary

Project Old New Diff Outcome
prefect 686.03MB 686.08MB +0.01% (46.37kB)
sphinx 256.19MB 256.23MB +0.01% (36.12kB)
trio 115.55MB 115.55MB +0.01% (6.98kB)
flake8 47.42MB 47.43MB +0.01% (3.20kB)

Significant changes

Click to expand detailed breakdown

prefect

Name Old New Diff Outcome
Type<'db>::class_member_with_policy_ 17.70MB 17.75MB +0.26% (46.37kB)

sphinx

Name Old New Diff Outcome
Type<'db>::class_member_with_policy_ 7.63MB 7.67MB +0.46% (36.12kB)

trio

Name Old New Diff Outcome
Type<'db>::class_member_with_policy_ 2.04MB 2.05MB +0.33% (6.98kB)

flake8

Name Old New Diff Outcome
Type<'db>::class_member_with_policy_ 575.23kB 577.49kB +0.39% (2.26kB)
infer_definition_types 1.72MB 1.72MB +0.03% (464.00B)
FunctionType 451.45kB 451.67kB +0.05% (224.00B)
OverloadLiteral 120.94kB 121.05kB +0.09% (112.00B)
place_by_id 143.72kB 143.81kB +0.06% (88.00B)
place_by_id::interned_arguments 107.02kB 107.09kB +0.07% (72.00B)

@charliermarsh charliermarsh force-pushed the charlie/frozen-dataclass-subclass-attrs branch from b782b6b to ee4262c Compare May 10, 2026 13:37
@astral-sh-bot

astral-sh-bot Bot commented May 10, 2026

Copy link
Copy Markdown

ecosystem-analyzer results

Lint rule Added Removed Changed
invalid-assignment 0 15 2
Total 0 15 2
Raw diff (17 changes)
isort (https://github.com/pycqa/isort)
- isort/settings.py:624:9 error[invalid-assignment] Property `_known_patterns` defined in `Self@known_patterns` is read-only
- isort/settings.py:648:9 error[invalid-assignment] Property `_section_comments` defined in `Self@section_comments` is read-only
- isort/settings.py:656:9 error[invalid-assignment] Property `_section_comments_end` defined in `Self@section_comments_end` is read-only
- isort/settings.py:664:9 error[invalid-assignment] Property `_skips` defined in `Self@skips` is read-only
- isort/settings.py:672:9 error[invalid-assignment] Property `_skip_globs` defined in `Self@skip_globs` is read-only
- isort/settings.py:681:13 error[invalid-assignment] Property `_sorting_function` defined in `Self@sorting_function` is read-only
- isort/settings.py:683:13 error[invalid-assignment] Property `_sorting_function` defined in `Self@sorting_function` is read-only
- isort/settings.py:689:21 error[invalid-assignment] Property `_sorting_function` defined in `Self@sorting_function` is read-only

mitmproxy (https://github.com/mitmproxy/mitmproxy)
- mitmproxy/proxy/mode_specs.py:217:17 error[invalid-assignment] Property `address` defined in `Self@__post_init__` is read-only
- mitmproxy/proxy/mode_specs.py:220:9 error[invalid-assignment] Property `scheme` defined in `Self@__post_init__` is read-only
- mitmproxy/proxy/mode_specs.py:235:9 error[invalid-assignment] Property `scheme` defined in `Self@__post_init__` is read-only
- mitmproxy/proxy/mode_specs.py:235:22 error[invalid-assignment] Property `address` defined in `Self@__post_init__` is read-only
- mitmproxy/proxy/mode_specs.py:240:9 error[invalid-assignment] Property `description` defined in `Self@__post_init__` is read-only
- mitmproxy/proxy/mode_specs.py:237:13 error[invalid-assignment] Property `transport_protocol` defined in `Self@__post_init__` is read-only
+ mitmproxy/proxy/mode_specs.py:237:13 error[invalid-assignment] Object of type `Literal["tcp", "udp", "both"]` is not assignable to attribute `transport_protocol` of type `Literal["tcp"]`
- mitmproxy/proxy/mode_specs.py:239:13 error[invalid-assignment] Property `transport_protocol` defined in `Self@__post_init__` is read-only
+ mitmproxy/proxy/mode_specs.py:239:13 error[invalid-assignment] Object of type `Literal["tcp", "udp", "both"]` is not assignable to attribute `transport_protocol` of type `Literal["tcp"]`

rotki (https://github.com/rotki/rotki)
- rotkehlchen/chain/arbitrum_one/types.py:31:9 error[invalid-assignment] Property `tx_type` defined in `Self@__init__` is read-only
- rotkehlchen/chain/evm/l2_with_l1_fees/types.py:46:9 error[invalid-assignment] Property `l1_fee` defined in `Self@__init__` is read-only

Full report with detailed diff (timing results)

@charliermarsh charliermarsh force-pushed the charlie/frozen-dataclass-subclass-attrs branch 5 times, most recently from ec5a2aa to 4e19a45 Compare May 10, 2026 17:46
@charliermarsh charliermarsh marked this pull request as ready for review May 10, 2026 17:57
@charliermarsh charliermarsh force-pushed the charlie/frozen-dataclass-subclass-attrs branch 2 times, most recently from e6b0e1f to 7bfa9a9 Compare May 10, 2026 18:02
Comment on lines 678 to 703
Non-field attributes on subclasses of slotted frozen dataclasses are still rejected:

```py
from dataclasses import dataclass

@dataclass(frozen=True, slots=True)
class MySlottedFrozenClass:
x: int = 1

class MySlottedFrozenChildClass(MySlottedFrozenClass):
y: int

class MySlottedFrozenGrandchildClass(MySlottedFrozenChildClass):
z: int

frozen = MySlottedFrozenChildClass()
frozen.x = 2 # error: [invalid-assignment]
frozen.y = 2 # error: [invalid-assignment]
frozen.z = 2 # error: [invalid-assignment]

grandchild = MySlottedFrozenGrandchildClass()
grandchild.x = 2 # error: [invalid-assignment]
grandchild.y = 2 # error: [invalid-assignment]
grandchild.z = 2 # error: [invalid-assignment]
grandchild.unknown = 2 # error: [invalid-assignment]
```

@AlexWaygood AlexWaygood May 10, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think it's correct for us to model the runtime behaviour here (and the runtime implementation rejects these, as you point out), but FWIW this isn't the behaviour I'd expect at runtime -- and the error message makes it look more like a bug than intentional behaviour:

% uvx python3.13
Python 3.13.13 (main, Apr  7 2026, 18:19:01) [Clang 21.0.0 (clang-2100.0.123.102)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> from dataclasses import dataclass
...
... @dataclass(frozen=True, slots=True)
... class MySlottedFrozenClass:
...     x: int = 1
...
... class MySlottedFrozenChildClass(MySlottedFrozenClass):
...     y: int
...
... frozen = MySlottedFrozenChildClass()
... frozen.y = 2  # error: [invalid-assignment]
...
Traceback (most recent call last):
  File "<python-input-0>", line 11, in <module>
    frozen.y = 2  # error: [invalid-assignment]
    ^^^^^^^^
  File "<string>", line 16, in __setattr__
TypeError: super(type, obj): obj (instance of MySlottedFrozenChildClass) is not an instance or subtype of type (MySlottedFrozenClass).

If one doesn't exist already, we could consider filing a bug upstream at CPython about it (cc. CPython dataclasses maintainer @carljm)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Why would you not expect this behavior?

@AlexWaygood AlexWaygood May 10, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Subclasses of slotted classes usually allow arbitrary attributes to be set on them unless the subclass also explicitly declares __slots__ -- observe the difference between Child and SlottedChild here:

>>> class Parent:
...     __slots__ = ()
...
>>> class Child(Parent): ...
...
>>> Parent().x = 42
Traceback (most recent call last):
  File "<python-input-2>", line 1, in <module>
    Parent().x = 42
    ^^^^^^^^^^
AttributeError: 'Parent' object has no attribute 'x' and no __dict__ for setting new attributes
>>> Child().x = 56
>>> class SlottedChild(Parent):
...     __slots__ = ()
...
>>> SlottedChild().x = 42
Traceback (most recent call last):
  File "<python-input-5>", line 1, in <module>
    SlottedChild().x = 42
    ^^^^^^^^^^^^^^^^
AttributeError: 'SlottedChild' object has no attribute 'x' and no __dict__ for setting new attributes

MySlottedFrozenChildClass does not define __slots__, so I would expect instances of that class to have a __dict__ allowing for arbitrary additional attributes to be set, just like for the non-dataclass case. And indeed -- unlike its parent -- it does!

>>> from dataclasses import dataclass
>>> @dataclass(frozen=True, slots=True)
... class MySlottedFrozenClass:
...     x: int = 1
...
>>> class MySlottedFrozenChildClass(MySlottedFrozenClass):
...     y: int
...
>>> MySlottedFrozenClass().__dict__
Traceback (most recent call last):
  File "<python-input-10>", line 1, in <module>
    MySlottedFrozenClass().__dict__
AttributeError: 'MySlottedFrozenClass' object has no attribute '__dict__'. Did you mean: '__dir__'?
>>> MySlottedFrozenChildClass().__dict__
{}

However, that __dict__ is intercepted by the parent class's __setattr__ method, which appears from the error message to be broken when called on subclasses.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Personally, I am fine modeling our behavior based on whatever we think is intended rather than following the runtime here.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I could go either way... on the one hand, it's definitely pretty strange to me that this is how it works at runtime, and modelling the runtime correctly essentially means that we have to have pretty strange behaviour too 😆

On the other hand, I do think it does a disservice to our users not to model the runtime correctly -- this will always fail at runtime, so it's a shame not to catch that and warn the user about it

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

So does this require a change here, or are we happy with the current state of the PR?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I'm happy with the semantics, but I think it might be worth adding some prose to the mdtest that this seems like surprising behaviour that might be a CPython bug, and we should change our behaviour here if cpython ever does

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Otherwise I think it's quite confusing for readers of the test why we have this behaviour (I was confused, anyway, until I verified that cpython had the same behaviour!)

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.

Agree with @AlexWaygood on all counts here. python/cpython#143969 is the upstream bug.

@dcreager dcreager left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Modulo whether @AlexWaygood comments require changing anything, this lgtm

Comment on lines 678 to 703
Non-field attributes on subclasses of slotted frozen dataclasses are still rejected:

```py
from dataclasses import dataclass

@dataclass(frozen=True, slots=True)
class MySlottedFrozenClass:
x: int = 1

class MySlottedFrozenChildClass(MySlottedFrozenClass):
y: int

class MySlottedFrozenGrandchildClass(MySlottedFrozenChildClass):
z: int

frozen = MySlottedFrozenChildClass()
frozen.x = 2 # error: [invalid-assignment]
frozen.y = 2 # error: [invalid-assignment]
frozen.z = 2 # error: [invalid-assignment]

grandchild = MySlottedFrozenGrandchildClass()
grandchild.x = 2 # error: [invalid-assignment]
grandchild.y = 2 # error: [invalid-assignment]
grandchild.z = 2 # error: [invalid-assignment]
grandchild.unknown = 2 # error: [invalid-assignment]
```

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

So does this require a change here, or are we happy with the current state of the PR?

@charliermarsh charliermarsh force-pushed the charlie/frozen-dataclass-subclass-attrs branch from 80c50ee to ceb4994 Compare May 18, 2026 21:50
@charliermarsh charliermarsh enabled auto-merge (squash) May 18, 2026 21:53
@charliermarsh charliermarsh merged commit d810dff into main May 18, 2026
58 checks passed
@charliermarsh charliermarsh deleted the charlie/frozen-dataclass-subclass-attrs branch May 18, 2026 22:01
thejchap pushed a commit to thejchap/ruff that referenced this pull request May 23, 2026
…ral-sh#25087)

## Summary

A `frozen=True` dataclass rejects writes to all declared and undeclared
attributes; however, a non-frozen subclass of a `frozen=True` dataclass
only rejects writes to the attributes declared on the dataclass.

This PR synthesizes a set of `__setattr__` overloads for such subclasses
to narrow the scope of the "frozen" behavior to only those attributes
defined by the dataclass, matching the behavior used in Mypy and Pyright
(and at runtime).

For example, `child.y = 2` is now accepted:

```python
from dataclasses import dataclass

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

class Child(Frozen):
    y: int

child = Child()
child.x = 2  # runtime: FrozenInstanceError
child.y = 2  # runtime: OK
```

Closes astral-sh/ty#3434.
anishgirianish pushed a commit to anishgirianish/ruff that referenced this pull request May 28, 2026
…ral-sh#25087)

## Summary

A `frozen=True` dataclass rejects writes to all declared and undeclared
attributes; however, a non-frozen subclass of a `frozen=True` dataclass
only rejects writes to the attributes declared on the dataclass.

This PR synthesizes a set of `__setattr__` overloads for such subclasses
to narrow the scope of the "frozen" behavior to only those attributes
defined by the dataclass, matching the behavior used in Mypy and Pyright
(and at runtime).

For example, `child.y = 2` is now accepted:

```python
from dataclasses import dataclass

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

class Child(Frozen):
    y: int

child = Child()
child.x = 2  # runtime: FrozenInstanceError
child.y = 2  # runtime: OK
```

Closes astral-sh/ty#3434.
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.

Allow assignment to non-dataclass fields on frozen dataclass subclasses

4 participants