From 41864849e02031de6b7b19d10c2e6359ea73b8d3 Mon Sep 17 00:00:00 2001 From: Eneg <42005170+Enegg@users.noreply.github.com> Date: Mon, 25 Aug 2025 17:16:00 +0200 Subject: [PATCH 01/10] Update Post-History Clarify that bare `ReadOnly` is not allowed, and add that to rejected ideas Specify that `type[Protocol]` does not inherit the protocol's attributes Close issue: "Extending Initialization" Bikeshed on some wording Use my real name --- peps/pep-0767.rst | 88 ++++++++++++++++++++++------------------------- 1 file changed, 42 insertions(+), 46 deletions(-) diff --git a/peps/pep-0767.rst b/peps/pep-0767.rst index 88be22ce078..26b8eb99676 100644 --- a/peps/pep-0767.rst +++ b/peps/pep-0767.rst @@ -1,6 +1,6 @@ PEP: 767 Title: Annotating Read-Only Attributes -Author: Eneg +Author: Łukasz Modzelewski Sponsor: Carl Meyer Discussions-To: https://discuss.python.org/t/pep-767-annotating-read-only-attributes/73408 Status: Draft @@ -9,6 +9,7 @@ Topic: Typing Created: 18-Nov-2024 Python-Version: 3.15 Post-History: `09-Oct-2024 `__ + `05-Dec-2024 `__ Abstract @@ -58,7 +59,7 @@ Today, there are three major ways of achieving read-only attributes, honored by - Overriding ``number`` is not possible - the specification of ``Final`` imposes that the name cannot be overridden in subclasses. -* read-only proxy via ``@property``:: +* marking the attribute "_internal", and exposing it via read-only ``@property``:: class Foo: _number: int @@ -70,7 +71,7 @@ Today, there are three major ways of achieving read-only attributes, honored by def number(self) -> int: return self._number - - Overriding ``number`` is possible. *Type checkers disagree about the specific rules*. [#overriding_property]_ + - Overriding ``number`` is possible, but limited to using ``@property``. [#overriding_property]_ - Read-only at runtime. [#runtime]_ - Requires extra boilerplate. - Supported by :mod:`dataclasses`, but does not compose well - the synthesized @@ -90,8 +91,7 @@ Today, there are three major ways of achieving read-only attributes, honored by - Read-only at runtime. [#runtime]_ - No per-attribute control - these mechanisms apply to the whole class. - Frozen dataclasses incur some runtime overhead. - - ``NamedTuple`` is still a ``tuple``. Most classes do not need to inherit - indexing, iteration, or concatenation. + - Most classes do not need indexing, iteration, or concatenation, inherited from ``NamedTuple``. .. _protocols: @@ -123,8 +123,10 @@ This syntax has several drawbacks: * It is somewhat verbose. * It is not obvious that the quality conveyed here is the read-only character of a property. * It is not composable with :external+typing:term:`type qualifiers `. -* Not all type checkers agree [#property_in_protocol]_ that all of the above five - objects are assignable to this structural type. +* Currently, Pyright disagrees that some of the above five objects + are assignable to this structural type. + `[Pyright] `_ + `[mypy] `_ Rationale ========= @@ -155,7 +157,7 @@ A class with a read-only instance attribute can now be defined as:: return f"Hello, {obj.name}!" * A subclass of ``Member`` can redefine ``.id`` as a writable attribute or a - :term:`descriptor`. It can also :external+typing:term:`narrow` the type. + :term:`descriptor`. It can also :external+typing:term:`narrow` its type. * The ``HasName`` protocol has a more succinct definition, and is agnostic to the writability of the attribute. * The ``greet`` function can now accept a wide variety of compatible objects, @@ -167,7 +169,7 @@ Specification The :external+py3.13:data:`typing.ReadOnly` :external+typing:term:`type qualifier` becomes a valid annotation for :term:`attributes ` of classes and protocols. -It can be used at class-level or within ``__init__`` to mark individual attributes read-only:: +It can be used at class-level and within ``__init__`` to mark individual attributes read-only:: class Book: id: ReadOnly[int] @@ -176,6 +178,7 @@ It can be used at class-level or within ``__init__`` to mark individual attribut self.id = id self.name: ReadOnly[str] = name +Use of bare ``ReadOnly`` (without ``[]``) is not allowed. Type checkers should error on any attempt to reassign or ``del``\ ete an attribute annotated with ``ReadOnly``. Type checkers should also error on any attempt to delete an attribute annotated as ``Final``. @@ -248,16 +251,16 @@ with ``ReadOnly`` is redundant, but it should not be seen as an error: Initialization -------------- -Assignment to a read-only attribute can only occur in the class declaring the attribute. +Assignment to a read-only attribute can only occur in the class declaring the attribute, +at sites described below. There is no restriction to how many times the attribute can be assigned to. -Depending on the kind of the attribute, they can be assigned to at different sites: Instance Attributes ''''''''''''''''''' -Assignment to an instance attribute must be allowed in the following contexts: +Assignment to a read-only instance attribute must be allowed in the following contexts: -* In ``__init__``, on the instance received as the first parameter (likely, ``self``). +* In ``__init__``, on the instance received as the first parameter (usually, ``self``). * In ``__new__``, on instances of the declaring class created via a call to a super-class' ``__new__`` method. * At declaration in the body of the class. @@ -340,7 +343,7 @@ Read-only class attributes are attributes annotated as both ``ReadOnly`` and ``C Assignment to such attributes must be allowed in the following contexts: * At declaration in the body of the class. -* In ``__init_subclass__``, on the class object received as the first parameter (likely, ``cls``). +* In ``__init_subclass__``, on the class object received as the first parameter (usually, ``cls``). .. code-block:: python @@ -365,8 +368,8 @@ default for instances: self.number = number .. note:: - This feature conflicts with :data:`~object.__slots__`. An attribute with - a class-level value cannot be included in slots, effectively making it a class variable. + This is possible only in classes without :data:`~object.__slots__`. + An attribute included in slots cannot have a class-level default. Type checkers may choose to warn on read-only attributes which could be left uninitialized after an instance is created (except in :external+typing:term:`stubs `, @@ -464,8 +467,9 @@ This has a few subtyping implications. Borrowing from :pep:`705#inheritance`: def pprint(self) -> None: print(self.foo, self.bar, self.baz) -* In a protocol attribute declaration, ``name: ReadOnly[T]`` indicates that a structural - subtype must support ``.name`` access, and the returned value is assignable to ``T``:: +* In a protocol attribute declaration, ``name: ReadOnly[T]`` indicates that values + that inhabit the protocol must support ``.name`` access, and the returned value + is assignable to ``T``:: class HasName(Protocol): name: ReadOnly[str] @@ -493,6 +497,14 @@ This has a few subtyping implications. Borrowing from :pep:`705#inheritance`: has_name = NamedClassVar() has_name = NamedDescriptor() + Type checkers should not assume that access to a protocol's read-only attributes + is supported by the protocol's type (``type[HasName]``). + + Accurately modeling the behavior and type of ``type[HasName].name`` is difficult, + therefore it was left out from this PEP to reduce its complexity; + future enhancements to the typing specification may refine this behavior. + + Interaction with Other Type Qualifiers -------------------------------------- @@ -596,37 +608,27 @@ since to allow assignment in ``__new__`` and classmethods under a set of rules described in the :ref:`init` section. -Open Issues -=========== +Allowing Bare ``ReadOnly`` With Initializing Value +-------------------------------------------------- -Extending Initialization ------------------------- +An earlier version of this PEP allowed the use of bare ``ReadOnly`` when the attribute +being annotated had an initializing value. The type of the attribute was supposed +to be determined by type checkers using their usual type inference rules. -Mechanisms such as :func:`dataclasses.__post_init__` or attrs' `initialization hooks `_ -augment object creation by providing a set of special hooks which are called -during initialization. +`This thread `_ +surfaced a few non-trivial issues with this feature, like undesirable inference +of ``Literal[...]`` from literal values, differences in type checker inference rules, +or complexity of implementation due to class-level and ``__init__``-level assignments. +We decided to always require a type for ``ReadOnly[...]``, as *explicit is better than implicit*. -The current initialization rules defined in this PEP disallow assignment to -read-only attributes in such methods. It is unclear whether the rules could be -satisfyingly shaped in a way that is inclusive of those 3rd party hooks, while -upkeeping the invariants associated with the read-only-ness of those attributes. - -The Python type system has a long and detailed `specification `_ -regarding the behavior of ``__new__`` and ``__init__``. It is rather unfeasible -to expect the same level of detail from 3rd party hooks. - -A potential solution would involve type checkers providing configuration in this -regard, requiring end users to manually specify a set of methods they wish -to allow initialization in. This however could easily result in users mistakenly -or purposefully breaking the aforementioned invariants. It is also a fairly -big ask for a relatively niche feature. Footnotes ========= .. [#overriding_property] Pyright in strict mode disallows non-property overrides. - Mypy does not impose this restriction and allows an override with a plain attribute. + Mypy permits an override with a plain attribute. + Non-property overrides are technically unsafe, as they may break class-level ``Foo.number`` access. `[Pyright playground] `_ `[mypy playground] `_ @@ -634,12 +636,6 @@ Footnotes This PEP focuses solely on the type-checking behavior. Nevertheless, it should be desirable the name is read-only at runtime. -.. [#property_in_protocol] - Pyright disallows class variable and non-property descriptor overrides. - `[Pyright] `_ - `[mypy] `_ - `[Pyre] `_ - .. [#final_mutability] As noted above the second-to-last code example of https://typing.python.org/en/latest/spec/qualifiers.html#semantics-and-examples From fbe019173e50cb1307cdaea9446d5cf9e2158e0a Mon Sep 17 00:00:00 2001 From: Eneg <42005170+Enegg@users.noreply.github.com> Date: Mon, 25 Aug 2025 21:54:25 +0200 Subject: [PATCH 02/10] Attribute access off a protocol's type shouldn't have a known type --- peps/pep-0767.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/peps/pep-0767.rst b/peps/pep-0767.rst index 26b8eb99676..c9c2829c0ec 100644 --- a/peps/pep-0767.rst +++ b/peps/pep-0767.rst @@ -498,7 +498,8 @@ This has a few subtyping implications. Borrowing from :pep:`705#inheritance`: has_name = NamedDescriptor() Type checkers should not assume that access to a protocol's read-only attributes - is supported by the protocol's type (``type[HasName]``). + is supported by the protocol's type (``type[HasName]``). Even if the attribute + exists on the protocol's type, no assumptions should be made about its type. Accurately modeling the behavior and type of ``type[HasName].name`` is difficult, therefore it was left out from this PEP to reduce its complexity; From afa30568d7e06e0f0af96eb4c28c168ff62c4bf2 Mon Sep 17 00:00:00 2001 From: Eneg <42005170+Enegg@users.noreply.github.com> Date: Mon, 25 Aug 2025 21:59:03 +0200 Subject: [PATCH 03/10] the -> an --- peps/pep-0767.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/peps/pep-0767.rst b/peps/pep-0767.rst index c9c2829c0ec..65d9cac1cba 100644 --- a/peps/pep-0767.rst +++ b/peps/pep-0767.rst @@ -498,7 +498,7 @@ This has a few subtyping implications. Borrowing from :pep:`705#inheritance`: has_name = NamedDescriptor() Type checkers should not assume that access to a protocol's read-only attributes - is supported by the protocol's type (``type[HasName]``). Even if the attribute + is supported by the protocol's type (``type[HasName]``). Even if an attribute exists on the protocol's type, no assumptions should be made about its type. Accurately modeling the behavior and type of ``type[HasName].name`` is difficult, From 11f4522a750c3ef5bd7fe7c74f56e36963f93305 Mon Sep 17 00:00:00 2001 From: Eneg <42005170+Enegg@users.noreply.github.com> Date: Fri, 5 Sep 2025 20:48:20 +0200 Subject: [PATCH 04/10] Remove internal mutation restriction --- peps/pep-0767.rst | 267 +++++++++++++++++----------------------------- 1 file changed, 99 insertions(+), 168 deletions(-) diff --git a/peps/pep-0767.rst b/peps/pep-0767.rst index 65d9cac1cba..a245a113f67 100644 --- a/peps/pep-0767.rst +++ b/peps/pep-0767.rst @@ -30,10 +30,11 @@ Motivation The Python type system lacks a single concise way to mark an attribute read-only. This feature is present in other statically and gradually typed languages -(such as `C# `_ -or `TypeScript `_), -and is useful for removing the ability to reassign or ``del``\ ete an attribute -at a type checker level, as well as defining a broad interface for structural subtyping. +(such as `C# `__ +or `TypeScript `__), +and is useful for removing the ability to externally assign to or ``del``\ ete +an attribute at a type checker level, as well as defining a broad interface +for structural subtyping. .. _classes: @@ -167,6 +168,9 @@ A class with a read-only instance attribute can now be defined as:: Specification ============= +Usage +----- + The :external+py3.13:data:`typing.ReadOnly` :external+typing:term:`type qualifier` becomes a valid annotation for :term:`attributes ` of classes and protocols. It can be used at class-level and within ``__init__`` to mark individual attributes read-only:: @@ -179,100 +183,13 @@ It can be used at class-level and within ``__init__`` to mark individual attribu self.name: ReadOnly[str] = name Use of bare ``ReadOnly`` (without ``[]``) is not allowed. -Type checkers should error on any attempt to reassign or ``del``\ ete an attribute -annotated with ``ReadOnly``. -Type checkers should also error on any attempt to delete an attribute annotated as ``Final``. -(This is not currently specified.) - -Use of ``ReadOnly`` in annotations at other sites where it currently has no meaning -(such as local/global variables or function parameters) is considered out of scope -for this PEP. - -Akin to ``Final`` [#final_mutability]_, ``ReadOnly`` does not influence how -type checkers perceive the mutability of the assigned object. Immutable :term:`ABCs ` -and :mod:`containers ` may be used in combination with ``ReadOnly`` -to forbid mutation of such values at a type checker level: - -.. code-block:: python - - from collections import abc - from dataclasses import dataclass - from typing import Protocol, ReadOnly - - - @dataclass - class Game: - name: str - - - class HasGames[T: abc.Collection[Game]](Protocol): - games: ReadOnly[T] - - - def add_games(shelf: HasGames[list[Game]]) -> None: - shelf.games.append(Game("Half-Life")) # ok: list is mutable - shelf.games[-1].name = "Black Mesa" # ok: "name" is not read-only - shelf.games = [] # error: "games" is read-only - del shelf.games # error: "games" is read-only and cannot be deleted - - - def read_games(shelf: HasGames[abc.Sequence[Game]]) -> None: - shelf.games.append(...) # error: "Sequence" has no attribute "append" - shelf.games[0].name = "Blue Shift" # ok: "name" is not read-only - shelf.games = [] # error: "games" is read-only - - -All instance attributes of frozen dataclasses and ``NamedTuple`` should be -implied to be read-only. Type checkers may inform that annotating such attributes -with ``ReadOnly`` is redundant, but it should not be seen as an error: - -.. code-block:: python - - from dataclasses import dataclass - from typing import NewType, ReadOnly - - - @dataclass(frozen=True) - class Point: - x: int # implicit read-only - y: ReadOnly[int] # ok, redundant +Type checkers should error on any attempt to *externally mutate* an attribute +annotated with ``ReadOnly``. - uint = NewType("uint", int) - - - @dataclass(frozen=True) - class UnsignedPoint(Point): - x: ReadOnly[uint] # ok, redundant; narrower type - y: Final[uint] # not redundant, Final imposes extra restrictions; narrower type - -.. _init: - -Initialization --------------- - -Assignment to a read-only attribute can only occur in the class declaring the attribute, -at sites described below. -There is no restriction to how many times the attribute can be assigned to. - -Instance Attributes -''''''''''''''''''' - -Assignment to a read-only instance attribute must be allowed in the following contexts: - -* In ``__init__``, on the instance received as the first parameter (usually, ``self``). -* In ``__new__``, on instances of the declaring class created via a call - to a super-class' ``__new__`` method. -* At declaration in the body of the class. - -Additionally, a type checker may choose to allow the assignment: - -* In ``__new__``, on instances of the declaring class, without regard - to the origin of the instance. - (This choice trades soundness, as the instance may already be initialized, - for the simplicity of implementation.) -* In ``@classmethod``\ s, on instances of the declaring class created via - a call to the class' or super-class' ``__new__`` method. +We define "externally" here as occurring outside the body of the class declaring +the attribute, or its subclasses. +"Mutate" means to assign to or ``del``\ ete the attribute. .. code-block:: python @@ -292,8 +209,7 @@ Additionally, a type checker may choose to allow the assignment: self.songs = list(songs) # multiple assignments are fine def clear(self) -> None: - # error: assignment to read-only "songs" outside initialization - self.songs = [] + self.songs = [] # ok band = Band(name="Bôa", songs=["Duvet"]) @@ -302,10 +218,6 @@ Additionally, a type checker may choose to allow the assignment: band.songs.append("Twilight") # ok: list is mutable - class SubBand(Band): - def __init__(self) -> None: - self.songs = [] # error: cannot assign to a read-only attribute of a base class - .. code-block:: python # a simplified immutable Fraction class @@ -336,61 +248,76 @@ Additionally, a type checker may choose to allow the assignment: self.numerator, self.denominator = f.as_integer_ratio() return self -Class Attributes -'''''''''''''''' -Read-only class attributes are attributes annotated as both ``ReadOnly`` and ``ClassVar``. -Assignment to such attributes must be allowed in the following contexts: +It should also be error to delete an attribute annotated as ``Final``. +(This is not currently specified.) -* At declaration in the body of the class. -* In ``__init_subclass__``, on the class object received as the first parameter (usually, ``cls``). +Use of ``ReadOnly`` in annotations at other sites where it currently has no meaning +(such as local/global variables or function parameters) is considered out of scope +for this PEP. + +``ReadOnly`` does not influence the mutability of the attribute's value. Immutable +protocols and :mod:`collections ` may be used in combination +with ``ReadOnly`` to forbid mutation of those values at a type checker level: .. code-block:: python - class URI: - protocol: ReadOnly[ClassVar[str]] = "" + from collections import abc + from dataclasses import dataclass + from typing import Protocol, ReadOnly - def __init_subclass__(cls, protocol: str = "") -> None: - cls.protocol = protocol - class File(URI, protocol="file"): ... + @dataclass + class Game: + name: str -When a class-level declaration has an initializing value, it can serve as a `flyweight `_ -default for instances: + + class HasGames[T: abc.Collection[Game]](Protocol): + games: ReadOnly[T] + + + def add_games(shelf: HasGames[list[Game]]) -> None: + shelf.games.append(Game("Half-Life")) # ok: list is mutable + shelf.games[-1].name = "Black Mesa" # ok: "name" is not read-only + shelf.games = [] # error: "games" is read-only + del shelf.games # error: "games" is read-only and cannot be deleted + + + def read_games(shelf: HasGames[abc.Sequence[Game]]) -> None: + # shelf.games.append(...) error, "Sequence" has no "append"! + shelf.games[0].name = "Blue Shift" # ok: "name" is not read-only + shelf.games = [] # error: "games" is read-only + + +All instance attributes of frozen dataclasses and ``NamedTuple`` should be +implied to be read-only. Type checkers may inform that annotating such attributes +with ``ReadOnly`` is redundant, but it should not be seen as an error: .. code-block:: python - class Patient: - number: ReadOnly[int] = 0 + from dataclasses import dataclass + from typing import NewType, ReadOnly - def __init__(self, number: int | None = None) -> None: - if number is not None: - self.number = number -.. note:: - This is possible only in classes without :data:`~object.__slots__`. - An attribute included in slots cannot have a class-level default. + @dataclass(frozen=True) + class Point: + x: int # implicit read-only + y: ReadOnly[int] # ok, redundant -Type checkers may choose to warn on read-only attributes which could be left uninitialized -after an instance is created (except in :external+typing:term:`stubs `, -protocols or ABCs):: - class Patient: - id: ReadOnly[int] # error: "id" is not initialized on all code paths - name: ReadOnly[str] # error: "name" is never initialized + uint = NewType("uint", int) - def __init__(self) -> None: - if random.random() > 0.5: - self.id = 123 + @dataclass(frozen=True) + class UnsignedPoint(Point): + x: ReadOnly[uint] # ok, redundant; narrower type + y: Final[uint] # not redundant, Final imposes extra restrictions; narrower type - class HasName(Protocol): - name: ReadOnly[str] # ok Subtyping --------- -The inability to reassign read-only attributes makes them covariant. +The inability to externally mutate read-only attributes makes them covariant. This has a few subtyping implications. Borrowing from :pep:`705#inheritance`: * Read-only attributes can be redeclared as writable attributes, descriptors @@ -409,7 +336,7 @@ This has a few subtyping implications. Borrowing from :pep:`705#inheritance`: game = Game(title="DOOM", year=1993) game.year = 1994 - game.title = "DOOM II" # ok: attribute is not read-only + game.title = "DOOM II" # ok: attribute is no longer read-only class TitleProxy(HasTitle): @@ -422,17 +349,13 @@ This has a few subtyping implications. Borrowing from :pep:`705#inheritance`: * If a read-only attribute is not redeclared, it remains read-only:: + @dataclass class Game(HasTitle): year: int - def __init__(self, title: str, year: int) -> None: - super().__init__(title) - self.title = title # error: cannot assign to a read-only attribute of base class - self.year = year - - game = Game(title="Robot Wants Kitty", year=2010) game.title = "Robot Wants Puppy" # error: "title" is read-only + game.year = 2012 # ok * Subtypes can :external+typing:term:`narrow` the type of read-only attributes:: @@ -526,10 +449,25 @@ Interaction with Other Type Qualifiers This is consistent with the interaction of ``ReadOnly`` and :class:`typing.TypedDict` defined in :pep:`705`. -An attribute cannot be annotated as both ``ReadOnly`` and ``Final``, as the two -qualifiers differ in semantics, and ``Final`` is generally more restrictive. -``Final`` remains allowed as an annotation of attributes that are only implied -to be read-only. It can be also used to redeclare a ``ReadOnly`` attribute of a base class. +Read-only class attributes can be *internally* assigned to in the same places +a normal class variable can: + +.. code-block:: python + + class URI: + protocol: ReadOnly[ClassVar[str]] = "" + + def __init_subclass__(cls, protocol: str = "") -> None: + cls.protocol = protocol + + class File(URI, protocol="file"): ... + + URI.protocol = "http" # error: "protocol" is read-only + +``Final`` attributes are implicitly read-only. Annotating an attribute as both +``Final`` and ``ReadOnly`` is redundant and should be flagged as such by type checkers. +``Final`` may be used to override both implicit and explicit read-only attributes +of a base class. Backwards Compatibility @@ -570,8 +508,8 @@ following the footsteps of :pep:`705#how-to-teach-this`: `type qualifiers `_ section: The ``ReadOnly`` type qualifier in class attribute annotations indicates - that the attribute of the class may be read, but not reassigned or ``del``\ eted. - For usage in ``TypedDict``, see `ReadOnly `_. + that outside of the class, the attribute may be read but not assigned to + or ``del``\ eted. For usage in ``TypedDict``, see `ReadOnly `_. Rejected Ideas @@ -588,26 +526,22 @@ quality of such properties. This PEP makes ``ReadOnly`` a better alternative for defining read-only attributes in protocols, superseding the use of properties for this purpose. +Assignment Only in ``__init__`` and Class Scope +----------------------------------------------- -Assignment Only in ``__init__`` and Class Body ----------------------------------------------- - -An earlier version of this PEP proposed that read-only attributes could only be -assigned to in ``__init__`` and the class' body. A later discussion revealed that -this restriction would severely limit the usability of ``ReadOnly`` within -immutable classes, which typically do not define ``__init__``. +An earlier version of this PEP specified that internal mutation of read-only +attributes could only happen in ``__init__`` and at class-level. This was done +to follow suit the specification of C#'s `readonly `__. -:class:`fractions.Fraction` is one example of an immutable class, where the -initialization of its attributes happens within ``__new__`` and classmethods. -However, unlike in ``__init__``, the assignment in ``__new__`` and classmethods -is potentially unsound, as the instance they work on can be sourced from -an arbitrary place, including an already finalized instance. - -We find it imperative that this type checking feature is useful to the foremost -use site of read-only attributes - immutable classes. Thus, the PEP has changed -since to allow assignment in ``__new__`` and classmethods under a set of rules -described in the :ref:`init` section. +Later revision of this PEP loosened the restriction to also include ``__new__``, +``__init_subclass__`` and ``@classmethod``\ s, as it was revealed that the initial +version would severely limit the usability of ``ReadOnly`` within immutable classes, +which typically do not define ``__init__``. +Further revision removed this restriction entirely, as it turned out unnecessary +to achieve soundness of the effects of ``ReadOnly`` as described in this PEP. +In turn, this allowed to simplify the PEP, and should reduce the complexity +of type checker implementations. Allowing Bare ``ReadOnly`` With Initializing Value -------------------------------------------------- @@ -637,9 +571,6 @@ Footnotes This PEP focuses solely on the type-checking behavior. Nevertheless, it should be desirable the name is read-only at runtime. -.. [#final_mutability] - As noted above the second-to-last code example of https://typing.python.org/en/latest/spec/qualifiers.html#semantics-and-examples - Copyright ========= From 9cbf97ef023dc644f4813346e8375bda50c3c5a6 Mon Sep 17 00:00:00 2001 From: Eneg <42005170+Enegg@users.noreply.github.com> Date: Thu, 26 Feb 2026 20:40:05 +0100 Subject: [PATCH 05/10] Remove non-email address Co-authored-by: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> --- peps/pep-0767.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/peps/pep-0767.rst b/peps/pep-0767.rst index a245a113f67..6b9f8056caf 100644 --- a/peps/pep-0767.rst +++ b/peps/pep-0767.rst @@ -1,6 +1,6 @@ PEP: 767 Title: Annotating Read-Only Attributes -Author: Łukasz Modzelewski +Author: Łukasz Modzelewski Sponsor: Carl Meyer Discussions-To: https://discuss.python.org/t/pep-767-annotating-read-only-attributes/73408 Status: Draft From 837d0f417d12985a5ada8b11efe0b427eac90c60 Mon Sep 17 00:00:00 2001 From: Eneg <42005170+Enegg@users.noreply.github.com> Date: Thu, 26 Feb 2026 20:45:57 +0100 Subject: [PATCH 06/10] Revert "Remove internal mutation restriction" This reverts commit 11f4522a750c3ef5bd7fe7c74f56e36963f93305. --- peps/pep-0767.rst | 267 +++++++++++++++++++++++++++++----------------- 1 file changed, 168 insertions(+), 99 deletions(-) diff --git a/peps/pep-0767.rst b/peps/pep-0767.rst index 6b9f8056caf..126e3db9fc7 100644 --- a/peps/pep-0767.rst +++ b/peps/pep-0767.rst @@ -30,11 +30,10 @@ Motivation The Python type system lacks a single concise way to mark an attribute read-only. This feature is present in other statically and gradually typed languages -(such as `C# `__ -or `TypeScript `__), -and is useful for removing the ability to externally assign to or ``del``\ ete -an attribute at a type checker level, as well as defining a broad interface -for structural subtyping. +(such as `C# `_ +or `TypeScript `_), +and is useful for removing the ability to reassign or ``del``\ ete an attribute +at a type checker level, as well as defining a broad interface for structural subtyping. .. _classes: @@ -168,9 +167,6 @@ A class with a read-only instance attribute can now be defined as:: Specification ============= -Usage ------ - The :external+py3.13:data:`typing.ReadOnly` :external+typing:term:`type qualifier` becomes a valid annotation for :term:`attributes ` of classes and protocols. It can be used at class-level and within ``__init__`` to mark individual attributes read-only:: @@ -183,13 +179,100 @@ It can be used at class-level and within ``__init__`` to mark individual attribu self.name: ReadOnly[str] = name Use of bare ``ReadOnly`` (without ``[]``) is not allowed. - -Type checkers should error on any attempt to *externally mutate* an attribute +Type checkers should error on any attempt to reassign or ``del``\ ete an attribute annotated with ``ReadOnly``. +Type checkers should also error on any attempt to delete an attribute annotated as ``Final``. +(This is not currently specified.) + +Use of ``ReadOnly`` in annotations at other sites where it currently has no meaning +(such as local/global variables or function parameters) is considered out of scope +for this PEP. + +Akin to ``Final`` [#final_mutability]_, ``ReadOnly`` does not influence how +type checkers perceive the mutability of the assigned object. Immutable :term:`ABCs ` +and :mod:`containers ` may be used in combination with ``ReadOnly`` +to forbid mutation of such values at a type checker level: + +.. code-block:: python + + from collections import abc + from dataclasses import dataclass + from typing import Protocol, ReadOnly + + + @dataclass + class Game: + name: str + + + class HasGames[T: abc.Collection[Game]](Protocol): + games: ReadOnly[T] + + + def add_games(shelf: HasGames[list[Game]]) -> None: + shelf.games.append(Game("Half-Life")) # ok: list is mutable + shelf.games[-1].name = "Black Mesa" # ok: "name" is not read-only + shelf.games = [] # error: "games" is read-only + del shelf.games # error: "games" is read-only and cannot be deleted + + + def read_games(shelf: HasGames[abc.Sequence[Game]]) -> None: + shelf.games.append(...) # error: "Sequence" has no attribute "append" + shelf.games[0].name = "Blue Shift" # ok: "name" is not read-only + shelf.games = [] # error: "games" is read-only + + +All instance attributes of frozen dataclasses and ``NamedTuple`` should be +implied to be read-only. Type checkers may inform that annotating such attributes +with ``ReadOnly`` is redundant, but it should not be seen as an error: + +.. code-block:: python + + from dataclasses import dataclass + from typing import NewType, ReadOnly + + + @dataclass(frozen=True) + class Point: + x: int # implicit read-only + y: ReadOnly[int] # ok, redundant -We define "externally" here as occurring outside the body of the class declaring -the attribute, or its subclasses. -"Mutate" means to assign to or ``del``\ ete the attribute. + + uint = NewType("uint", int) + + + @dataclass(frozen=True) + class UnsignedPoint(Point): + x: ReadOnly[uint] # ok, redundant; narrower type + y: Final[uint] # not redundant, Final imposes extra restrictions; narrower type + +.. _init: + +Initialization +-------------- + +Assignment to a read-only attribute can only occur in the class declaring the attribute, +at sites described below. +There is no restriction to how many times the attribute can be assigned to. + +Instance Attributes +''''''''''''''''''' + +Assignment to a read-only instance attribute must be allowed in the following contexts: + +* In ``__init__``, on the instance received as the first parameter (usually, ``self``). +* In ``__new__``, on instances of the declaring class created via a call + to a super-class' ``__new__`` method. +* At declaration in the body of the class. + +Additionally, a type checker may choose to allow the assignment: + +* In ``__new__``, on instances of the declaring class, without regard + to the origin of the instance. + (This choice trades soundness, as the instance may already be initialized, + for the simplicity of implementation.) +* In ``@classmethod``\ s, on instances of the declaring class created via + a call to the class' or super-class' ``__new__`` method. .. code-block:: python @@ -209,7 +292,8 @@ the attribute, or its subclasses. self.songs = list(songs) # multiple assignments are fine def clear(self) -> None: - self.songs = [] # ok + # error: assignment to read-only "songs" outside initialization + self.songs = [] band = Band(name="Bôa", songs=["Duvet"]) @@ -218,6 +302,10 @@ the attribute, or its subclasses. band.songs.append("Twilight") # ok: list is mutable + class SubBand(Band): + def __init__(self) -> None: + self.songs = [] # error: cannot assign to a read-only attribute of a base class + .. code-block:: python # a simplified immutable Fraction class @@ -248,76 +336,61 @@ the attribute, or its subclasses. self.numerator, self.denominator = f.as_integer_ratio() return self +Class Attributes +'''''''''''''''' -It should also be error to delete an attribute annotated as ``Final``. -(This is not currently specified.) +Read-only class attributes are attributes annotated as both ``ReadOnly`` and ``ClassVar``. +Assignment to such attributes must be allowed in the following contexts: -Use of ``ReadOnly`` in annotations at other sites where it currently has no meaning -(such as local/global variables or function parameters) is considered out of scope -for this PEP. - -``ReadOnly`` does not influence the mutability of the attribute's value. Immutable -protocols and :mod:`collections ` may be used in combination -with ``ReadOnly`` to forbid mutation of those values at a type checker level: +* At declaration in the body of the class. +* In ``__init_subclass__``, on the class object received as the first parameter (usually, ``cls``). .. code-block:: python - from collections import abc - from dataclasses import dataclass - from typing import Protocol, ReadOnly - - - @dataclass - class Game: - name: str - - - class HasGames[T: abc.Collection[Game]](Protocol): - games: ReadOnly[T] - - - def add_games(shelf: HasGames[list[Game]]) -> None: - shelf.games.append(Game("Half-Life")) # ok: list is mutable - shelf.games[-1].name = "Black Mesa" # ok: "name" is not read-only - shelf.games = [] # error: "games" is read-only - del shelf.games # error: "games" is read-only and cannot be deleted + class URI: + protocol: ReadOnly[ClassVar[str]] = "" + def __init_subclass__(cls, protocol: str = "") -> None: + cls.protocol = protocol - def read_games(shelf: HasGames[abc.Sequence[Game]]) -> None: - # shelf.games.append(...) error, "Sequence" has no "append"! - shelf.games[0].name = "Blue Shift" # ok: "name" is not read-only - shelf.games = [] # error: "games" is read-only + class File(URI, protocol="file"): ... - -All instance attributes of frozen dataclasses and ``NamedTuple`` should be -implied to be read-only. Type checkers may inform that annotating such attributes -with ``ReadOnly`` is redundant, but it should not be seen as an error: +When a class-level declaration has an initializing value, it can serve as a `flyweight `_ +default for instances: .. code-block:: python - from dataclasses import dataclass - from typing import NewType, ReadOnly + class Patient: + number: ReadOnly[int] = 0 + def __init__(self, number: int | None = None) -> None: + if number is not None: + self.number = number - @dataclass(frozen=True) - class Point: - x: int # implicit read-only - y: ReadOnly[int] # ok, redundant +.. note:: + This is possible only in classes without :data:`~object.__slots__`. + An attribute included in slots cannot have a class-level default. +Type checkers may choose to warn on read-only attributes which could be left uninitialized +after an instance is created (except in :external+typing:term:`stubs `, +protocols or ABCs):: - uint = NewType("uint", int) + class Patient: + id: ReadOnly[int] # error: "id" is not initialized on all code paths + name: ReadOnly[str] # error: "name" is never initialized + def __init__(self) -> None: + if random.random() > 0.5: + self.id = 123 - @dataclass(frozen=True) - class UnsignedPoint(Point): - x: ReadOnly[uint] # ok, redundant; narrower type - y: Final[uint] # not redundant, Final imposes extra restrictions; narrower type + class HasName(Protocol): + name: ReadOnly[str] # ok Subtyping --------- -The inability to externally mutate read-only attributes makes them covariant. +The inability to reassign read-only attributes makes them covariant. This has a few subtyping implications. Borrowing from :pep:`705#inheritance`: * Read-only attributes can be redeclared as writable attributes, descriptors @@ -336,7 +409,7 @@ This has a few subtyping implications. Borrowing from :pep:`705#inheritance`: game = Game(title="DOOM", year=1993) game.year = 1994 - game.title = "DOOM II" # ok: attribute is no longer read-only + game.title = "DOOM II" # ok: attribute is not read-only class TitleProxy(HasTitle): @@ -349,13 +422,17 @@ This has a few subtyping implications. Borrowing from :pep:`705#inheritance`: * If a read-only attribute is not redeclared, it remains read-only:: - @dataclass class Game(HasTitle): year: int + def __init__(self, title: str, year: int) -> None: + super().__init__(title) + self.title = title # error: cannot assign to a read-only attribute of base class + self.year = year + + game = Game(title="Robot Wants Kitty", year=2010) game.title = "Robot Wants Puppy" # error: "title" is read-only - game.year = 2012 # ok * Subtypes can :external+typing:term:`narrow` the type of read-only attributes:: @@ -449,25 +526,10 @@ Interaction with Other Type Qualifiers This is consistent with the interaction of ``ReadOnly`` and :class:`typing.TypedDict` defined in :pep:`705`. -Read-only class attributes can be *internally* assigned to in the same places -a normal class variable can: - -.. code-block:: python - - class URI: - protocol: ReadOnly[ClassVar[str]] = "" - - def __init_subclass__(cls, protocol: str = "") -> None: - cls.protocol = protocol - - class File(URI, protocol="file"): ... - - URI.protocol = "http" # error: "protocol" is read-only - -``Final`` attributes are implicitly read-only. Annotating an attribute as both -``Final`` and ``ReadOnly`` is redundant and should be flagged as such by type checkers. -``Final`` may be used to override both implicit and explicit read-only attributes -of a base class. +An attribute cannot be annotated as both ``ReadOnly`` and ``Final``, as the two +qualifiers differ in semantics, and ``Final`` is generally more restrictive. +``Final`` remains allowed as an annotation of attributes that are only implied +to be read-only. It can be also used to redeclare a ``ReadOnly`` attribute of a base class. Backwards Compatibility @@ -508,8 +570,8 @@ following the footsteps of :pep:`705#how-to-teach-this`: `type qualifiers `_ section: The ``ReadOnly`` type qualifier in class attribute annotations indicates - that outside of the class, the attribute may be read but not assigned to - or ``del``\ eted. For usage in ``TypedDict``, see `ReadOnly `_. + that the attribute of the class may be read, but not reassigned or ``del``\ eted. + For usage in ``TypedDict``, see `ReadOnly `_. Rejected Ideas @@ -526,22 +588,26 @@ quality of such properties. This PEP makes ``ReadOnly`` a better alternative for defining read-only attributes in protocols, superseding the use of properties for this purpose. -Assignment Only in ``__init__`` and Class Scope ------------------------------------------------ -An earlier version of this PEP specified that internal mutation of read-only -attributes could only happen in ``__init__`` and at class-level. This was done -to follow suit the specification of C#'s `readonly `__. +Assignment Only in ``__init__`` and Class Body +---------------------------------------------- + +An earlier version of this PEP proposed that read-only attributes could only be +assigned to in ``__init__`` and the class' body. A later discussion revealed that +this restriction would severely limit the usability of ``ReadOnly`` within +immutable classes, which typically do not define ``__init__``. -Later revision of this PEP loosened the restriction to also include ``__new__``, -``__init_subclass__`` and ``@classmethod``\ s, as it was revealed that the initial -version would severely limit the usability of ``ReadOnly`` within immutable classes, -which typically do not define ``__init__``. +:class:`fractions.Fraction` is one example of an immutable class, where the +initialization of its attributes happens within ``__new__`` and classmethods. +However, unlike in ``__init__``, the assignment in ``__new__`` and classmethods +is potentially unsound, as the instance they work on can be sourced from +an arbitrary place, including an already finalized instance. + +We find it imperative that this type checking feature is useful to the foremost +use site of read-only attributes - immutable classes. Thus, the PEP has changed +since to allow assignment in ``__new__`` and classmethods under a set of rules +described in the :ref:`init` section. -Further revision removed this restriction entirely, as it turned out unnecessary -to achieve soundness of the effects of ``ReadOnly`` as described in this PEP. -In turn, this allowed to simplify the PEP, and should reduce the complexity -of type checker implementations. Allowing Bare ``ReadOnly`` With Initializing Value -------------------------------------------------- @@ -571,6 +637,9 @@ Footnotes This PEP focuses solely on the type-checking behavior. Nevertheless, it should be desirable the name is read-only at runtime. +.. [#final_mutability] + As noted above the second-to-last code example of https://typing.python.org/en/latest/spec/qualifiers.html#semantics-and-examples + Copyright ========= From 50a10e434e20b44c7d1d16c1489b5f860cc8b8a3 Mon Sep 17 00:00:00 2001 From: Eneg <42005170+Enegg@users.noreply.github.com> Date: Fri, 27 Feb 2026 02:37:23 +0100 Subject: [PATCH 07/10] Cherry-pick changes from reverted commit "Terminology" section allow initializing read-only attributes by subclass allow `@classmethod` to initialize instance attributes allow `ReadOnly` + `Final`, though redundant --- peps/pep-0767.rst | 124 +++++++++++++++++++++++----------------------- 1 file changed, 61 insertions(+), 63 deletions(-) diff --git a/peps/pep-0767.rst b/peps/pep-0767.rst index 126e3db9fc7..2c7b3acec34 100644 --- a/peps/pep-0767.rst +++ b/peps/pep-0767.rst @@ -25,15 +25,26 @@ Akin to :pep:`705`, it makes no changes to setting attributes at runtime. Correc usage of read-only attributes is intended to be enforced only by static type checkers. +Terminology +=========== + +This PEP uses "attribute mutation" as a reference to assignment to, +or ``del``\ etion of the attribute. +This is distinct from :term:`mutability ` of the attribute's value. + +"Read-only" is used to describe attributes which may be read, but not assigned to +or deleted. + + Motivation ========== The Python type system lacks a single concise way to mark an attribute read-only. This feature is present in other statically and gradually typed languages -(such as `C# `_ -or `TypeScript `_), -and is useful for removing the ability to reassign or ``del``\ ete an attribute -at a type checker level, as well as defining a broad interface for structural subtyping. +(such as `C# `__ +or `TypeScript `__), +and is useful for removing the ability to mutate an attribute at a type checker level, +as well as defining a broad interface for structural subtyping. .. _classes: @@ -139,7 +150,6 @@ A class with a read-only instance attribute can now be defined as:: from typing import ReadOnly - class Member: def __init__(self, id: int) -> None: self.id: ReadOnly[int] = id @@ -148,25 +158,21 @@ A class with a read-only instance attribute can now be defined as:: from typing import Protocol, ReadOnly - class HasName(Protocol): name: ReadOnly[str] - - def greet(obj: HasName, /) -> str: - return f"Hello, {obj.name}!" - * A subclass of ``Member`` can redefine ``.id`` as a writable attribute or a :term:`descriptor`. It can also :external+typing:term:`narrow` its type. -* The ``HasName`` protocol has a more succinct definition, and is agnostic - to the writability of the attribute. -* The ``greet`` function can now accept a wide variety of compatible objects, - while being explicit about no modifications being done to the input. +* The ``HasName`` protocol has a more succinct definition, + and can be implemented with custom descriptors or class variables. Specification ============= +Usage +----- + The :external+py3.13:data:`typing.ReadOnly` :external+typing:term:`type qualifier` becomes a valid annotation for :term:`attributes ` of classes and protocols. It can be used at class-level and within ``__init__`` to mark individual attributes read-only:: @@ -179,19 +185,19 @@ It can be used at class-level and within ``__init__`` to mark individual attribu self.name: ReadOnly[str] = name Use of bare ``ReadOnly`` (without ``[]``) is not allowed. -Type checkers should error on any attempt to reassign or ``del``\ ete an attribute -annotated with ``ReadOnly``. -Type checkers should also error on any attempt to delete an attribute annotated as ``Final``. +Type checkers should error on any attempt to mutate an attribute annotated with ``ReadOnly``. + +It should also be an error to delete an attribute annotated as ``Final``. (This is not currently specified.) Use of ``ReadOnly`` in annotations at other sites where it currently has no meaning (such as local/global variables or function parameters) is considered out of scope for this PEP. -Akin to ``Final`` [#final_mutability]_, ``ReadOnly`` does not influence how -type checkers perceive the mutability of the assigned object. Immutable :term:`ABCs ` -and :mod:`containers ` may be used in combination with ``ReadOnly`` -to forbid mutation of such values at a type checker level: +``ReadOnly`` does not influence the mutability of the attribute's value. Immutable +protocols and :term:`ABCs ` (such as those in :mod:`collections.abc`) +may be used in combination with ``ReadOnly`` to forbid mutation of those values +at a type checker level: .. code-block:: python @@ -217,12 +223,12 @@ to forbid mutation of such values at a type checker level: def read_games(shelf: HasGames[abc.Sequence[Game]]) -> None: - shelf.games.append(...) # error: "Sequence" has no attribute "append" + # shelf.games.append(...) # error: "Sequence" has no attribute "append" shelf.games[0].name = "Blue Shift" # ok: "name" is not read-only shelf.games = [] # error: "games" is read-only -All instance attributes of frozen dataclasses and ``NamedTuple`` should be +All instance attributes of frozen dataclasses and named tuples should be implied to be read-only. Type checkers may inform that annotating such attributes with ``ReadOnly`` is redundant, but it should not be seen as an error: @@ -246,13 +252,12 @@ with ``ReadOnly`` is redundant, but it should not be seen as an error: x: ReadOnly[uint] # ok, redundant; narrower type y: Final[uint] # not redundant, Final imposes extra restrictions; narrower type -.. _init: Initialization -------------- -Assignment to a read-only attribute can only occur in the class declaring the attribute, -at sites described below. +Assignment to a read-only attribute can only occur in the class declaring the attribute +and its nominal subclasses, at sites described below. There is no restriction to how many times the attribute can be assigned to. Instance Attributes @@ -263,7 +268,9 @@ Assignment to a read-only instance attribute must be allowed in the following co * In ``__init__``, on the instance received as the first parameter (usually, ``self``). * In ``__new__``, on instances of the declaring class created via a call to a super-class' ``__new__`` method. -* At declaration in the body of the class. +* In ``@classmethod``\ s, on instances of the declaring class created via a call + to a super-class' ``__new__`` method. +* At declaration in the class scope. Additionally, a type checker may choose to allow the assignment: @@ -301,11 +308,6 @@ Additionally, a type checker may choose to allow the assignment: band.songs = [] # error: "songs" is read-only band.songs.append("Twilight") # ok: list is mutable - - class SubBand(Band): - def __init__(self) -> None: - self.songs = [] # error: cannot assign to a read-only attribute of a base class - .. code-block:: python # a simplified immutable Fraction class @@ -342,7 +344,7 @@ Class Attributes Read-only class attributes are attributes annotated as both ``ReadOnly`` and ``ClassVar``. Assignment to such attributes must be allowed in the following contexts: -* At declaration in the body of the class. +* At declaration in the class scope. * In ``__init_subclass__``, on the class object received as the first parameter (usually, ``cls``). .. code-block:: python @@ -390,7 +392,7 @@ protocols or ABCs):: Subtyping --------- -The inability to reassign read-only attributes makes them covariant. +The inability to mutate read-only attributes makes them covariant. This has a few subtyping implications. Borrowing from :pep:`705#inheritance`: * Read-only attributes can be redeclared as writable attributes, descriptors @@ -409,7 +411,7 @@ This has a few subtyping implications. Borrowing from :pep:`705#inheritance`: game = Game(title="DOOM", year=1993) game.year = 1994 - game.title = "DOOM II" # ok: attribute is not read-only + game.title = "DOOM II" # ok: attribute is no longer read-only class TitleProxy(HasTitle): @@ -426,8 +428,8 @@ This has a few subtyping implications. Borrowing from :pep:`705#inheritance`: year: int def __init__(self, title: str, year: int) -> None: - super().__init__(title) - self.title = title # error: cannot assign to a read-only attribute of base class + super().__init__(title) # preferred + self.title = title # ok self.year = year @@ -436,6 +438,8 @@ This has a few subtyping implications. Borrowing from :pep:`705#inheritance`: * Subtypes can :external+typing:term:`narrow` the type of read-only attributes:: + from collections import abc + class GameCollection(Protocol): games: ReadOnly[abc.Collection[Game]] @@ -448,6 +452,8 @@ This has a few subtyping implications. Borrowing from :pep:`705#inheritance`: * Nominal subclasses of protocols and ABCs should redeclare read-only attributes in order to implement them, unless the base class initializes them in some way:: + import abc + class MyBase(abc.ABC): foo: ReadOnly[int] bar: ReadOnly[str] = "abc" @@ -526,10 +532,13 @@ Interaction with Other Type Qualifiers This is consistent with the interaction of ``ReadOnly`` and :class:`typing.TypedDict` defined in :pep:`705`. -An attribute cannot be annotated as both ``ReadOnly`` and ``Final``, as the two -qualifiers differ in semantics, and ``Final`` is generally more restrictive. -``Final`` remains allowed as an annotation of attributes that are only implied -to be read-only. It can be also used to redeclare a ``ReadOnly`` attribute of a base class. +``Final`` can be used to (re)declare an attribute which is already read-only, +whether due to mechanisms such as ``NamedTuple``, or because a parent class +declared it as ``ReadOnly``. + +Semantics of ``Final`` take precedence over the semantics of read-only attributes; +combining ``ReadOnly`` and ``Final`` is redundant, +and type checkers may choose to warn or error on the redundancy. Backwards Compatibility @@ -570,7 +579,7 @@ following the footsteps of :pep:`705#how-to-teach-this`: `type qualifiers `_ section: The ``ReadOnly`` type qualifier in class attribute annotations indicates - that the attribute of the class may be read, but not reassigned or ``del``\ eted. + that the attribute of the class may be read, but not assigned to or ``del``\ eted. For usage in ``TypedDict``, see `ReadOnly `_. @@ -589,25 +598,17 @@ This PEP makes ``ReadOnly`` a better alternative for defining read-only attribut in protocols, superseding the use of properties for this purpose. -Assignment Only in ``__init__`` and Class Body ----------------------------------------------- - -An earlier version of this PEP proposed that read-only attributes could only be -assigned to in ``__init__`` and the class' body. A later discussion revealed that -this restriction would severely limit the usability of ``ReadOnly`` within -immutable classes, which typically do not define ``__init__``. +Assignment Only in ``__init__`` and Class Scope +----------------------------------------------- -:class:`fractions.Fraction` is one example of an immutable class, where the -initialization of its attributes happens within ``__new__`` and classmethods. -However, unlike in ``__init__``, the assignment in ``__new__`` and classmethods -is potentially unsound, as the instance they work on can be sourced from -an arbitrary place, including an already finalized instance. - -We find it imperative that this type checking feature is useful to the foremost -use site of read-only attributes - immutable classes. Thus, the PEP has changed -since to allow assignment in ``__new__`` and classmethods under a set of rules -described in the :ref:`init` section. +An earlier version of this PEP specified that read-only attributes could only be +assigned to in ``__init__`` and the class' body. This decision was based on +the specification of C#'s `readonly `__. +Later revision of this PEP loosened the restriction to also include ``__new__``, +``__init_subclass__`` and ``@classmethod``\ s, as it was revealed that the initial +version would severely limit the usability of ``ReadOnly`` within immutable classes, +which typically do not define ``__init__``. Allowing Bare ``ReadOnly`` With Initializing Value -------------------------------------------------- @@ -634,12 +635,9 @@ Footnotes `[mypy playground] `_ .. [#runtime] - This PEP focuses solely on the type-checking behavior. Nevertheless, it should + This PEP focuses solely on type-checking behavior. Nevertheless, it should be desirable the name is read-only at runtime. -.. [#final_mutability] - As noted above the second-to-last code example of https://typing.python.org/en/latest/spec/qualifiers.html#semantics-and-examples - Copyright ========= From c3549d910cab2ca74da0dbbc8554fba34f039b34 Mon Sep 17 00:00:00 2001 From: Eneg <42005170+Enegg@users.noreply.github.com> Date: Mon, 9 Mar 2026 23:27:32 +0100 Subject: [PATCH 08/10] Revert using "mutation" in reference to assignment/deletion Improve init rules regarding `__new__` and classmethods Address Carl's comments --- peps/pep-0767.rst | 67 ++++++++++++++++++++++------------------------- 1 file changed, 31 insertions(+), 36 deletions(-) diff --git a/peps/pep-0767.rst b/peps/pep-0767.rst index 2c7b3acec34..150be871c8e 100644 --- a/peps/pep-0767.rst +++ b/peps/pep-0767.rst @@ -28,12 +28,8 @@ usage of read-only attributes is intended to be enforced only by static type che Terminology =========== -This PEP uses "attribute mutation" as a reference to assignment to, -or ``del``\ etion of the attribute. -This is distinct from :term:`mutability ` of the attribute's value. - -"Read-only" is used to describe attributes which may be read, but not assigned to -or deleted. +This PEP uses "read-only" to describe attributes which may be read, +but not assigned to (except in limited cases to support initialization) or deleted. Motivation @@ -43,10 +39,9 @@ The Python type system lacks a single concise way to mark an attribute read-only This feature is present in other statically and gradually typed languages (such as `C# `__ or `TypeScript `__), -and is useful for removing the ability to mutate an attribute at a type checker level, -as well as defining a broad interface for structural subtyping. +and is useful for removing the ability to assign to or delete an attribute +at a type checker level, as well as defining a broad interface for structural subtyping. -.. _classes: Classes ------- @@ -164,7 +159,7 @@ A class with a read-only instance attribute can now be defined as:: * A subclass of ``Member`` can redefine ``.id`` as a writable attribute or a :term:`descriptor`. It can also :external+typing:term:`narrow` its type. * The ``HasName`` protocol has a more succinct definition, - and can be implemented with custom descriptors or class variables. + and can be implemented with writable instance/class attributes or custom descriptors. Specification @@ -174,8 +169,9 @@ Usage ----- The :external+py3.13:data:`typing.ReadOnly` :external+typing:term:`type qualifier` -becomes a valid annotation for :term:`attributes ` of classes and protocols. -It can be used at class-level and within ``__init__`` to mark individual attributes read-only:: +becomes a valid annotation for :term:`attributes ` of nominal classes +and protocols. It can be used at class-level and within ``__init__`` to mark +individual attributes read-only:: class Book: id: ReadOnly[int] @@ -185,14 +181,15 @@ It can be used at class-level and within ``__init__`` to mark individual attribu self.name: ReadOnly[str] = name Use of bare ``ReadOnly`` (without ``[]``) is not allowed. -Type checkers should error on any attempt to mutate an attribute annotated with ``ReadOnly``. +Type checkers should error on any attempt to assign to or delete an attribute +annotated with ``ReadOnly``, except in contexts described under :ref:`initialization`. It should also be an error to delete an attribute annotated as ``Final``. (This is not currently specified.) Use of ``ReadOnly`` in annotations at other sites where it currently has no meaning (such as local/global variables or function parameters) is considered out of scope -for this PEP. +for this PEP, and remains forbidden. ``ReadOnly`` does not influence the mutability of the attribute's value. Immutable protocols and :term:`ABCs ` (such as those in :mod:`collections.abc`) @@ -223,7 +220,7 @@ at a type checker level: def read_games(shelf: HasGames[abc.Sequence[Game]]) -> None: - # shelf.games.append(...) # error: "Sequence" has no attribute "append" + shelf.games.append(...) # error: "Sequence" has no attribute "append" shelf.games[0].name = "Blue Shift" # ok: "name" is not read-only shelf.games = [] # error: "games" is read-only @@ -235,12 +232,12 @@ with ``ReadOnly`` is redundant, but it should not be seen as an error: .. code-block:: python from dataclasses import dataclass - from typing import NewType, ReadOnly + from typing import Final, NewType, ReadOnly @dataclass(frozen=True) class Point: - x: int # implicit read-only + x: int # implicitly read-only y: ReadOnly[int] # ok, redundant @@ -252,12 +249,13 @@ with ``ReadOnly`` is redundant, but it should not be seen as an error: x: ReadOnly[uint] # ok, redundant; narrower type y: Final[uint] # not redundant, Final imposes extra restrictions; narrower type +.. _initialization: Initialization -------------- -Assignment to a read-only attribute can only occur in the class declaring the attribute -and its nominal subclasses, at sites described below. +Assignment to a read-only attribute of a nominal class can only occur in the class +declaring the attribute and its nominal subclasses, at sites described below. There is no restriction to how many times the attribute can be assigned to. Instance Attributes @@ -265,21 +263,18 @@ Instance Attributes Assignment to a read-only instance attribute must be allowed in the following contexts: -* In ``__init__``, on the instance received as the first parameter (usually, ``self``). -* In ``__new__``, on instances of the declaring class created via a call - to a super-class' ``__new__`` method. -* In ``@classmethod``\ s, on instances of the declaring class created via a call - to a super-class' ``__new__`` method. +* In ``__init__``, on the instance of the declaring class received as + the first parameter (usually ``self``). +* In ``__new__`` and ``@classmethod``\ s, on instances of the declaring class + created via a call to ``__new__`` on any object of type ``type[T]``, where + ``T`` is a *nominal supertype* of the declaring class. * At declaration in the class scope. -Additionally, a type checker may choose to allow the assignment: - -* In ``__new__``, on instances of the declaring class, without regard - to the origin of the instance. - (This choice trades soundness, as the instance may already be initialized, - for the simplicity of implementation.) -* In ``@classmethod``\ s, on instances of the declaring class created via - a call to the class' or super-class' ``__new__`` method. +Additionally, a type checker may choose to allow the assignment +in ``__new__`` and ``@classmethod``\ s, on instances of the declaring class, +without regard to the origin of the instance. +(This choice trades soundness, as the instance may already be initialized, +for the simplicity of implementation.) .. code-block:: python @@ -345,7 +340,7 @@ Read-only class attributes are attributes annotated as both ``ReadOnly`` and ``C Assignment to such attributes must be allowed in the following contexts: * At declaration in the class scope. -* In ``__init_subclass__``, on the class object received as the first parameter (usually, ``cls``). +* In ``__init_subclass__``, on the class object received as the first parameter (usually ``cls``). .. code-block:: python @@ -357,7 +352,7 @@ Assignment to such attributes must be allowed in the following contexts: class File(URI, protocol="file"): ... -When a class-level declaration has an initializing value, it can serve as a `flyweight `_ +When a class-level declaration has an initializing value, it can serve as a `flyweight `__ default for instances: .. code-block:: python @@ -392,7 +387,7 @@ protocols or ABCs):: Subtyping --------- -The inability to mutate read-only attributes makes them covariant. +The inability to assign to or delete read-only attributes makes them covariant. This has a few subtyping implications. Borrowing from :pep:`705#inheritance`: * Read-only attributes can be redeclared as writable attributes, descriptors @@ -436,7 +431,7 @@ This has a few subtyping implications. Borrowing from :pep:`705#inheritance`: game = Game(title="Robot Wants Kitty", year=2010) game.title = "Robot Wants Puppy" # error: "title" is read-only -* Subtypes can :external+typing:term:`narrow` the type of read-only attributes:: +* Subclasses can :external+typing:term:`narrow` the type of read-only attributes:: from collections import abc From c24a6a58b262151866d4b9fdfc66ef7b94153002 Mon Sep 17 00:00:00 2001 From: Eneg <42005170+Enegg@users.noreply.github.com> Date: Mon, 9 Mar 2026 23:27:56 +0100 Subject: [PATCH 09/10] Move stuff around --- peps/pep-0767.rst | 150 +++++++++++++++++++++------------------------- 1 file changed, 67 insertions(+), 83 deletions(-) diff --git a/peps/pep-0767.rst b/peps/pep-0767.rst index 150be871c8e..1c780badeba 100644 --- a/peps/pep-0767.rst +++ b/peps/pep-0767.rst @@ -258,6 +258,22 @@ Assignment to a read-only attribute of a nominal class can only occur in the cla declaring the attribute and its nominal subclasses, at sites described below. There is no restriction to how many times the attribute can be assigned to. +Type checkers may choose to warn on read-only attributes which could be left uninitialized +after an instance is created (except in :external+typing:term:`stubs `, +protocols or ABCs):: + + class Patient: + id: ReadOnly[int] # error: "id" is not initialized on all code paths + name: ReadOnly[str] # error: "name" is never initialized + + def __init__(self) -> None: + if random.random() > 0.5: + self.id = 123 + + + class HasName(Protocol): + name: ReadOnly[str] # ok + Instance Attributes ''''''''''''''''''' @@ -333,6 +349,22 @@ for the simplicity of implementation.) self.numerator, self.denominator = f.as_integer_ratio() return self +When a class-level declaration has an initializing value, it can serve as a `flyweight `__ +default for instances: + +.. code-block:: python + + class Patient: + number: ReadOnly[int] = 0 + + def __init__(self, number: int | None = None) -> None: + if number is not None: + self.number = number + +.. note:: + This is possible only in classes without :data:`~object.__slots__`. + An attribute included in slots cannot have a class-level default. + Class Attributes '''''''''''''''' @@ -352,37 +384,51 @@ Assignment to such attributes must be allowed in the following contexts: class File(URI, protocol="file"): ... -When a class-level declaration has an initializing value, it can serve as a `flyweight `__ -default for instances: +Protocols +--------- + +In a protocol attribute declaration, ``name: ReadOnly[T]`` indicates that values +that inhabit the protocol must support ``.name`` access, and the returned value +is assignable to ``T``: .. code-block:: python - class Patient: - number: ReadOnly[int] = 0 + class HasName(Protocol): + name: ReadOnly[str] - def __init__(self, number: int | None = None) -> None: - if number is not None: - self.number = number -.. note:: - This is possible only in classes without :data:`~object.__slots__`. - An attribute included in slots cannot have a class-level default. + class NamedAttr: + name: str -Type checkers may choose to warn on read-only attributes which could be left uninitialized -after an instance is created (except in :external+typing:term:`stubs `, -protocols or ABCs):: + class NamedProp: + @property + def name(self) -> str: ... - class Patient: - id: ReadOnly[int] # error: "id" is not initialized on all code paths - name: ReadOnly[str] # error: "name" is never initialized + class NamedClassVar: + name: ClassVar[str] - def __init__(self) -> None: - if random.random() > 0.5: - self.id = 123 + class NamedDescriptor: + @cached_property + def name(self) -> str: ... + # all of the following are ok + has_name: HasName + has_name = NamedAttr() + has_name = NamedProp() + has_name = NamedClassVar + has_name = NamedClassVar() + has_name = NamedDescriptor() + +Read-only protocol attributes may not be assigned to or deleted in any context. + +Type checkers should not assume that access to a protocol's read-only attributes +is supported by the protocol's type (``type[HasName]``). Even if an attribute +exists on the protocol's type, no assumptions should be made about its type. + +Accurately modeling the behavior and type of ``type[HasName].name`` is difficult, +therefore it was left out from this PEP to reduce its complexity; +future enhancements to the typing specification may refine this behavior. - class HasName(Protocol): - name: ReadOnly[str] # ok Subtyping --------- @@ -444,68 +490,6 @@ This has a few subtyping implications. Borrowing from :pep:`705#inheritance`: name: str games: ReadOnly[list[Game]] # ok: list[Game] is assignable to Collection[Game] -* Nominal subclasses of protocols and ABCs should redeclare read-only attributes - in order to implement them, unless the base class initializes them in some way:: - - import abc - - class MyBase(abc.ABC): - foo: ReadOnly[int] - bar: ReadOnly[str] = "abc" - baz: ReadOnly[float] - - def __init__(self, baz: float) -> None: - self.baz = baz - - @abstractmethod - def pprint(self) -> None: ... - - - @final - class MySubclass(MyBase): - # error: MySubclass does not override "foo" - - def pprint(self) -> None: - print(self.foo, self.bar, self.baz) - -* In a protocol attribute declaration, ``name: ReadOnly[T]`` indicates that values - that inhabit the protocol must support ``.name`` access, and the returned value - is assignable to ``T``:: - - class HasName(Protocol): - name: ReadOnly[str] - - - class NamedAttr: - name: str - - class NamedProp: - @property - def name(self) -> str: ... - - class NamedClassVar: - name: ClassVar[str] - - class NamedDescriptor: - @cached_property - def name(self) -> str: ... - - # all of the following are ok - has_name: HasName - has_name = NamedAttr() - has_name = NamedProp() - has_name = NamedClassVar - has_name = NamedClassVar() - has_name = NamedDescriptor() - - Type checkers should not assume that access to a protocol's read-only attributes - is supported by the protocol's type (``type[HasName]``). Even if an attribute - exists on the protocol's type, no assumptions should be made about its type. - - Accurately modeling the behavior and type of ``type[HasName].name`` is difficult, - therefore it was left out from this PEP to reduce its complexity; - future enhancements to the typing specification may refine this behavior. - Interaction with Other Type Qualifiers -------------------------------------- From 3ae2ce5324eb22ce62d1b7903e844765c64aaa2b Mon Sep 17 00:00:00 2001 From: Eneg <42005170+Enegg@users.noreply.github.com> Date: Tue, 10 Mar 2026 20:30:06 +0100 Subject: [PATCH 10/10] Specially mention `super().__new__()` Clarify that inheriting from a protocol makes it behave like a nominal class (wrt ReadOnly) --- peps/pep-0767.rst | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/peps/pep-0767.rst b/peps/pep-0767.rst index 1c780badeba..1e73a655e70 100644 --- a/peps/pep-0767.rst +++ b/peps/pep-0767.rst @@ -281,9 +281,12 @@ Assignment to a read-only instance attribute must be allowed in the following co * In ``__init__``, on the instance of the declaring class received as the first parameter (usually ``self``). -* In ``__new__`` and ``@classmethod``\ s, on instances of the declaring class - created via a call to ``__new__`` on any object of type ``type[T]``, where - ``T`` is a *nominal supertype* of the declaring class. +* In ``__new__`` and ``@classmethod``\ s, on instances of the declaring class created via: + + - a call to ``super().__new__()``, + - a call to ``__new__`` on any object of type ``type[T]``, + where ``T`` is a *nominal supertype* of the declaring class. + * At declaration in the class scope. Additionally, a type checker may choose to allow the assignment @@ -421,6 +424,12 @@ is assignable to ``T``: Read-only protocol attributes may not be assigned to or deleted in any context. +Note that when inheriting from a protocol to `explicitly declare its implementation `__, +for the purpose of applying rules regarding read-only attributes (that the protocol may define), +the protocol should be treated as if it was a nominal class. +In particular, this means that subclasses *can* initialize read-only attributes +that have been defined by the protocol. + Type checkers should not assume that access to a protocol's read-only attributes is supported by the protocol's type (``type[HasName]``). Even if an attribute exists on the protocol's type, no assumptions should be made about its type. @@ -436,8 +445,8 @@ Subtyping The inability to assign to or delete read-only attributes makes them covariant. This has a few subtyping implications. Borrowing from :pep:`705#inheritance`: -* Read-only attributes can be redeclared as writable attributes, descriptors - or class variables:: +* Read-only attributes can be redeclared by a subclass as writable attributes, + descriptors or class variables:: @dataclass class HasTitle: