From cabd61afb4b1f384202e45ab5af6108431f3cbbd Mon Sep 17 00:00:00 2001 From: David C Ellis Date: Sat, 13 Jun 2026 11:31:54 +0100 Subject: [PATCH 1/3] MethodMakers now require built classes and add_methods This is a fix for a potential race where two threads both attempt to generate the method simultaneously and one succeeds before the other can find itself in the MRO. This would trigger an AttributeError if hit. Some test tweaks are required where this wasn't used. --- .../docs_ex10_frozen_attributes.py | 12 ++++-- src/ducktools/classbuilder/methods.py | 19 +++++++-- tests/test_core.py | 40 +++++++++++-------- 3 files changed, 47 insertions(+), 24 deletions(-) diff --git a/docs/code_examples/docs_ex10_frozen_attributes.py b/docs/code_examples/docs_ex10_frozen_attributes.py index 93815b4..6e0f604 100644 --- a/docs/code_examples/docs_ex10_frozen_attributes.py +++ b/docs/code_examples/docs_ex10_frozen_attributes.py @@ -94,8 +94,10 @@ def freezable(cls=None, /, *, frozen=False): # Frozen attributes need to be added afterwards # Due to the need to know if frozen fields exist if frozen: - setattr(cls, "__setattr__", dtmethods.frozen_setattr_maker) - setattr(cls, "__delattr__", dtmethods.frozen_delattr_maker) + dtmethods.add_methods( + cls, + [dtmethods.frozen_setattr_maker, dtmethods.frozen_delattr_maker] + ) else: fields = dtfuncs.get_fields(cls) has_frozen_fields = False @@ -105,8 +107,10 @@ def freezable(cls=None, /, *, frozen=False): break if has_frozen_fields: - setattr(cls, "__setattr__", frozen_setattr_field_maker) - setattr(cls, "__delattr__", frozen_delattr_field_maker) + dtmethods.add_methods( + cls, + [frozen_setattr_field_maker, frozen_delattr_field_maker] + ) return cls diff --git a/src/ducktools/classbuilder/methods.py b/src/ducktools/classbuilder/methods.py index 2d2422e..c5d3a81 100644 --- a/src/ducktools/classbuilder/methods.py +++ b/src/ducktools/classbuilder/methods.py @@ -40,7 +40,7 @@ from .annotations import apply_annotations from .constants import INTERNALS_DICT, NOTHING, REPLACE_NAME -from .functions import get_fields, get_flags +from .functions import get_fields, get_flags, get_methods try: from ._cached_methods import init_cache, setattr_cache @@ -109,6 +109,15 @@ def generate(self): return method +def _get_method(cls, name): + try: + methods = get_methods(cls) + except TypeError: + return None + + return methods.get(name, None) + + class MethodMaker: """ The descriptor class to place where methods should be generated. @@ -135,16 +144,18 @@ def __repr__(self): return f"" def __get__(self, inst, cls): - # This can be called via super().funcname(...) in which case the class + # This can be called via a subclass or through + # super().funcname(...) in which case the class # may not be the correct one. If this is the correct class # it should have this descriptor in the class dict under # the correct funcname. # Otherwise is should be found in the MRO of the class. - if cls.__dict__.get(self.funcname) is self: + + if _get_method(cls, self.funcname) is self: gen_cls = cls else: for c in cls.__mro__[1:]: # skip 'cls' as special cased - if c.__dict__.get(self.funcname) is self: + if _get_method(c, self.funcname) is self: gen_cls = c break else: diff --git a/tests/test_core.py b/tests/test_core.py index 6b5e71b..3d7c39e 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -4,31 +4,37 @@ import pickle from ducktools.classbuilder import ( + builder, + default_methods, + make_unified_gatherer, + slot_gatherer, + slotclass, + + Field, + GatheredFields, + SlotFields, +) +from ducktools.classbuilder.constants import ( _NothingType, INTERNALS_DICT, NOTHING, FIELD_NOTHING, +) +from ducktools.classbuilder.functions import ( + get_fields, + get_flags, + get_methods, +) +from ducktools.classbuilder.methods import ( + GeneratedCode, + MethodMaker, add_methods, - builder, - default_methods, eq_maker, frozen_delattr_maker, frozen_setattr_maker, - get_fields, - get_flags, - get_methods, init_maker, - make_unified_gatherer, - slot_gatherer, - slotclass, - - Field, - GatheredFields, - GeneratedCode, - MethodMaker, - SlotFields, ) from ducktools.classbuilder.annotations import get_ns_annotations @@ -68,12 +74,14 @@ def generator(cls, funcname="demo"): assert repr(method_desc) == "" + gatherer = GatheredFields({}, {}) + @builder(gatherer=gatherer, methods={method_desc}) class ValueX: - demo = method_desc - def __init__(self): self.x = "Example Value" + add_methods(ValueX, [method_desc]) + ex = ValueX() assert ValueX.__dict__["demo"] == method_desc From 982f80dd45adcd9e4bc28c6d611636bf615b47a1 Mon Sep 17 00:00:00 2001 From: David C Ellis Date: Sat, 13 Jun 2026 11:32:18 +0100 Subject: [PATCH 2/3] Make the argument name match the others for gatherers. --- src/ducktools/classbuilder/__init__.py | 4 ++-- src/ducktools/classbuilder/__init__.pyi | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ducktools/classbuilder/__init__.py b/src/ducktools/classbuilder/__init__.py index 9e94fd9..014996b 100644 --- a/src/ducktools/classbuilder/__init__.py +++ b/src/ducktools/classbuilder/__init__.py @@ -463,8 +463,8 @@ def __eq__(self, other): def __repr__(self): return f"{type(self).__name__}(fields={self.fields!r}, modifications={self.modifications!r})" - def __call__(self, cls_dict): - # cls_dict will be provided, but isn't needed + def __call__(self, cls_or_ns): + # cls_or_ns will be provided, but isn't needed return self.fields, self.modifications diff --git a/src/ducktools/classbuilder/__init__.pyi b/src/ducktools/classbuilder/__init__.pyi index 5f4defd..b78b6e5 100644 --- a/src/ducktools/classbuilder/__init__.pyi +++ b/src/ducktools/classbuilder/__init__.pyi @@ -139,7 +139,7 @@ class GatheredFields: def __repr__(self) -> str: ... def __eq__(self, other) -> bool: ... def __call__( - self, cls_dict: type | dict[str, typing.Any] + self, cls_or_ns: type | dict[str, typing.Any] ) -> tuple[dict[str, Field], dict[str, typing.Any]]: ... # Only technically frozen under testing but we should *act* like they are frozen From 37c763616c439410eb12f63d9d1d590756300a45 Mon Sep 17 00:00:00 2001 From: David C Ellis Date: Sat, 13 Jun 2026 11:33:11 +0100 Subject: [PATCH 3/3] The get_fields/flags/methods calls now only check the exact class. This prevents them from following the MRO and getting fields/flags/methods from parent classes. --- src/ducktools/classbuilder/functions.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ducktools/classbuilder/functions.py b/src/ducktools/classbuilder/functions.py index b30de9c..87e70d1 100644 --- a/src/ducktools/classbuilder/functions.py +++ b/src/ducktools/classbuilder/functions.py @@ -47,7 +47,7 @@ def get_fields(cls, *, local=False): """ key = "local_fields" if local else "fields" try: - return getattr(cls, INTERNALS_DICT)[key] + return cls.__dict__[INTERNALS_DICT][key] except (AttributeError, KeyError): raise TypeError(f"{cls} is not a classbuilder generated class") @@ -61,7 +61,7 @@ def get_flags(cls): :return: dictionary of keys and flag values """ try: - return getattr(cls, INTERNALS_DICT)["flags"] + return cls.__dict__[INTERNALS_DICT]["flags"] except (AttributeError, KeyError): raise TypeError(f"{cls} is not a classbuilder generated class") @@ -75,7 +75,7 @@ def get_methods(cls): :return: dict of generated methods attached to the class by name """ try: - return getattr(cls, INTERNALS_DICT)["methods"] + return cls.__dict__[INTERNALS_DICT]["methods"] except (AttributeError, KeyError): raise TypeError(f"{cls} is not a classbuilder generated class")