Python lacks a concise way to spell “this attribute is read-only”.
For normal classes, the current two solutions are:
- marking the attribute
Final
class Foo:
bar: Final[Bar]
def __init__(self, bar: Bar) -> None:
self.bar = bar
- using
@property
as a proxy
class Foo:
_bar: Bar
@property
def bar(self) -> Bar:
return self._bar
The former overspecifies the type, as Final
marks an attribute non-overridable by subclasses.
The latter requires extra boilerplate, and doesn’t work well for dataclasses (as now __init__
will be generated with _bar
as the param name). Freezing a dataclass works only if it’s desired that all attributes are read-only.
For protocols, the currently most permissive solution is to mark the name as a @property
. However, this rejects custom getters and class variables.
Final
attributes additionally reject @property
too.
(My observations here are based on what pyright reports)
from dataclasses import dataclass
from typing import ClassVar, Final, Protocol
class HasFooFinal(Protocol):
foo: Final[int]
class HasFooProp(Protocol):
@property
def foo(self) -> int: ...
class FooImpl:
foo: int
def __init__(self, foo: int = 123) -> None:
self.foo = foo
class FooProp:
@property
def foo(self) -> int:
return 123
@dataclass
class FooData:
foo: Final[int] = 123
class FooCls:
foo: ClassVar[int] = 123
class CustomGet:
def __get__(self, obj: object, cls: type | None) -> int:
return 123
# pyright reports foo as (variable)
class FooCustom:
foo = CustomGet()
def custom_get(fn: object) -> CustomGet:
return CustomGet()
# pyright reports foo as (property)
class FooCustom2:
@custom_get
def foo(self) -> object: ...
def read_foo_final(foo: HasFooFinal) -> int:
return foo.foo
def read_foo_prop(foo: HasFooProp) -> int:
return foo.foo
read_foo_final(FooImpl()) # ok
read_foo_final(FooProp()) # err: "property" is not assignable to "int"
read_foo_final(FooData()) # ok
read_foo_final(FooCls) # ok
read_foo_final(FooCls()) # err: "foo" is not defined as a ClassVar in protocol
read_foo_final(FooCustom()) # err: "CustomGet" is not assignable to "int"
read_foo_final(FooCustom2()) # err: "CustomGet" is not assignable to "int"
read_foo_prop(FooImpl()) # ok
read_foo_prop(FooProp()) # ok
read_foo_prop(FooData()) # ok
read_foo_prop(FooCls) # ok
read_foo_prop(FooCls()) # err: "foo" is not defined as a ClassVar in protocol
read_foo_prop(FooCustom()) # err: "CustomGet" is not assignable to "int"
read_foo_prop(FooCustom2()) # err: "CustomGet" is not assignable to "int"
I think this is a good opportunity for ReadOnly
to fill in the gap.
From pragmatic standpoint I’d expect
class HasName(Protocol):
name: ReadOnly[str]
to accept anything that supports .name
access, though I’m +0 whether this should also cover ClassVar
iables.
For normal classes, it should forbid writing to like Final
does, but permit subclasses to override how the name is defined.