mopidy / mopidy
1 2
import collections
2 2
import logging
3 2
from collections.abc import Mapping
4

5 2
import pkg_resources
6

7 2
from mopidy import config as config_lib
8 2
from mopidy import exceptions
9 2
from mopidy.internal import path
10

11 2
logger = logging.getLogger(__name__)
12

13

14 2
_extension_data_fields = [
15
    "extension",
16
    "entry_point",
17
    "config_schema",
18
    "config_defaults",
19
    "command",
20
]
21

22 2
ExtensionData = collections.namedtuple("ExtensionData", _extension_data_fields)
23

24

25 2
class Extension:
26

27
    """Base class for Mopidy extensions"""
28

29 2
    dist_name = None
30
    """The extension's distribution name, as registered on PyPI
31

32
    Example: ``Mopidy-Soundspot``
33
    """
34

35 2
    ext_name = None
36
    """The extension's short name, as used in setup.py and as config section
37
    name
38

39
    Example: ``soundspot``
40
    """
41

42 2
    version = None
43
    """The extension's version
44

45
    Should match the :attr:`__version__` attribute on the extension's main
46
    Python module and the version registered on PyPI.
47
    """
48

49 2
    def get_default_config(self):
50
        """The extension's default config as a bytestring
51

52
        :returns: bytes or unicode
53
        """
54 2
        raise NotImplementedError(
55
            'Add at least a config section with "enabled = true"'
56
        )
57

58 2
    def get_config_schema(self):
59
        """The extension's config validation schema
60

61
        :returns: :class:`~mopidy.config.schemas.ConfigSchema`
62
        """
63 2
        schema = config_lib.ConfigSchema(self.ext_name)
64 2
        schema["enabled"] = config_lib.Boolean()
65 2
        return schema
66

67 2
    @classmethod
68 1
    def get_cache_dir(cls, config):
69
        """Get or create cache directory for the extension.
70

71
        Use this directory to cache data that can safely be thrown away.
72

73
        :param config: the Mopidy config object
74
        :return: pathlib.Path
75
        """
76 2
        if cls.ext_name is None:
77 2
            raise AssertionError
78 2
        cache_dir_path = (
79
            path.expand_path(config["core"]["cache_dir"]) / cls.ext_name
80
        )
81 2
        path.get_or_create_dir(cache_dir_path)
82 2
        return cache_dir_path
83

84 2
    @classmethod
85 1
    def get_config_dir(cls, config):
86
        """Get or create configuration directory for the extension.
87

88
        :param config: the Mopidy config object
89
        :return: pathlib.Path
90
        """
91 2
        if cls.ext_name is None:
92 2
            raise AssertionError
93 2
        config_dir_path = (
94
            path.expand_path(config["core"]["config_dir"]) / cls.ext_name
95
        )
96 2
        path.get_or_create_dir(config_dir_path)
97 2
        return config_dir_path
98

99 2
    @classmethod
100 1
    def get_data_dir(cls, config):
101
        """Get or create data directory for the extension.
102

103
        Use this directory to store data that should be persistent.
104

105
        :param config: the Mopidy config object
106
        :returns: pathlib.Path
107
        """
108 2
        if cls.ext_name is None:
109 2
            raise AssertionError
110 2
        data_dir_path = (
111
            path.expand_path(config["core"]["data_dir"]) / cls.ext_name
112
        )
113 2
        path.get_or_create_dir(data_dir_path)
114 2
        return data_dir_path
115

116 2
    def get_command(self):
117
        """Command to expose to command line users running ``mopidy``.
118

119
        :returns:
120
          Instance of a :class:`~mopidy.commands.Command` class.
121
        """
122 2
        pass
123

124 2
    def validate_environment(self):
125
        """Checks if the extension can run in the current environment.
126

127
        Dependencies described by :file:`setup.py` are checked by Mopidy, so
128
        you should not check their presence here.
129

130
        If a problem is found, raise :exc:`~mopidy.exceptions.ExtensionError`
131
        with a message explaining the issue.
132

133
        :raises: :exc:`~mopidy.exceptions.ExtensionError`
134
        :returns: :class:`None`
135
        """
136 2
        pass
137

138 2
    def setup(self, registry):
139
        """
140
        Register the extension's components in the extension :class:`Registry`.
141

142
        For example, to register a backend::
143

144
            def setup(self, registry):
145
                from .backend import SoundspotBackend
146
                registry.add('backend', SoundspotBackend)
147

148
        See :class:`Registry` for a list of registry keys with a special
149
        meaning. Mopidy will instantiate and start any classes registered under
150
        the ``frontend`` and ``backend`` registry keys.
151

152
        This method can also be used for other setup tasks not involving the
153
        extension registry.
154

155
        :param registry: the extension registry
156
        :type registry: :class:`Registry`
157
        """
158 2
        raise NotImplementedError
159

160

161 2
class Registry(Mapping):
162

163
    """Registry of components provided by Mopidy extensions.
164

165
    Passed to the :meth:`~Extension.setup` method of all extensions. The
166
    registry can be used like a dict of string keys and lists.
167

168
    Some keys have a special meaning, including, but not limited to:
169

170
    - ``backend`` is used for Mopidy backend classes.
171
    - ``frontend`` is used for Mopidy frontend classes.
172

173
    Extensions can use the registry for allow other to extend the extension
174
    itself. For example the ``Mopidy-Local`` historically used the
175
    ``local:library`` key to allow other extensions to register library
176
    providers for ``Mopidy-Local`` to use. Extensions should namespace
177
    custom keys with the extension's :attr:`~Extension.ext_name`,
178
    e.g. ``local:foo`` or ``http:bar``.
179
    """
180

181 2
    def __init__(self):
182 0
        self._registry = {}
183

184 2
    def add(self, name, cls):
185
        """Add a component to the registry.
186

187
        Multiple classes can be registered to the same name.
188
        """
189 0
        self._registry.setdefault(name, []).append(cls)
190

191 2
    def __getitem__(self, name):
192 0
        return self._registry.setdefault(name, [])
193

194 2
    def __iter__(self):
195 0
        return iter(self._registry)
196

197 2
    def __len__(self):
198 0
        return len(self._registry)
199

200

201 2
def load_extensions():
202
    """Find all installed extensions.
203

204
    :returns: list of installed extensions
205
    """
206

207 2
    installed_extensions = []
208

209 2
    for entry_point in pkg_resources.iter_entry_points("mopidy.ext"):
210 2
        logger.debug("Loading entry point: %s", entry_point)
211 2
        try:
212 2
            extension_class = entry_point.resolve()
213 0
        except Exception as e:
214 0
            logger.exception(
215
                f"Failed to load extension {entry_point.name}: {e}"
216
            )
217 0
            continue
218

219 2
        try:
220 2
            if not issubclass(extension_class, Extension):
221 2
                raise TypeError  # issubclass raises TypeError on non-class
222 2
        except TypeError:
223 2
            logger.error(
224
                "Entry point %s did not contain a valid extension" "class: %r",
225
                entry_point.name,
226
                extension_class,
227
            )
228 2
            continue
229

230 2
        try:
231 2
            extension = extension_class()
232 2
            config_schema = extension.get_config_schema()
233 2
            default_config = extension.get_default_config()
234 2
            command = extension.get_command()
235 2
        except Exception:
236 2
            logger.exception(
237
                "Setup of extension from entry point %s failed, "
238
                "ignoring extension.",
239
                entry_point.name,
240
            )
241 2
            continue
242

243 2
        installed_extensions.append(
244
            ExtensionData(
245
                extension, entry_point, config_schema, default_config, command
246
            )
247
        )
248

249 2
        logger.debug(
250
            "Loaded extension: %s %s", extension.dist_name, extension.version
251
        )
252

253 2
    names = (ed.extension.ext_name for ed in installed_extensions)
254 2
    logger.debug("Discovered extensions: %s", ", ".join(names))
255 2
    return installed_extensions
256

257

258 2
def validate_extension_data(data):
259
    """Verify extension's dependencies and environment.
260

261
    :param extensions: an extension to check
262
    :returns: if extension should be run
263
    """
264

265 2
    logger.debug("Validating extension: %s", data.extension.ext_name)
266

267 2
    if data.extension.ext_name != data.entry_point.name:
268 2
        logger.warning(
269
            "Disabled extension %(ep)s: entry point name (%(ep)s) "
270
            "does not match extension name (%(ext)s)",
271
            {"ep": data.entry_point.name, "ext": data.extension.ext_name},
272
        )
273 2
        return False
274

275 2
    try:
276 2
        data.entry_point.require()
277 2
    except pkg_resources.DistributionNotFound as exc:
278 2
        logger.info(
279
            "Disabled extension %s: Dependency %s not found",
280
            data.extension.ext_name,
281
            exc,
282
        )
283 2
        return False
284 2
    except pkg_resources.VersionConflict as exc:
285 2
        if len(exc.args) == 2:
286 0
            found, required = exc.args
287 0
            logger.info(
288
                "Disabled extension %s: %s required, but found %s at %s",
289
                data.extension.ext_name,
290
                required,
291
                found,
292
                found.location,
293
            )
294
        else:
295 2
            logger.info(
296
                "Disabled extension %s: %s", data.extension.ext_name, exc
297
            )
298 2
        return False
299

300 2
    try:
301 2
        data.extension.validate_environment()
302 2
    except exceptions.ExtensionError as exc:
303 2
        logger.info("Disabled extension %s: %s", data.extension.ext_name, exc)
304 2
        return False
305 2
    except Exception:
306 2
        logger.exception(
307
            "Validating extension %s failed with an exception.",
308
            data.extension.ext_name,
309
        )
310 2
        return False
311

312 2
    if not data.config_schema:
313 2
        logger.error(
314
            "Extension %s does not have a config schema, disabling.",
315
            data.extension.ext_name,
316
        )
317 2
        return False
318 2
    elif not isinstance(data.config_schema.get("enabled"), config_lib.Boolean):
319 2
        logger.error(
320
            'Extension %s does not have the required "enabled" config'
321
            " option, disabling.",
322
            data.extension.ext_name,
323
        )
324 2
        return False
325

326 2
    for key, value in data.config_schema.items():
327 2
        if not isinstance(value, config_lib.ConfigValue):
328 2
            logger.error(
329
                "Extension %s config schema contains an invalid value"
330
                ' for the option "%s", disabling.',
331
                data.extension.ext_name,
332
                key,
333
            )
334 2
            return False
335

336 2
    if not data.config_defaults:
337 2
        logger.error(
338
            "Extension %s does not have a default config, disabling.",
339
            data.extension.ext_name,
340
        )
341 2
        return False
342

343 0
    return True

Read our documentation on viewing source code .

Loading