Skip to content

Instantly share code, notes, and snippets.

@mara004
Last active July 26, 2026 18:05
Show Gist options
  • Select an option

  • Save mara004/076e3110aba8e8a3f8a6f2c84af350ee to your computer and use it in GitHub Desktop.

Select an option

Save mara004/076e3110aba8e8a3f8a6f2c84af350ee to your computer and use it in GitHub Desktop.
Cached properties with slots, via metaclass (Clean, declarative variant)
# SPDX-FileCopyrightText: 2026 geisserml <geisserml@gmail.com>
# SPDX-License-Identifier: MPL-2.0
import stl # local
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from functools import cached_property as cached_prop_typehint
else:
def cached_prop_typehint(func):
return func
def _cp_getattr(self, key):
value = self._cp_map_[key](self)
setattr(self, key, value)
return value
class _ClassBuidlerMeta (type):
def __new__(metacls, clsname, bases, nsp, use_slots=False, prefer_getattr=None, **kwargs):
# See "Design thoughts" in the accompanying README file
fields = nsp.get("_fields_")
cached_props = nsp.get("_cached_", ())
if use_slots:
nsp["__slots__"] = (*fields, *cached_props)
if prefer_getattr or use_slots:
nsp["_cp_map_"] = {cp_name: nsp.pop(cp_name) for cp_name in cached_props}
nsp["__getattr__"] = _cp_getattr
else:
for cp_name in cached_props:
nsp[cp_name] = stl.cached_property(nsp[cp_name])
return super().__new__(metacls, clsname, bases, nsp, **kwargs)
class ClassBuilder (metaclass=_ClassBuidlerMeta):
# Child class slots effectively only work when all base classes are slotted, since otherwise there would be an accessible __dict__ attribute.
# That means we have to declare (empty) slots here. This does not negatively impact non-slotted child classes.
# See https://stackoverflow.com/a/28059785/15547292 and https://docs.python.org/3/reference/datamodel.html#slots for more background.
__slots__ = ()
# SPDX-FileCopyrightText: 2026 geisserml <geisserml@gmail.com>
# SPDX-License-Identifier: MPL-2.0
class cached_property:
def __init__(self, func):
self.func = func
self.assigned_name = None
self.__doc__ = func.__doc__
# Optional. On older Python versions (< 3.6) that do not call this hook, the func's __name__ will be used as fallback.
def __set_name__(self, cls, name):
if self.assigned_name is None:
self.assigned_name = name
else:
assert name == self.assigned_name, f"A cached property is tied to one attribute. You cannot assign to both {name!r} and {self.assigned_name!r}."
def __get__(self, obj, cls=None):
if obj is None:
return self
value = self.func(obj)
name = self.assigned_name or self.func.__name__
setattr(obj, name, value)
return value
# SPDX-FileCopyrightText: 2026 geisserml <geisserml@gmail.com>
# SPDX-License-Identifier: MPL-2.0
import sys
import uuid
from pathlib import Path
from class_kit import * # local
# USE_SLOTS, PREFER_GETATTR = False, None
# USE_SLOTS, PREFER_GETATTR = False, True
USE_SLOTS, PREFER_GETATTR = True, None
if USE_SLOTS:
def get_vars(obj):
return {k: getattr(d, k) for k in d._fields_}
else:
get_vars = vars # builtin
class TestData (ClassBuilder, use_slots=USE_SLOTS, prefer_getattr=PREFER_GETATTR):
_fields_ = ("file_name", )
_cached_ = ("file_content", "unique_id")
def __init__(self, file_name):
self.file_name = file_name
@cached_prop_typehint
def file_content(self):
print(f"Reading file {self.file_name}", file=sys.stderr)
return Path(self.file_name).read_text()
@cached_prop_typehint
def unique_id(self):
print(f"Generating unique id", file=sys.stderr)
return uuid.uuid4()
d = TestData(__file__)
print(d)
print(get_vars(d))
if USE_SLOTS or PREFER_GETATTR:
print(TestData.__getattr__)
print(TestData._cp_map_)
if USE_SLOTS:
print(d.__slots__)
try:
d.nonexistent = "test"
except AttributeError:
assert USE_SLOTS, "assigned new attribute, but slotting was expected"
print("Confirmed the class is slotted")
else:
assert not USE_SLOTS, "failed to assign new attribute, but slotting was not expected"
print("Confirmed the class is non-slotted")
print(d.unique_id)
print(d.unique_id)
del d.unique_id
print(d.unique_id)
print(d.file_content[:10])
print(d.file_content[:10])
del d.file_content
print(d.file_content[:10])

Class Creation Kit

Design thoughts

Programatically, the starting point we need for a cached property transformation is just a list of the property names in question.

Considering that, we simply let the caller declare the cached property names through an attribute in the class body (_cached_). This allows us to handle both implementations (descriptor or __getattr__) in an equally elegant fashion.

Sticking with a decorator API, on the other hand, would disadvantage the __getattr__ approach: We'd have to search for any cached_property instances among the members, unwrap the original method, and throw away the unnecessarily constructed wrapper that just served as a marker for us to track down the property names.

@mara004

mara004 commented Jul 21, 2026

Copy link
Copy Markdown
Author

(An alternative attempt was https://gist.github.com/mara004/fd944fe3a4a3fc514171e9cf617292d7, with classical decorator API / inference, but it is messier)

@mara004

mara004 commented Jul 22, 2026

Copy link
Copy Markdown
Author

Here's yet another approach: we let the caller set a passthrough function when USE_SLOTS is true:

diff --git a/class_kit.py b/class_kit.py
index 8668cb7..38a6e33 100644
--- a/class_kit.py
+++ b/class_kit.py
@@ -1,15 +1,6 @@
 # SPDX-FileCopyrightText: 2026 geisserml <geisserml@gmail.com>
 # SPDX-License-Identifier: MPL-2.0
 
-import stl  # local
-
-from typing import TYPE_CHECKING
-if TYPE_CHECKING:
-    from functools import cached_property as cached_prop_typehint
-else:
-    def cached_prop_typehint(func):
-        return func
-
 def _cp_getattr(self, key):
     value = self._cp_map_[key](self)
     setattr(self, key, value)
@@ -25,9 +16,9 @@ class _ClassBuidlerMeta (type):
         if prefer_getattr or use_slots:
             nsp["_cp_map_"] = {cp_name: nsp.pop(cp_name) for cp_name in cached_props}
             nsp["__getattr__"] = _cp_getattr
-        else:
-            for cp_name in cached_props:
-                nsp[cp_name] = stl.cached_property(nsp[cp_name])
+        # else:
+        #     for cp_name in cached_props:
+        #         nsp[cp_name] = cached_property(nsp[cp_name])
         return super().__new__(metacls, clsname, bases, nsp, **kwargs)
 
 class ClassBuilder (metaclass=_ClassBuidlerMeta):
diff --git a/test.py b/test.py
index a2705a6..0663b75 100644
--- a/test.py
+++ b/test.py
@@ -6,17 +6,25 @@ import uuid
 from pathlib import Path
 
 from class_kit import *  # local
+from stl import cached_property  # local
 
 # USE_SLOTS, PREFER_GETATTR = False, None
 # USE_SLOTS, PREFER_GETATTR = False, True
 USE_SLOTS, PREFER_GETATTR = True, None
 
 if USE_SLOTS:
+    def cached_property(func):  # passthrough
+        return func
     def get_vars(obj):
         return {k: getattr(d, k) for k in d._fields_}
 else:
     get_vars = vars  # builtin
 
+from typing import TYPE_CHECKING
+if TYPE_CHECKING:
+    from functools import cached_property
+
+
 class TestData (ClassBuilder, use_slots=USE_SLOTS, prefer_getattr=PREFER_GETATTR):
     
     _fields_ = ("file_name", )
@@ -25,12 +33,12 @@ class TestData (ClassBuilder, use_slots=USE_SLOTS, prefer_getattr=PREFER_GETATTR
     def __init__(self, file_name):
         self.file_name = file_name
     
-    @cached_prop_typehint
+    @cached_property
     def file_content(self):
         print(f"Reading file {self.file_name}", file=sys.stderr)
         return Path(self.file_name).read_text()
     
-    @cached_prop_typehint
+    @cached_property
     def unique_id(self):
         print(f"Generating unique id", file=sys.stderr)
         return uuid.uuid4()

@mara004

mara004 commented Jul 26, 2026

Copy link
Copy Markdown
Author

I've got yet a cleaner idea that would use decorators for both cases, and the embedder would just have to select the right one depending on whether the class is slotted or not.
A small metaclass would probably still be needed for the slots case, but otherwise this should be much more straightforward.

I'll try to draft it tomorrow / when I have time.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment