From ad33cd1e4a2b6f86c15fb24992b0576c982f9bc2 Mon Sep 17 00:00:00 2001 From: David C Ellis Date: Fri, 19 Jun 2026 12:42:09 +0100 Subject: [PATCH 1/3] Add a new test for multithreading to check a method is only generated once. --- tests/method_tests/test_threading.py | 34 ++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 tests/method_tests/test_threading.py diff --git a/tests/method_tests/test_threading.py b/tests/method_tests/test_threading.py new file mode 100644 index 0000000..f14cae1 --- /dev/null +++ b/tests/method_tests/test_threading.py @@ -0,0 +1,34 @@ +import time +from concurrent.futures import ThreadPoolExecutor, as_completed + +from ducktools.classbuilder.prefab import prefab +from ducktools.classbuilder.methods import class_init_generator, MethodMaker + + +def test_multithreaded_generator(): + def slow_init_generator(cls: type, funcname: str = "__init__"): + # A generator with a delay so multiple threads can attempt + # to generate + time.sleep(0.01) + return class_init_generator(cls, funcname=funcname) + + slow_init_maker = MethodMaker("__init__", slow_init_generator) + + @prefab(init=False, eq=False, repr=False) + class Example: + a: int + b: int + + slow_init_maker.attach(Example) + + get_init = lambda cls: cls.__init__ + + with ThreadPoolExecutor() as pool: + futures = [pool.submit(get_init, Example) for _ in range(50)] + results = set() + for future in as_completed(futures): + results.add(future.result()) + + # Assert generation has only occured once as there is only + # one unique function in results + assert len(results) == 1 From 6df42d46db56d7c62e5c028f4a545048ea391ec4 Mon Sep 17 00:00:00 2001 From: David C Ellis Date: Fri, 19 Jun 2026 12:42:22 +0100 Subject: [PATCH 2/3] Move cached methods tests --- tests/{ => method_tests}/test_cached_methods.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/{ => method_tests}/test_cached_methods.py (100%) diff --git a/tests/test_cached_methods.py b/tests/method_tests/test_cached_methods.py similarity index 100% rename from tests/test_cached_methods.py rename to tests/method_tests/test_cached_methods.py From 314019cb1268dad5d92df4bb637cb806fb7a93c8 Mon Sep 17 00:00:00 2001 From: David C Ellis Date: Fri, 19 Jun 2026 12:43:19 +0100 Subject: [PATCH 3/3] Add a lock around the method generation to prevent the same method from being generated multiple times in multiple threads. --- src/ducktools/classbuilder/methods.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/ducktools/classbuilder/methods.py b/src/ducktools/classbuilder/methods.py index e98c3ff..9fdde86 100644 --- a/src/ducktools/classbuilder/methods.py +++ b/src/ducktools/classbuilder/methods.py @@ -27,6 +27,7 @@ import builtins import reprlib +import _thread try: from _types import ( # type: ignore FunctionType as _FunctionType, @@ -176,11 +177,15 @@ class _AttachedMethod: """ Descriptor for attaching a method maker to a class. """ - __slots__ = ("maker", "cls") + __slots__ = ("maker", "cls", "_generated_method", "_lock") def __init__(self, maker, cls): self.maker = maker self.cls = cls + # Internals + self._generated_method = None + self._lock = _thread.allocate_lock() # in 3.12 _thread.lock doesn't exist + def __repr__(self): return f"<_AttachedMethod for {self.maker.funcname!r} method on {self.cls.__qualname__!r}>" @@ -193,13 +198,17 @@ def __eq__(self, other): ) def __get__(self, inst, cls=None): - method = self.maker.generate(self.cls) - # Replace this descriptor on the class with the generated function - setattr(self.cls, self.maker.funcname, method) + with self._lock: + # Check again in case something held the lock + if self._generated_method is None: + self._generated_method = self.maker.generate(self.cls) + + # Replace this descriptor on the class with the generated function + setattr(self.cls, self.maker.funcname, self._generated_method) # Use 'get' to return the generated function as a bound method # instead of as a regular function for first usage. - return method.__get__(inst, cls) + return self._generated_method.__get__(inst, cls) # Argument getters for the generic cached methods