From aabca0173f68c34d6fce89c121922f044f2d52f3 Mon Sep 17 00:00:00 2001 From: "Laurent Mignon (ACSONE)" Date: Fri, 14 Jul 2023 18:09:49 +0200 Subject: [PATCH 1/3] Safe access to the contextvar Access to the contextvar holding the registry returns None if not yet initialized --- news/.feature | 3 +++ src/extendable/context.py | 6 +++--- 2 files changed, 6 insertions(+), 3 deletions(-) create mode 100644 news/.feature diff --git a/news/.feature b/news/.feature new file mode 100644 index 0000000..5842848 --- /dev/null +++ b/news/.feature @@ -0,0 +1,3 @@ +Access to the context variable used to store the current extended Classes +returns None if no context is available. Previously the access to the context +throws an exception if no context was available. diff --git a/src/extendable/context.py b/src/extendable/context.py index 4bda8d3..2f3cbb2 100644 --- a/src/extendable/context.py +++ b/src/extendable/context.py @@ -1,11 +1,11 @@ # define context vars to hold the extendable registry from contextvars import ContextVar -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Optional if TYPE_CHECKING: from .registry import ExtendableClassesRegistry -extendable_registry: ContextVar["ExtendableClassesRegistry"] = ContextVar( - "extendable_registry" +extendable_registry: ContextVar[Optional["ExtendableClassesRegistry"]] = ContextVar( + "extendable_registry", default=None ) From adf66655289f765c526a190d5a3f0813cfe96836 Mon Sep 17 00:00:00 2001 From: "Laurent Mignon (ACSONE)" Date: Fri, 14 Jul 2023 18:22:39 +0200 Subject: [PATCH 2/3] Raises specific exception if registry not initialized Calls to the classmethod _get_assembled_cls now raises RegistryNotInitializedError if the registry is not initialized. --- news/exceptions.feature | 2 ++ src/extendable/exceptions.py | 2 ++ src/extendable/main.py | 5 +++++ 3 files changed, 9 insertions(+) create mode 100644 news/exceptions.feature create mode 100644 src/extendable/exceptions.py diff --git a/news/exceptions.feature b/news/exceptions.feature new file mode 100644 index 0000000..59e2274 --- /dev/null +++ b/news/exceptions.feature @@ -0,0 +1,2 @@ +Calls to the classmethod "_get_assembled_cls" now raises RegistryNotInitializedError +if the registry is not initialized. diff --git a/src/extendable/exceptions.py b/src/extendable/exceptions.py new file mode 100644 index 0000000..7d23720 --- /dev/null +++ b/src/extendable/exceptions.py @@ -0,0 +1,2 @@ +class RegistryNotInitializedError(Exception): + pass diff --git a/src/extendable/main.py b/src/extendable/main.py index a0da920..9a3f57b 100644 --- a/src/extendable/main.py +++ b/src/extendable/main.py @@ -12,6 +12,7 @@ from abc import ABCMeta from .context import extendable_registry +from .exceptions import RegistryNotInitializedError _registry_build_mode = False if TYPE_CHECKING: @@ -249,4 +250,8 @@ def _get_assembled_cls( """An helper method to get the final class (the aggregated one) for the current class.""" registry = registry if registry else extendable_registry.get() + if not registry: + raise RegistryNotInitializedError( + "Extendable classes registry is not initialized" + ) return registry[cls.__xreg_name__] From 0a1aca2bc9fdccb7b34602c718d62a2b1cd80963 Mon Sep 17 00:00:00 2001 From: "Laurent Mignon (ACSONE)" Date: Fri, 14 Jul 2023 18:22:51 +0200 Subject: [PATCH 3/3] Refactor: add new method used to wrap single classmethod The metadaclass now provides the method . This method can be used to wrap class methods in a way that when the method is called the logic is delegated to the aggregated class instance if it exists. --- news/refactor.feature | 3 +++ src/extendable/main.py | 54 +++++++++++++++++++++++++----------------- 2 files changed, 35 insertions(+), 22 deletions(-) create mode 100644 news/refactor.feature diff --git a/news/refactor.feature b/news/refactor.feature new file mode 100644 index 0000000..165cb33 --- /dev/null +++ b/news/refactor.feature @@ -0,0 +1,3 @@ +The metadaclass now provides the method `_wrap_class_method`. This method +can be used to wrap class methods in a way that when the method is called +the logic is delegated to the aggregated class instance if it exists. diff --git a/src/extendable/main.py b/src/extendable/main.py index 9a3f57b..2d77804 100644 --- a/src/extendable/main.py +++ b/src/extendable/main.py @@ -18,6 +18,8 @@ if TYPE_CHECKING: from .registry import ExtendableClassesRegistry + AnyClassMethod = classmethod[Any, Any, Any] + class ExtendableClassDef: name: str @@ -191,28 +193,7 @@ def _wrap_class_methods(metacls, namespace: Dict[str, Any]) -> Dict[str, Any]: new_namespace: Dict[str, Any] = {} for key, value in namespace.items(): if isinstance(value, classmethod): - func = value.__func__ - - @no_type_check - def new_method( - cls, *args, _method_name=None, _initial_func=None, **kwargs - ): - # ensure that arggs and kwargs are conform to the - # initial signature - inspect.signature(_initial_func).bind(cls, *args, **kwargs) - try: - return getattr(cls._get_assembled_cls(), _method_name)( - *args, **kwargs - ) - except KeyError: - return _initial_func(cls, *args, **kwargs) - - new_method_def = functools.partial( - new_method, _method_name=key, _initial_func=func - ) - # preserve signature for IDE - functools.update_wrapper(new_method_def, func) - new_namespace[key] = classmethod(new_method_def) + new_namespace[key] = metacls._wrap_class_method(value, key) else: new_namespace[key] = value return new_namespace @@ -221,6 +202,35 @@ def new_method( def _is_extendable(metacls, cls: Type[Any]) -> bool: return issubclass(type(cls), ExtendableMeta) + @classmethod + def _wrap_class_method( + metacls, method: "AnyClassMethod", method_name: str + ) -> "AnyClassMethod": + """Wrap a class method to delegate the call to the final class. + + In addition to preserve the signature and the docstring, this + method will also preserve the validation of args and kwargs + against the signature of the initial method at method call. + """ + func = method.__func__ + + @no_type_check + def new_method(cls, *args, _method_name=None, _initial_func=None, **kwargs): + # ensure that args and kwargs are conform to the + # initial signature + inspect.signature(_initial_func).bind(cls, *args, **kwargs) + try: + return getattr(cls._get_assembled_cls(), _method_name)(*args, **kwargs) + except (RegistryNotInitializedError, KeyError): + return _initial_func(cls, *args, **kwargs) + + new_method_def = functools.partial( + new_method, _method_name=method_name, _initial_func=func + ) + # preserve signature for IDE + functools.update_wrapper(new_method_def, func) + return classmethod(new_method_def) + @no_type_check def __call__(cls, *args, **kwargs) -> "ExtendableMeta": """Create the aggregated class in place of the original class definition.