From 7712b7889e2388d1557ee358cf5066b1236302e3 Mon Sep 17 00:00:00 2001 From: David C Ellis Date: Tue, 12 May 2026 17:25:52 +0100 Subject: [PATCH 1/6] Simplify get_attributes for prefab to fix typing --- src/ducktools/classbuilder/prefab.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/ducktools/classbuilder/prefab.py b/src/ducktools/classbuilder/prefab.py index 5fe8d6b..70fbe87 100644 --- a/src/ducktools/classbuilder/prefab.py +++ b/src/ducktools/classbuilder/prefab.py @@ -108,14 +108,10 @@ def get_attributes(cls, *, local=False): :param cls: class built with _make_prefab :return: dict[str, Attribute] of all gathered attributes """ - attributes = get_fields(cls, local=local) - - if any(type(obj) is Field for obj in attributes.values()): - attributes = { - k: Attribute.from_field(v) if type(v) is Field else v - for k, v in attributes.items() - } - + attributes = { + k: v if isinstance(v, Attribute) else Attribute.from_field(v) + for k, v in get_fields(cls, local=local).items() + } return attributes From e3b4129ffe4af3cbc504d51bf3da6792853b9ec9 Mon Sep 17 00:00:00 2001 From: David C Ellis Date: Tue, 12 May 2026 17:27:41 +0100 Subject: [PATCH 2/6] Add delattr and hash cached methods --- src/ducktools/classbuilder/__init__.py | 88 +++++++++++++------ src/ducktools/classbuilder/__init__.pyi | 4 + src/ducktools/classbuilder/_cached_methods.py | 56 ++++++++++++ .../classbuilder/_create_precached_methods.py | 32 ++++++- 4 files changed, 150 insertions(+), 30 deletions(-) diff --git a/src/ducktools/classbuilder/__init__.py b/src/ducktools/classbuilder/__init__.py index 4900867..771bdc7 100644 --- a/src/ducktools/classbuilder/__init__.py +++ b/src/ducktools/classbuilder/__init__.py @@ -36,6 +36,7 @@ ] import os +from string.templatelib import convert import sys try: @@ -56,12 +57,13 @@ from ._version import __version__, __version_tuple__ # noqa: F401 try: - from ._cached_methods import eq_cache, replace_cache, repr_cache + from ._cached_methods import eq_cache, replace_cache, repr_cache, delattr_cache except ImportError: # pragma: nocover # Needed for generating cached methods after deletion eq_cache = {} replace_cache = {} repr_cache = {} + delattr_cache = {} # Change this name if you make heavy modifications @@ -478,13 +480,13 @@ def _fix_consts(consts, active_pair, pairs): return tuple(new_consts) -def counter_to_class_generator( +def convert_to_class_generator( generic_generator, argument_getter, cache=None, replace_strings=False, ): - # This takes a counting source generator and converts it into a function + # This takes a counting or no argument source generator and converts it into a function # generator with cached methods backing it @_simple_cache(cache_seed=cache) def source_exec(*args, funcname): @@ -494,9 +496,13 @@ def source_exec(*args, funcname): def method_generator(cls, funcname): args = argument_getter(cls) - argnames = args[0] - argcount = len(args[0]) - exec_args = (argcount, *args[1:]) + if len(args) > 0: + argnames = args[0] + argcount = len(args[0]) + exec_args = (argcount, *args[1:]) + else: + argnames = [] + exec_args = () gen = generic_generator(*exec_args, funcname=funcname) raw_func = source_exec(*exec_args, funcname=funcname) @@ -857,11 +863,11 @@ def frozen_setattr_generator(cls, funcname="__setattr__"): return GeneratedCode(code, globs) -def frozen_delattr_generator(cls, funcname="__delattr__"): +@_simple_cache() +def generic_frozen_delattr_generator(*, funcname="__delattr__"): body = ( ' raise TypeError(\n' - ' f"{type(self).__name__!r} object "\n' - ' f"does not support attribute deletion"\n' + ' f"{type(self).__name__!r} object does not support attribute deletion"\n' ' )\n' ) code = f"def {funcname}(self, name):\n{body}" @@ -869,27 +875,42 @@ def frozen_delattr_generator(cls, funcname="__delattr__"): return GeneratedCode(code, globs) -def hash_generator(cls, funcname="__hash__"): - fields = get_fields(cls) - vals = ", ".join( - f"self.{name}" - for name, attrib in fields.items() - if attrib.compare - ) - if len(fields) == 1: +def frozen_delattr_generator(cls, funcname="__delattr__"): + return generic_frozen_delattr_generator(funcname=funcname) + + +def generic_hash_generator(field_names, *, funcname="__hash__"): + vals = ", ".join(f"self.{name}" for name in field_names) + if len(field_names) == 1: + # Needs a trailing comma for only 1 argument + # to make a tuple vals += "," + code = f"def {funcname}(self):\n return hash(({vals}))\n" globs = {} return GeneratedCode(code, globs) +@_simple_cache() +def _counter_hash_generator(argcount, *, funcname="__hash__"): + field_names = [ + f"{REPLACE_NAME}{i}_" for i in range(argcount) + ] + return generic_hash_generator(field_names, funcname=funcname) + + +def hash_generator(cls, funcname="__hash__"): + field_names = [name for name, attrib in get_fields(cls).items() if attrib.compare] + return generic_hash_generator(field_names, funcname=funcname) + + # As only the __get__ method refers to the class we can use the same # Descriptor instances for every class. init_maker = MethodMaker("__init__", init_generator) repr_maker = MethodMaker( "__repr__", class_repr_generator, - cached_generator=counter_to_class_generator( + cached_generator=convert_to_class_generator( _counter_repr_generator, get_repr_args, cache=repr_cache, @@ -900,7 +921,7 @@ def hash_generator(cls, funcname="__hash__"): eq_maker = MethodMaker( "__eq__", class_eq_generator, - cached_generator=counter_to_class_generator( + cached_generator=convert_to_class_generator( _counter_eq_generator, get_compare_args, cache=eq_cache, @@ -909,7 +930,7 @@ def hash_generator(cls, funcname="__hash__"): lt_maker = MethodMaker( "__lt__", class_lt_generator, - cached_generator=counter_to_class_generator( + cached_generator=convert_to_class_generator( _counter_lt_generator, get_compare_args, ), @@ -917,7 +938,7 @@ def hash_generator(cls, funcname="__hash__"): le_maker = MethodMaker( "__le__", class_le_generator, - cached_generator=counter_to_class_generator( + cached_generator=convert_to_class_generator( _counter_le_generator, get_compare_args, ), @@ -925,7 +946,7 @@ def hash_generator(cls, funcname="__hash__"): gt_maker = MethodMaker( "__gt__", class_gt_generator, - cached_generator=counter_to_class_generator( + cached_generator=convert_to_class_generator( _counter_gt_generator, get_compare_args, ), @@ -933,7 +954,7 @@ def hash_generator(cls, funcname="__hash__"): ge_maker = MethodMaker( "__ge__", class_ge_generator, - cached_generator=counter_to_class_generator( + cached_generator=convert_to_class_generator( _counter_ge_generator, get_compare_args, ), @@ -941,7 +962,7 @@ def hash_generator(cls, funcname="__hash__"): replace_maker = MethodMaker( "__replace__", class_replace_generator, - cached_generator=counter_to_class_generator( + cached_generator=convert_to_class_generator( _counter_replace_generator, get_replace_args, cache=replace_cache, @@ -949,8 +970,23 @@ def hash_generator(cls, funcname="__hash__"): ), ) frozen_setattr_maker = MethodMaker("__setattr__", frozen_setattr_generator) -frozen_delattr_maker = MethodMaker("__delattr__", frozen_delattr_generator) -hash_maker = MethodMaker("__hash__", hash_generator) +frozen_delattr_maker = MethodMaker( + "__delattr__", + frozen_delattr_generator, + cached_generator=convert_to_class_generator( + generic_frozen_delattr_generator, + lambda cls: (), + cache=delattr_cache, + ) +) +hash_maker = MethodMaker( + "__hash__", + hash_generator, + cached_generator=convert_to_class_generator( + _counter_hash_generator, + get_compare_args, + ) +) default_methods = frozenset({init_maker, repr_maker, eq_maker}) # Special `__init__` maker for 'Field' subclasses - needs its own NOTHING option diff --git a/src/ducktools/classbuilder/__init__.pyi b/src/ducktools/classbuilder/__init__.pyi index 72e43f2..eda8169 100644 --- a/src/ducktools/classbuilder/__init__.pyi +++ b/src/ducktools/classbuilder/__init__.pyi @@ -156,7 +156,11 @@ def generic_replace_generator(field_pairs: list[tuple[str, str]], *, funcname: s def class_replace_generator(cls: type, funcname: str = ...) -> GeneratedCode: ... def frozen_setattr_generator(cls: type, funcname: str = ...) -> GeneratedCode: ... + +def generic_frozen_delattr_generator(*, funcname: str = ...) -> GeneratedCode: ... def frozen_delattr_generator(cls: type, funcname: str = ...) -> GeneratedCode: ... + +def generic_hash_generator(field_names: list[str], *, funcname: str = ...) -> GeneratedCode: ... def hash_generator(cls: type, funcname: str = ...) -> GeneratedCode: ... init_maker: MethodMaker diff --git a/src/ducktools/classbuilder/_cached_methods.py b/src/ducktools/classbuilder/_cached_methods.py index ed87053..fb82fd1 100644 --- a/src/ducktools/classbuilder/_cached_methods.py +++ b/src/ducktools/classbuilder/_cached_methods.py @@ -316,3 +316,59 @@ def _replace_10(self, /, **changes): (10,): _replace_10, } +def _hash_0(self): + return hash(()) + +def _hash_1(self): + return hash((self._classbuilder_cache_names_0_,)) + +def _hash_2(self): + return hash((self._classbuilder_cache_names_0_, self._classbuilder_cache_names_1_)) + +def _hash_3(self): + return hash((self._classbuilder_cache_names_0_, self._classbuilder_cache_names_1_, self._classbuilder_cache_names_2_)) + +def _hash_4(self): + return hash((self._classbuilder_cache_names_0_, self._classbuilder_cache_names_1_, self._classbuilder_cache_names_2_, self._classbuilder_cache_names_3_)) + +def _hash_5(self): + return hash((self._classbuilder_cache_names_0_, self._classbuilder_cache_names_1_, self._classbuilder_cache_names_2_, self._classbuilder_cache_names_3_, self._classbuilder_cache_names_4_)) + +def _hash_6(self): + return hash((self._classbuilder_cache_names_0_, self._classbuilder_cache_names_1_, self._classbuilder_cache_names_2_, self._classbuilder_cache_names_3_, self._classbuilder_cache_names_4_, self._classbuilder_cache_names_5_)) + +def _hash_7(self): + return hash((self._classbuilder_cache_names_0_, self._classbuilder_cache_names_1_, self._classbuilder_cache_names_2_, self._classbuilder_cache_names_3_, self._classbuilder_cache_names_4_, self._classbuilder_cache_names_5_, self._classbuilder_cache_names_6_)) + +def _hash_8(self): + return hash((self._classbuilder_cache_names_0_, self._classbuilder_cache_names_1_, self._classbuilder_cache_names_2_, self._classbuilder_cache_names_3_, self._classbuilder_cache_names_4_, self._classbuilder_cache_names_5_, self._classbuilder_cache_names_6_, self._classbuilder_cache_names_7_)) + +def _hash_9(self): + return hash((self._classbuilder_cache_names_0_, self._classbuilder_cache_names_1_, self._classbuilder_cache_names_2_, self._classbuilder_cache_names_3_, self._classbuilder_cache_names_4_, self._classbuilder_cache_names_5_, self._classbuilder_cache_names_6_, self._classbuilder_cache_names_7_, self._classbuilder_cache_names_8_)) + +def _hash_10(self): + return hash((self._classbuilder_cache_names_0_, self._classbuilder_cache_names_1_, self._classbuilder_cache_names_2_, self._classbuilder_cache_names_3_, self._classbuilder_cache_names_4_, self._classbuilder_cache_names_5_, self._classbuilder_cache_names_6_, self._classbuilder_cache_names_7_, self._classbuilder_cache_names_8_, self._classbuilder_cache_names_9_)) + +hash_cache = { + (0,): _hash_0, + (1,): _hash_1, + (2,): _hash_2, + (3,): _hash_3, + (4,): _hash_4, + (5,): _hash_5, + (6,): _hash_6, + (7,): _hash_7, + (8,): _hash_8, + (9,): _hash_9, + (10,): _hash_10, +} + +def _delattr(self, name): + raise TypeError( + f"{type(self).__name__!r} object does not support attribute deletion" + ) + +delattr_cache = { + (): _delattr, +} + diff --git a/src/ducktools/classbuilder/_create_precached_methods.py b/src/ducktools/classbuilder/_create_precached_methods.py index 0cc5e44..70970f5 100644 --- a/src/ducktools/classbuilder/_create_precached_methods.py +++ b/src/ducktools/classbuilder/_create_precached_methods.py @@ -31,7 +31,29 @@ COUNT = 11 -def pre_generate_cache(funcname, func, count, cache_name): +def pre_generate_non_counter_cache(funcname, func, cache_name): + methods_list = [] + cache_lines_list = [] + + try: + # Clear the cache of potentially differently named functions + func.clear_cache() + except AttributeError: # pragma: no cover + pass + + methods_list.append( + func(funcname=funcname).source_code + ) + + cache_lines_list.append(f" (): {funcname},") + + methods = "\n".join(methods_list) + cache_lines = "\n".join(cache_lines_list) + + return f"{methods}\n{cache_name} = {{\n{cache_lines}\n}}\n\n" + + +def pre_generate_counter_cache(funcname, func, count, cache_name): methods_list = [] cache_lines_list = [] @@ -60,9 +82,11 @@ def generate_all_caches(): cache_lines.append("# This module is automatically generated from a script\n") cache_lines.append("# DO NOT EDIT BY HAND\n\n") - cache_lines.append(pre_generate_cache("_eq", dtbuild._counter_eq_generator, COUNT, "eq_cache")) # type: ignore - cache_lines.append(pre_generate_cache("_repr", dtbuild._counter_repr_generator, COUNT, "repr_cache")) # type: ignore - cache_lines.append(pre_generate_cache("_replace", dtbuild._counter_replace_generator, COUNT, "replace_cache")) # type: ignore + cache_lines.append(pre_generate_counter_cache("_eq", dtbuild._counter_eq_generator, COUNT, "eq_cache")) # type: ignore + cache_lines.append(pre_generate_counter_cache("_repr", dtbuild._counter_repr_generator, COUNT, "repr_cache")) # type: ignore + cache_lines.append(pre_generate_counter_cache("_replace", dtbuild._counter_replace_generator, COUNT, "replace_cache")) # type: ignore + cache_lines.append(pre_generate_counter_cache("_hash", dtbuild._counter_hash_generator, COUNT, "hash_cache")) # type: ignore + cache_lines.append(pre_generate_non_counter_cache("_delattr", dtbuild.generic_frozen_delattr_generator, "delattr_cache")) return "".join(cache_lines) From a6d8af5fd2c991168502947ab6af9e369f682513 Mon Sep 17 00:00:00 2001 From: David C Ellis Date: Tue, 12 May 2026 17:35:25 +0100 Subject: [PATCH 3/6] typing fixes and ignores --- src/ducktools/classbuilder/__init__.pyi | 4 ++-- src/ducktools/classbuilder/prefab.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ducktools/classbuilder/__init__.pyi b/src/ducktools/classbuilder/__init__.pyi index eda8169..b560071 100644 --- a/src/ducktools/classbuilder/__init__.pyi +++ b/src/ducktools/classbuilder/__init__.pyi @@ -5,7 +5,7 @@ import typing_extensions __lazy_modules__: list[str] -from collections.abc import Callable, Iterable +from collections.abc import Callable, Iterable, Mapping from types import MappingProxyType if sys.version_info >= (3, 14): @@ -32,7 +32,7 @@ REPLACE_NAME: str @typing.type_check_only class GetFieldsProtocol(typing.Protocol): - def __call__(self, cls: type, *, local: bool = ...) -> dict[str, Field]: ... + def __call__(self, cls: type, *, local: bool = ...) -> Mapping[str, Field]: ... def get_fields(cls: type, *, local: bool = ...) -> dict[str, Field]: ... diff --git a/src/ducktools/classbuilder/prefab.py b/src/ducktools/classbuilder/prefab.py index 70fbe87..470757c 100644 --- a/src/ducktools/classbuilder/prefab.py +++ b/src/ducktools/classbuilder/prefab.py @@ -197,7 +197,7 @@ def init_generator(cls, funcname="__init__"): annotations[name] = post_init_annotations[name] elif attrib.type is not NOTHING: # Use the internal type to avoid evaluating DeferredAnnotation values - annotations[name] = attrib._type + annotations[name] = attrib._type # type: ignore if attrib.default is not NOTHING: if type(attrib.default) in LITERAL_TYPES: From f7dc0ce234d3519f79772272e4b8902323ae1493 Mon Sep 17 00:00:00 2001 From: David C Ellis Date: Wed, 20 May 2026 12:49:24 +0100 Subject: [PATCH 4/6] Remove auto-inserted import --- src/ducktools/classbuilder/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/ducktools/classbuilder/__init__.py b/src/ducktools/classbuilder/__init__.py index 771bdc7..ece4c6a 100644 --- a/src/ducktools/classbuilder/__init__.py +++ b/src/ducktools/classbuilder/__init__.py @@ -36,7 +36,6 @@ ] import os -from string.templatelib import convert import sys try: From d3aceda9a87981c225e6993d8ce4404709adfcf1 Mon Sep 17 00:00:00 2001 From: David C Ellis Date: Wed, 20 May 2026 15:19:01 +0100 Subject: [PATCH 5/6] Reduce caching to only actual code generation and not source generators. --- src/ducktools/classbuilder/__init__.py | 26 ++++++++++---------------- 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/src/ducktools/classbuilder/__init__.py b/src/ducktools/classbuilder/__init__.py index ece4c6a..e270033 100644 --- a/src/ducktools/classbuilder/__init__.py +++ b/src/ducktools/classbuilder/__init__.py @@ -376,11 +376,15 @@ def __get__(self, inst, cls): ) if self.cached_generator: - gen, method = self.cached_generator(gen_cls, self.funcname) + method = self.cached_generator(gen_cls, self.funcname) else: gen = self.code_generator(gen_cls, self.funcname) method = gen.generate() + # Annotations are only supported in non-cached generators + if gen.annotations: + apply_annotations(method, gen.annotations) + # Patch up the method name and annotations try: method.__qualname__ = f"{cls.__qualname__}.{self.funcname}" @@ -389,9 +393,6 @@ def __get__(self, inst, cls): # descriptor. Don't try to rename. pass - if gen.annotations: - apply_annotations(method, gen.annotations) - if self.decorator: method = self.decorator(method) @@ -503,7 +504,6 @@ def method_generator(cls, funcname): argnames = [] exec_args = () - gen = generic_generator(*exec_args, funcname=funcname) raw_func = source_exec(*exec_args, funcname=funcname) arg_fixes = { @@ -530,12 +530,14 @@ def method_generator(cls, funcname): new_co_names = co_names new_co_consts = co_consts + globs = {} + method = _FunctionType( raw_func.__code__.replace( co_names=new_co_names, co_consts=new_co_consts, ), - gen.globs, + globs, name=funcname, argdefs=raw_func.__defaults__, closure=raw_func.__closure__, @@ -545,7 +547,7 @@ def method_generator(cls, funcname): # Remove the module reference to avoid retrieving incorrect code method.__module__ = None # type: ignore - return gen, method + return method method_generator.get_stats = source_exec.get_stats # type: ignore method_generator.clear_cache = source_exec.clear_cache # type: ignore @@ -646,7 +648,6 @@ def class_repr_generator(cls, funcname="__repr__"): return generic_repr_generator(field_names, funcname=funcname) -@_simple_cache() def _counter_repr_generator(argcount, *, funcname="__repr__"): field_names = [ f"{REPLACE_NAME}{i}_" @@ -690,7 +691,6 @@ def class_eq_generator(cls, funcname="__eq__"): return generic_eq_generator(field_names, funcname=funcname) -@_simple_cache() def _counter_eq_generator(argcount, *, funcname="__eq__"): # This is a cached accelerated eq generator # It returns uglier source, but the source can be cached @@ -751,28 +751,25 @@ def _get_counter_order_generator(argcount, operator, *, funcname): def class_lt_generator(cls, funcname="__lt__"): return get_class_order_generator(cls, "<", funcname=funcname) -@_simple_cache() def _counter_lt_generator(argcount, *, funcname="__lt__"): return _get_counter_order_generator(argcount, "<", funcname=funcname) def class_le_generator(cls, funcname="__le__"): return get_class_order_generator(cls, "<=", funcname=funcname) -@_simple_cache() def _counter_le_generator(argcount, *, funcname="__le__"): return _get_counter_order_generator(argcount, "<=", funcname=funcname) def class_gt_generator(cls, funcname="__gt__"): return get_class_order_generator(cls, ">", funcname=funcname) -@_simple_cache() + def _counter_gt_generator(argcount, *, funcname="__gt__"): return _get_counter_order_generator(argcount, ">", funcname=funcname) def class_ge_generator(cls, funcname="__ge__"): return get_class_order_generator(cls, ">=", funcname=funcname) -@_simple_cache() def _counter_ge_generator(argcount, *, funcname="__ge__"): return _get_counter_order_generator(argcount, ">=", funcname=funcname) @@ -821,7 +818,6 @@ def _field_replace_generator(cls, funcname="__replace__"): ] return generic_replace_generator(field_pairs, funcname=funcname) -@_simple_cache() def _counter_replace_generator(argcount, *, funcname="__replace__"): field_pairs = [ (f"{REPLACE_NAME}{i}_", f"{REPLACE_NAME}{i}_") for i in range(argcount) @@ -862,7 +858,6 @@ def frozen_setattr_generator(cls, funcname="__setattr__"): return GeneratedCode(code, globs) -@_simple_cache() def generic_frozen_delattr_generator(*, funcname="__delattr__"): body = ( ' raise TypeError(\n' @@ -890,7 +885,6 @@ def generic_hash_generator(field_names, *, funcname="__hash__"): return GeneratedCode(code, globs) -@_simple_cache() def _counter_hash_generator(argcount, *, funcname="__hash__"): field_names = [ f"{REPLACE_NAME}{i}_" for i in range(argcount) From f87c9585e52e8ce76fbaff6c2530fb62c4ecc5cd Mon Sep 17 00:00:00 2001 From: David C Ellis Date: Tue, 26 May 2026 11:12:45 +0100 Subject: [PATCH 6/6] rename method in stub too --- src/ducktools/classbuilder/__init__.pyi | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ducktools/classbuilder/__init__.pyi b/src/ducktools/classbuilder/__init__.pyi index b560071..7d8ecc9 100644 --- a/src/ducktools/classbuilder/__init__.pyi +++ b/src/ducktools/classbuilder/__init__.pyi @@ -124,7 +124,7 @@ def get_compare_args(cls: type) -> tuple[str, ...]: ... def get_repr_args(cls: type) -> tuple[str, ...]: ... def get_replace_args(cls: type) -> tuple[str, ...]: ... -def counter_to_class_generator( +def convert_to_class_generator( generic_generator: _ArgcountCodegenType, argument_getter: Callable[[type], tuple], cache: None | dict[str, types.FunctionType] = ...,