Skip to content

← Interfaces with Protocols and ABCs step 9 of 26

Hard Primitives

Make a Protocol Member Read-Only with a Property

The previous exercise showed that a type parameter used only for output can be covariant. A protocol's attribute follows the same rule. If callers can only read it, its type can be covariant. If they can also assign it, its type is invariant, because assigning is an input.

describe(envelope) reads envelope.payload and lists its keys. It accepts any HasPayload:

class HasPayload(Protocol):
    payload: Mapping[str, object]

Three envelope classes should fit, and every test already passes. mypy rejects all three. The first error is:

L45:22 [misc]
Generator has incompatible item type "JsonEnvelope"; expected "HasPayload"

Under each error, a note gives the reason: Protocol member HasPayload.payload expected settable variable, got read-only attribute.

A plain attribute lets callers assign it

A plain attribute in a protocol lets callers read it and also assign it. That causes two problems here:

  • Because callers may assign it, the attribute's type must match exactly. Code holding a HasPayload could assign any Mapping[str, object], such as {"count": 3}, to a JsonEnvelope whose field must hold a dict[str, str].
  • None of the three classes allows assignment. Two are frozen dataclasses, and LazyEnvelope has a property with no setter. So even FrozenEnvelope, whose field type matches exactly, does not fit.

Make the member a read-only property

describe only reads the payload, so the protocol needs only a getter:

class HasPayload(Protocol):
    @property
    def payload(self) -> Mapping[str, object]: ...

A property with no setter is read-only, so mypy checks it covariantly. An implementation can return a more specific type, such as dict[str, str]. A class can fit a property member with a dataclass field or a plain attribute. Its member does not need to be a property itself.

What to write

Change only HasPayload. Leave the three envelope classes, describe and solve as they are. Do not widen the envelope types, and do not use cast.

The driver takes two lists of plain string-to-string dictionaries, mutable and readonly. The names only say which envelopes each list becomes. The driver builds the envelopes in three groups:

  1. A JsonEnvelope for each dictionary in mutable, in order.
  2. A FrozenEnvelope for each dictionary in readonly, in order.
  3. A LazyEnvelope for each dictionary in readonly, in order.

describe returns the key count, a colon, and the sorted keys joined by commas. An empty dictionary gives "0:". For mutable=[] and readonly=[{"a": "1"}, {"b": "2", "c": "3"}], solve returns ["1:a", "2:b,c", "1:a", "2:b,c"].

The property only stops code assigning envelope.payload through a HasPayload. It does not freeze the dictionary, so code that holds the original dict can still change it.