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/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/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/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 ) 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..2d77804 100644 --- a/src/extendable/main.py +++ b/src/extendable/main.py @@ -12,11 +12,14 @@ from abc import ABCMeta from .context import extendable_registry +from .exceptions import RegistryNotInitializedError _registry_build_mode = False if TYPE_CHECKING: from .registry import ExtendableClassesRegistry + AnyClassMethod = classmethod[Any, Any, Any] + class ExtendableClassDef: name: str @@ -190,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 @@ -220,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. @@ -249,4 +260,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__]