Skip to content

Commit db3d76e

Browse files
committed
Rationale & specification
1 parent 5d2e422 commit db3d76e

File tree

1 file changed

+138
-24
lines changed

1 file changed

+138
-24
lines changed

peps/pep-0763.rst

Lines changed: 138 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,9 @@ Motivation
2626
==========
2727

2828
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.
3232

3333
Classes
3434
-------
@@ -37,32 +37,32 @@ There are 3 major ways of achieving read-only attributes, honored by type checke
3737

3838
* annotating the attribute with :data:`typing.Final`::
3939

40-
class Foo:
41-
number: Final[int]
40+
class Foo:
41+
number: Final[int]
4242

43-
def __init__(self, number: int) -> None:
44-
self.number = number
43+
def __init__(self, number: int) -> None:
44+
self.number = number
4545

4646

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
5050

5151
- Supported by :mod:`dataclasses` (since `typing#1669 <https://github.com/python/typing/pull/1669>`_).
5252
- Overriding ``number`` is not possible - the specification of ``Final``
5353
imposes the name cannot be overridden in subclasses.
5454

5555
* read-only proxy via ``@property``::
5656

57-
class Foo:
58-
_number: int
57+
class Foo:
58+
_number: int
5959

60-
def __init__(self, number: int) -> None:
61-
self._number = number
60+
def __init__(self, number: int) -> None:
61+
self._number = number
6262

63-
@property
64-
def number(self) -> int:
65-
return self._number
63+
@property
64+
def number(self) -> int:
65+
return self._number
6666

6767
- Overriding ``number`` is possible. *Type checkers disagree about the specific rules*. [#overriding_property]_
6868
- Read-only at runtime. [#runtime]_
@@ -72,20 +72,22 @@ There are 3 major ways of achieving read-only attributes, honored by type checke
7272

7373
* using a "freezing" mechanism, such as :func:`dataclasses.dataclass` or :class:`typing.NamedTuple`::
7474

75-
@dataclass(frozen=True)
76-
class Foo:
77-
number: int
75+
@dataclass(frozen=True)
76+
class Foo:
77+
number: int
7878

7979

80-
class Bar(NamedTuple):
81-
number: int
80+
class Bar(NamedTuple):
81+
number: int
8282

8383
- Overriding ``number`` is possible in the ``@dataclass`` case.
8484
- Read-only at runtime. [#runtime]_
8585
- No per-attribute control - these methods apply to the whole class.
8686
- Frozen dataclasses incur some runtime overhead.
8787
- Not all classes qualify to be a ``NamedTuple``.
8888

89+
.. _protocols:
90+
8991
Protocols
9092
---------
9193

@@ -122,13 +124,125 @@ Worse yet, all of that is multiplied for each additional read-only attribute.
122124
Rationale
123125
=========
124126

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.
126158

127159

128160
Specification
129161
=============
130162

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
171+
initializing methods in the same class::
172+
173+
from collections import abc
174+
from typing import ReadOnly
175+
176+
177+
class Band:
178+
name: str
179+
songs: ReadOnly[list[str]]
180+
181+
def __init__(self, name: str, songs: abc.Iterable[str] | None = None) -> None:
182+
self.name = name
183+
self.songs = []
184+
185+
if songs is not None:
186+
# 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+
class Foo:
229+
foo: ClassVar[ReadOnly[str]] = "foo"
230+
bar: Annotated[ReadOnly[int], Gt(0)]
231+
232+
.. code-block:: python
233+
234+
class Foo:
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.
132246

133247

134248
Backwards Compatibility

0 commit comments

Comments
 (0)