[ty] Allow known non-field writes on frozen dataclass subclasses#25087
Conversation
Typing conformance resultsNo changes detected ✅Current numbersThe 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. |
Memory usage reportSummary
Significant changesClick to expand detailed breakdownprefect
sphinx
trio
flake8
|
b782b6b to
ee4262c
Compare
|
| 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-onlyec5a2aa to
4e19a45
Compare
e6b0e1f to
7bfa9a9
Compare
| 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] | ||
| ``` |
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
Why would you not expect this behavior?
There was a problem hiding this comment.
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 attributesMySlottedFrozenChildClass 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.
There was a problem hiding this comment.
Personally, I am fine modeling our behavior based on whatever we think is intended rather than following the runtime here.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
So does this require a change here, or are we happy with the current state of the PR?
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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!)
There was a problem hiding this comment.
Agree with @AlexWaygood on all counts here. python/cpython#143969 is the upstream bug.
dcreager
left a comment
There was a problem hiding this comment.
Modulo whether @AlexWaygood comments require changing anything, this lgtm
| 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] | ||
| ``` |
There was a problem hiding this comment.
So does this require a change here, or are we happy with the current state of the PR?
80c50ee to
ceb4994
Compare
…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.
…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.
Summary
A
frozen=Truedataclass rejects writes to all declared and undeclared attributes; however, a non-frozen subclass of afrozen=Truedataclass 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 = 2is now accepted:Closes astral-sh/ty#3434.