Movatterモバイル変換


[0]ホーム

URL:


Following system colour schemeSelected dark colour schemeSelected light colour scheme

Python Enhancement Proposals

PEP 767 – Annotating Read-Only Attributes

Author:
Eneg <eneg at discuss.python.org>
Sponsor:
Carl Meyer <carl at oddbird.net>
Discussions-To:
Discourse thread
Status:
Draft
Type:
Standards Track
Topic:
Typing
Created:
18-Nov-2024
Python-Version:
3.15
Post-History:
09-Oct-2024

Table of Contents

Abstract

PEP 705 introduced thetyping.ReadOnly type qualifierto allow defining read-onlytyping.TypedDict items.

This PEP proposes usingReadOnly inannotations of class and protocolattributes, as a single concise way to mark them read-only.

Akin toPEP 705, it makes no changes to setting attributes at runtime. Correctusage of read-only attributes is intended to be enforced only by static type checkers.

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 asC#orTypeScript),and is useful for removing the ability to reassign ordelete an attributeat a type checker level, as well as defining a broad interface for structural subtyping.

Classes

Today, there are three major ways of achieving read-only attributes, honored by type checkers:

  • annotating the attribute withtyping.Final:
    classFoo:number:Final[int]def__init__(self,number:int)->None:self.number=numberclassBar:def__init__(self,number:int)->None:self.number:Final=number
    • Supported bydataclasses (and type checkers sincetyping#1669).
    • Overridingnumber is not possible - the specification ofFinalimposes that the name cannot be overridden in subclasses.
  • read-only proxy via@property:
    classFoo:_number:intdef__init__(self,number:int)->None:self._number=number@propertydefnumber(self)->int:returnself._number
    • Overridingnumber is possible.Type checkers disagree about the specific rules.[1]
    • Read-only at runtime.[2]
    • Requires extra boilerplate.
    • Supported bydataclasses, but does not compose well - the synthesized__init__ and__repr__ will use_number as the parameter/attribute name.
  • using a “freezing” mechanism, such asdataclasses.dataclass() ortyping.NamedTuple:
    @dataclass(frozen=True)classFoo:number:int# implicitly read-onlyclassBar(NamedTuple):number:int# implicitly read-only
    • Overridingnumber is possible in the@dataclass case.
    • Read-only at runtime.[2]
    • No per-attribute control - these mechanisms apply to the whole class.
    • Frozen dataclasses incur some runtime overhead.
    • NamedTuple is still atuple. Most classes do not need to inheritindexing, iteration, or concatenation.

Protocols

Suppose aProtocol membername:T defining two requirements:

  1. hasattr(obj,"name")
  2. isinstance(obj.name,T)

Those requirements are satisfiable at runtime by all of the following:

  • an object with an attributename:T,
  • a class with a class variablename:ClassVar[T],
  • an instance of the class above,
  • an object with a@propertydefname(self)->T,
  • an object with a custom descriptor, such asfunctools.cached_property().

The currenttyping specallows creation of such protocol members using (abstract) properties:

classHasName(Protocol):@propertydefname(self)->T:...

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 withtype qualifiers.
  • Not all type checkers agree[3] that all of the above fiveobjects are assignable to this structural type.

Rationale

These problems can be resolved by an attribute-level type qualifier.ReadOnly has been chosen for this role, as its name conveys the intent well,and the newly proposed changes complement its semantics defined inPEP 705.

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

fromtypingimportReadOnlyclassMember:def__init__(self,id:int)->None:self.id:ReadOnly[int]=id

…and the protocol described inProtocols is now just:

fromtypingimportProtocol,ReadOnlyclassHasName(Protocol):name:ReadOnly[str]defgreet(obj:HasName,/)->str:returnf"Hello,{obj.name}!"
  • A subclass ofMember can redefine.id as a writable attribute or adescriptor. It can alsonarrow the type.
  • TheHasName protocol has a more succinct definition, and is agnosticto the writability of the attribute.
  • Thegreet function can now accept a wide variety of compatible objects,while being explicit about no modifications being done to the input.

Specification

Thetyping.ReadOnlytype qualifierbecomes a valid annotation forattributes of classes and protocols.It can be used at class-level or within__init__ to mark individual attributes read-only:

classBook:id:ReadOnly[int]def__init__(self,id:int,name:str)->None:self.id=idself.name:ReadOnly[str]=name

Type checkers should error on any attempt to reassign ordelete an attributeannotated withReadOnly.Type checkers should also error on any attempt to delete an attribute annotated asFinal.(This is not currently specified.)

Use ofReadOnly in annotations at other sites where it currently has no meaning(such as local/global variables or function parameters) is considered out of scopefor this PEP.

Akin toFinal[4],ReadOnly does not influence howtype checkers perceive the mutability of the assigned object. ImmutableABCsandcontainers may be used in combination withReadOnlyto forbid mutation of such values at a type checker level:

fromcollectionsimportabcfromdataclassesimportdataclassfromtypingimportProtocol,ReadOnly@dataclassclassGame:name:strclassHasGames[T:abc.Collection[Game]](Protocol):games:ReadOnly[T]defadd_games(shelf:HasGames[list[Game]])->None:shelf.games.append(Game("Half-Life"))# ok: list is mutableshelf.games[-1].name="Black Mesa"# ok: "name" is not read-onlyshelf.games=[]# error: "games" is read-onlydelshelf.games# error: "games" is read-only and cannot be deleteddefread_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-onlyshelf.games=[]# error: "games" is read-only

All instance attributes of frozen dataclasses andNamedTuple should beimplied to be read-only. Type checkers may inform that annotating such attributeswithReadOnly is redundant, but it should not be seen as an error:

fromdataclassesimportdataclassfromtypingimportNewType,ReadOnly@dataclass(frozen=True)classPoint:x:int# implicit read-onlyy:ReadOnly[int]# ok, redundantuint=NewType("uint",int)@dataclass(frozen=True)classUnsignedPoint(Point):x:ReadOnly[uint]# ok, redundant; narrower typey:Final[uint]# not redundant, Final imposes extra restrictions; narrower type

Initialization

Assignment to a read-only attribute can only occur in the class declaring the attribute.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:

  • In__init__, on the instance received as the first parameter (likely,self).
  • In__new__, on instances of the declaring class created via a callto 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 regardto the origin of the instance.(This choice trades soundness, as the instance may already be initialized,for the simplicity of implementation.)
  • In@classmethods, on instances of the declaring class created viaa call to the class’ or super-class’__new__ method.
fromcollectionsimportabcfromtypingimportReadOnlyclassBand:name:strsongs:ReadOnly[list[str]]def__init__(self,name:str,songs:abc.Iterable[str]|None=None)->None:self.name=nameself.songs=[]ifsongsisnotNone:self.songs=list(songs)# multiple assignments are finedefclear(self)->None:# error: assignment to read-only "songs" outside initializationself.songs=[]band=Band(name="Bôa",songs=["Duvet"])band.name="Python"# ok: "name" is not read-onlyband.songs=[]# error: "songs" is read-onlyband.songs.append("Twilight")# ok: list is mutableclassSubBand(Band):def__init__(self)->None:self.songs=[]# error: cannot assign to a read-only attribute of a base class
# a simplified immutable Fraction classclassFraction:numerator:ReadOnly[int]denominator:ReadOnly[int]def__new__(cls,numerator:str|int|float|Decimal|Rational=0,denominator:int|Rational|None=None)->Self:self=super().__new__(cls)ifdenominatorisNone:iftype(numerator)isint:self.numerator=numeratorself.denominator=1returnselfelifisinstance(numerator,Rational):...else:...@classmethoddeffrom_float(cls,f:float,/)->Self:self=super().__new__(cls)self.numerator,self.denominator=f.as_integer_ratio()returnself

Class Attributes

Read-only class attributes are attributes annotated as bothReadOnly andClassVar.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).
classURI:protocol:ReadOnly[ClassVar[str]]=""def__init_subclass__(cls,protocol:str="")->None:cls.protocol=protocolclassFile(URI,protocol="file"):...

When a class-level declaration has an initializing value, it can serve as aflyweightdefault for instances:

classPatient:number:ReadOnly[int]=0def__init__(self,number:int|None=None)->None:ifnumberisnotNone:self.number=number

Note

This feature conflicts with__slots__. An attribute witha class-level value cannot be included in slots, effectively making it a class variable.

Type checkers may choose to warn on read-only attributes which could be left uninitializedafter an instance is created (except instubs,protocols or ABCs):

classPatient:id:ReadOnly[int]# error: "id" is not initialized on all code pathsname:ReadOnly[str]# error: "name" is never initializeddef__init__(self)->None:ifrandom.random()>0.5:self.id=123classHasName(Protocol):name:ReadOnly[str]# ok

Subtyping

The inability to reassign read-only attributes makes them covariant.This has a few subtyping implications. Borrowing fromPEP 705:

  • Read-only attributes can be redeclared as writable attributes, descriptorsor class variables:
    @dataclassclassHasTitle:title:ReadOnly[str]@dataclassclassGame(HasTitle):title:stryear:intgame=Game(title="DOOM",year=1993)game.year=1994game.title="DOOM II"# ok: attribute is not read-onlyclassTitleProxy(HasTitle):@functools.cached_propertydeftitle(self)->str:...classSharedTitle(HasTitle):title:ClassVar[str]="Still Grey"
  • If a read-only attribute is not redeclared, it remains read-only:
    classGame(HasTitle):year:intdef__init__(self,title:str,year:int)->None:super().__init__(title)self.title=title# error: cannot assign to a read-only attribute of base classself.year=yeargame=Game(title="Robot Wants Kitty",year=2010)game.title="Robot Wants Puppy"# error: "title" is read-only
  • Subtypes cannarrow the type of read-only attributes:
    classGameCollection(Protocol):games:ReadOnly[abc.Collection[Game]]@dataclassclassGameSeries(GameCollection):name:strgames:ReadOnly[list[Game]]# ok: list[Game] is assignable to Collection[Game]
  • Nominal subclasses of protocols and ABCs should redeclare read-only attributesin order to implement them, unless the base class initializes them in some way:
    classMyBase(abc.ABC):foo:ReadOnly[int]bar:ReadOnly[str]="abc"baz:ReadOnly[float]def__init__(self,baz:float)->None:self.baz=baz@abstractmethoddefpprint(self)->None:...@finalclassMySubclass(MyBase):# error: MySubclass does not override "foo"defpprint(self)->None:print(self.foo,self.bar,self.baz)
  • In a protocol attribute declaration,name:ReadOnly[T] indicates that a structuralsubtype must support.name access, and the returned value is assignable toT:
    classHasName(Protocol):name:ReadOnly[str]classNamedAttr:name:strclassNamedProp:@propertydefname(self)->str:...classNamedClassVar:name:ClassVar[str]classNamedDescriptor:@cached_propertydefname(self)->str:...# all of the following are okhas_name:HasNamehas_name=NamedAttr()has_name=NamedProp()has_name=NamedClassVarhas_name=NamedClassVar()has_name=NamedDescriptor()

Interaction with Other Type Qualifiers

ReadOnly can be used withClassVar andAnnotated in any nesting order:

classFoo:foo:ClassVar[ReadOnly[str]]="foo"bar:Annotated[ReadOnly[int],Gt(0)]
classFoo:foo:ReadOnly[ClassVar[str]]="foo"bar:ReadOnly[Annotated[int,Gt(0)]]

This is consistent with the interaction ofReadOnly andtyping.TypedDictdefined inPEP 705.

An attribute cannot be annotated as bothReadOnly andFinal, as the twoqualifiers differ in semantics, andFinal is generally more restrictive.Final remains allowed as an annotation of attributes that are only impliedto be read-only. It can be also used to redeclare aReadOnly attribute of a base class.

Backwards Compatibility

This PEP introduces new contexts whereReadOnly is valid. Programs inspectingthose places will have to change to support it. This is expected to mainly affect type checkers.

However, caution is advised while using the backportedtyping_extensions.ReadOnlyin older versions of Python. Mechanisms inspecting annotations may behave incorrectlywhen encounteringReadOnly; in particular, the@dataclass decoratorwhichlooks forClassVar may mistakenly treatReadOnly[ClassVar[...]] as an instance attribute.

To avoid issues with introspection, useClassVar[ReadOnly[...]] instead ofReadOnly[ClassVar[...]].

Security Implications

There are no known security consequences arising from this PEP.

How to Teach This

Suggested changes to thetyping module documentation,following the footsteps ofPEP 705:

  • Add this PEP to the others listed.
  • Linktyping.ReadOnly to this PEP.
  • Update the description oftyping.ReadOnly:
    A special typing construct to mark an attribute of a class or an item ofaTypedDict as read-only.
  • Add a standalone entry forReadOnly under thetype qualifiers section:
    TheReadOnly type qualifier in class attribute annotations indicatesthat the attribute of the class may be read, but not reassigned ordeleted.For usage inTypedDict, seeReadOnly.

Rejected Ideas

Clarifying Interaction of@property and Protocols

TheProtocols section mentions an inconsistency between type checkers inthe interpretation of properties in protocols. The problem could be fixedby amending the typing specification, clarifying what implements the read-onlyquality of such properties.

This PEP makesReadOnly a better alternative for defining read-only attributesin 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 beassigned to in__init__ and the class’ body. A later discussion revealed thatthis restriction would severely limit the usability ofReadOnly withinimmutable classes, which typically do not define__init__.

fractions.Fraction is one example of an immutable class, where theinitialization of its attributes happens within__new__ and classmethods.However, unlike in__init__, the assignment in__new__ and classmethodsis potentially unsound, as the instance they work on can be sourced froman arbitrary place, including an already finalized instance.

We find it imperative that this type checking feature is useful to the foremostuse site of read-only attributes - immutable classes. Thus, the PEP has changedsince to allow assignment in__new__ and classmethods under a set of rulesdescribed in theInitialization section.

Open Issues

Extending Initialization

Mechanisms such asdataclasses.__post_init__() or attrs’initialization hooksaugment object creation by providing a set of special hooks which are calledduring initialization.

The current initialization rules defined in this PEP disallow assignment toread-only attributes in such methods. It is unclear whether the rules could besatisfyingly shaped in a way that is inclusive of those 3rd party hooks, whileupkeeping the invariants associated with the read-only-ness of those attributes.

The Python type system has a long and detailedspecificationregarding the behavior of__new__ and__init__. It is rather unfeasibleto expect the same level of detail from 3rd party hooks.

A potential solution would involve type checkers providing configuration in thisregard, requiring end users to manually specify a set of methods they wishto allow initialization in. This however could easily result in users mistakenlyor purposefully breaking the aforementioned invariants. It is also a fairlybig ask for a relatively niche feature.

Footnotes

[1]
Pyright in strict mode disallows non-property overrides.Mypy does not impose this restriction and allows an override with a plain attribute.[Pyright playground][mypy playground]
[2] (1,2)
This PEP focuses solely on the type-checking behavior. Nevertheless, it shouldbe desirable the name is read-only at runtime.
[3]
Pyright disallows class variable and non-property descriptor overrides.[Pyright][mypy][Pyre]
[4]
As noted above the second-to-last code example ofhttps://typing.python.org/en/latest/spec/qualifiers.html#semantics-and-examples

Copyright

This document is placed in the public domain or under theCC0-1.0-Universal license, whichever is more permissive.


Source:https://github.com/python/peps/blob/main/peps/pep-0767.rst

Last modified:2025-05-06 20:54:28 GMT


[8]ページ先頭

©2009-2025 Movatter.jp