Source code for xcube.util.extension

# Copyright (c) 2018-2024 by xcube team and contributors
# Permissions are hereby granted under the terms of the MIT License:
# https://opensource.org/licenses/MIT.


import importlib
from typing import List, Any, Dict, Callable, Optional
from collections.abc import Mapping, Sequence

__author__ = "Norman Fomferra (Brockmann Consult GmbH)"

from xcube.util.ipython import register_json_formatter

Component = Any
ComponentLoader = Callable[["Extension"], Component]
ComponentTransform = Callable[[Component, "Extension"], Component]
ExtensionPredicate = Callable[["Extension"], bool]


[docs] class Extension: """An extension that provides a component of any type. Extensions are registered in a :class:`ExtensionRegistry`. Extension objects are not meant to be instantiated directly. Instead, :meth:`ExtensionRegistry#add_extension` is used to register extensions. Args: point: extension point identifier name: extension name component: extension component loader: extension component loader function metadata: extension metadata """ # noinspection PyShadowingBuiltins def __init__( self, point: str, name: str, component: Component = None, loader: ComponentLoader = None, **metadata, ): if point is None: raise ValueError(f"point must be given") if name is None: raise ValueError(f"name must be given") if (loader is not None and component is not None) or ( loader is None and component is None ): raise ValueError(f"either component or loader must be given") if loader is not None and not callable(loader): raise ValueError(f"loader must be callable") self._component = component self._loader = loader self._point = point self._name = name self._metadata = metadata self._deleted = False @property def is_lazy(self) -> bool: """Whether this is a lazy extension that uses a loader.""" return self._loader is not None @property def component(self) -> Component: """Extension component.""" if self._component is None and self._loader is not None: self._component = self._loader(self) return self._component @property def point(self) -> str: """Extension point identifier.""" return self._point @property def name(self) -> str: """Extension name.""" return self._name @property def metadata(self) -> dict[str, Any]: """Extension metadata.""" return dict(self._metadata)
[docs] def to_dict(self) -> dict[str, Any]: """Get a JSON-serializable dictionary representation of this extension.""" # Note: we avoid loading the component! if self._component is not None: if hasattr(self._component, "to_dict") and callable( getattr(self._component, "to_dict") ): component = self._component.to_dict() else: component = repr(self._component) else: component = "<not loaded yet>" d = dict( name=self.name, **self.metadata, point=self.point, component=component, ) return d
register_json_formatter(Extension) # noinspection PyShadowingBuiltins
[docs] class ExtensionRegistry: """A registry of extensions. Typically used by plugins to register extensions. """ def __init__(self): self._extension_points = {}
[docs] def has_extension(self, point: str, name: str) -> bool: """Test if an extension with given *point* and *name* is registered. Args: point: extension point identifier name: extension name Returns: True, if extension exists """ return point in self._extension_points and name in self._extension_points[point]
[docs] def get_extension(self, point: str, name: str) -> Optional[Extension]: """Get registered extension for given *point* and *name*. Args: point: extension point identifier name: extension name Returns: the extension or None, if no such exists """ if point not in self._extension_points: return None return self._extension_points[point].get(name)
[docs] def get_component(self, point: str, name: str) -> Any: """Get extension component for given *point* and *name*. Raises a ValueError if no such extension exists. Args: point: extension point identifier name: extension name Returns: extension component """ extension = self.get_extension(point, name) if extension is None: raise ValueError( f"extension {name!r} not found for extension point {point!r}" ) return extension.component
[docs] def find_extensions( self, point: str, predicate: ExtensionPredicate = None ) -> list[Extension]: """Find extensions for *point* and optional filter function *predicate*. The filter function is called with an extension and should return a truth value to indicate a match or mismatch. Args: point: extension point identifier predicate: optional filter function Returns: list of matching extensions """ if point not in self._extension_points: return [] point_extensions = self._extension_points[point] if predicate is None: return list(point_extensions.values()) return [ extension for extension in point_extensions.values() if predicate(extension) ]
[docs] def find_components( self, point: str, predicate: ExtensionPredicate = None ) -> list[Component]: """Find extension components for *point* and optional filter function *predicate*. The filter function is called with an extension and should return a truth value to indicate a match or mismatch. Args: point: extension point identifier predicate: optional filter function Returns: list of matching extension components """ return [ extension.component for extension in self.find_extensions(point, predicate=predicate) ]
[docs] def add_extension( self, point: str, name: str, component: Component = None, loader: ComponentLoader = None, **metadata, ) -> Extension: """Register an extension *component* or an extension component *loader* for the given extension *point*, *name*, and additional *metadata*. Either *component* or *loader* must be specified, but not both. A given *loader* must be a callable with one positional argument *extension* of type :class:`Extension` and is expected to return the actual extension component, which may be of any type. The *loader* will only be called once and only when the actual extension component is requested for the first time. Consider using the function :func:`import_component` to create a loader that lazily imports a component from a module and optionally executes it. Args: point: extension point identifier name: extension name component: extension component loader: extension component loader function **metadata: extension metadata Returns: a registered extension """ extension = Extension( point, name, component=component, loader=loader, **metadata ) if point in self._extension_points: self._extension_points[point][name] = extension else: self._extension_points[point] = {name: extension} return extension
[docs] def remove_extension(self, point: str, name: str): """Remove registered extension *name* from given *point*. Args: point: extension point identifier name: extension name """ point_extensions = self._extension_points[point] del point_extensions[name]
[docs] def to_dict(self): """Get a JSON-serializable dictionary representation of this extension registry.""" return { k: {ek: ev.to_dict() for ek, ev in v.items()} for k, v in self._extension_points.items() }
register_json_formatter(ExtensionRegistry) _EXTENSION_REGISTRY_SINGLETON = ExtensionRegistry() def get_extension_registry() -> ExtensionRegistry: """Return the extension registry singleton.""" return _EXTENSION_REGISTRY_SINGLETON
[docs] def import_component( spec: str, transform: ComponentTransform = None, call: bool = False, call_args: Sequence[Any] = None, call_kwargs: Mapping[str, Any] = None, ) -> ComponentLoader: """Return a component loader that imports a module or module component from *spec*. To import a module, *spec* should be the fully qualified module name. To import a component, *spec* must also append the component name to the fully qualified module name separated by a color (":") character. An optional *transform* callable my be used to transform the imported component. If given, a new component is computed:: component = transform(component, extension) If the *call* flag is set, the component is expected to be a callable which will be called using the given *call_args* and *call_kwargs* to produce a new component:: component = component(*call_kwargs, **call_kwargs) Finally, the component is returned. Args: spec: String of the form "module_path" or "module_path:component_name" transform: callable that takes two positional arguments, the imported component and the extension of type :class:`Extension` call: Whether to finally call the component with given *call_args* and *call_kwargs* call_args: arguments passed to a callable component if *call* flag is set call_kwargs: keyword arguments passed to callable component if *call* flag is set Returns: a component loader """ # noinspection PyUnusedLocal def _load(extension: Extension): nonlocal spec component = _import_component(spec, force_component=call) if transform is not None: component = transform(component, extension) if call or call_args or call_kwargs: component = component(*(call_args or []), **(call_kwargs or {})) return component return _load
def _import_component(component_spec: str, force_component: bool = False): """Import a module or module component from *spec*. Args: component_spec: String of the form "module_name" or "module_name:component_name" where module_name must an absolute, fully qualified path to a module. force_component: If True, *spec* must specify a component name Returns: the imported module or module component """ if ":" in component_spec: module_name, component_name = component_spec.split(":", maxsplit=1) else: module_name, component_name = component_spec, None if force_component and component_name is None: raise ValueError("illegal spec, must specify a component") if module_name == "": raise ValueError("illegal spec, missing module path") if component_name == "": raise ValueError("illegal spec, missing component name") module = importlib.import_module(module_name) return getattr(module, component_name) if component_name else module