This allows us to do more of the data loading that might fail safely in the mopidy.ext module instead of having things spread all over the place. Note that only minimal changes have been made to __main__ to make things work. Further refactoring should follow.
234 lines
7.0 KiB
Python
234 lines
7.0 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()
|
|
except Exception:
|
|
continue # TODO: log this
|
|
|
|
# TODO: handle exceptions and validate result...
|
|
config_schema = extension.get_config_schema()
|
|
default_config = extension.get_default_config()
|
|
command = extension.get_command()
|
|
|
|
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
|