-
-
Notifications
You must be signed in to change notification settings - Fork 1.7k
PEP 767: Address feedback & open issues #4559
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 | ||
|
@@ -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 | ||
|
@@ -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 <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 | ||
========= | ||
|
@@ -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 <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] | ||
|
@@ -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 | ||
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 <stub>`, | ||
|
@@ -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]``). | ||
Enegg marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
||
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,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 | ||
|
||
|
There was a problem hiding this comment.
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.