Skip to content

Commit baa008c

Browse files
committed
References, changes to Final, open issues
1 parent a75e977 commit baa008c

File tree

1 file changed

+146
-74
lines changed

1 file changed

+146
-74
lines changed

peps/pep-0763.rst

Lines changed: 146 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ Discussions-To: <REQUIRED: URL of current canonical discussion thread>
66
Status: Draft
77
Type: Standards Track
88
Topic: Typing
9-
Created: 11-Oct-2024
9+
Created: ?-Nov-2024
1010
Python-Version: 3.?
1111

1212

@@ -18,7 +18,7 @@ to allow defining read-only :class:`typing.TypedDict` items.
1818

1919
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
21-
are also made to :class:`typing.Final`.
21+
are also made to :data:`typing.Final`.
2222

2323
Akin to :pep:`705`, it makes no Python grammar changes. Correct usage of
2424
read-only attributes is intended to be enforced only by static type checkers.
@@ -27,7 +27,7 @@ read-only attributes is intended to be enforced only by static type checkers.
2727
Motivation
2828
==========
2929

30-
Python type system lacks a single concise way to mark an attribute read-only.
30+
Python type system lacks a single concise way to mark an :term:`attribute` read-only.
3131
This feature is common in other object-oriented languages (such as `C# <https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/readonly>`_),
3232
and is useful for restricting attribute mutation at a type checker level, as well
3333
as defining a broad interface for structural subtyping.
@@ -52,7 +52,7 @@ There are 3 major ways of achieving read-only attributes, honored by type checke
5252
def __init__(self, number: int) -> None:
5353
self.number: Final = number
5454

55-
- Supported by :mod:`dataclasses` (since `typing#1669 <https://github.com/python/typing/pull/1669>`_).
55+
- Supported by :mod:`dataclasses` (and type checkers since `typing#1669 <https://github.com/python/typing/pull/1669>`_).
5656
- Overriding ``number`` is not possible - the specification of ``Final``
5757
imposes the name cannot be overridden in subclasses.
5858

@@ -116,7 +116,7 @@ do not impact this.
116116

117117
The most common practice is to define such a protocol with a ``@property``::
118118

119-
class HasName[T](Protocol):
119+
class HasName(Protocol):
120120
@property
121121
def name(self) -> T: ...
122122

@@ -133,22 +133,20 @@ Worse yet, all of that is multiplied for each additional read-only attribute.
133133
Rationale
134134
=========
135135

136-
These problems can be resolved by an attribute-level type qualifier. ``ReadOnly``
137-
has been chosen for this role, as its name conveys the intent well, and the newly
138-
proposed changes complement its semantics defined in :pep:`705`.
136+
These problems can be resolved by an attribute-level :external+typing:term:`type qualifier`.
137+
``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 the :pep:`705`.
139139

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

142142
from typing import ReadOnly
143143

144144

145145
class Member:
146-
id: ReadOnly[int]
147-
148146
def __init__(self, id: int) -> None:
149-
self.id = id
147+
self.id: ReadOnly = id
150148

151-
...and a protocol as described in :ref:`protocols` is now just::
149+
...and a protocol described in :ref:`protocols` is now just::
152150

153151
from typing import Protocol, ReadOnly
154152

@@ -160,8 +158,8 @@ A class with a read-only instance attribute can be now defined as such::
160158
def greet(obj: HasName, /) -> str:
161159
return f"Hello, {obj.name}!"
162160

163-
* A subclass of ``Member`` can redefine ``id`` as a ``property`` or writable
164-
attribute, while staying compatible with the base class.
161+
* A subclass of ``Member`` can redefine ``.id`` as a writable attribute or a
162+
:term:`descriptor`. It can also :external+typing:term:`narrow` the type.
165163
* The ``HasName`` protocol can be implemented by any mechanism allowing for ``.name`` access.
166164
* The ``greet`` function can now accept a wide variety of compatible objects,
167165
while being explicit about no modifications being done to the input.
@@ -170,47 +168,53 @@ A class with a read-only instance attribute can be now defined as such::
170168
Specification
171169
=============
172170

173-
The :external+py3.13:data:`typing.ReadOnly` type qualifier becomes a valid annotation
174-
for attributes of classes and protocols.
171+
The :external+py3.13:data:`typing.ReadOnly` :external+typing:term:`type qualifier`
172+
becomes a valid annotation for :term:`attributes <attribute>` of classes and protocols.
173+
It is used to indicate that an attribute should not be reassigned or ``del``\ eted.
174+
175+
The deletability rule should be extended to ``Final`` as well, as it is currently
176+
not specified.
175177

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+
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.
178181

179182
Syntax
180183
------
181184

182-
``ReadOnly`` can be used at class-level or within ``__init__`` to declare
183-
an attribute read-only:
185+
``ReadOnly`` can be used at class-level or within ``__init__`` to mark individual
186+
attributes read-only:
184187

185188
.. code-block:: python
186189
187-
class Base:
190+
class Book:
188191
id: ReadOnly[int]
189192
190-
def __init__(self, id: int, rate: float) -> None:
193+
def __init__(self, id: int, name: str) -> None:
191194
self.id = id
192-
self.rate: ReadOnly = rate
195+
self.name: ReadOnly = name
193196
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``.
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``.
197200

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``:
201+
If an attribute is already implied to be read-only, like in the frozen :ref:`classes`,
202+
explicit declarations should be permitted and seen as equivalent, except ``Final``
203+
additionally forbids overriding in subclasses:
200204

201205
.. code-block:: python
202206
203207
@dataclass(frozen=True)
204208
class Point:
205209
x: ReadOnly[int]
206-
y: ReadOnly[int]
210+
y: Final[int]
207211
208212
Initialization
209213
--------------
210214

211215
Assignment to a ``ReadOnly`` attribute can only occur as a part of the declaration,
212216
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:
217+
times the attribute can be assigned to.
214218

215219
.. code-block:: python
216220
@@ -235,38 +239,53 @@ times the attribute can be assigned to in those contexts. Example:
235239
self.songs = []
236240
237241
238-
band = Band(name="Boa", songs=["Duvet"])
239-
band.name = "Emma" # ok: "name" is not read-only
242+
band = Band(name="Bôa", songs=["Duvet"])
243+
band.name = "Python" # ok: "name" is not read-only
240244
band.songs = [] # error: "songs" is read-only
241245
band.songs.append("Twilight") # ok: list is mutable
242246
243-
Classes which do not define `__slots__ <https://docs.python.org/3/reference/datamodel.html#object.__slots__>`_
244-
retain the ability to initialize a read-only attribute both at declaration and
245-
within ``__init__``:
247+
248+
class SubBand(Band):
249+
def __init__(self) -> None:
250+
# error: cannot assign to a read-only attribute of base class
251+
self.songs = []
252+
253+
An initializing value at a class level can serve as a `flyweight <https://en.wikipedia.org/wiki/Flyweight_pattern>`_
254+
default for instances:
246255

247256
.. code-block:: python
248257
249-
class Foo:
250-
number: ReadOnly[int] = 0
258+
class Patient:
259+
number: ReadOnly = 0
251260
252261
def __init__(self, number: int | None = None) -> None:
253262
if number is not None:
254263
self.number = number
255264
256-
Type checkers should warn on attributes declared ``ReadOnly`` which may be left
257-
uninitialized after ``__init__`` exits, unless the class is a protocol or an ABC::
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__``.
258268

259-
class Foo:
269+
.. 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``.
274+
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::
277+
278+
class Patient:
260279
id: ReadOnly[int] # error: "id" is not initialized on all code paths
261280
name: ReadOnly[str] # error: "name" is never initialized
262281

263282
def __init__(self) -> None:
264283
if random.random() > 0.5:
265284
self.id = 123
266285

267-
This rule stems from the fact the body of the class declaring the attribute is
268-
the only place able to initialize it, in contrast to non-read-only attributes,
269-
which could be initialized outside of the class body.
286+
287+
class HasName(Protocol):
288+
name: ReadOnly[str] # ok
270289

271290
Subtyping
272291
---------
@@ -275,7 +294,7 @@ Read-only attributes are covariant. This has a few subtyping implications.
275294
Borrowing from :pep:`PEP 705 <705#inheritance>`:
276295

277296
* Read-only attributes can be redeclared as writable attributes, descriptors
278-
and class variables::
297+
or class variables::
279298

280299
@dataclass
281300
class HasTitle:
@@ -311,7 +330,7 @@ Borrowing from :pep:`PEP 705 <705#inheritance>`:
311330
game = Game(title="DOOM", year=1993)
312331
game.title = "DOOM II" # error: attribute is read-only
313332

314-
* Subtypes can narrow the type of read-only attributes::
333+
* Subtypes can :external+typing:term:`narrow` the type of read-only attributes::
315334

316335
class GameCollection(Protocol):
317336
games: ReadOnly[abc.Collection[Game]]
@@ -322,8 +341,8 @@ Borrowing from :pep:`PEP 705 <705#inheritance>`:
322341
name: str
323342
games: ReadOnly[list[Game]] # ok: list[Game] is assignable to Collection[Game]
324343

325-
* In nominal subclasses of protocols and ABCs, a read-only attribute should be
326-
considered abstract, unless it is initialized::
344+
* Nominal subclasses of protocols and ABCs should redeclare read-only attributes
345+
in order to implement them, unless the base class initializes them in some way::
327346

328347
class MyBase(abc.ABC):
329348
foo: ReadOnly[int]
@@ -334,14 +353,14 @@ Borrowing from :pep:`PEP 705 <705#inheritance>`:
334353
self.baz = baz
335354

336355
@abstractmethod
337-
def do_something(self) -> None: ...
356+
def pprint(self) -> None: ...
338357

339358

340359
@final
341360
class MySubclass(MyBase):
342361
# error: MySubclass does not override "foo"
343362

344-
def do_something(self) -> None:
363+
def pprint(self) -> None:
345364
print(self.foo, self.bar, self.baz)
346365

347366
* In a protocol attribute declaration, ``name: ReadOnly[T]`` indicates that a structural
@@ -373,19 +392,6 @@ Borrowing from :pep:`PEP 705 <705#inheritance>`:
373392
has_name = NamedClassVar()
374393
has_name = NamedDescriptor()
375394

376-
377-
Changes to ``Final``
378-
--------------------
379-
380-
.. TODO
381-
- deletability
382-
- final in protocols?
383-
- interaction of Final and ReadOnly - once parity changes are done, the
384-
following shouldn't be true:
385-
``ReadOnly`` cannot be combined with ``Final``, as the two qualifiers differ in
386-
initialization rules, leading to ambiguity and/or significance of ordering.
387-
- section on "Type consistency"?
388-
389395
Interaction with other special types
390396
------------------------------------
391397

@@ -409,6 +415,9 @@ defined in :pep:`705`.
409415
``ClassVar`` excludes read-only attributes from being assignable to within
410416
initialization methods.
411417

418+
Rules of ``Final`` should take priority when combined with ``ReadOnly``. As such,
419+
type checkers may warn on the redundancy of combining the two type qualifiers.
420+
412421

413422
Backwards Compatibility
414423
=======================
@@ -435,18 +444,81 @@ How to Teach This
435444
[How to teach users, new and experienced, how to apply the PEP to their work.]
436445

437446

438-
Rejected ideas
439-
==============
447+
Open Issues
448+
===========
449+
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+
493+
Extending initialization
494+
------------------------
495+
496+
Mechanisms such as :func:`dataclasses.__post_init__` or attrs' `initialization hooks <https://www.attrs.org/en/stable/init.html#hooking-yourself-into-initialization>`_
497+
augment initialization by providing a set of dunder hooks which will be called
498+
once during instance creation. The current rules would disallow assignment in those
499+
hooks. Specifying any single method in the pep isn't enough, as the naming and
500+
functionality differs between mechanisms (``__post_init__`` vs ``__attrs_post_init__``).
501+
502+
``ReadOnly[ClassVar[...]]`` and ``__init_subclass__``
503+
-----------------------------------------------------
504+
505+
Should this be allowed?
506+
507+
.. code-block:: python
508+
509+
class URI:
510+
protocol: ReadOnly[ClassVar[str]] = ""
511+
512+
def __init_subclass__(cls, protocol: str = "") -> None:
513+
cls.foo = protocol
514+
515+
class File(URI, protocol="file"): ...
440516
441-
Assignment in ``__post_init__``
442-
-------------------------------
517+
``Final`` in protocols
518+
----------------------
443519

444-
An earlier version of this PEP specified that assignment to read-only attributes
445-
may also be permitted within methods augmenting initialization, such as
446-
dataclasses' `__post_init__ <https://docs.python.org/3/library/dataclasses.html#dataclasses.__post_init__>`_
447-
or attrs' `initialization hooks <https://www.attrs.org/en/stable/init.html#hooking-yourself-into-initialization>`_.
448-
This has been set aside for now, as defining rules regarding inclusion of
449-
such methods has proven difficult.
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.
450522

451523

452524
Footnotes
@@ -463,8 +535,8 @@ Footnotes
463535
be desirable the name is read-only at runtime.
464536
465537
.. [#invalid_typevar]
466-
The implied type variable is not valid in this context. It has been used here
467-
for ease of demonstration.
538+
The implied type variable is not valid in this context; it has been used for
539+
the ease of demonstration. See `ClassVar <https://typing.readthedocs.io/en/latest/spec/class-compat.html#classvar>`_.
468540
469541
470542
Copyright

0 commit comments

Comments
 (0)