Skip to content

Commit e36c190

Browse files
committed
Add __new__ and precise initialization, TypeScript, ReadOnly must have a type, better immutability examples, yeet changes to Final
1 parent cc85100 commit e36c190

File tree

1 file changed

+113
-109
lines changed

1 file changed

+113
-109
lines changed

peps/pep-0767.rst

Lines changed: 113 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
PEP: 767
2-
Title: Classes & protocols: Read-only attributes
2+
Title: Read-only class and protocol attributes
33
Author: <REQUIRED: list of authors' real names and optionally, email addrs>
44
Sponsor: Carl Meyer <carl@oddbird.net>
55
Discussions-To: <REQUIRED: URL of current canonical discussion thread>
@@ -20,17 +20,19 @@ This PEP proposes expanding the scope of ``ReadOnly`` to class and protocol
2020
attributes, as a single concise way to mark them read-only. Some parity changes
2121
are also made to :data:`typing.Final`.
2222

23-
Akin to :pep:`705`, it makes no changes to setting attributes at runtime. Correct usage of
24-
read-only attributes is intended to be enforced only by static type checkers.
23+
Akin to the :pep:`705`, it makes no changes to setting attributes at runtime. Correct
24+
usage of read-only attributes is intended to be enforced only by static type checkers.
2525

2626

2727
Motivation
2828
==========
2929

3030
The Python type system lacks a single concise way to mark an :term:`attribute` read-only.
31-
This feature is common in other object-oriented languages (such as `C# <https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/readonly>`_),
32-
and is useful for restricting attribute mutation at a type checker level, as well
33-
as defining a broad interface for structural subtyping.
31+
This feature is present in other statically and gradually typed languages
32+
(such as `C# <https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/readonly>`_
33+
or `TypeScript <https://www.typescriptlang.org/docs/handbook/2/objects.html#readonly-properties>`_),
34+
and is useful for restricting attribute reassignment at a type checker level,
35+
as well as defining a broad interface for structural subtyping.
3436

3537
.. _classes:
3638

@@ -78,11 +80,11 @@ Today, there are three major ways of achieving read-only attributes, honored by
7880

7981
@dataclass(frozen=True)
8082
class Foo:
81-
number: int
83+
number: int # implicitly read-only
8284

8385

8486
class Bar(NamedTuple):
85-
number: int
87+
number: int # implicitly read-only
8688

8789
- Overriding ``number`` is possible in the ``@dataclass`` case.
8890
- Read-only at runtime. [#runtime]_
@@ -97,8 +99,8 @@ Protocols
9799
---------
98100

99101
Paraphrasing `this post <https://github.com/python/typing/discussions/1525>`_,
100-
there is no way of defining an attribute ``name: T`` on a :class:`~typing.Protocol`, such that the only
101-
requirements to satisfy are:
102+
there is no way of defining an attribute ``name: T`` on a :class:`~typing.Protocol`,
103+
such that the only requirements to satisfy are:
102104

103105
1. ``hasattr(obj, "name")``
104106
2. ``isinstance(obj.name, T)`` [#invalid_typevar]_
@@ -135,7 +137,7 @@ Rationale
135137

136138
These problems can be resolved by an attribute-level :external+typing:term:`type qualifier`.
137139
``ReadOnly`` has been chosen for this role, as its name conveys the intent well,
138-
and the newly proposed changes complement its semantics defined in :pep:`705`.
140+
and the newly proposed changes complement its semantics defined in the :pep:`705`.
139141

140142
A class with a read-only instance attribute can now be defined as::
141143

@@ -144,7 +146,7 @@ A class with a read-only instance attribute can now be defined as::
144146

145147
class Member:
146148
def __init__(self, id: int) -> None:
147-
self.id: ReadOnly = id
149+
self.id: ReadOnly[int] = id
148150

149151
...and the protocol described in :ref:`protocols` is now just::
150152

@@ -170,51 +172,98 @@ Specification
170172

171173
The :external+py3.13:data:`typing.ReadOnly` :external+typing:term:`type qualifier`
172174
becomes a valid annotation for :term:`attributes <attribute>` of classes and protocols.
173-
Type checkers should error on any attempt to reassign or ``del``\ ete an attribute annotated with ``ReadOnly``.
175+
It can be used at class-level or within ``__init__`` to mark individual attributes read-only::
176+
177+
class Book:
178+
id: ReadOnly[int]
179+
180+
def __init__(self, id: int, name: str) -> None:
181+
self.id = id
182+
self.name: ReadOnly[str] = name
183+
184+
Type checkers should error on any attempt to reassign or ``del``\ ete an attribute
185+
annotated with ``ReadOnly``.
174186

175187
Type checkers should also error on any attempt to delete an attribute annotated as ``Final``.
176188
(This is not currently specified.)
177189

178-
Akin to ``Final``, read-only attributes do not influence the mutability of
179-
the assigned object. Immutable ABCs and containers may be used in combination with
180-
``ReadOnly`` to prevent mutation of such values.
190+
Akin to ``Final`` [#final_mutability]_, ``ReadOnly`` does not influence how
191+
type checkers perceive the mutability of the assigned object. Immutable :term:`ABCs <abstract base class>`
192+
and :mod:`containers <collections.abc>` may be used in combination with ``ReadOnly``
193+
to forbid mutation of such values:
181194

182-
Syntax
183-
------
195+
.. code-block:: python
184196
185-
``ReadOnly`` can be used at class-level or within ``__init__`` to mark individual
186-
attributes read-only:
197+
from collections import abc
198+
from dataclasses import dataclass
199+
from typing import Protocol, ReadOnly
187200
188-
.. code-block:: python
189201
190-
class Book:
191-
id: ReadOnly[int]
202+
@dataclass
203+
class Game:
204+
name: str
192205
193-
def __init__(self, id: int, name: str) -> None:
194-
self.id = id
195-
self.name: ReadOnly = name
196206
197-
The explicit type in ``ReadOnly[<type>]`` can be omitted if the declaration has
198-
an initializing value. A type checker should apply its usual type inference
199-
rules to determine the type of ``name``.
207+
class HasGames[T: abc.Collection[Game]](Protocol):
208+
games: ReadOnly[T]
209+
210+
211+
def add_games(shelf: HasGames[list[Game]]) -> None:
212+
shelf.games.append(Game("Half-Life")) # ok: list is mutable
213+
shelf.games[-1].name = "Black Mesa" # ok: "name" is not read-only
214+
shelf.games = [] # error: "games" is read-only
215+
del shelf.games # error: "games" is read-only and cannot be deleted
216+
217+
218+
def read_games(shelf: HasGames[abc.Sequence[Game]]) -> None:
219+
shelf.games.append(...) # error: "Sequence" has no attribute "append"
220+
shelf.games[0].name = "Blue Shift" # ok: "name" is not read-only
221+
shelf.games = [] # error: "games" is read-only
222+
200223
201-
If an attribute is already implied to be read-only, like in frozen :ref:`classes`,
202-
explicit declarations should be permitted and seen as equivalent, except that ``Final``
203-
additionally forbids overriding in subclasses:
224+
All instance attributes of frozen dataclasses and ``NamedTuple`` should be
225+
implied to be read-only. Type checkers **should not** flag redundant annotations
226+
of such attributes with ``ReadOnly``:
204227

205228
.. code-block:: python
206229
230+
from dataclasses import dataclass
231+
from typing import NewType, ReadOnly
232+
233+
207234
@dataclass(frozen=True)
208235
class Point:
209-
x: ReadOnly[int]
210-
y: Final[int]
236+
x: int # implicit read-only
237+
y: ReadOnly[int] # ok, explicit read-only
238+
239+
240+
uint = NewType("uint", int)
241+
242+
243+
@dataclass(frozen=True)
244+
class UnsignedPoint(Point):
245+
x: ReadOnly[uint] # ok, explicit & narrower type
246+
y: uint # ok, narrower type
211247
212248
Initialization
213249
--------------
214250

215-
Assignment to a ``ReadOnly`` attribute can only occur as a part of the declaration,
216-
or within ``__init__`` of the same class. There is no restriction to how many
217-
times the attribute can be assigned to.
251+
Assignment to a read-only attribute can only occur in the class declaring the attribute.
252+
There is no restriction to how many times the attribute can be assigned to.
253+
The assignment can happen only\* in the following contexts:
254+
255+
1. In the body of ``__init__``, on the instance received as the first parameter (likely, ``self``).
256+
2. In the body of ``__new__``, on instances of the declaring class created via
257+
a direct call to a super-class' ``__new__`` method.
258+
3. In the body of the class.
259+
260+
\*A type checker may choose to allow assignment to read-only attributes on instances
261+
of the declaring class in ``__new__``, without regard to the origin of the instance.
262+
(This choice trades soundness, as the instance may already be initialized,
263+
for the simplicity of implementation.)
264+
265+
Note that child classes cannot assign to read-only attributes of parent classes
266+
in any of the aforementioned contexts, unless the attributes are redeclared.
218267

219268
.. code-block:: python
220269
@@ -240,40 +289,34 @@ times the attribute can be assigned to.
240289
241290
242291
band = Band(name="Bôa", songs=["Duvet"])
243-
band.name = "Python" # ok: "name" is not read-only
244-
band.songs = [] # error: "songs" is read-only
292+
band.name = "Python" # ok: "name" is not read-only
293+
band.songs = [] # error: "songs" is read-only
245294
band.songs.append("Twilight") # ok: list is mutable
246295
247296
248297
class SubBand(Band):
249298
def __init__(self) -> None:
250-
# error: cannot assign to a read-only attribute of base class
251-
self.songs = []
299+
self.songs = [] # error: cannot assign to a read-only attribute of base class
252300
253-
An initializing value at a class level can serve as a `flyweight <https://en.wikipedia.org/wiki/Flyweight_pattern>`_
301+
When a class-level declaration has an initializing value, it can serve as a `flyweight <https://en.wikipedia.org/wiki/Flyweight_pattern>`_
254302
default for instances:
255303

256304
.. code-block:: python
257305
258306
class Patient:
259-
number: ReadOnly = 0
307+
number: ReadOnly[int] = 0
260308
261309
def __init__(self, number: int | None = None) -> None:
262310
if number is not None:
263311
self.number = number
264312
265-
This feature should also be supported by ``Final`` attributes. Specifically,
266-
``Final`` attributes initialized in a class body **should no longer** imply ``ClassVar``,
267-
and should remain assignable to within ``__init__``.
268-
269313
.. note::
270-
Classes defining :data:`~object.__slots__` cannot make use of this feature.
271-
An attribute with a class-level value cannot be included in slots,
272-
effectively making it a class variable.
273-
Type checkers may warn or suggest explicitly marking the attribute as a ``ClassVar``.
314+
This feature conflicts with :data:`~object.__slots__`. An attribute with
315+
a class-level value cannot be included in slots, effectively making it a class variable.
274316

275-
Type checkers should warn on read-only attributes which may be left uninitialized
276-
after ``__init__`` exits, except in :external+typing:term:`stubs <stub>`, protocols or ABCs::
317+
Type checkers can choose to warn on read-only attributes which may be left uninitialized
318+
after an instance is created (except in :external+typing:term:`stubs <stub>`,
319+
protocols or ABCs)::
277320

278321
class Patient:
279322
id: ReadOnly[int] # error: "id" is not initialized on all code paths
@@ -322,13 +365,16 @@ Borrowing from :pep:`PEP 705 <705#inheritance>`:
322365

323366
* If a read-only attribute is not redeclared, it remains read-only::
324367

325-
@dataclass
326368
class Game(HasTitle):
327369
year: int
328370

371+
def __init__(self, title: str, year: int) -> None:
372+
self.title = title # error: cannot assign to a read-only attribute of base class
373+
self.year = year
329374

330-
game = Game(title="DOOM", year=1993)
331-
game.title = "DOOM II" # error: attribute is read-only
375+
376+
game = Game(title="Robot Wants Kitty", year=2010)
377+
game.title = "Robot Wants Puppy" # error: "title" is read-only
332378

333379
* Subtypes can :external+typing:term:`narrow` the type of read-only attributes::
334380

@@ -412,8 +458,8 @@ Interaction with other special types
412458
This is consistent with the interaction of ``ReadOnly`` and :class:`typing.TypedDict`
413459
defined in :pep:`705`.
414460

415-
``ClassVar`` excludes read-only attributes from being assignable to within
416-
initialization methods.
461+
An attribute annotated as both ``ReadOnly`` and ``ClassVar`` cannot be assigned to
462+
within ``__new__`` or ``__init__``.
417463

418464
Rules of ``Final`` should take priority when combined with ``ReadOnly``. As such,
419465
type checkers may warn on the redundancy of combining the two type qualifiers.
@@ -429,7 +475,10 @@ However, caution is advised while using the backported ``typing_extensions.ReadO
429475
in older versions of Python. Mechanisms inspecting annotations may behave incorrectly
430476
when encountering ``ReadOnly``; in particular, the ``@dataclass`` decorator
431477
which `looks for <https://docs.python.org/3/library/dataclasses.html#class-variables>`_
432-
``ClassVar`` will incorrectly treat ``ReadOnly[ClassVar[...]]`` as an instance attribute.
478+
``ClassVar`` may incorrectly treat ``ReadOnly[ClassVar[...]]`` as an instance attribute.
479+
480+
In such circumstances, authors should prefer ``ClassVar[ReadOnly[...]]`` over
481+
``ReadOnly[ClassVar[...]]``.
433482

434483

435484
Security Implications
@@ -447,49 +496,6 @@ How to Teach This
447496
Open Issues
448497
===========
449498

450-
Assignment in ``__new__``
451-
-------------------------
452-
453-
Immutable classes like :class:`fractions.Fraction` often do not define ``__init__``;
454-
instead, they perform initialization in ``__new__`` or classmethods. The proposed
455-
feature won't be useful to them.
456-
457-
OTOH, allowing assignment within ``__new__`` (and/or classmethods) could open way
458-
to non-trivial bugs:
459-
460-
.. code-block:: python
461-
462-
class Foo:
463-
# fully initialized objects
464-
object_cache: ReadOnly[ClassVar[dict[int, Self]]] = {}
465-
466-
foo: ReadOnly[int]
467-
468-
def __new__(cls, foo: int) -> Self:
469-
if foo + 1 in cls.object_cache:
470-
# this instance is already initialized
471-
self = cls.object_cache[foo + 1]
472-
473-
else:
474-
# this instance is not
475-
self = super().__new__(cls)
476-
477-
# assignment to an object which has been initialized before,
478-
# breaking the invariant a read-only attribute can be assigned to
479-
# only during its initialization?
480-
self.foo = foo
481-
482-
cls.object_cache[foo] = self
483-
return self
484-
485-
To my understanding, properly detecting this problem would require type checkers
486-
to keep track of the "level of initialization" of an object.
487-
488-
This issue doesn't seem to impact ``__init__``, since it's rather uncommon to
489-
ever rebind ``self`` within it to any other object, and type checkers could
490-
flag the action as whole.
491-
492-
493499
Extending initialization
494500
------------------------
495501

@@ -502,7 +508,8 @@ functionality differs between mechanisms (``__post_init__`` vs ``__attrs_post_in
502508
``ReadOnly[ClassVar[...]]`` and ``__init_subclass__``
503509
-----------------------------------------------------
504510

505-
Should this be allowed?
511+
Should read-only class variables be assignable to within the defining class'
512+
``__init_subclass__``?
506513

507514
.. code-block:: python
508515
@@ -514,12 +521,6 @@ Should this be allowed?
514521
515522
class File(URI, protocol="file"): ...
516523
517-
``Final`` in protocols
518-
----------------------
519-
520-
It's been `suggested <https://discuss.python.org/t/expanding-readonly-to-normal-classes-protocols/67359/45>`_
521-
to clarify in this PEP whether ``Final`` should be supported by protocols.
522-
523524
524525
Footnotes
525526
=========
@@ -538,6 +539,9 @@ Footnotes
538539
The implied type variable is not valid in this context; it has been used for
539540
the ease of demonstration. See `ClassVar <https://typing.readthedocs.io/en/latest/spec/class-compat.html#classvar>`_.
540541
542+
.. [#final_mutability]
543+
As noted above the second-to-last code example of https://typing.readthedocs.io/en/latest/spec/qualifiers.html#semantics-and-examples
544+
541545
542546
Copyright
543547
=========

0 commit comments

Comments
 (0)