Skip to content

Commit 7893b78

Browse files
committed
Syntax
1 parent db3d76e commit 7893b78

File tree

1 file changed

+99
-41
lines changed

1 file changed

+99
-41
lines changed

peps/pep-0763.rst

Lines changed: 99 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
PEP: 763
22
Title: Classes & protocols: Read-only attributes
33
Author: <REQUIRED: list of authors' real names and optionally, email addrs>
4-
Sponsor: <real name of sponsor>
4+
Sponsor: Carl Meyer <carl@oddbird.net>
55
Discussions-To: <REQUIRED: URL of current canonical discussion thread>
66
Status: Draft
77
Type: Standards Track
@@ -17,7 +17,9 @@ Abstract
1717
to allow defining read-only :class:`typing.TypedDict` items.
1818

1919
This PEP proposes expanding the scope of ``ReadOnly`` to class and protocol
20-
attributes, as a single concise way to mark them read-only.
20+
attributes, as a single concise way to mark them read-only. Some parity changes
21+
are also made to :class:`typing.Final`.
22+
2123
Akin to :pep:`705`, it makes no Python grammar changes. Correct usage of
2224
read-only attributes is intended to be enforced only by static type checkers.
2325

@@ -30,6 +32,8 @@ This feature is common in other object-oriented languages (such as `C# <https://
3032
and is useful for restricting attribute mutation at a type checker level, as well
3133
as defining a broad interface for structural subtyping.
3234

35+
.. _classes:
36+
3337
Classes
3438
-------
3539

@@ -82,17 +86,19 @@ There are 3 major ways of achieving read-only attributes, honored by type checke
8286

8387
- Overriding ``number`` is possible in the ``@dataclass`` case.
8488
- Read-only at runtime. [#runtime]_
85-
- No per-attribute control - these methods apply to the whole class.
89+
- No per-attribute control - these mechanisms apply to the whole class.
8690
- Frozen dataclasses incur some runtime overhead.
87-
- Not all classes qualify to be a ``NamedTuple``.
91+
- ``NamedTuple`` is still a ``tuple``. Most classes do not need to inherit
92+
indexing, iteration, or concatenation.
8893

8994
.. _protocols:
9095

9196
Protocols
9297
---------
9398

9499
Paraphrasing `this post <https://github.com/python/typing/discussions/1525>`_,
95-
there is no way of defining a :class:`~typing.Protocol`, such that the only requirements to satisfy are:
100+
there is no way of defining a :class:`~typing.Protocol`, such that the only
101+
requirements to satisfy are:
96102

97103
1. ``hasattr(obj, name)``
98104
2. ``isinstance(obj.name, T)`` [#invalid_typevar]_
@@ -105,6 +111,9 @@ The above are satisfiable at runtime by all of the following:
105111
4. an object with a ``@property`` ``def name(self) -> T``,
106112
5. an object with a custom descriptor, such as :func:`functools.cached_property`.
107113

114+
Note that the attribute being marked ``Final`` or the property defining a setter
115+
do not impact this.
116+
108117
The most common practice is to define such a protocol with a ``@property``::
109118

110119
class HasName[T](Protocol):
@@ -117,21 +126,22 @@ other than ``property`` are rejected.
117126

118127
Covering the extra possibilities induces a great amount of boilerplate, involving
119128
creation of an abstract descriptor protocol, possibly also accounting for
120-
class vs instance level overloads.
129+
class and instance level overloads.
121130
Worse yet, all of that is multiplied for each additional read-only attribute.
122131

123132

124133
Rationale
125134
=========
126135

127-
This problem can be resolved by an attribute-level type qualifier. ``ReadOnly``
136+
These problems can be resolved by an attribute-level type qualifier. ``ReadOnly``
128137
has been chosen for this role, as its name conveys the intent well, and the newly
129138
proposed changes complement its semantics defined in :pep:`705`.
130139

131140
A class with a read-only instance attribute can be now defined as such::
132141

133142
from typing import ReadOnly
134143

144+
135145
class Member:
136146
id: ReadOnly[int]
137147

@@ -142,19 +152,19 @@ A class with a read-only instance attribute can be now defined as such::
142152

143153
from typing import Protocol, ReadOnly
144154

155+
145156
class HasName(Protocol):
146157
name: ReadOnly[str]
147158

159+
148160
def greet(obj: HasName, /) -> str:
149161
return f"Hello, {obj.name}!"
150162

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.
163+
* A subclass of ``Member`` can redefine ``id`` as a ``property`` or writable
164+
attribute, while staying compatible with the base class.
165+
* The ``HasName`` protocol can be implemented by any mechanism allowing for ``.name`` access.
166+
* The ``greet`` function can now accept a wide variety of compatible objects,
167+
while being explicit about no modifications being done to the input.
158168

159169

160170
Specification
@@ -163,12 +173,46 @@ Specification
163173
The :external+py3.13:data:`typing.ReadOnly` type qualifier becomes a valid annotation
164174
for attributes of classes and protocols.
165175

166-
Classes
167-
-------
176+
It remains invalid in annotations of global and local variables, as in those contexts
177+
it would have the same meaning as using ``Final``.
178+
179+
Syntax
180+
------
181+
182+
``ReadOnly`` can be used at class-level or within ``__init__`` to declare
183+
an attribute read-only:
184+
185+
.. code-block:: python
186+
187+
class Base:
188+
id: ReadOnly[int]
189+
190+
def __init__(self, id: int, rate: float) -> None:
191+
self.id = id
192+
self.rate: ReadOnly = rate
193+
194+
The explicit type in ``ReadOnly[<type>]`` can be omitted if an initializing value
195+
is assigned to the attribute. A type checker should apply its usual type inference
196+
rules to determine the type of ``rate``.
168197

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::
198+
In contexts where an attribute is already implied to be read-only, like in the
199+
frozen :ref:`classes`, it should be valid to explicitly declare it ``ReadOnly``:
200+
201+
.. code-block:: python
202+
203+
@dataclass(frozen=True)
204+
class Point:
205+
x: ReadOnly[int]
206+
y: ReadOnly[int]
207+
208+
Initialization
209+
--------------
210+
211+
Assignment to a ``ReadOnly`` attribute can only occur as a part of the declaration,
212+
or within ``__init__`` of the same class. There is no restriction to how many
213+
times the attribute can be assigned to in those contexts. Example:
214+
215+
.. code-block:: python
172216
173217
from collections import abc
174218
from typing import ReadOnly
@@ -187,37 +231,41 @@ initializing methods in the same class::
187231
self.songs = list(songs)
188232
189233
def clear(self) -> None:
190-
self.songs = [] # Type check error: "songs" is read only
234+
# Type check error: assignment to read-only "songs" outside initialization
235+
self.songs = []
191236
192237
193-
band = Band("Boa", ["Duvet"])
238+
band = Band(name="Boa", songs=["Duvet"])
194239
band.name = "Emma" # Ok: "name" is not read-only
195240
band.songs = [] # Type check error: "songs" is read-only
196241
band.songs.append("Twilight") # Ok: list is mutable
197242
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.
243+
Classes which do not define `__slots__ <https://docs.python.org/3/reference/datamodel.html#object.__slots__>`_
244+
may give the attribute a default value, overridable at instance level:
202245

203-
A read-only attribute with an initializer remains assignable to during initialization::
246+
.. code-block:: python
204247
205248
class Foo:
206249
number: ReadOnly[int] = 0
207250
208251
def __init__(self, number: int | None = None) -> None:
209252
if number is not None:
210-
self.number = number
253+
self.number = number
211254
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-
---------
255+
Inheritance
256+
-----------
217257

218258
In a protocol attribute declaration, ``name: ReadOnly[T]`` indicates that a structural
219259
subtype must support ``.name`` access, and the returned value is compatible with ``T``.
220260

261+
Changes to ``Final``
262+
--------------------
263+
264+
.. TODO
265+
once changes are done, this probably won't be true
266+
``ReadOnly`` cannot be combined with ``Final``, as the two qualifiers differ in
267+
initialization rules, leading to ambiguity and/or significance of ordering.
268+
221269
Interaction with other special types
222270
------------------------------------
223271

@@ -238,11 +286,8 @@ Interaction with other special types
238286
This is consistent with the interaction of ``ReadOnly`` and :class:`typing.TypedDict`
239287
defined in :pep:`705`.
240288

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.
289+
``ClassVar`` excludes read-only attributes from being assignable to within
290+
initialization methods.
246291

247292

248293
Backwards Compatibility
@@ -252,10 +297,10 @@ This PEP introduces new contexts where ``ReadOnly`` is valid. Programs inspectin
252297
those places will have to change to support it. This is expected to mainly affect type checkers.
253298

254299
However, caution is advised while using the backported ``typing_extensions.ReadOnly``
255-
in older versions of Python, especially in conjunction with other type qualifiers;
256-
not all nesting orderings might be treated equal. In particular, the ``@dataclass``
257-
decorator which looks for ``ClassVar`` will incorrectly treat
258-
``ReadOnly[ClassVar[...]]`` as an instance attribute.
300+
in older versions of Python. Mechanisms inspecting annotations may behave incorrectly
301+
when encountering ``ReadOnly``; in particular, the ``@dataclass`` decorator
302+
which `looks for <https://docs.python.org/3/library/dataclasses.html#class-variables>`_
303+
``ClassVar`` will incorrectly treat ``ReadOnly[ClassVar[...]]`` as an instance attribute.
259304

260305

261306
Security Implications
@@ -270,6 +315,19 @@ How to Teach This
270315
[How to teach users, new and experienced, how to apply the PEP to their work.]
271316

272317

318+
Rejected ideas
319+
==============
320+
321+
Assignment in ``__post_init__``
322+
-------------------------------
323+
324+
An earlier version of this PEP specified that assignment to read-only attributes
325+
may also be permitted within methods augmenting initialization, such as
326+
dataclasses' `__post_init__ <https://docs.python.org/3/library/dataclasses.html#dataclasses.__post_init__>`_
327+
or attrs' `initialization hooks <https://www.attrs.org/en/stable/init.html#hooking-yourself-into-initialization>`_.
328+
This has been set aside for now, as defining rules regarding inclusion of
329+
such methods has proven difficult.
330+
273331
Footnotes
274332
=========
275333

0 commit comments

Comments
 (0)