236 lines
7.1 KiB
Python
236 lines
7.1 KiB
Python
from __future__ import absolute_import, unicode_literals
|
|
|
|
import collections
|
|
import logging
|
|
|
|
import pkg_resources
|
|
|
|
from mopidy import config as config_lib, exceptions
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
_extension_data_fields = ['extension', 'entry_point', 'config_schema',
|
|
'config_defaults', 'command']
|
|
|
|
ExtensionData = collections.namedtuple('ExtensionData', _extension_data_fields)
|
|
|
|
|
|
class Extension(object):
|
|
|
|
"""Base class for Mopidy extensions"""
|
|
|
|
dist_name = None
|
|
"""The extension's distribution name, as registered on PyPI
|
|
|
|
Example: ``Mopidy-Soundspot``
|
|
"""
|
|
|
|
ext_name = None
|
|
"""The extension's short name, as used in setup.py and as config section
|
|
name
|
|
|
|
Example: ``soundspot``
|
|
"""
|
|
|
|
version = None
|
|
"""The extension's version
|
|
|
|
Should match the :attr:`__version__` attribute on the extension's main
|
|
Python module and the version registered on PyPI.
|
|
"""
|
|
|
|
def get_default_config(self):
|
|
"""The extension's default config as a bytestring
|
|
|
|
:returns: bytes or unicode
|
|
"""
|
|
raise NotImplementedError(
|
|
'Add at least a config section with "enabled = true"')
|
|
|
|
def get_config_schema(self):
|
|
"""The extension's config validation schema
|
|
|
|
:returns: :class:`~mopidy.config.schema.ExtensionConfigSchema`
|
|
"""
|
|
schema = config_lib.ConfigSchema(self.ext_name)
|
|
schema['enabled'] = config_lib.Boolean()
|
|
return schema
|
|
|
|
def get_command(self):
|
|
"""Command to expose to command line users running ``mopidy``.
|
|
|
|
:returns:
|
|
Instance of a :class:`~mopidy.commands.Command` class.
|
|
"""
|
|
pass
|
|
|
|
def validate_environment(self):
|
|
"""Checks if the extension can run in the current environment.
|
|
|
|
Dependencies described by :file:`setup.py` are checked by Mopidy, so
|
|
you should not check their presence here.
|
|
|
|
If a problem is found, raise :exc:`~mopidy.exceptions.ExtensionError`
|
|
with a message explaining the issue.
|
|
|
|
:raises: :exc:`~mopidy.exceptions.ExtensionError`
|
|
:returns: :class:`None`
|
|
"""
|
|
pass
|
|
|
|
def setup(self, registry):
|
|
"""
|
|
Register the extension's components in the extension :class:`Registry`.
|
|
|
|
For example, to register a backend::
|
|
|
|
def setup(self, registry):
|
|
from .backend import SoundspotBackend
|
|
registry.add('backend', SoundspotBackend)
|
|
|
|
See :class:`Registry` for a list of registry keys with a special
|
|
meaning. Mopidy will instantiate and start any classes registered under
|
|
the ``frontend`` and ``backend`` registry keys.
|
|
|
|
This method can also be used for other setup tasks not involving the
|
|
extension registry.
|
|
|
|
:param registry: the extension registry
|
|
:type registry: :class:`Registry`
|
|
"""
|
|
raise NotImplementedError
|
|
|
|
|
|
class Registry(collections.Mapping):
|
|
|
|
"""Registry of components provided by Mopidy extensions.
|
|
|
|
Passed to the :meth:`~Extension.setup` method of all extensions. The
|
|
registry can be used like a dict of string keys and lists.
|
|
|
|
Some keys have a special meaning, including, but not limited to:
|
|
|
|
- ``backend`` is used for Mopidy backend classes.
|
|
- ``frontend`` is used for Mopidy frontend classes.
|
|
- ``local:library`` is used for Mopidy-Local libraries.
|
|
|
|
Extensions can use the registry for allow other to extend the extension
|
|
itself. For example the ``Mopidy-Local`` use the ``local:library`` key to
|
|
allow other extensions to register library providers for ``Mopidy-Local``
|
|
to use. Extensions should namespace custom keys with the extension's
|
|
:attr:`~Extension.ext_name`, e.g. ``local:foo`` or ``http:bar``.
|
|
"""
|
|
|
|
def __init__(self):
|
|
self._registry = {}
|
|
|
|
def add(self, name, cls):
|
|
"""Add a component to the registry.
|
|
|
|
Multiple classes can be registered to the same name.
|
|
"""
|
|
self._registry.setdefault(name, []).append(cls)
|
|
|
|
def __getitem__(self, name):
|
|
return self._registry.setdefault(name, [])
|
|
|
|
def __iter__(self):
|
|
return iter(self._registry)
|
|
|
|
def __len__(self):
|
|
return len(self._registry)
|
|
|
|
|
|
def load_extensions():
|
|
"""Find all installed extensions.
|
|
|
|
:returns: list of installed extensions
|
|
"""
|
|
|
|
installed_extensions = []
|
|
|
|
for entry_point in pkg_resources.iter_entry_points('mopidy.ext'):
|
|
logger.debug('Loading entry point: %s', entry_point)
|
|
extension_class = entry_point.load(require=False)
|
|
|
|
# TODO: start using _extension_error_handling(...) pattern
|
|
|
|
try:
|
|
if not issubclass(extension_class, Extension):
|
|
continue # TODO: log this
|
|
except TypeError:
|
|
continue # TODO: log that extension_class is not a class
|
|
|
|
try:
|
|
extension = extension_class()
|
|
config_schema = extension.get_config_schema()
|
|
default_config = extension.get_default_config()
|
|
except Exception:
|
|
continue # TODO: log this
|
|
|
|
try:
|
|
command = extension.get_command()
|
|
except Exception:
|
|
command = None # TODO: log this.
|
|
|
|
installed_extensions.append(ExtensionData(
|
|
extension, entry_point, config_schema, default_config, command))
|
|
|
|
# TODO: call validate_extension here?
|
|
# TODO: do basic config tests like schema contains enabled?
|
|
|
|
logger.debug(
|
|
'Loaded extension: %s %s', extension.dist_name, extension.version)
|
|
|
|
names = (ed.extension.ext_name for ed in installed_extensions)
|
|
logger.debug('Discovered extensions: %s', ', '.join(names))
|
|
return installed_extensions
|
|
|
|
|
|
def validate_extension(extension, entry_point):
|
|
"""Verify extension's dependencies and environment.
|
|
|
|
:param extensions: an extension to check
|
|
:returns: if extension should be run
|
|
"""
|
|
|
|
logger.debug('Validating extension: %s', extension.ext_name)
|
|
|
|
if extension.ext_name != entry_point.name:
|
|
logger.warning(
|
|
'Disabled extension %(ep)s: entry point name (%(ep)s) '
|
|
'does not match extension name (%(ext)s)',
|
|
{'ep': entry_point.name, 'ext': extension.ext_name})
|
|
return False
|
|
|
|
try:
|
|
entry_point.require()
|
|
except pkg_resources.DistributionNotFound as ex:
|
|
logger.info(
|
|
'Disabled extension %s: Dependency %s not found',
|
|
extension.ext_name, ex)
|
|
return False
|
|
except pkg_resources.VersionConflict as ex:
|
|
if len(ex.args) == 2:
|
|
found, required = ex.args
|
|
logger.info(
|
|
'Disabled extension %s: %s required, but found %s at %s',
|
|
extension.ext_name, required, found, found.location)
|
|
else:
|
|
logger.info(
|
|
'Disabled extension %s: %s', extension.ext_name, ex)
|
|
return False
|
|
|
|
try:
|
|
extension.validate_environment()
|
|
except exceptions.ExtensionError as ex:
|
|
logger.info(
|
|
'Disabled extension %s: %s', extension.ext_name, ex.message)
|
|
return False
|
|
except Exception:
|
|
return False # TODO: log
|
|
|
|
return True
|