You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Python type system lacks a single concise way to mark an attribute read-only.
29
-
This feature is common in other object-oriented languages (such as C#), and is
30
-
useful for restricting attribute mutation at a type checker level, as well as
31
-
defining a broad interface for structural subtyping.
29
+
This feature is common in other object-oriented languages (such as `C# <https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/readonly>`_),
30
+
and is useful for restricting attribute mutation at a type checker level, as well
31
+
as defining a broad interface for structural subtyping.
32
32
33
33
Classes
34
34
-------
@@ -37,32 +37,32 @@ There are 3 major ways of achieving read-only attributes, honored by type checke
37
37
38
38
* annotating the attribute with :data:`typing.Final`::
39
39
40
-
class Foo:
41
-
number: Final[int]
40
+
class Foo:
41
+
number: Final[int]
42
42
43
-
def __init__(self, number: int) -> None:
44
-
self.number = number
43
+
def __init__(self, number: int) -> None:
44
+
self.number = number
45
45
46
46
47
-
class Bar:
48
-
def __init__(self, number: int) -> None:
49
-
self.number: Final = number
47
+
class Bar:
48
+
def __init__(self, number: int) -> None:
49
+
self.number: Final = number
50
50
51
51
- Supported by :mod:`dataclasses` (since `typing#1669 <https://github.com/python/typing/pull/1669>`_).
52
52
- Overriding ``number`` is not possible - the specification of ``Final``
53
53
imposes the name cannot be overridden in subclasses.
54
54
55
55
* read-only proxy via ``@property``::
56
56
57
-
class Foo:
58
-
_number: int
57
+
class Foo:
58
+
_number: int
59
59
60
-
def __init__(self, number: int) -> None:
61
-
self._number = number
60
+
def __init__(self, number: int) -> None:
61
+
self._number = number
62
62
63
-
@property
64
-
def number(self) -> int:
65
-
return self._number
63
+
@property
64
+
def number(self) -> int:
65
+
return self._number
66
66
67
67
- Overriding ``number`` is possible. *Type checkers disagree about the specific rules*. [#overriding_property]_
68
68
- Read-only at runtime. [#runtime]_
@@ -72,20 +72,22 @@ There are 3 major ways of achieving read-only attributes, honored by type checke
72
72
73
73
* using a "freezing" mechanism, such as :func:`dataclasses.dataclass` or :class:`typing.NamedTuple`::
74
74
75
-
@dataclass(frozen=True)
76
-
class Foo:
77
-
number: int
75
+
@dataclass(frozen=True)
76
+
class Foo:
77
+
number: int
78
78
79
79
80
-
class Bar(NamedTuple):
81
-
number: int
80
+
class Bar(NamedTuple):
81
+
number: int
82
82
83
83
- Overriding ``number`` is possible in the ``@dataclass`` case.
84
84
- Read-only at runtime. [#runtime]_
85
85
- No per-attribute control - these methods apply to the whole class.
86
86
- Frozen dataclasses incur some runtime overhead.
87
87
- Not all classes qualify to be a ``NamedTuple``.
88
88
89
+
.. _protocols:
90
+
89
91
Protocols
90
92
---------
91
93
@@ -122,13 +124,125 @@ Worse yet, all of that is multiplied for each additional read-only attribute.
122
124
Rationale
123
125
=========
124
126
125
-
[Describe why particular design decisions were made.]
127
+
This problem can be resolved by an attribute-level type qualifier. ``ReadOnly``
128
+
has been chosen for this role, as its name conveys the intent well, and the newly
129
+
proposed changes complement its semantics defined in :pep:`705`.
130
+
131
+
A class with a read-only instance attribute can be now defined as such::
132
+
133
+
from typing import ReadOnly
134
+
135
+
class Member:
136
+
id: ReadOnly[int]
137
+
138
+
def __init__(self, id: int) -> None:
139
+
self.id = id
140
+
141
+
...and a protocol as described in :ref:`protocols` is now just::
142
+
143
+
from typing import Protocol, ReadOnly
144
+
145
+
class HasName(Protocol):
146
+
name: ReadOnly[str]
147
+
148
+
def greet(obj: HasName, /) -> str:
149
+
return f"Hello, {obj.name}!"
150
+
151
+
A subclass of ``Member`` can redefine ``id`` as a ``property`` or writable
152
+
attribute, while staying compatible with the base class.
153
+
154
+
The ``HasName`` protocol can be implemented by any mechanism allowing for ``.name`` access.
155
+
156
+
The ``greet`` function can now accept a wide variety of compatible objects,
157
+
while being explicit about no modifications being done to the input.
126
158
127
159
128
160
Specification
129
161
=============
130
162
131
-
[Describe the syntax and semantics of any new language feature.]
163
+
The :external+py3.13:data:`typing.ReadOnly` type qualifier becomes a valid annotation
164
+
for attributes of classes and protocols.
165
+
166
+
Classes
167
+
-------
168
+
169
+
In a class attribute declaration, ``ReadOnly`` indicates that assignment to the
170
+
attribute can only occur as a part of the declaration, or within a set of
# multiple assignments during initialization are fine
187
+
self.songs = list(songs)
188
+
189
+
def clear(self) -> None:
190
+
self.songs = [] # Type check error: "songs" is read only
191
+
192
+
193
+
band = Band("Boa", ["Duvet"])
194
+
band.name = "Emma" # Ok: "name" is not read-only
195
+
band.songs = [] # Type check error: "songs" is read-only
196
+
band.songs.append("Twilight") # Ok: list is mutable
197
+
198
+
The set of initializing methods consists of ``__new__`` and ``__init__``.
199
+
However, type checkers may permit assignment in additional `special methods <https://docs.python.org/3/glossary.html#term-special-method>`_
200
+
to facilitate 3rd party mechanisms such as dataclasses' `__post_init__ <https://docs.python.org/3/library/dataclasses.html#dataclasses.__post_init__>`_.
201
+
It should be non-ambiguous that those methods are not called outside initialization.
202
+
203
+
A read-only attribute with an initializer remains assignable to during initialization::
204
+
205
+
class Foo:
206
+
number: ReadOnly[int] = 0
207
+
208
+
def __init__(self, number: int | None = None) -> None:
209
+
if number is not None:
210
+
self.number = number
211
+
212
+
Note that this cannot be used in a class defining ``__slots__``, as slots prohibit
213
+
the existence of class-level and instance-level attributes of same name.
214
+
215
+
Protocols
216
+
---------
217
+
218
+
In a protocol attribute declaration, ``name: ReadOnly[T]`` indicates that a structural
219
+
subtype must support ``.name`` access, and the returned value is compatible with ``T``.
220
+
221
+
Interaction with other special types
222
+
------------------------------------
223
+
224
+
``ReadOnly`` can be used with ``ClassVar`` and ``Annotated`` in any nesting order:
225
+
226
+
.. code-block:: python
227
+
228
+
classFoo:
229
+
foo: ClassVar[ReadOnly[str]] ="foo"
230
+
bar: Annotated[ReadOnly[int], Gt(0)]
231
+
232
+
.. code-block:: python
233
+
234
+
classFoo:
235
+
foo: ReadOnly[ClassVar[str]] ="foo"
236
+
bar: ReadOnly[Annotated[int, Gt(0)]]
237
+
238
+
This is consistent with the interaction of ``ReadOnly`` and :class:`typing.TypedDict`
239
+
defined in :pep:`705`.
240
+
241
+
The combination of ``ReadOnly`` and ``ClassVar`` imposes the attribute must be
242
+
initialized in the class scope, as there are no other valid initialization scopes.
243
+
244
+
``ReadOnly`` cannot be combined with ``Final``, as the two qualifiers define incompatible
245
+
initialization rules, leading to ambiguity and/or significance of ordering.
0 commit comments