Skip to content
Open
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 42 additions & 46 deletions peps/pep-0767.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
PEP: 767
Title: Annotating Read-Only Attributes
Author: Eneg <eneg at discuss.python.org>
Author: Łukasz Modzelewski <eneg at discuss.python.org>
Sponsor: Carl Meyer <[email protected]>
Discussions-To: https://discuss.python.org/t/pep-767-annotating-read-only-attributes/73408
Status: Draft
Expand All @@ -9,6 +9,7 @@ Topic: Typing
Created: 18-Nov-2024
Python-Version: 3.15
Post-History: `09-Oct-2024 <https://discuss.python.org/t/expanding-readonly-to-normal-classes-protocols/67359>`__
`05-Dec-2024 <https://discuss.python.org/t/pep-767-annotating-read-only-attributes/73408>`__


Abstract
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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:

Expand Down Expand Up @@ -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 <type qualifier>`.
* 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] <https://pyright-play.net/?pyrightVersion=1.1.404&pythonVersion=3.13&strict=true&code=GYJw9gtgBAhgRgYygSwgBzCALrOBnLEGBLCAUywAswATAKFEimAFcA7EsMAGzxXUw4ExSmRoB9NODRlsATwbhoWOWmRsA5vwzYoAYW4w8eAGowQAGigAFcFjAIeV4Opjc6HhIeNQAEkYAxLgAKWzB7R24ASgAuOigEqAABKTAZeXjEpPgCIhJyKlpMhJoyYGYQvDJuYCioAFoAPhQ2LBioADoujzoAYlhjZA02eGRuZBUeryM%2BILAAQSxCZDgWLDI4xIqwdvUsT29ZrjD0lU2s1NOFLdLy4Erq2obmvfaQChYQNigABgOZqBzAwzMwgc4Je47fSHUEAbT2AF0oABeX7-HxzAAiZDwCBAyDQ9jBxWSwgQogkl1kkxuZW2wSqNTqTRabSg7ywn2%2Bfzo0wxx2k1LkejAADdzMgYK1wckqRlaXcHkznlA4FxuG8Pl9AW4quijmAAJJscXjGgyyHtXL6qAAOTAcxlcHMVsIPTAcAAVu1-Hg5nQPZ6UYCuItlqt1sE6lB%2BmAANYBr3BuYnIVRxKxhOB5NcYHGUFbGNQeOJoOooEw8zphKZ0s5sDY3H4wmYdO17PlgVpIUi8X4qVYNvFrNJztGk1uZA0as1qCyEB11H2uYzwtF%2BeLu1gNhkNdr-objwHgAeaHGCAm2ncNrmYfxEbIhvQ3GCvrmsRJltZN67VyfZ9fQIuA-LYUkFeVEluelGSeFlXnZLVuR-MA81Mcx-xfN9gItLh2lQuFEWDHk%2BQNRs8QJIkMMAv1sJJJIyQpSRwJpSC6UhBlHmZF5pQQzltWIw4QzAVN5F7CUByorCwBAi5mOuVjFTADjlRZNUeE1PjvgCXUyGQ41TSnSSgOknCoWtOgkhcEZ3BIrc5iMmiTJJZ0wSga0gA>`_
`[mypy] <https://mypy-play.net/?mypy=1.17.1&python=3.13&flags=strict&gist=12d556bb6ef4a9a49ff4ed4776604750>`_

Rationale
=========
Expand Down Expand Up @@ -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,
Expand All @@ -167,7 +169,7 @@ Specification

The :external+py3.13:data:`typing.ReadOnly` :external+typing:term:`type qualifier`
becomes a valid annotation for :term:`attributes <attribute>` 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]
Expand All @@ -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 ``[<type>]``) is not allowed.
Type checkers should error on any attempt to reassign or ``del``\ ete an attribute
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The meaning of "reassign" here is not clear. (What is an "assignment" and what is a "reassignment"?)

I think it's clarified below, but maybe we should reference that here.

annotated with ``ReadOnly``.
Type checkers should also error on any attempt to delete an attribute annotated as ``Final``.
Expand Down Expand Up @@ -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:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think @oscarbenjamin raised an interesting point in the Discourse thread about this. The effects of ReadOnly as described in this PEP (structural typing and variance of generics) only require for soundness that we disallow external mutation of the attribute. Disallowing internal mutation (within methods of the class) is not required for soundness, and is arguably veering too far into Final territory.

It would simplify this section (and type checker implementations) quite a lot if we just specified that ReadOnly describes the external interface of the attribute, and all internal reassignments of a ReadOnly attribute (within methods of the class) are permitted.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


* 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.
Expand Down Expand Up @@ -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

Expand All @@ -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 <stub>`,
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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
--------------------------------------

Expand Down Expand Up @@ -596,50 +608,34 @@ 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 <https://www.attrs.org/en/stable/init.html#hooking-yourself-into-initialization>`_
augment object creation by providing a set of special hooks which are called
during initialization.
`This thread <https://github.com/python/peps/pull/4127#discussion_r1849261608>`_
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 <https://typing.python.org/en/latest/spec/constructors.html>`_
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] <https://pyright-play.net/?strict=true&code=MYGwhgzhAEBiD28BcBYAUNT0D6A7ArgLYBGApgE5LQCWuALuultACakBmO2t1d22ACgikQ7ADTQCJClVp0AlNAC0APmgA5eLlKoMzLMNEA6PETLloAXklmKjPZgACAB3LxnFOgE8mWNpylzIRF2RVUael19LHJSOnxyXGhDdhNAuzR7UEgYACEwcgEEeHkorHTKCIY0IA>`_
`[mypy playground] <https://mypy-play.net/?mypy=latest&python=3.12&flags=strict&gist=6f860a865c5d13cce07d6cbb08b9fb85>`_

.. [#runtime]
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] <https://pyright-play.net/?pyrightVersion=1.1.389&pythonVersion=3.13&strict=true&code=GYJw9gtgBAhgRgYygSwgBzCALrOBnLEGBLCAUywAswATAKFEimAFcA7EsMAGzxXUw4ExSmRoB9NODRlsATwbhoWOWmRsA5vwzYoAYW4w8eAGowQAGigAFcFjAIeV4Opjc6HhIeNQAEkYAxLgAKWzB7R24ASgAuOigEqAABKTAZeXjEpPgCIhJyKlpMhJoyYGYQvDJuYCioAFoAPhQ2LBioADoujzoAYlhjZA02eGRuZBUeryM%2BILAAQSxCZDgWLDI4xIqwdvUsT29ZrjD0lU2s1NOFLdLy4Erq2obmvfaQChYQNigABgOZqBzAwzMwgc4Je47fSHUEAbT2AF0oABeX7-HxzAAiZDwCBAyDQ9jBxWSwgQogkl1kkxuZW2wSqNTqTRabSg7ywn2%2Bfzo0wxx2k1LkejAADdzMgYK1wckqRlaXcHkznlA4FxuG8Pl9AW4quijmAAJJscXjGgyyHtXL6qAAOTAcxlcHMVsIPTAcAAVu1-Hg5nQPZ6UYCuItlqt1sE6lB%2BmAANYBr3BuYnIVRxKxhOB5NcYHGUFbGNQeOJoOooEw8zphKZ0s5sDY3H4wmYdO17PlgVpIUi8X4qVYNvFrNJztGk1uZA0as1qCyEB11H2uYzwtF%2BeLu1gNhkNdr-objwHgAeaHGCAm2ncNrmYfxEbIhvQ3GCvrmsRJltZN67VyfZ9fQIuA-LYUkFeVEluelGSeFlXnZLVuR-MA81Mcx-xfN9gItLh2lQuFEWDHk%2BQNRs8QJIkMMAv1sJJJIyQpSRwJpSC6UhBlHmZF5pQQzltWIw4QzAVN5F7CUByorCwBAi5mOuVjFTADjlRZNUeE1PjvgCXUyGQ41TSnSSgOknCoWtOgkhcEZ3BIrc5iMmiTJJZ0wSga0gA>`_
`[mypy] <https://mypy-play.net/?mypy=1.13.0&python=3.12&flags=strict&gist=12d556bb6ef4a9a49ff4ed4776604750>`_
`[Pyre] <https://pyre-check.org/play/?input=%23%20pyre-strict%0Afrom%20abc%20import%20abstractmethod%0Afrom%20functools%20import%20cached_property%0Afrom%20typing%20import%20ClassVar%2C%20Protocol%2C%20final%0A%0A%0Aclass%20HasFoo(Protocol)%3A%0A%20%20%20%20%40property%0A%20%20%20%20%40abstractmethod%0A%20%20%20%20def%20foo(self)%20-%3E%20int%3A%20...%0A%0A%0A%23%20assignability%0A%0A%0Aclass%20FooAttribute%3A%0A%20%20%20%20foo%3A%20int%0A%0Aclass%20FooProperty%3A%0A%20%20%20%20%40property%0A%20%20%20%20def%20foo(self)%20-%3E%20int%3A%20return%200%0A%0Aclass%20FooClassVar%3A%0A%20%20%20%20foo%3A%20ClassVar%5Bint%5D%20%3D%200%0A%0Aclass%20FooDescriptor%3A%0A%20%20%20%20%40cached_property%0A%20%20%20%20def%20foo(self)%20-%3E%20int%3A%20return%200%0A%0Aclass%20FooPropertyCovariant%3A%0A%20%20%20%20%40property%0A%20%20%20%20def%20foo(self)%20-%3E%20bool%3A%20return%20False%0A%0Aclass%20FooInvalid%3A%0A%20%20%20%20foo%3A%20str%0A%0Aclass%20NoFoo%3A%0A%20%20%20%20bar%3A%20str%0A%0A%0Aobj%3A%20HasFoo%0Aobj%20%3D%20FooAttribute()%20%20%23%20ok%0Aobj%20%3D%20FooProperty()%20%20%20%23%20ok%0Aobj%20%3D%20FooClassVar%20%20%20%20%20%23%20ok%0Aobj%20%3D%20FooClassVar()%20%20%20%23%20ok%0Aobj%20%3D%20FooDescriptor()%20%23%20ok%0Aobj%20%3D%20FooPropertyCovariant()%20%23%20ok%0Aobj%20%3D%20FooInvalid()%20%20%20%20%23%20err%0Aobj%20%3D%20NoFoo()%20%20%20%20%20%20%20%20%20%23%20err%0Aobj%20%3D%20None%20%20%20%20%20%20%20%20%20%20%20%20%23%20err%0A%0A%0A%23%20explicit%20impl%0A%0A%0Aclass%20FooAttributeImpl(HasFoo)%3A%0A%20%20%20%20foo%3A%20int%0A%0Aclass%20FooPropertyImpl(HasFoo)%3A%0A%20%20%20%20%40property%0A%20%20%20%20def%20foo(self)%20-%3E%20int%3A%20return%200%0A%0Aclass%20FooClassVarImpl(HasFoo)%3A%0A%20%20%20%20foo%3A%20ClassVar%5Bint%5D%20%3D%200%0A%0Aclass%20FooDescriptorImpl(HasFoo)%3A%0A%20%20%20%20%40cached_property%0A%20%20%20%20def%20foo(self)%20-%3E%20int%3A%20return%200%0A%0Aclass%20FooPropertyCovariantImpl(HasFoo)%3A%0A%20%20%20%20%40property%0A%20%20%20%20def%20foo(self)%20-%3E%20bool%3A%20return%20False%0A%0Aclass%20FooInvalidImpl(HasFoo)%3A%0A%20%20%20%20foo%3A%20str%0A%0A%40final%0Aclass%20NoFooImpl(HasFoo)%3A%0A%20%20%20%20bar%3A%20str%0A>`_

.. [#final_mutability]
As noted above the second-to-last code example of https://typing.python.org/en/latest/spec/qualifiers.html#semantics-and-examples

Expand Down