"""
This module defines functions for registering, loading, and querying girder plugins.
"""
import hashlib
import importlib.metadata
import importlib.resources
import logging
from collections import OrderedDict
from collections import OrderedDict as OrderedDictType
from dataclasses import dataclass
from functools import wraps
from pathlib import Path
from girder import __version__
from girder.exceptions import GirderException
logger = logging.getLogger(__name__)
[docs]
@dataclass
class PluginStaticContent:
css: list[str]
js: list[str]
_NAMESPACE = 'girder.plugin'
_pluginRegistry = None
_pluginLoadOrder = []
_pluginStaticContent: OrderedDictType[str, PluginStaticContent] = OrderedDict()
def getPluginStaticContent():
return _pluginStaticContent
def registerPluginStaticContent(plugin: str, css: list[str], js: list[str], staticDir: Path, tree):
from girder.utility.server import _errorDefault
if plugin not in _pluginStaticContent:
tree.mount(
None,
f'/plugin_static/{plugin}',
{
'/': {
'tools.staticdir.on': True,
'tools.staticdir.dir': str(staticDir),
'request.show_tracebacks': False,
'response.headers.server': f'Girder {__version__}',
'error_page.default': _errorDefault,
}
},
)
def cache_bust_url(filename: str) -> str:
filename = filename.lstrip('/')
if '?' in filename:
# For now, we assume this means the plugin is managing its own cache busting
return f'plugin_static/{plugin}/{filename}'
hash_md5 = hashlib.md5()
with (staticDir / filename).open('rb') as f:
for chunk in iter(lambda: f.read(4096), b''):
hash_md5.update(chunk)
return f'plugin_static/{plugin}/{filename}?h={hash_md5.hexdigest()[:10]}'
_pluginStaticContent[plugin] = PluginStaticContent(
css=[cache_bust_url(f) for f in css],
js=[cache_bust_url(f) for f in js],
)
class _PluginMeta(type):
"""
This is a metaclass applied to the ``GirderPlugin`` descriptor class. It
exists to automatically wrap subclass load methods.
"""
def __new__(cls, classname, bases, classdict):
if 'load' in classdict:
classdict['load'] = _PluginMeta._wrapPluginLoad(classdict['load'])
return type.__new__(cls, classname, bases, classdict)
@staticmethod
def _wrapPluginLoad(func):
"""Wrap a plugin load method to provide logging and ensure it is loaded only once."""
@wraps(func)
def wrapper(self, *args, **kwargs):
if not getattr(self, '_loaded', False):
# This block is executed on the first call to the function.
# The return value of the call is saved an attribute on the wrapper
# for future invocations.
self._return = func(self, *args, **kwargs)
self._loaded = True
_pluginLoadOrder.append(self.name)
logger.info('Loaded plugin "%s"', self.name)
return self._return
return wrapper
[docs]
class GirderPlugin(metaclass=_PluginMeta):
"""
This is a base class for describing a girder plugin. A plugin is registered by adding
an entrypoint under the namespace ``girder.plugin``. This entrypoint should return a
class derived from this class.
Example ::
class Cats(GirderPlugin):
def load(self, info):
# load dependent plugins
girder.plugin.getPlugin('pets').load(info)
import rest # register new rest endpoints, etc.
"""
#: This is the named displayed to users on the plugin page. Unlike the entrypoint name
#: used internally, this name can be an arbitrary string.
DISPLAY_NAME = None
def __init__(self, entrypoint):
self._name = entrypoint.name
self._loaded = False
self._dist = (
entrypoint.dist if hasattr(entrypoint, 'dist') else
importlib.metadata.distribution(entrypoint.value.split(':')[0].split('.')[0]))
self._metadata = _readPackageMetadata(self._dist)
@property
def name(self):
"""Return the plugin name defaulting to the entrypoint name."""
return self._name
@property
def displayName(self):
"""Return a user-friendly plugin name (defaults to the entrypoint name)."""
return self.DISPLAY_NAME or self._name
@property
def description(self):
"""Return the plugin description defaulting to the classes docstring."""
return self._metadata.get('Summary', self._metadata.get('description', ''))
@property
def url(self):
"""Return a url reference to the plugin (usually a readthedocs page)."""
return self._metadata.get('url', self._metadata.get('Home-page', ''))
@property
def version(self):
"""Return the version of the plugin automatically determined from setup.py."""
return self._dist.version
@property
def loaded(self):
"""Return true if this plugin has been loaded."""
return getattr(self, '_loaded', False)
def load(self, info):
raise NotImplementedError('Plugins must define a load method')
def _readPackageMetadata(distribution):
"""Get a metadata object associated with a python package."""
return distribution.metadata
def _listPluginEntryPoints():
if hasattr(importlib.metadata.entry_points(), 'select'):
return importlib.metadata.entry_points().select(group=_NAMESPACE)
return importlib.metadata.entry_points().get(_NAMESPACE, [])
def _getPluginRegistry():
"""Return a dictionary containing all detected plugins.
This function will discover plugins registered via entrypoints and return
a mapping of plugin name -> plugin definition. The result is memoized
because iteration through entrypoints is a slow operation.
"""
global _pluginRegistry
if _pluginRegistry is not None:
return _pluginRegistry
_pluginRegistry = {}
for entryPoint in _listPluginEntryPoints():
pluginClass = entryPoint.load()
plugin = pluginClass(entryPoint)
_pluginRegistry[plugin.name] = plugin
return _pluginRegistry
[docs]
def getPlugin(name):
"""Return a plugin configuration object or None if the plugin is not found."""
registry = _getPluginRegistry()
return registry.get(name)
def _loadPlugins(info, names=None):
"""Load plugins with the given app info object.
If `names` is None, all installed plugins will be loaded. If `names` is a
list, then only those plugins in the provided list will be loaded.
"""
if names is None:
names = allPlugins()
for name in names:
pluginObject = getPlugin(name)
if pluginObject is None:
raise GirderException('Plugin %s is not installed' % name)
pluginObject.load(info)
[docs]
def allPlugins():
"""Return a list of all detected plugins."""
return list(_getPluginRegistry().keys())
[docs]
def loadedPlugins():
"""Return a list of successfully loaded plugins."""
return _pluginLoadOrder[:]