How do I tell the typechecker that my class is frozen?

Consider my stub file:

@final
class Meta:
    title: str | None
    passwords: tuple[str, ...]
    tags: tuple[str, ...]
    category: str | None

    def __new__(
        cls,
        *,
        title: str | None = None,
        passwords: Sequence[str] = (),
        tags: Sequence[str] = (),
        category: str | None = None,
    ) -> Meta: ...
    def __eq__(self, value: object) -> bool: ...
    def __hash__(self) -> int: ...

The stub above represents the following Rust class:

#[pyclass(frozen, eq, hash)]
#[derive(Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Meta(foobar::Meta);

This class is frozen, so the following example code fails at runtime but passes mypy --strict:

meta = Meta(title="hello")
assert meta.title == "hello"
meta.title = "world"
Traceback (most recent call last):
  File "redacted", line 5, in <module>
    meta.title = "world"
    ^^^^^^^^^^
AttributeError: attribute 'title' of 'Meta' objects is not writable

How do I tell the typecheckers that this is a frozen class?

1 Like

Like this? PEP 767: Annotating Read-Only Attributes

1 Like

This seems like exactly what I need! Although I would love a way to mark the entire class frozen (similar to dataclass(frozen=True)). Unfortunately it seems the PEP is still draft so I can’t use it right now.

A horrible hack, but it seems to work:

from collections.abc import Sequence
from typing import Any, Callable, TypeVar, dataclass_transform

_T = TypeVar("_T")

def field(
        *,
        default: Any,
        converter: Callable[[Any], Any],
    ) -> Any: ...

@dataclass_transform(frozen_default=True, field_specifiers=(field,), eq_default=True)
def frozen_class(cls: type[_T]) -> type[_T]: ...

def sequence_to_tuples(s: Sequence[_T]) -> tuple[_T, ...]: ...

@frozen_class
class Meta:
    title: str | None
    passwords: tuple[str, ...] = field(converter=sequence_to_tuples, default=())
    tags: tuple[str, ...] = field(converter=sequence_to_tuples, default=())
    category: str | None = None

meta = Meta(title="hello")
assert meta.title == "hello"
meta.title = "world"

mypy:

main.py:26: error: Property "title" defined in "Meta" is read-only  [misc]

pyright:

Cannot assign to attribute "title" for class "Meta"
  Attribute "title" is read-only
  Attribute "title" is read-only  (reportAttributeAccessIssue)

You can use property for this:

    @property
    def title(self) -> str | None: ...

If you don’t define a setter method then it is assumed to be read only.

2 Likes

I believe it is possible to use a dataclass as a type stub, so you could actually use

@final
@dataclass(frozen=True)
class Meta:
    title: str | None = None
    passwords: tuple[str, ...] = field(default_factory=tuple)
    tags: tuple[str, ...] = field(default_factory=tuple)
    category: str | None = None

in a stub file.

But I’m not a type stub expert by any means.

Be aware that type checkers assume a bunch of stuff about dataclass-transformed classes, like the presence of __replace__ and __dataclass_fields__

Since you’ve marked the class @final you may very well use Final on each field to denote the read-only aspect.

2 Likes

I don’t think that stubtest will like this.

This is also what I would do.

1 Like

Thank you everyone, typing.Final will get the job done for now. I do wish we can get a way to mark the entire class frozen in future, something like a @typing.frozen decorator.