1 3
import argparse
2 3
import collections
3 3
import contextlib
4 3
import logging
5 3
import os
6 3
import pathlib
7 3
import signal
8 3
import sys
9

10 3
import pykka
11 3
from pykka.messages import ProxyCall
12

13 3
from mopidy import config as config_lib
14 3
from mopidy import exceptions
15 3
from mopidy.audio import Audio
16 3
from mopidy.core import Core
17 3
from mopidy.internal import deps, process, timer, versioning
18 3
from mopidy.internal.gi import GLib
19

20 3
logger = logging.getLogger(__name__)
21

22 3
_default_config = [
23
    (pathlib.Path(base) / "mopidy" / "mopidy.conf").resolve()
24
    for base in GLib.get_system_config_dirs() + [GLib.get_user_config_dir()]
25
]
26 3
DEFAULT_CONFIG = ":".join(map(str, _default_config))
27

28

29 3
def config_files_type(value):
30 3
    return value.split(":")
31

32

33 3
def config_override_type(value):
34 3
    try:
35 3
        section, remainder = value.split("/", 1)
36 3
        key, value = remainder.split("=", 1)
37 3
        return (section.strip(), key.strip(), value.strip())
38 3
    except ValueError:
39 3
        raise argparse.ArgumentTypeError(
40
            f"{value} must have the format section/key=value"
41
        )
42

43

44 3
class _ParserError(Exception):
45 3
    def __init__(self, message):
46 3
        self.message = message
47

48

49 3
class _HelpError(Exception):
50 3
    pass
51

52

53 3
class _ArgumentParser(argparse.ArgumentParser):
54 3
    def error(self, message):
55 3
        raise _ParserError(message)
56

57

58 3
class _HelpAction(argparse.Action):
59 3
    def __init__(self, option_strings, dest=None, help=None):
60 3
        super().__init__(
61
            option_strings=option_strings,
62
            dest=dest or argparse.SUPPRESS,
63
            default=argparse.SUPPRESS,
64
            nargs=0,
65
            help=help,
66
        )
67

68 3
    def __call__(self, parser, namespace, values, option_string=None):
69 3
        raise _HelpError()
70

71

72 3
class Command:
73

74
    """Command parser and runner for building trees of commands.
75

76
    This class provides a wraper around :class:`argparse.ArgumentParser`
77
    for handling this type of command line application in a better way than
78
    argprases own sub-parser handling.
79
    """
80

81 3
    help = None
82
    #: Help text to display in help output.
83

84 3
    def __init__(self):
85 3
        self._children = collections.OrderedDict()
86 3
        self._arguments = []
87 3
        self._overrides = {}
88

89 3
    def _build(self):
90 3
        actions = []
91 3
        parser = _ArgumentParser(add_help=False)
92 3
        parser.register("action", "help", _HelpAction)
93

94 3
        for args, kwargs in self._arguments:
95 3
            actions.append(parser.add_argument(*args, **kwargs))
96

97 3
        parser.add_argument(
98
            "_args", nargs=argparse.REMAINDER, help=argparse.SUPPRESS
99
        )
100 3
        return parser, actions
101

102 3
    def add_child(self, name, command):
103
        """Add a child parser to consider using.
104

105
        :param name: name to use for the sub-command that is being added.
106
        :type name: string
107
        """
108 3
        self._children[name] = command
109

110 3
    def add_argument(self, *args, **kwargs):
111
        """Add an argument to the parser.
112

113
        This method takes all the same arguments as the
114
        :class:`argparse.ArgumentParser` version of this method.
115
        """
116 3
        self._arguments.append((args, kwargs))
117

118 3
    def set(self, **kwargs):
119
        """Override a value in the finaly result of parsing."""
120 3
        self._overrides.update(kwargs)
121

122 3
    def exit(self, status_code=0, message=None, usage=None):
123
        """Optionally print a message and exit."""
124 0
        print("\n\n".join(m for m in (usage, message) if m))
125 0
        sys.exit(status_code)
126

127 3
    def format_usage(self, prog=None):
128
        """Format usage for current parser."""
129 3
        actions = self._build()[1]
130 3
        prog = prog or os.path.basename(sys.argv[0])
131 3
        return self._usage(actions, prog) + "\n"
132

133 3
    def _usage(self, actions, prog):
134 3
        formatter = argparse.HelpFormatter(prog)
135 3
        formatter.add_usage(None, actions, [])
136 3
        return formatter.format_help().strip()
137

138 3
    def format_help(self, prog=None):
139
        """Format help for current parser and children."""
140 3
        actions = self._build()[1]
141 3
        prog = prog or os.path.basename(sys.argv[0])
142

143 3
        formatter = argparse.HelpFormatter(prog)
144 3
        formatter.add_usage(None, actions, [])
145

146 3
        if self.help:
147 3
            formatter.add_text(self.help)
148

149 3
        if actions:
150 3
            formatter.add_text("OPTIONS:")
151 3
            formatter.start_section(None)
152 3
            formatter.add_arguments(actions)
153 3
            formatter.end_section()
154

155 3
        subhelp = []
156 3
        for name, child in self._children.items():
157 3
            child._subhelp(name, subhelp)
158

159 3
        if subhelp:
160 3
            formatter.add_text("COMMANDS:")
161 3
            subhelp.insert(0, "")
162

163 3
        return formatter.format_help() + "\n".join(subhelp)
164

165 3
    def _subhelp(self, name, result):
166 3
        actions = self._build()[1]
167

168 3
        if self.help or actions:
169 3
            formatter = argparse.HelpFormatter(name)
170 3
            formatter.add_usage(None, actions, [], "")
171 3
            formatter.start_section(None)
172 3
            formatter.add_text(self.help)
173 3
            formatter.start_section(None)
174 3
            formatter.add_arguments(actions)
175 3
            formatter.end_section()
176 3
            formatter.end_section()
177 3
            result.append(formatter.format_help())
178

179 3
        for childname, child in self._children.items():
180 3
            child._subhelp(" ".join((name, childname)), result)
181

182 3
    def parse(self, args, prog=None):
183
        """Parse command line arguments.
184

185
        Will recursively parse commands until a final parser is found or an
186
        error occurs. In the case of errors we will print a message and exit.
187
        Otherwise, any overrides are applied and the current parser stored
188
        in the command attribute of the return value.
189

190
        :param args: list of arguments to parse
191
        :type args: list of strings
192
        :param prog: name to use for program
193
        :type prog: string
194
        :rtype: :class:`argparse.Namespace`
195
        """
196 3
        prog = prog or os.path.basename(sys.argv[0])
197 3
        try:
198 3
            return self._parse(
199
                args, argparse.Namespace(), self._overrides.copy(), prog
200
            )
201 3
        except _HelpError:
202 3
            self.exit(0, self.format_help(prog))
203

204 3
    def _parse(self, args, namespace, overrides, prog):
205 3
        overrides.update(self._overrides)
206 3
        parser, actions = self._build()
207

208 3
        try:
209 3
            result = parser.parse_args(args, namespace)
210 3
        except _ParserError as exc:
211 3
            self.exit(1, str(exc), self._usage(actions, prog))
212

213 3
        if not result._args:
214 3
            for attr, value in overrides.items():
215 3
                setattr(result, attr, value)
216 3
            delattr(result, "_args")
217 3
            result.command = self
218 3
            return result
219

220 3
        child = result._args.pop(0)
221 3
        if child not in self._children:
222 3
            usage = self._usage(actions, prog)
223 3
            self.exit(1, f"unrecognized command: {child}", usage)
224

225 3
        return self._children[child]._parse(
226
            result._args, result, overrides, " ".join([prog, child])
227
        )
228

229 3
    def run(self, *args, **kwargs):
230
        """Run the command.
231

232
        Must be implemented by sub-classes that are not simply an intermediate
233
        in the command namespace.
234
        """
235 3
        raise NotImplementedError
236

237

238 3
@contextlib.contextmanager
239 2
def _actor_error_handling(name):
240 0
    try:
241 0
        yield
242 0
    except exceptions.BackendError as exc:
243 0
        logger.error("Backend (%s) initialization error: %s", name, exc)
244 0
    except exceptions.FrontendError as exc:
245 0
        logger.error("Frontend (%s) initialization error: %s", name, exc)
246 0
    except exceptions.MixerError as exc:
247 0
        logger.error("Mixer (%s) initialization error: %s", name, exc)
248 0
    except Exception:
249 0
        logger.exception("Got un-handled exception from %s", name)
250

251

252
# TODO: move out of this utility class
253 3
class RootCommand(Command):
254 3
    def __init__(self):
255 3
        super().__init__()
256 3
        self.set(base_verbosity_level=0)
257 3
        self.add_argument(
258
            "-h", "--help", action="help", help="Show this message and exit"
259
        )
260 3
        self.add_argument(
261
            "--version",
262
            action="version",
263
            version=f"Mopidy {versioning.get_version()}",
264
        )
265 3
        self.add_argument(
266
            "-q",
267
            "--quiet",
268
            action="store_const",
269
            const=-1,
270
            dest="verbosity_level",
271
            help="less output (warning level)",
272
        )
273 3
        self.add_argument(
274
            "-v",
275
            "--verbose",
276
            action="count",
277
            dest="verbosity_level",
278
            default=0,
279
            help="more output (repeat up to 4 times for even more)",
280
        )
281 3
        self.add_argument(
282
            "--config",
283
            action="store",
284
            dest="config_files",
285
            type=config_files_type,
286
            default=DEFAULT_CONFIG,
287
            metavar="FILES",
288
            help="config files to use, colon seperated, later files override",
289
        )
290 3
        self.add_argument(
291
            "-o",
292
            "--option",
293
            action="append",
294
            dest="config_overrides",
295
            type=config_override_type,
296
            metavar="OPTIONS",
297
            help="`section/key=value` values to override config options",
298
        )
299

300 3
    def run(self, args, config):
301 0
        def on_sigterm(loop):
302 0
            logger.info("GLib mainloop got SIGTERM. Exiting...")
303 0
            loop.quit()
304

305 0
        loop = GLib.MainLoop()
306 0
        GLib.unix_signal_add(
307
            GLib.PRIORITY_DEFAULT, signal.SIGTERM, on_sigterm, loop
308
        )
309

310 0
        mixer_class = self.get_mixer_class(config, args.registry["mixer"])
311 0
        backend_classes = args.registry["backend"]
312 0
        frontend_classes = args.registry["frontend"]
313 0
        core = None
314

315 0
        exit_status_code = 0
316 0
        try:
317 0
            mixer = None
318 0
            if mixer_class is not None:
319 0
                mixer = self.start_mixer(config, mixer_class)
320 0
            if mixer:
321 0
                self.configure_mixer(config, mixer)
322 0
            audio = self.start_audio(config, mixer)
323 0
            backends = self.start_backends(config, backend_classes, audio)
324 0
            core = self.start_core(config, mixer, backends, audio)
325 0
            self.start_frontends(config, frontend_classes, core)
326 0
            logger.info("Starting GLib mainloop")
327 0
            loop.run()
328 0
        except (
329
            exceptions.BackendError,
330
            exceptions.FrontendError,
331
            exceptions.MixerError,
332
        ):
333 0
            logger.info("Initialization error. Exiting...")
334 0
            exit_status_code = 1
335 0
        except KeyboardInterrupt:
336 0
            logger.info("Interrupted. Exiting...")
337 0
        except Exception:
338 0
            logger.exception("Uncaught exception")
339
        finally:
340 0
            loop.quit()
341 0
            self.stop_frontends(frontend_classes)
342 0
            self.stop_core(core)
343 0
            self.stop_backends(backend_classes)
344 0
            self.stop_audio()
345 0
            if mixer_class is not None:
346 0
                self.stop_mixer(mixer_class)
347 0
            process.stop_remaining_actors()
348 0
        return exit_status_code
349

350 3
    def get_mixer_class(self, config, mixer_classes):
351 0
        logger.debug(
352
            "Available Mopidy mixers: %s",
353
            ", ".join(m.__name__ for m in mixer_classes) or "none",
354
        )
355

356 0
        if config["audio"]["mixer"] == "none":
357 0
            logger.debug("Mixer disabled")
358 0
            return None
359

360 0
        selected_mixers = [
361
            m for m in mixer_classes if m.name == config["audio"]["mixer"]
362
        ]
363 0
        if len(selected_mixers) != 1:
364 0
            logger.error(
365
                'Did not find unique mixer "%s". Alternatives are: %s',
366
                config["audio"]["mixer"],
367
                ", ".join(m.name for m in mixer_classes) + ", none" or "none",
368
            )
369 0
            process.exit_process()
370 0
        return selected_mixers[0]
371

372 3
    def start_mixer(self, config, mixer_class):
373 0
        logger.info("Starting Mopidy mixer: %s", mixer_class.__name__)
374 0
        with _actor_error_handling(mixer_class.__name__):
375 0
            mixer = mixer_class.start(config=config).proxy()
376 0
            try:
377 0
                mixer.ping().get()
378 0
                return mixer
379 0
            except pykka.ActorDeadError as exc:
380 0
                logger.error("Actor died: %s", exc)
381 0
        return None
382

383 3
    def configure_mixer(self, config, mixer):
384 0
        volume = config["audio"]["mixer_volume"]
385 0
        if volume is not None:
386 0
            mixer.set_volume(volume)
387 0
            logger.info("Mixer volume set to %d", volume)
388
        else:
389 0
            logger.debug("Mixer volume left unchanged")
390

391 3
    def start_audio(self, config, mixer):
392 0
        logger.info("Starting Mopidy audio")
393 0
        return Audio.start(config=config, mixer=mixer).proxy()
394

395 3
    def start_backends(self, config, backend_classes, audio):
396 0
        logger.info(
397
            "Starting Mopidy backends: %s",
398
            ", ".join(b.__name__ for b in backend_classes) or "none",
399
        )
400

401 0
        backends = []
402 0
        for backend_class in backend_classes:
403 0
            with _actor_error_handling(backend_class.__name__):
404 0
                with timer.time_logger(backend_class.__name__):
405 0
                    backend = backend_class.start(
406
                        config=config, audio=audio
407
                    ).proxy()
408 0
                    backends.append(backend)
409

410
        # Block until all on_starts have finished, letting them run in parallel
411 0
        for backend in backends[:]:
412 0
            try:
413 0
                backend.ping().get()
414 0
            except pykka.ActorDeadError as exc:
415 0
                backends.remove(backend)
416 0
                logger.error("Actor died: %s", exc)
417

418 0
        return backends
419

420 3
    def start_core(self, config, mixer, backends, audio):
421 0
        logger.info("Starting Mopidy core")
422 0
        core = Core.start(
423
            config=config, mixer=mixer, backends=backends, audio=audio
424
        ).proxy()
425 0
        call = ProxyCall(attr_path=["_setup"], args=[], kwargs={})
426 0
        core.actor_ref.ask(call, block=True)
427 0
        return core
428

429 3
    def start_frontends(self, config, frontend_classes, core):
430 0
        logger.info(
431
            "Starting Mopidy frontends: %s",
432
            ", ".join(f.__name__ for f in frontend_classes) or "none",
433
        )
434

435 0
        for frontend_class in frontend_classes:
436 0
            with _actor_error_handling(frontend_class.__name__):
437 0
                with timer.time_logger(frontend_class.__name__):
438 0
                    frontend_class.start(config=config, core=core)
439

440 3
    def stop_frontends(self, frontend_classes):
441 0
        logger.info("Stopping Mopidy frontends")
442 0
        for frontend_class in frontend_classes:
443 0
            process.stop_actors_by_class(frontend_class)
444

445 3
    def stop_core(self, core):
446 0
        logger.info("Stopping Mopidy core")
447 0
        if core:
448 0
            call = ProxyCall(attr_path=["_teardown"], args=[], kwargs={})
449 0
            core.actor_ref.ask(call, block=True)
450 0
        process.stop_actors_by_class(Core)
451

452 3
    def stop_backends(self, backend_classes):
453 0
        logger.info("Stopping Mopidy backends")
454 0
        for backend_class in backend_classes:
455 0
            process.stop_actors_by_class(backend_class)
456

457 3
    def stop_audio(self):
458 0
        logger.info("Stopping Mopidy audio")
459 0
        process.stop_actors_by_class(Audio)
460

461 3
    def stop_mixer(self, mixer_class):
462 0
        logger.info("Stopping Mopidy mixer")
463 0
        process.stop_actors_by_class(mixer_class)
464

465

466 3
class ConfigCommand(Command):
467 3
    help = "Show currently active configuration."
468

469 3
    def __init__(self):
470 0
        super().__init__()
471 0
        self.set(base_verbosity_level=-1)
472

473 3
    def run(self, config, errors, schemas):
474 0
        data = config_lib.format(config, schemas, errors)
475

476
        # Throw away all bytes that are not valid UTF-8 before printing
477 0
        data = data.encode(errors="surrogateescape").decode(errors="replace")
478

479 0
        print(data)
480 0
        return 0
481

482

483 3
class DepsCommand(Command):
484 3
    help = "Show dependencies and debug information."
485

486 3
    def __init__(self):
487 0
        super().__init__()
488 0
        self.set(base_verbosity_level=-1)
489

490 3
    def run(self):
491 0
        print(deps.format_dependency_list())
492 0
        return 0

Read our documentation on viewing source code .

Loading