Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to generate __slots__ based on annotations in Python 3.8-3.14

I would like to make __slots__-based classes, without having to repeat the attribute names that I've already listed in type annotations.

Before Python 3.14, I could do this:

class C:
    foo: int
    bar: int
    __slots__ = (*__annotations__,)
    not_a_slot: ClassVar[str] = 'some class variable'

Python 3.14, however, switched to lazily-evaluated annotations, which means that when it comes time to assign __slots__, the __annotations__ dict does not yet exist. Python's own best practices say to not use __annotations__ directly after 3.10 anyway.

@dataclass(slots=True) works for python 3.10 and up (with many side-effects). Unfortunately, my code targets a lot of legacy systems, and so has to work both on python 3.8 and on the 3.14 I have on my laptop.

Is there a 3.8-compatible, but future-proof way to achieve a __slots__ based on some subset of annotated attributes, but without repeating the names?

The only workaround I've found myself is to make a class decorator that creates a class with __slots__ set, thus:

from typing import ClassVar, get_origin

try:
    from inspect import get_annotations
except ImportError:

    def get_annotations(x):
        return x.__annotations__


def annotations_to_slots(klass):
    class Out(klass):
        __slots__ = tuple(
            name
            for name, declared_type in get_annotations(klass).items()
            if get_origin(declared_type) is not ClassVar
        )

    Out.__name__ = klass.__name__
    try:
        Out.__doc__ = klass.__doc__
    except AttributeError:
        pass
    return Out


@annotations_to_slots
class C:
    __slots__ = ()  # will be filled in by @annotations_to_slots
    foo: int
    bar: int
    not_a_slot: ClassVar[str] = 'not a slot'

This seems to work, but is ugly for a couple of reasons. The __slots__ end up on a machine-generated class rather than on the class that defined the field, the defining class has to include __slots__ = () which is misleading even with the comment, and the extra class in the MRO makes inheritance less-obvious.

Is there a better approach?

like image 397
Dan Avatar asked Aug 14 '26 19:08

Dan


1 Answers

If you'd rather have the slots on the class that defines the fields rather than on a "machine-generated" class (from @dataclasses.dataclass and @attrs.define for example), one approach is to use a meta class to retrieve the annotations from the namespace before the class is created with the slots using those annotations.

This is especially necessary in Python 3.14 where type annotations are no longer available as a dict while the class body is being executed, but are instead returned lazily as a dict from an annotate function that can be obtained by calling annotationlib.get_annotate_from_class_namespace with the class' namespace.

So one way to write code that works for both Python 3.14 and older verions is to define a simulated annotationlib.get_annotate_from_class_namespace function when it is not available:

from typing import ClassVar, get_origin
try:
    from annotationlib import get_annotate_from_class_namespace
except ImportError:
    def get_annotate_from_class_namespace(ns):
        if (annotations := ns.get('__annotations__')) is not None:
            return lambda format: annotations

class AnnotationSlotted(type):
    def __new__(metacls, name, bases, namespace, **kwargs):
        annotate = get_annotate_from_class_namespace(namespace)
        namespace['__slots__'] = [
            name for name, declared_type in (annotate(1) if annotate else {}).items()
            if get_origin(declared_type) is not ClassVar
        ]
        return super().__new__(metacls, name, bases, namespace, **kwargs)

class C(metaclass=AnnotationSlotted):
    foo: int
    bar: int
    not_a_slot: ClassVar[str] = 'not a slot'

print(C.__slots__)

This outputs in Python 3.8+:

['foo', 'bar']

Demo here

like image 187
blhsing Avatar answered Aug 16 '26 16:08

blhsing