11PEP: 767
2- Title: Classes & protocols: Read-only attributes
2+ Title: Read-only class and protocol attributes
33Author: <REQUIRED: list of authors' real names and optionally, email addrs>
44Sponsor: Carl Meyer <carl@oddbird.net>
55Discussions-To: <REQUIRED: URL of current canonical discussion thread>
@@ -20,17 +20,19 @@ This PEP proposes expanding the scope of ``ReadOnly`` to class and protocol
2020attributes, as a single concise way to mark them read-only. Some parity changes
2121are 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
2727Motivation
2828==========
2929
3030The 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
99101Paraphrasing `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
1031051. ``hasattr(obj, "name") ``
1041062. ``isinstance(obj.name, T) `` [#invalid_typevar ]_
@@ -135,7 +137,7 @@ Rationale
135137
136138These 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
140142A 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
171173The :external+py3.13:data: `typing.ReadOnly ` :external+typing:term: `type qualifier `
172174becomes 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
175187Type 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 >`_
254302default 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 `
413459defined 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
418464Rules of ``Final `` should take priority when combined with ``ReadOnly ``. As such,
419465type 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
429475in older versions of Python. Mechanisms inspecting annotations may behave incorrectly
430476when encountering ``ReadOnly ``; in particular, the ``@dataclass `` decorator
431477which `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
435484Security Implications
@@ -447,49 +496,6 @@ How to Teach This
447496Open 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-
493499Extending 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