diff --git a/changes/7976.removal b/changes/7976.removal new file mode 100644 index 00000000000..3ae064236cb --- /dev/null +++ b/changes/7976.removal @@ -0,0 +1,12 @@ +- PyUtilib dependency removed. All the primitives for the plugin system are now defined in CKAN. +- The deprecated methods with the form ``after_`` and ``before_`` of the :py:class:`~ckan.plugins.interfaces.IPackageController` and :py:class:`~ckan.plugins.interfaces.IResourceController` interfaces have been removed. The form ``after__`` must be used from now on. E.g. ``after_create()`` -> ``after_dataset_create()`` or ``after_resource_create()``. +- It is now possible to extend interface classes directly when implementing plugins, which provides better integration with development tools, e.g.:: + + class Plugin(p.SingletonPlugin, IClick): + pass + + This is equivalent to:: + + class Plugin(p.SingletonPlugin): + p.implements(p.IClick, inherit=True) + diff --git a/ckan/lib/plugins.py b/ckan/lib/plugins.py index 0a1809f43a8..d16039c4271 100644 --- a/ckan/lib/plugins.py +++ b/ckan/lib/plugins.py @@ -310,8 +310,7 @@ def set_default_group_plugin() -> None: global _group_controllers # Setup the fallback behaviour if one hasn't been defined. if _default_group_plugin is None: - _default_group_plugin = cast( - plugins.IDatasetForm, DefaultGroupForm()) + _default_group_plugin = cast(plugins.IGroupForm, DefaultGroupForm()) if _default_organization_plugin is None: _default_organization_plugin = cast( plugins.IGroupForm, DefaultOrganizationForm()) diff --git a/ckan/logic/action/create.py b/ckan/logic/action/create.py index e4098343bf8..b77b8e0728d 100644 --- a/ckan/logic/action/create.py +++ b/ckan/logic/action/create.py @@ -724,7 +724,7 @@ def _group_or_org_create(context: Context, group_type = data_dict.get('type', 'organization' if is_org else 'group') group_plugin = lib_plugins.lookup_group_plugin(group_type) try: - schema: Schema = group_plugin.form_to_db_schema_options({ + schema: Schema = getattr(group_plugin, "form_to_db_schema_options")({ 'type': 'create', 'api': 'api_version' in context, 'context': context}) except AttributeError: diff --git a/ckan/logic/action/get.py b/ckan/logic/action/get.py index dc49797e0c0..672dedfe0be 100644 --- a/ckan/logic/action/get.py +++ b/ckan/logic/action/get.py @@ -1216,7 +1216,7 @@ def _group_or_org_show( group_plugin = lib_plugins.lookup_group_plugin(group_dict['type']) try: - schema: Schema = group_plugin.db_to_form_schema_options({ + schema: Schema = getattr(group_plugin, "db_to_form_schema_options")({ 'type': 'show', 'api': 'api_version' in context, 'context': context}) diff --git a/ckan/logic/action/update.py b/ckan/logic/action/update.py index c68e02de6db..85c8124b7cf 100644 --- a/ckan/logic/action/update.py +++ b/ckan/logic/action/update.py @@ -663,9 +663,11 @@ def _group_or_org_update( # get the schema group_plugin = lib_plugins.lookup_group_plugin(group.type) try: - schema = group_plugin.form_to_db_schema_options({'type': 'update', - 'api': 'api_version' in context, - 'context': context}) + schema = getattr(group_plugin, "form_to_db_schema_options")({ + 'type': 'update', + 'api': 'api_version' in context, + 'context': context + }) except AttributeError: schema = group_plugin.form_to_db_schema() diff --git a/ckan/plugins/__init__.py b/ckan/plugins/__init__.py index 804d7efcdc4..8720ae2af02 100644 --- a/ckan/plugins/__init__.py +++ b/ckan/plugins/__init__.py @@ -1,7 +1,7 @@ # encoding: utf-8 -from ckan.plugins.core import * # noqa -from ckan.plugins.interfaces import * # noqa +from ckan.plugins.interfaces import * # noqa: F401, F403 +from ckan.plugins.core import * # noqa: F401, F403 def __getattr__(name: str): diff --git a/ckan/plugins/base.py b/ckan/plugins/base.py new file mode 100644 index 00000000000..1772aa000ab --- /dev/null +++ b/ckan/plugins/base.py @@ -0,0 +1,198 @@ +"""Base code units used by plugin system. + +This module contains adapted and simplified version of pyutilib plugin system +that was used historically by CKAN. + +""" + +from __future__ import annotations + +import sys + +from typing import Any +from typing_extensions import ClassVar, TypeVar + +TSingleton = TypeVar("TSingleton", bound="SingletonPlugin") + + +class PluginException(Exception): + """Exception base class for plugin errors.""" + + +class ExistingInterfaceException(PluginException): + """Interface with the same name already exists.""" + + def __init__(self, name: str): + self.name = name + + def __str__(self): + return f"Interface {self.name} has already been defined" + + +class PluginNotFoundException(PluginException): + """Requested plugin cannot be found.""" + + def __init__(self, name: str): + self.name = name + + def __str__(self): + return f"Interface {self.name} does not exist" + + +class Interface: + """Base class for custom interfaces. + + Marker base class for extension point interfaces. This class is not + intended to be instantiated. Instead, the declaration of subclasses of + Interface are recorded, and these classes are used to define extension + points. + + Example: + >>> class IExample(Interface): + >>> def example_method(self): + >>> pass + """ + + # force PluginImplementations to iterate over interface in reverse order + _reverse_iteration_order: ClassVar[bool] = False + + # collection of interface-classes extending base Interface. This is used to + # guarantee unique names of interfaces. + _interfaces: ClassVar[set[type[Interface]]] = set() + + # there is no practical use of `name` attribute in interface, because + # interfaces are never instantiated. But declaring this attribute + # simplifies typing when iterating over interface implementations. + name: str + + def __init_subclass__(cls, **kwargs: Any): + """Prevent interface name duplication when interfaces are created.""" + + # `implements(..., inherit=True)` adds interface to the list of + # plugin's bases. There is no reason to disallow identical plugin-class + # names, so this scenario stops execution early. + if isinstance(cls, Plugin): + return + + if cls in Interface._interfaces: + raise ExistingInterfaceException(cls.__name__) + + Interface._interfaces.add(cls) + + @classmethod + def provided_by(cls, instance: Plugin) -> bool: + """Check that the object is an instance of the class that implements + the interface. + + Example: + >>> activity = get_plugin("activity") + >>> assert IConfigurer.provided_by(activity) + """ + return cls.implemented_by(type(instance)) + + @classmethod + def implemented_by(cls, other: type[Plugin]) -> bool: + """Check whether the class implements the current interface. + + Example: + >>> assert IConfigurer.implemented_by(ActivityPlugin) + + """ + try: + return issubclass(other, cls) or cls in other._implements + except AttributeError: + return False + + +class PluginMeta(type): + """Metaclass for plugins that initializes supplementary attributes required + by interface implementations. + + """ + + def __new__(cls, name: str, bases: tuple[type, ...], data: dict[str, Any]): + data.setdefault("_implements", set()) + data.setdefault("_inherited_interfaces", set()) + + # add all interfaces with `inherit=True` to the bases of plugin + # class. It adds default implementation of methods from interface to + # the plugin's class + bases += tuple(data["_inherited_interfaces"] - set(bases)) + + # copy interfaces implemented by the parent classes into a new one to + # correctly identify if interface is provided_by/implemented_by the new + # class. + for base in bases: + data["_implements"].update(getattr(base, "_implements", set())) + + return super().__new__(cls, name, tuple(bases), data) + + +class Plugin(metaclass=PluginMeta): + """Base class for plugins which require multiple instances. + + Unless you need multiple instances of your plugin object you should + probably use SingletonPlugin. + + """ + + # collection of all interfaces implemented by the plugin. Used by + # `Interface.implemented_by` check + _implements: ClassVar[set[type[Interface]]] + + # collection of interfaces implemented with `inherit=True`. These + # interfaces are added as parent classes to the plugin + _inherited_interfaces: ClassVar[set[type[Interface]]] + + # name of the plugin instance. All known plugins are instances of + # `SingletonPlugin`, so it may be converted to ClassVar in future. Right + # now it's kept as instance variable for compatibility with original + # implementation from pyutilib + name: str + + def __init__(self, *args: Any, **kwargs: Any): + name = kwargs.pop("name", None) + if not name: + name = self.__class__.__name__ + self.name = name + + def __str__(self): + return f"" + + +class SingletonPlugin(Plugin): + """Base class for plugins which are singletons (ie most of them) + + One singleton instance of this class will be created when the plugin is + loaded. Subsequent calls to the class constructor will always return the + same singleton instance. + """ + + def __new__(cls, *args: Any, **kwargs: Any): + if not hasattr(cls, "_instance"): + cls._instance = super().__new__(cls) + + return cls._instance + + +def implements(interface: type[Interface], inherit: bool = False): + """Can be used in the class definition of `Plugin` subclasses to + declare the extension points that are implemented by this + interface class. + + Example: + >>> class MyPlugin(Plugin): + >>> implements(IConfigurer, inherit=True) + + If compatibility with CKAN pre-v2.11 is not required, plugin class should + extend interface class. + + Example: + >>> class MyPlugin(Plugin, IConfigurer): + >>> pass + """ + frame = sys._getframe(1) + locals_ = frame.f_locals + locals_.setdefault("_implements", set()).add(interface) + if inherit: + locals_.setdefault("_inherited_interfaces", set()).add(interface) diff --git a/ckan/plugins/blanket.py b/ckan/plugins/blanket.py index 8113a1f3cdc..5b934ef3837 100644 --- a/ckan/plugins/blanket.py +++ b/ckan/plugins/blanket.py @@ -148,9 +148,9 @@ class MyPlugin(p.SingletonPlugin): log = logging.getLogger(__name__) -PluginSubject = Type[p.SingletonPlugin] +PluginSubject = Type[p.Plugin] SimpleSubject = Union[types.ModuleType, "dict[str, Any]", "list[Any]", str] -SubjectFactory = Callable[..., Any] +SubjectFactory = Callable[..., Union["list[Any]", "dict[str, Any]"]] Subject = Union[PluginSubject, SimpleSubject, SubjectFactory] ModuleHarvester = Callable[[types.ModuleType], "dict[str, Any]"] @@ -167,7 +167,7 @@ class Blanket(enum.Flag): validators = enum.auto() config_declarations = enum.auto() - def get_subject(self, plugin: p.SingletonPlugin) -> Subject: + def get_subject(self, plugin: type[p.Plugin]) -> Subject: """Extract artifacts required for the default implementation. Depending on interface, this method can produce function that satisfy @@ -184,13 +184,13 @@ def method_name(self) -> str: """Return the name of the method, required for implementation.""" return _mapping[self].method_name - def interface(self) -> p.Interface: + def interface(self) -> type[p.Interface]: """Return interface provided by blanket.""" return _mapping[self].interface def implement( self, - plugin: p.SingletonPlugin, + plugin: type[p.Plugin], subject: Optional[Subject], ): """Implement for interface inside the given plugin.""" @@ -200,16 +200,16 @@ def implement( class Mapping(NamedTuple): - extract_subject: Callable[[p.SingletonPlugin], Subject] + extract_subject: Callable[[type[p.Plugin]], Subject] method_name: str - interface: p.Interface + interface: type[p.Interface] implementation_factory: Callable[..., Any] def _module_extractor(path: str): """Import sub-modue of the plugin.""" - def source(plugin: p.SingletonPlugin): + def source(plugin: type[p.Plugin]): root = plugin.__module__.rsplit(".", 1)[0] import_path = ".".join([root, path]) @@ -226,7 +226,7 @@ def source(plugin: p.SingletonPlugin): return source -def _declaration_file_extractor(plugin: p.SingletonPlugin): +def _declaration_file_extractor(plugin: type[p.Plugin]): """Compute the path to a file that contains config declarations.""" path = _plugin_root(plugin) options = list(path.glob("config_declaration.*")) @@ -245,7 +245,7 @@ def _declaration_file_extractor(plugin: p.SingletonPlugin): return str(options[0]) -def _plugin_root(plugin: p.SingletonPlugin) -> pathlib.Path: +def _plugin_root(plugin: type[p.Plugin]) -> pathlib.Path: """Return the path to the plugin's root(`ckanext/ext`).""" root = plugin.__module__.rsplit(".", 1)[0] file_ = inspect.getsourcefile(import_module(root)) @@ -255,22 +255,28 @@ def _plugin_root(plugin: p.SingletonPlugin) -> pathlib.Path: return pathlib.Path(file_).parent.resolve() -def _dict_implementation(subject: Subject) -> Callable[..., dict[str, Any]]: +def _dict_implementation( + subject: SimpleSubject | SubjectFactory +) -> Callable[..., dict[str, Any]]: return _as_implementation(subject, False, _get_public_members) -def _list_implementation(subject: Subject) -> Callable[..., list[Any]]: +def _list_implementation( + subject: SimpleSubject | SubjectFactory +) -> Callable[..., list[Any]]: return _as_implementation(subject, True, _get_public_members) def _blueprint_implementation( - subject: Subject, + subject: SimpleSubject | SubjectFactory, ) -> Callable[..., list[flask.Blueprint]]: return _as_implementation(subject, True, _get_blueprint_members) def _as_implementation( - subject: Subject, as_list: bool, harvester: ModuleHarvester + subject: SimpleSubject | SubjectFactory, + as_list: bool, + harvester: ModuleHarvester ) -> Callable[..., Any]: """Convert subject into acceptable interface implementation. @@ -281,7 +287,7 @@ def _as_implementation( """ def func( - self: p.SingletonPlugin, *args: Any, **kwargs: Any + self: type[p.Plugin], *args: Any, **kwargs: Any ) -> Union[dict[str, Any], list[Any]]: if callable(subject): return subject(*args, **kwargs) @@ -313,7 +319,7 @@ def _declaration_implementation(subject: Subject) -> Callable[..., None]: except ImportError: pass - def func(plugin: p.SingletonPlugin, declaration: Any, key: Any): + def func(plugin: type[p.Plugin], declaration: Any, key: Any): if isinstance(subject, types.FunctionType): return subject(declaration, key) elif isinstance(subject, dict): @@ -417,17 +423,21 @@ def _blanket_implementation( `oneInterface-oneMethod-oneImportPath`. """ + @overload + def decorator( + subject: SubjectFactory + ) -> Callable[[PluginSubject], PluginSubject]: ... @overload def decorator(subject: PluginSubject) -> PluginSubject: ... @overload def decorator( - subject: Union[SimpleSubject, SubjectFactory, None] - ) -> types.FunctionType: ... + subject: Union[SimpleSubject, None] + ) -> Callable[[PluginSubject], PluginSubject]: ... def decorator( - subject: Optional[Subject] = None + subject: Any = None ) -> Union[PluginSubject, Callable[[PluginSubject], PluginSubject]]: def wrapper(plugin: PluginSubject) -> PluginSubject: @@ -435,16 +445,15 @@ def wrapper(plugin: PluginSubject) -> PluginSubject: if key & group: # short version of the trick performed by # `ckan.plugin.implements` - if not hasattr(plugin, "_implements"): - setattr(plugin, "_implements", {}) - plugin._implements.setdefault(key.interface(), [None]) - plugin.__interfaces__.setdefault(key.interface(), [None]) - + interface = key.interface() + plugin._implements.add(interface) + if interface not in plugin.__bases__: + plugin.__bases__ += (key.interface(),) key.implement(plugin, subject) return plugin if not isinstance(subject, type) or not issubclass( - subject, p.SingletonPlugin): + subject, p.Plugin): return wrapper plugin = subject diff --git a/ckan/plugins/core.py b/ckan/plugins/core.py index 732640e9746..893da672830 100644 --- a/ckan/plugins/core.py +++ b/ckan/plugins/core.py @@ -1,28 +1,24 @@ -# encoding: utf-8 - ''' Provides plugin services to the CKAN ''' from __future__ import annotations import logging -import warnings from contextlib import contextmanager -from typing import (Any, Generic, Iterator, Optional, - Type, TypeVar, Union) +from typing import Generic, Iterator, TypeVar +from typing_extensions import TypeGuard from pkg_resources import iter_entry_points -from pyutilib.component.core import PluginGlobals, implements -from pyutilib.component.core import ExtensionPoint -from pyutilib.component.core import SingletonPlugin as _pca_SingletonPlugin -from pyutilib.component.core import Plugin as _pca_Plugin - - -import ckan.plugins.interfaces as interfaces from ckan.common import config from ckan.types import SignalMapping -from ckan.exceptions import CkanDeprecationWarning + +from . import interfaces +from .base import ( + Interface, Plugin, + SingletonPlugin, PluginNotFoundException, + implements, +) __all__ = [ @@ -34,7 +30,7 @@ 'unload_non_system_plugins', ] -TInterface = TypeVar('TInterface', bound=interfaces.Interface) +TInterface = TypeVar('TInterface', bound="Interface") log = logging.getLogger(__name__) @@ -55,16 +51,22 @@ ] # These lists are used to ensure that the correct extensions are enabled. _PLUGINS: list[str] = [] -_PLUGINS_CLASS: list[Type["SingletonPlugin"]] = [] # To aid retrieving extensions by name -_PLUGINS_SERVICE: dict[str, "SingletonPlugin"] = {} +_PLUGINS_SERVICE: dict[str, Plugin] = {} + + +def implemented_by( + service: Plugin, + interface: type[TInterface] +) -> TypeGuard[TInterface]: + return interface.provided_by(service) @contextmanager def use_plugin( *plugins: str -) -> Iterator[Union['SingletonPlugin', list['SingletonPlugin']]]: +) -> Iterator[Plugin | list[Plugin]]: '''Load plugin(s) for testing purposes e.g. @@ -82,22 +84,18 @@ def use_plugin( unload(*plugins) -class PluginImplementations(ExtensionPoint, Generic[TInterface]): - def __init__(self, interface: Type[TInterface], *args: Any): - super().__init__(interface, *args) - - def __iter__(self) -> Iterator[TInterface]: - ''' - When we upgraded pyutilib on CKAN 2.9 the order in which - plugins were returned by `PluginImplementations` changed - so we use this wrapper to maintain the previous order - (which is the same as the ckan.plugins config option) - ''' +class PluginImplementations(Generic[TInterface]): + def __init__(self, interface: type[TInterface]): + self.interface = interface - iterator = super(PluginImplementations, self).__iter__() - - plugin_lookup = {pf.name: pf for pf in iterator} + def extensions(self): + return [ + p for p in _PLUGINS_SERVICE.values() + if self.interface.implemented_by(type(p)) + ] + def __iter__(self) -> Iterator[TInterface]: + plugin_lookup = {pf.name: pf for pf in self.extensions()} plugins = config.get("ckan.plugins", []) if isinstance(plugins, str): # this happens when core declarations loaded and validated @@ -122,72 +120,7 @@ def __iter__(self) -> Iterator[TInterface]: return iter(ordered_plugins) -class PluginNotFoundException(Exception): - ''' - Raised when a requested plugin cannot be found. - ''' - - -class Plugin(_pca_Plugin): - ''' - Base class for plugins which require multiple instances. - - Unless you need multiple instances of your plugin object you should - probably use SingletonPlugin. - ''' - - -class SingletonPlugin(_pca_SingletonPlugin): - ''' - Base class for plugins which are singletons (ie most of them) - - One singleton instance of this class will be created when the plugin is - loaded. Subsequent calls to the class constructor will always return the - same singleton instance. - ''' - def __init__(self, *args: Any, **kwargs: Any): - # Drop support by removing this __init__ function - super().__init__(*args, **kwargs) - - if interfaces.IPackageController.implemented_by(type(self)): - for old_name, new_name in [ - ["after_create", "after_dataset_create"], - ["after_update", "after_dataset_update"], - ["after_delete", "after_dataset_delete"], - ["after_show", "after_dataset_show"], - ["before_search", "before_dataset_search"], - ["after_search", "after_dataset_search"], - ["before_index", "before_dataset_index"], - ["before_view", "before_dataset_view"]]: - if hasattr(self, old_name) and not hasattr(self, new_name): - msg = ( - f"The method 'IPackageController.{old_name}' is " - + f"deprecated. Please use '{new_name}' instead!" - ) - log.warning(msg) - warnings.warn(msg, CkanDeprecationWarning) - setattr(self, new_name, getattr(self, old_name)) - - if interfaces.IResourceController.implemented_by(type(self)): - for old_name, new_name in [ - ["before_create", "before_resource_create"], - ["after_create", "after_resource_create"], - ["before_update", "before_resource_update"], - ["after_update", "after_resource_update"], - ["before_delete", "before_resource_delete"], - ["after_delete", "after_resource_delete"], - ["before_show", "before_resource_show"]]: - if hasattr(self, old_name) and not hasattr(self, new_name): - msg = ( - f"The method 'IResourceController.{old_name}' is " - + f"deprecated. Please use '{new_name}' instead!" - ) - log.warning(msg) - warnings.warn(msg, CkanDeprecationWarning) - setattr(self, new_name, getattr(self, old_name)) - - -def get_plugin(plugin: str) -> Optional[SingletonPlugin]: +def get_plugin(plugin: str) -> Plugin | None: ''' Get an instance of a active plugin by name. This is helpful for testing. ''' if plugin in _PLUGINS_SERVICE: @@ -199,17 +132,6 @@ def plugins_update() -> None: ''' This is run when plugins have been loaded or unloaded and allows us to run any specific code to ensure that the new plugin setting are correctly setup ''' - - # It is posible for extra SingletonPlugin extensions to be activated if - # the file containing them is imported, for example if two or more - # extensions are defined in the same file. Therefore we do a sanity - # check and disable any that should not be active. - for env in PluginGlobals.env.values(): - for service, id_ in env.singleton_services.items(): - if service not in _PLUGINS_CLASS: - PluginGlobals.plugin_instances[id_].deactivate() - - # Reset CKAN to reflect the currently enabled extensions. import ckan.config.environment as environment environment.update_config() @@ -221,14 +143,14 @@ def load_all() -> None: # Clear any loaded plugins unload_all() - plugins = config.get('ckan.plugins') + find_system_plugins() + plugins = config['ckan.plugins'] + find_system_plugins() load(*plugins) def load( *plugins: str -) -> Union[SingletonPlugin, list[SingletonPlugin]]: +) -> Plugin | list[Plugin]: ''' Load named plugin(s). ''' @@ -242,16 +164,15 @@ def load( service = _get_service(plugin) for observer_plugin in observers: observer_plugin.before_load(service) - service.activate() + + _PLUGINS_SERVICE[plugin] = service + for observer_plugin in observers: observer_plugin.after_load(service) _PLUGINS.append(plugin) - _PLUGINS_CLASS.append(service.__class__) - if isinstance(service, SingletonPlugin): - _PLUGINS_SERVICE[plugin] = service - if interfaces.ISignal.implemented_by(service.__class__): + if implemented_by(service, interfaces.ISignal): _connect_signals(service.get_signal_subscriptions()) output.append(service) plugins_update() @@ -276,31 +197,27 @@ def unload(*plugins: str) -> None: ''' Unload named plugin(s). ''' - observers = PluginImplementations(interfaces.IPluginObserver) for plugin in plugins: if plugin in _PLUGINS: _PLUGINS.remove(plugin) - if plugin in _PLUGINS_SERVICE: - del _PLUGINS_SERVICE[plugin] else: raise Exception('Cannot unload plugin `%s`' % plugin) - service = _get_service(plugin) - if interfaces.ISignal.implemented_by(service.__class__): - _disconnect_signals(service.get_signal_subscriptions()) + if plugin in _PLUGINS_SERVICE: + del _PLUGINS_SERVICE[plugin] for observer_plugin in observers: observer_plugin.before_unload(service) - service.deactivate() - - _PLUGINS_CLASS.remove(service.__class__) - for observer_plugin in observers: observer_plugin.after_unload(service) + + if implemented_by(service, interfaces.ISignal): + _disconnect_signals(service.get_signal_subscriptions()) + plugins_update() @@ -347,28 +264,22 @@ def unload_non_system_plugins(): unload(*plugins_to_unload) -def _get_service(plugin_name: Union[str, Any]) -> SingletonPlugin: - ''' - Return a service (ie an instance of a plugin class). +def _get_service(plugin_name: str) -> Plugin: + """Return a plugin instance using its entry point name. - :param plugin_name: the name of a plugin entry point - :type plugin_name: string - - :return: the service object - ''' - - if isinstance(plugin_name, str): - for group in GROUPS: - iterator = iter_entry_points( - group=group, - name=plugin_name - ) - plugin = next(iterator, None) - if plugin: - return plugin.load()(name=plugin_name) - raise PluginNotFoundException(plugin_name) - else: - raise TypeError('Expected a plugin name', plugin_name) + Example: + >>> plugin = _get_service("activity") + >>> assert isinstance(plugin, ActivityPlugin) + """ + for group in GROUPS: + iterator = iter_entry_points( + group=group, + name=plugin_name + ) + plugin = next(iterator, None) + if plugin: + return plugin.load()(name=plugin_name) + raise PluginNotFoundException(plugin_name) def _connect_signals(mapping: SignalMapping): diff --git a/ckan/plugins/interfaces.py b/ckan/plugins/interfaces.py index 47cb00bfe83..0360f188e12 100644 --- a/ckan/plugins/interfaces.py +++ b/ckan/plugins/interfaces.py @@ -7,21 +7,20 @@ from __future__ import annotations from typing import ( - Any, Callable, ClassVar, Iterable, Mapping, Optional, Sequence, - TYPE_CHECKING, Type, Union, + Any, Callable, Iterable, Mapping, Optional, Sequence, + TYPE_CHECKING, Union, ) -from pyutilib.component.core import Interface as _pca_Interface - from flask.blueprints import Blueprint from flask.wrappers import Response -from ckan.model.user import User from ckan.types import ( Action, AuthFunction, Context, DataDict, PFeedFactory, PUploader, PResourceUploader, Schema, SignalMapping, Validator, CKANApp) +from .base import Interface, Plugin + if TYPE_CHECKING: import click import ckan.model as model @@ -30,7 +29,6 @@ from ckan.common import CKANConfig from ckan.config.middleware.flask_app import CKANFlask from ckan.config.declaration import Declaration, Key - from .core import SingletonPlugin __all__ = [ @@ -68,35 +66,6 @@ ] -class Interface(_pca_Interface): - u'''Base class for custom interfaces. - - Marker base class for extension point interfaces. This class - is not intended to be instantiated. Instead, the declaration - of subclasses of Interface are recorded, and these - classes are used to define extension points. - ''' - - # force PluginImplementations to iterate over interface in reverse order - _reverse_iteration_order: ClassVar[bool] = False - - @classmethod - def provided_by(cls, instance: "SingletonPlugin") -> bool: - u'''Check that the object is an instance of the class that implements - the interface. - ''' - return cls.implemented_by(instance.__class__) - - @classmethod - def implemented_by(cls, other: Type["SingletonPlugin"]) -> bool: - u'''Check whether the class implements the current interface. - ''' - try: - return bool(cls in other._implements) - except AttributeError: - return False - - class IMiddleware(Interface): u'''Hook into the CKAN middleware stack @@ -700,25 +669,25 @@ class IPluginObserver(Interface): Hook into the plugin loading mechanism itself ''' - def before_load(self, plugin: 'SingletonPlugin') -> None: + def before_load(self, plugin: Plugin) -> None: u''' Called before a plugin is loaded. - This method is passed the plugin class. + This method is passed the instantiated service object. ''' - def after_load(self, service: Any) -> None: + def after_load(self, service: Plugin) -> None: u''' Called after a plugin has been loaded. This method is passed the instantiated service object. ''' - def before_unload(self, plugin: 'SingletonPlugin') -> None: + def before_unload(self, plugin: Plugin) -> None: u''' Called before a plugin is loaded. - This method is passed the plugin class. + This method is passed the instantiated service object. ''' - def after_unload(self, service: Any) -> None: + def after_unload(self, service: Plugin) -> None: u''' Called after a plugin has been unloaded. This method is passed the instantiated service object. @@ -1368,6 +1337,8 @@ class IGroupForm(Interface): ''' + is_organization = False + # These methods control when the plugin is delegated to ################### def is_fallback(self) -> bool: @@ -1729,7 +1700,7 @@ def abort( def authenticate( self, identity: 'Mapping[str, Any]' - ) -> Optional["User"]: + ) -> model.User | None: """Called before the authentication starts (that is after clicking the login button) @@ -1877,7 +1848,7 @@ class IPermissionLabels(Interface): See ``ckanext/example_ipermissionlabels`` for an example plugin. ''' - def get_dataset_labels(self, dataset_obj: 'model.Package') -> list[str]: + def get_dataset_labels(self, dataset_obj: model.Package) -> list[str]: u''' Return a list of unicode strings to be stored in the search index as the permission lables for a dataset dict. @@ -1890,8 +1861,9 @@ def get_dataset_labels(self, dataset_obj: 'model.Package') -> list[str]: ''' return [] - def get_user_dataset_labels(self, - user_obj: Optional['model.User']) -> list[str]: + def get_user_dataset_labels( + self, user_obj: model.User | None + ) -> list[str]: u''' Return the permission labels that give a user permission to view a dataset. If any of the labels returned from this method match diff --git a/ckan/tests/plugins/ckantestplugins.py b/ckan/tests/plugins/ckantestplugins.py index 97c8142f1dc..ef1b8e4570b 100644 --- a/ckan/tests/plugins/ckantestplugins.py +++ b/ckan/tests/plugins/ckantestplugins.py @@ -66,10 +66,7 @@ def edit(self, entity): def delete(self, entity): self.calls["delete"] += 1 - # this method deliberately uses deprecated `before_search` name instead of - # `before_dataset_search`. Change the name after support for deprecated - # names is dropped. - def before_search(self, search_params): + def before_dataset_search(self, search_params): self.calls["before_dataset_search"] += 1 return search_params @@ -111,6 +108,7 @@ class MockResourceViewExtension(mock_plugin.MockSingletonPlugin): p.implements(p.IResourceView) def __init__(self, *args, **kw): + super().__init__(*args, **kw) self.calls = defaultdict(int) def info(self): diff --git a/ckan/tests/plugins/test_core.py b/ckan/tests/plugins/test_core.py index 56ae4fe51f1..599724d6796 100644 --- a/ckan/tests/plugins/test_core.py +++ b/ckan/tests/plugins/test_core.py @@ -1,7 +1,6 @@ # encoding: utf-8 import pytest -from ckan.common import config import ckan.logic as logic import ckan.authz as authz @@ -31,19 +30,31 @@ class IBar(plugins.Interface): pass -class FooImpl(object): +class IBaz(plugins.Interface): + pass + + +class FooImpl(plugins.Plugin): plugins.implements(IFoo) -class BarImpl(object): +class BarImpl(plugins.Plugin): plugins.implements(IBar) -class FooBarImpl(object): +class FooBarImpl(plugins.Plugin): plugins.implements(IFoo) plugins.implements(IBar) +class BarBazImpl(BarImpl): + plugins.implements(IBaz) + + +class Ext(plugins.Plugin, IFoo, IBar): + pass + + @pytest.mark.usefixtures("with_plugins") @pytest.mark.ckan_config( "ckan.plugins", @@ -77,6 +88,17 @@ def test_implemented_by(): assert not IFoo.implemented_by(BarImpl) +def test_implemented_by_through_inheritance(): + assert IBaz.implemented_by(BarBazImpl) + assert IBar.implemented_by(BarBazImpl) + + +def test_implemented_by_through_extending(): + assert IFoo.implemented_by(Ext) + assert IBar.implemented_by(Ext) + assert not IBaz.implemented_by(Ext) + + def test_provided_by(): assert IFoo.provided_by(FooImpl()) assert IFoo.provided_by(FooBarImpl()) @@ -112,8 +134,9 @@ def test_notified_on_unload(observer): @pytest.fixture(autouse=True) def reset_observer(): - plugins.load("test_observer_plugin") + observer = plugins.load("test_observer_plugin") plugins.unload("test_observer_plugin") + observer.reset_calls() @pytest.mark.ckan_config("ckan.plugins", "action_plugin") @@ -153,19 +176,17 @@ def test_inexistent_plugin_loading(): class TestPlugins: - def teardown_method(self): - plugins.unload_all() - - def test_plugin_loading_order(self): + def test_plugin_loading_order(self, ckan_config, monkeypatch): """ Check that plugins are loaded in the order specified in the config """ - config_plugins = config["ckan.plugins"] - config[ - "ckan.plugins" - ] = "test_observer_plugin action_plugin auth_plugin" - plugins.load_all() + monkeypatch.setitem( + ckan_config, + "ckan.plugins", + "test_observer_plugin action_plugin auth_plugin" + ) + plugins.load_all() observerplugin = plugins.get_plugin("test_observer_plugin") expected_order = _make_calls( @@ -174,6 +195,7 @@ def test_plugin_loading_order(self): ) assert observerplugin.before_load.calls[:2] == expected_order + expected_order = _make_calls( plugins.get_plugin("test_observer_plugin"), plugins.get_plugin("action_plugin"), @@ -181,9 +203,13 @@ def test_plugin_loading_order(self): ) assert observerplugin.after_load.calls[:3] == expected_order - config[ - "ckan.plugins" - ] = "test_observer_plugin auth_plugin action_plugin" + observerplugin.reset_calls() + + monkeypatch.setitem( + ckan_config, + "ckan.plugins", + "test_observer_plugin auth_plugin action_plugin", + ) plugins.load_all() expected_order = _make_calls( @@ -197,6 +223,3 @@ def test_plugin_loading_order(self): plugins.get_plugin("action_plugin"), ) assert observerplugin.after_load.calls[:3] == expected_order - # cleanup - config["ckan.plugins"] = config_plugins - plugins.load_all() diff --git a/ckanext/datastore/plugin.py b/ckanext/datastore/plugin.py index 613fc5f3da4..963100f9501 100644 --- a/ckanext/datastore/plugin.py +++ b/ckanext/datastore/plugin.py @@ -3,7 +3,7 @@ import logging import os -from typing import Any, Callable, Union, cast +from typing import Any, Callable, Union import ckan.plugins as p from ckan.model.core import State @@ -50,7 +50,7 @@ class DatastorePlugin(p.SingletonPlugin): resource_show_action = None - def __new__(cls: Any, *args: Any, **kwargs: Any) -> Any: + def __new__(cls: Any, *args: Any, **kwargs: Any): idatastore_extensions: Any = p.PluginImplementations( interfaces.IDatastore) idatastore_extensions = idatastore_extensions.extensions() @@ -61,8 +61,7 @@ def __new__(cls: Any, *args: Any, **kwargs: Any) -> Any: '"ckan.plugins" in your CKAN .ini file and try again.') raise DatastoreException(msg) - return cast("DatastorePlugin", - super(cls, cls).__new__(cls, *args, **kwargs)) + return super().__new__(cls, *args, **kwargs) # IDatastoreBackend diff --git a/ckanext/example_iresourcecontroller/plugin.py b/ckanext/example_iresourcecontroller/plugin.py index 6da8f503e29..603e28c7c91 100644 --- a/ckanext/example_iresourcecontroller/plugin.py +++ b/ckanext/example_iresourcecontroller/plugin.py @@ -13,10 +13,7 @@ def __init__(self, *args: Any, **kwargs: Any): super().__init__(*args, **kwargs) self.counter = defaultdict(int) - # this method deliberately uses deprecated `before_create` name instead of - # `before_resource_create`. Change the name after support for deprecated - # names is dropped. - def before_create(self, context: Any, resource: Any): + def before_resource_create(self, context: Any, resource: Any): self.counter['before_resource_create'] += 1 def after_resource_create(self, context: Any, resource: Any): diff --git a/pyproject.toml b/pyproject.toml index 1213c6ac56b..c7fccd46eb5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -142,7 +142,6 @@ module = [ "flask_multistatic.*", "passlib.*", "pysolr.*", - "pyutilib.*", "repoze.*", "rq.*", "webassets.*", diff --git a/requirements.in b/requirements.in index 5a4044f6537..8adf19c5de4 100644 --- a/requirements.in +++ b/requirements.in @@ -25,7 +25,6 @@ python-magic==0.4.27 pysolr==3.9.0 python-dateutil==2.9.0.post0 pytz -PyUtilib==6.0.0 pyyaml==6.0.1 requests==2.31.0 rq==1.16.1 diff --git a/requirements.txt b/requirements.txt index 6db38449917..dd695612f99 100644 --- a/requirements.txt +++ b/requirements.txt @@ -85,8 +85,6 @@ mypy==1.9.0 # via sqlalchemy mypy-extensions==1.0.0 # via mypy -nose==1.3.7 - # via pyutilib packaging==24.0 # via -r requirements.in passlib==1.7.4 @@ -111,8 +109,6 @@ pytz==2024.1 # via # -r requirements.in # flask-babel -pyutilib==6.0.0 - # via -r requirements.in pyyaml==6.0.1 # via -r requirements.in redis==5.0.4 @@ -129,7 +125,6 @@ six==1.16.0 # via # bleach # python-dateutil - # pyutilib sqlalchemy[mypy]==1.4.52 # via # -r requirements.in