PEP 767: Annotating Read-Only Attributes

Hi, I’m presenting PEP 767.

It enables the use of ReadOnly within attribute annotations, as a way to remove [1] the ability to reassign or delete an attribute of an object.

Any feedback is welcome!


  1. at a type checking level ↩︎

13 Likes

Read-only attributes are covariant.

Does that mean that the type parameter T@ReadOnly is covariant in the same way as e.g. T@Sequence, or does it mean that the T that’s used in a ReadOnly[T] of a generic class or protocol must always be covariant?

To illustrate, consider this example:

class Box[T]:
    thing: ReadOnly[T]

    def match(self, other_thing: T, /) -> bool:
        return other_thing == self.thing

In the first case, the T@Box would inferred as invariant (so the same behaviour as with a @property instead of a ReadOnly). But in the second case, it would be covariant.

The reason I’m asking, is because if T is explicitly marked as read-only within its defining generic type, then it’s safe to assume it won’t mutate. So even if T is used to annotate a method parameter (or any anything else that in other situations might have led to a mutation of something that has type T), the attribute of type T won’t be changed. So there’s no need to make it invariant, and can therefore “stay” covariant.

I believe that this is a special case of what’s commonly referred to as use-site variance, and avoids situations where T@Box is inferred as invariant.

This. It cannot force a type variable to be covariant. Rather, it limits the variance of T to covariance. Further places where T occurs may limit its variance further.

In your example, it appears in the type of a method parameter. Parameters of callables limit the variance of type variables to contravariance; application of both limits results in T being invariant.

Typing docs for reference

1 Like

Ok that’s too bad. Because I think PEP could also be used to improve the current variance inference, which can be unnecessarily restrictive, as my example shows.

That’s a pretty good explanation of the way that variance is currently inferred of Python.
But the point I’m trying to make is actually a bit more subtle than that, and was trying to show that Box[T] with T covariant is type-safe:

a: Box[int]
b: Box[object] = a
b.match(object())  # no problem

And even if we add another method to Box with the same signature as match, then this would still be type-safe. That’s because there is nothing of type T in Box[T] that can be mutated, thanks to ReadOnly[T].
Note that this is only the case for Box itself, because there might be subtypes that override thing in a mutable way.

There are some ways that variance could be fixed that have been brought up before, but that should probably be handled for variance as a whole rather than try to use this change to make it happen. For what you want to be sound, it has to be the case that subclasses that restrict variance more than the parent are considered an LSP violation and rejected (as they are no longer a suitable replacement for the parent behaviorally). This probably won’t ever happen because there are some extremely deeply rooted cases, like Sequence/MutableSequence, so the narrowing by the assumption of covariance can’t be safely done, and people are still proposing new exceptions to LSP despite the problems LSP violations cause, such as excluding __replace__ in 3.13+'s dataclasses.

1 Like

The match method in your example doesn’t really depend on the type of T, so it could be likely replaced with object (as is typically done for __eq__ or __contains__)

A subclass doesn’t need to enable mutability for thing to make covariant T unsafe:

class Box[T]:
    thing: ReadOnly[T]

    def match(self, other_thing: T, /) -> bool: ...


class Rect(Box[int]):
    def match(self, other: int, /) -> bool:
        return self.thing.denominator == other.denominator


a: Box[object] = Rect()
a.match(object())  # boom

You could remove the ability to subclass Box by decorating it with @typing.final, in which case I can’t think of other problems.
However, that won’t work for protocols.

I think that makes sense, especially considering the difficulty of this topic.

This will probably have to wait until we variance can be manually declared (again).

Yes your example nicely illustrates when I said

This is something that with the current automatically inferred variances is difficult (but not impossible) to deal with.

I like the PEP, especially the open question at the bottom regarding __init_subclass__.

I’ve done patterns where I utilize __init_subclass__ to force a child class defines class variables through class parameterizations, these class variables are read only from my point of view.

In the example below the desired behavior is for subclasses of OurABC to be forced to have a class variable abstract_classvar and for that classvar to be read only i.e. I want Concrete.abstract_classvar to be readonly.

from abc import ABC
from inspect import isabstract
from typing import ClassVar, Optional

class OurABC(ABC):
   abstract_classvar: ClassVar[int]
   abstract: ClassVar[bool]

   def __init_subclass__(cls, *, abstract_classvar: Optional[int] = None, abstract: bool = False):
       cls.abstract = True
       if not abstract and not isabstract(cls):
           cls.abstract = False
           if abstract_classvar is None:
                # handling of requirement of classvar to be passed as class parameter
                raise Exception
           cls.abstract_classvar = abstract_classvar
       return super().__init_subclass__()

class Concrete(OurABC, abstract_classvar=5):
   ...

It seems to me like it makes sense to allow initialization of ReadOnly[ClassVar[...]] in __init_subclass__