Extensible cached_property

Thank you, that’s exactly the main counterargument for leaving cached_property as is. I also wrote my own descriptor for a thread-local cached property, although I did subclasss cached_property to inherit __init__ and __set_name__ and avoid copying 12 lines. I could save a few more lines if there were _get_cached/_set_cached/_del_cached hooks to override but as you point out the whole class is ~40 lines so at the end of the day it’s not a huge deal for a relatively rare need.

Werkzeug also has a relatively simple variation of cached_property that handles both __dict__ and __slots__. In the case of slotted classes, it expects a _cache_{property name} attribute to exist.

werkzeug/src/werkzeug/utils.py at main · pallets/werkzeug

2 Likes

Or you can do it the old fashioned way:

#!/usr/bin/env python3

class Matrix:
    __slots__ = ("_the_one")

    def __init__(self):
        self._the_one = None

    @property
    def the_one(self)->str:
        if self._the_one is None:
            self._the_one = "Neo" 
        return self._the_one

    def take_blue_pill(self)->None:
        self._the_one = None 

the_matrix = Matrix()
print(the_matrix.the_one)
the_matrix.take_blue_pill()
print(the_matrix._the_one)

This is interesting.

Previously I’ve just had my dataclass-like slotting tool add __dict__ in the case where it found a @cached_property in order to just make them work. For whatever reason I didn’t think you could replace the slot attribute in a class.

It seems like wrapping the function and slot together can make for a fairly simple descriptor that can be used to replace a cached_property on the class[1]. The requirement being that it’s linked with a metaclass or class-rebuilder[2] that can remove the original object so the slot can be placed before replacing the slot descriptor with the wrapper. For dataclasses to support something like this the logic would probably need to be in the _add_slots function.


  1. Assuming there aren’t any sharp edges I’m missing here ↩︎

  2. Like the hacks that attrs and dataclasses do to support slots ↩︎

1 Like

Yes, indeed it can be done with a class rebuilding decorator like @dataclass(slots=True) as well.

Here’s a proof of concept:

def slotify(cls):
    slots = {}
    others = {}
    for name, obj in vars(cls).items():
        (slots if getattr(obj, '_is_slot', False) else others)[name] = obj
    cls = type(cls)(cls.__name__, cls.__bases__,
        {
            **others,
            '__slots__': (*getattr(cls, '__slots__', ()), *slots)
        }
    )
    for name, func in slots.items():
        slot = getattr(cls, name)
        def fallback(self, getter=slot.__get__, name=name, func=func):
            try:
                return getter(self)
            except AttributeError:
                setattr(self, name, value := func(self))
                return value
        setattr(cls, name, property(fallback, slot.__set__, slot.__delete__))
    return cls

def slot_property(func):
    func._is_slot = True
    return func

so that:

@slotify
class Foo:
    @slot_property
    def a(self):
        print('called')
        return 1

f = Foo()
print(f.a) # called, 1
print(f.a) # 1
f.a = 2
print(f.a) # 2
del f.a
print(f.a) # called, 1

I’ve gone with a descriptor that looks more like this rather than creating a fallback @property so that it looks roughly the same as the regular cached_property except it also wraps the slot:

class _SlottedCachedProperty:
    def __init__(self, slot, func):
        self.slot = slot
        self.func = func
        self.__doc__ = self.func.__doc__
        self.__module__ = self.func.__module__

    def __get__(self, instance, owner=None):
        if instance is None:
            return self
        try:
            return self.slot.__get__(instance, owner)
        except AttributeError:
            pass
        result = self.func(instance)
        self.slot.__set__(instance, result)
        return result

    def __set__(self, obj, value):
        self.slot.__set__(obj, value)

    def __delete__(self, obj):
        self.slot.__delete__(obj)

I have a PR for it here on my own dataclass-like tool.

I don’t really like the decorator rebuilding a class that dataclasses and attrs do as there’s a lot of extra cleaning up you have to do to make things like no arguments super() work and there are still ways the original unslotted class can be kept around unintentionally.

I do think this might be a cleaner approach than the __getattr__ rewriting attrs is doing.

1 Like