tinyjukebox 0.47.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. tinyjukebox/__init__.py +7 -0
  2. tinyjukebox/albumplayer/__init__.py +1 -0
  3. tinyjukebox/albumplayer/aalbumplayer.py +402 -0
  4. tinyjukebox/albumplayer/clock.py +39 -0
  5. tinyjukebox/albumplayer/dummyplayer.py +49 -0
  6. tinyjukebox/albumplayer/player_interface.py +97 -0
  7. tinyjukebox/albumplayer/radioplayer.py +391 -0
  8. tinyjukebox/albumplayer/simpleplayer.py +419 -0
  9. tinyjukebox/albumplayer/spotifyplayer.py +671 -0
  10. tinyjukebox/backup.py +176 -0
  11. tinyjukebox/backups.py +340 -0
  12. tinyjukebox/cli.py +1076 -0
  13. tinyjukebox/control.py +1386 -0
  14. tinyjukebox/core/__init__.py +1 -0
  15. tinyjukebox/core/config.py +100 -0
  16. tinyjukebox/core/logging/__init__.py +113 -0
  17. tinyjukebox/core/microservice.py +303 -0
  18. tinyjukebox/core/pubsub/__init__.py +15 -0
  19. tinyjukebox/core/pubsub/base.py +196 -0
  20. tinyjukebox/core/pubsub/localqueue.py +102 -0
  21. tinyjukebox/core/pubsub/statistics.py +57 -0
  22. tinyjukebox/core/py.typed +0 -0
  23. tinyjukebox/core/schema/__init__.py +5 -0
  24. tinyjukebox/core/schema/logger.py +40 -0
  25. tinyjukebox/core/schema/microservice.py +48 -0
  26. tinyjukebox/core/threads.py +39 -0
  27. tinyjukebox/core/ui/__init__.py +5 -0
  28. tinyjukebox/core/ui/cli.py +120 -0
  29. tinyjukebox/core/ui/tools.py +73 -0
  30. tinyjukebox/core/unexpected.py +46 -0
  31. tinyjukebox/data/__init__.py +0 -0
  32. tinyjukebox/data/activities.py +175 -0
  33. tinyjukebox/data/activity.py +138 -0
  34. tinyjukebox/data/common.py +91 -0
  35. tinyjukebox/data/degraded.py +39 -0
  36. tinyjukebox/data/jsondata.py +218 -0
  37. tinyjukebox/data/sanitycheck.py +71 -0
  38. tinyjukebox/data/tag.py +98 -0
  39. tinyjukebox/data/tags.py +160 -0
  40. tinyjukebox/data/target.py +568 -0
  41. tinyjukebox/data/targetalbumtools.py +88 -0
  42. tinyjukebox/data/targets.py +256 -0
  43. tinyjukebox/data/volumelimits.py +68 -0
  44. tinyjukebox/data_folder.py +180 -0
  45. tinyjukebox/devices/__init__.py +1 -0
  46. tinyjukebox/devices/baseclasses/__init__.py +1 -0
  47. tinyjukebox/devices/baseclasses/adriver.py +115 -0
  48. tinyjukebox/devices/baseclasses/aevents.py +47 -0
  49. tinyjukebox/devices/baseclasses/gpiolevel.py +16 -0
  50. tinyjukebox/devices/bluetooth/__init__.py +1 -0
  51. tinyjukebox/devices/bluetooth/avrcp_input.py +118 -0
  52. tinyjukebox/devices/bluetooth/bluez.py +446 -0
  53. tinyjukebox/devices/bluetooth/link.py +396 -0
  54. tinyjukebox/devices/bluetooth/virtual.py +155 -0
  55. tinyjukebox/devices/drivermanager/__init__.py +1 -0
  56. tinyjukebox/devices/drivermanager/driverfactory.py +75 -0
  57. tinyjukebox/devices/drivermanager/drivermanager.py +151 -0
  58. tinyjukebox/devices/drivers/__init__.py +31 -0
  59. tinyjukebox/devices/drivers/bluetooth.py +257 -0
  60. tinyjukebox/devices/drivers/input.py +165 -0
  61. tinyjukebox/devices/drivers/rfid.py +297 -0
  62. tinyjukebox/devices/drivers/rgbled.py +215 -0
  63. tinyjukebox/devices/py.typed +0 -0
  64. tinyjukebox/devices/schema/__init__.py +1 -0
  65. tinyjukebox/devices/schema/adriver.py +33 -0
  66. tinyjukebox/devices/schema/bluetooth.py +31 -0
  67. tinyjukebox/devices/schema/drivermanager.py +41 -0
  68. tinyjukebox/devices/schema/input.py +23 -0
  69. tinyjukebox/devices/schema/rfid.py +44 -0
  70. tinyjukebox/devices/schema/rgbled.py +26 -0
  71. tinyjukebox/diagnostics.py +179 -0
  72. tinyjukebox/errors.py +91 -0
  73. tinyjukebox/headset.py +296 -0
  74. tinyjukebox/images.py +133 -0
  75. tinyjukebox/latency.py +43 -0
  76. tinyjukebox/library/__init__.py +2 -0
  77. tinyjukebox/library/diagnostics.py +183 -0
  78. tinyjukebox/library/journal.py +94 -0
  79. tinyjukebox/library/releases.py +348 -0
  80. tinyjukebox/library/schema.py +98 -0
  81. tinyjukebox/library/service.py +1115 -0
  82. tinyjukebox/library/statistics.py +288 -0
  83. tinyjukebox/library/store.py +1386 -0
  84. tinyjukebox/messages.py +202 -0
  85. tinyjukebox/metadata.py +133 -0
  86. tinyjukebox/py.typed +0 -0
  87. tinyjukebox/restore.py +391 -0
  88. tinyjukebox/rgbled.py +158 -0
  89. tinyjukebox/schema/__init__.py +1 -0
  90. tinyjukebox/schema/activities.py +27 -0
  91. tinyjukebox/schema/tags.py +54 -0
  92. tinyjukebox/schema/targets.py +83 -0
  93. tinyjukebox/schema/tinyjukebox.py +217 -0
  94. tinyjukebox/schema/volume.py +28 -0
  95. tinyjukebox/slots.py +234 -0
  96. tinyjukebox/spotify/__init__.py +1 -0
  97. tinyjukebox/spotify/account.py +647 -0
  98. tinyjukebox/spotify/engine.py +568 -0
  99. tinyjukebox/spotify/oembed.py +65 -0
  100. tinyjukebox/spotify/soloist.py +198 -0
  101. tinyjukebox/spotify/sound.py +316 -0
  102. tinyjukebox/stm/__init__.py +0 -0
  103. tinyjukebox/stm/base.py +174 -0
  104. tinyjukebox/stm/playradio.py +237 -0
  105. tinyjukebox/stm/playspotify.py +418 -0
  106. tinyjukebox/stm/stmoperation.py +769 -0
  107. tinyjukebox/stm/stmplayalbum.py +655 -0
  108. tinyjukebox/stm/stmvolume.py +221 -0
  109. tinyjukebox/sync/__init__.py +42 -0
  110. tinyjukebox/sync/box.py +486 -0
  111. tinyjukebox/sync/client.py +189 -0
  112. tinyjukebox/sync/data.py +489 -0
  113. tinyjukebox/sync/files.py +447 -0
  114. tinyjukebox/sync/handshake.py +42 -0
  115. tinyjukebox/sync/link.py +379 -0
  116. tinyjukebox/sync/manifest.py +162 -0
  117. tinyjukebox/sync/util.py +137 -0
  118. tinyjukebox/tinyjukebox.py +680 -0
  119. tinyjukebox/tools/__init__.py +1 -0
  120. tinyjukebox/tools/backup.py +173 -0
  121. tinyjukebox/tools/fill_library.py +120 -0
  122. tinyjukebox/tools/import_cards.py +274 -0
  123. tinyjukebox/tools/import_covers.py +138 -0
  124. tinyjukebox/tools/migrate_box01.py +151 -0
  125. tinyjukebox/volumemanager.py +355 -0
  126. tinyjukebox/web/__init__.py +1 -0
  127. tinyjukebox/web/auth.py +373 -0
  128. tinyjukebox/web/certificates.py +111 -0
  129. tinyjukebox/web/logbuffer.py +62 -0
  130. tinyjukebox/web/server.py +1237 -0
  131. tinyjukebox/web/uploads.py +262 -0
  132. tinyjukebox/web/users.py +106 -0
  133. tinyjukebox/web/web/app.js +3050 -0
  134. tinyjukebox/web/web/favicon.svg +6 -0
  135. tinyjukebox/web/web/help/de/admin.html +714 -0
  136. tinyjukebox/web/web/help/de/manual.html +1147 -0
  137. tinyjukebox/web/web/help/de/quick-guide.html +79 -0
  138. tinyjukebox/web/web/help/en/admin.html +701 -0
  139. tinyjukebox/web/web/help/en/manual.html +1126 -0
  140. tinyjukebox/web/web/help/en/quick-guide.html +79 -0
  141. tinyjukebox/web/web/help/help.css +25 -0
  142. tinyjukebox/web/web/help/images/box-2026.jpg +0 -0
  143. tinyjukebox/web/web/help/images/box-back.jpg +0 -0
  144. tinyjukebox/web/web/help/images/lid-top.jpg +0 -0
  145. tinyjukebox/web/web/help/images/logo-mark.svg +38 -0
  146. tinyjukebox/web/web/index.html +867 -0
  147. tinyjukebox/web/web/style.css +412 -0
  148. tinyjukebox/web/web/texts/de.json +892 -0
  149. tinyjukebox/web/web/texts/en.json +892 -0
  150. tinyjukebox-0.47.0.dist-info/METADATA +236 -0
  151. tinyjukebox-0.47.0.dist-info/RECORD +154 -0
  152. tinyjukebox-0.47.0.dist-info/WHEEL +4 -0
  153. tinyjukebox-0.47.0.dist-info/entry_points.txt +9 -0
  154. tinyjukebox-0.47.0.dist-info/licenses/LICENSE.txt +21 -0
@@ -0,0 +1 @@
1
+ """Base of the box: config, logging, in-process pubsub, threads, command line and the life cycle of the components."""
@@ -0,0 +1,100 @@
1
+ """Reading, validating and masking of YAML configuration files.
2
+
3
+ A configuration is a YAML file that is converted into plain ``dict``/``list`` structures. All keys are converted to
4
+ lower case.
5
+ """
6
+
7
+ import re
8
+ from collections.abc import Iterable
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import jsonschema
13
+ import yaml
14
+
15
+ __all__ = [
16
+ "ConfigLoader",
17
+ "load_yaml",
18
+ "lowercase_keys",
19
+ "mask_entries",
20
+ "read_config",
21
+ "validate_config",
22
+ ]
23
+
24
+ _DEFAULT_MASK_PATTERNS = ("credentials", "password", "user")
25
+ _BOOL_TAG = "tag:yaml.org,2002:bool"
26
+
27
+ _BaseLoader: type[yaml.SafeLoader] = getattr(yaml, "CSafeLoader", yaml.SafeLoader)
28
+
29
+
30
+ class ConfigLoader(_BaseLoader): # type: ignore[valid-type,misc]
31
+ """Safe YAML loader with YAML 1.2 boolean semantics.
32
+
33
+ YAML 1.1 (and thus PyYAML) converts ``on/off/yes/no`` into booleans. Configuration values like ``state: on``
34
+ must stay strings, therefore only ``true``/``false`` are resolved to booleans. The adjustment is local to this
35
+ loader class and does not alter PyYAML's global state.
36
+ """
37
+
38
+
39
+ ConfigLoader.yaml_implicit_resolvers = {
40
+ first_char: [resolver for resolver in resolvers if resolver[0] != _BOOL_TAG]
41
+ for first_char, resolvers in yaml.SafeLoader.yaml_implicit_resolvers.items()
42
+ }
43
+ ConfigLoader.add_implicit_resolver(_BOOL_TAG, re.compile(r"^(?:true|True|TRUE|false|False|FALSE)$"), list("tTfF"))
44
+
45
+
46
+ def load_yaml(filename: str | Path) -> Any:
47
+ """Load a YAML file with :class:`ConfigLoader`."""
48
+ with open(filename, encoding="utf-8") as f:
49
+ return yaml.load(f, Loader=ConfigLoader) # noqa: S506 - ConfigLoader is a SafeLoader
50
+
51
+
52
+ def lowercase_keys(data: Any) -> Any:
53
+ """Return a deep copy of ``data`` in which all string keys of all (nested) dicts are lower case."""
54
+ if isinstance(data, dict):
55
+ return {(k.lower() if isinstance(k, str) else k): lowercase_keys(v) for k, v in data.items()}
56
+ if isinstance(data, list):
57
+ return [lowercase_keys(entry) for entry in data]
58
+ return data
59
+
60
+
61
+ def read_config(config_filename: str | Path) -> dict[str, Any]:
62
+ """Read a YAML config file and lower-case all keys.
63
+
64
+ :raises FileNotFoundError: if the config file does not exist.
65
+ """
66
+ config_file = Path(config_filename).expanduser()
67
+ if not config_file.is_file():
68
+ raise FileNotFoundError(f"config file '{config_filename}' not found.")
69
+
70
+ config: dict[str, Any] = lowercase_keys(load_yaml(config_file))
71
+ if not isinstance(config, dict):
72
+ raise ValueError(f"config file '{config_filename}' must contain a mapping at root level.")
73
+ return config
74
+
75
+
76
+ def validate_config(config: Any, schema: dict[str, Any]) -> str | None:
77
+ """Validate ``config`` against the JSON ``schema``.
78
+
79
+ :return: ``None`` if the config is valid, otherwise a description of the first validation error.
80
+ """
81
+ try:
82
+ jsonschema.validate(config, schema)
83
+ except jsonschema.ValidationError as e:
84
+ path = "/".join(str(p) for p in e.absolute_path) or "<root>"
85
+ return f"{e.message} (at '{path}')"
86
+ return None
87
+
88
+
89
+ def mask_entries(config: Any, patterns: Iterable[str] = _DEFAULT_MASK_PATTERNS, mask_string: str = "*****") -> None:
90
+ """Replace (in place) all string values whose key contains one of ``patterns`` with ``mask_string``."""
91
+ patterns = tuple(patterns)
92
+ if isinstance(config, dict):
93
+ for key, value in config.items():
94
+ if isinstance(value, (dict, list)):
95
+ mask_entries(value, patterns, mask_string)
96
+ elif isinstance(value, str) and isinstance(key, str) and any(p in key for p in patterns):
97
+ config[key] = mask_string
98
+ elif isinstance(config, list):
99
+ for entry in config:
100
+ mask_entries(entry, patterns, mask_string)
@@ -0,0 +1,113 @@
1
+ """Logger setup.
2
+
3
+ The configuration section ``logger`` (see :mod:`tinyjukebox.core.schema.logger`) defines the log level and the log file
4
+ of a service. :func:`create_logger` attaches the file handler to the root logger once; all loggers of a process thus
5
+ write into the same file. Components create child loggers with :func:`get_child`.
6
+ """
7
+
8
+ import logging
9
+ import logging.handlers
10
+ import os
11
+ import sys
12
+ from collections.abc import Mapping
13
+ from typing import Any
14
+
15
+ __all__ = [
16
+ "LOG_FORMAT",
17
+ "add_stdout_handler",
18
+ "create_logger",
19
+ "get_child",
20
+ "get_level_name",
21
+ "get_log_level",
22
+ ]
23
+
24
+ LOG_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
25
+
26
+ _FILE_HANDLER_NAME = "tinyjukebox-file"
27
+ _STDOUT_HANDLER_NAME = "tinyjukebox-stdout"
28
+ _LEVELS = ("CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG", "NOTSET")
29
+
30
+
31
+ def get_log_level(name: str) -> int:
32
+ """Convert a level name (case-insensitive, e.g. ``"info"``) into the numeric logging level.
33
+
34
+ :raises ValueError: for unknown level names.
35
+ """
36
+ upper = name.upper()
37
+ if upper not in _LEVELS:
38
+ raise ValueError(f"unknown value for logger level ('{name}').")
39
+ level: int = logging.getLevelName(upper)
40
+ return level
41
+
42
+
43
+ def get_level_name(level: int) -> str:
44
+ """Convert a numeric logging level into its name.
45
+
46
+ :raises ValueError: for levels that are not one of the standard levels.
47
+ """
48
+ name = logging.getLevelName(level)
49
+ if name not in _LEVELS:
50
+ raise ValueError(f"unknown value for logger level ('{level}').")
51
+ return str(name)
52
+
53
+
54
+ def get_child(logger: logging.Logger, logger_name: str, config: Mapping[str, Any] | None = None) -> logging.Logger:
55
+ """Create a child logger; if ``config`` contains ``log-level``, that level is applied to the child."""
56
+ child = logger.getChild(logger_name)
57
+ if config is not None and "log-level" in config:
58
+ child.setLevel(get_log_level(config["log-level"]))
59
+ return child
60
+
61
+
62
+ def _has_handler(logger: logging.Logger, name: str) -> bool:
63
+ return any(handler.name == name for handler in logger.handlers)
64
+
65
+
66
+ def create_logger(config: Mapping[str, Any], logger_name: str) -> logging.Logger:
67
+ """Create the logger ``logger_name`` and - once per process - a file handler at the root logger.
68
+
69
+ :param config: ``logger`` config section with ``log-level``, ``log-file`` (may begin with ``~``) and the optional
70
+ ``log-rotation`` (``maxbytes``, ``backupcount``).
71
+ :param logger_name: name of the logger.
72
+ """
73
+ level = get_log_level(config["log-level"])
74
+ logger = logging.getLogger(logger_name)
75
+ logger.setLevel(level)
76
+
77
+ root = logging.getLogger()
78
+ if not _has_handler(root, _FILE_HANDLER_NAME):
79
+ rotation = config.get("log-rotation")
80
+ log_file = os.path.expanduser(config["log-file"])
81
+ handler: logging.Handler
82
+ if rotation:
83
+ handler = logging.handlers.RotatingFileHandler(
84
+ log_file,
85
+ maxBytes=int(rotation["maxbytes"]),
86
+ backupCount=int(rotation["backupcount"]),
87
+ encoding="utf-8",
88
+ )
89
+ else:
90
+ handler = logging.FileHandler(log_file, encoding="utf-8")
91
+ handler.name = _FILE_HANDLER_NAME
92
+ handler.setLevel(level)
93
+ handler.setFormatter(logging.Formatter(LOG_FORMAT))
94
+ root.addHandler(handler)
95
+
96
+ return logger
97
+
98
+
99
+ def add_stdout_handler(level: str | None) -> None:
100
+ """Add a console handler (``sys.stdout``, message only) to the root logger.
101
+
102
+ Nothing happens if ``level`` is empty/``None`` (quiet mode) or if the handler has been added before.
103
+ """
104
+ if not level:
105
+ return
106
+ root = logging.getLogger()
107
+ if _has_handler(root, _STDOUT_HANDLER_NAME):
108
+ return
109
+ handler = logging.StreamHandler(sys.stdout)
110
+ handler.name = _STDOUT_HANDLER_NAME
111
+ handler.setLevel(get_log_level(level))
112
+ handler.setFormatter(logging.Formatter("%(message)s"))
113
+ root.addHandler(handler)
@@ -0,0 +1,303 @@
1
+ """Base class of the components of the box (TinyJukebox, DriverManager, drivers): config, logger, life cycle."""
2
+
3
+ import argparse
4
+ import logging
5
+ import pprint
6
+ import threading
7
+ from collections.abc import Mapping
8
+ from pathlib import Path
9
+ from typing import Any, ClassVar
10
+
11
+ import tinyjukebox.core.schema.microservice
12
+ from tinyjukebox.core import config as core_config
13
+ from tinyjukebox.core.logging import add_stdout_handler, create_logger
14
+ from tinyjukebox.core.pubsub import LocalQueue, PubSubClient
15
+ from tinyjukebox.core.threads import LoggerThread
16
+ from tinyjukebox.core.ui import UI, Command
17
+ from tinyjukebox.core.ui.tools import more
18
+
19
+ __all__ = ["AbstractMicroservice"]
20
+
21
+
22
+ class AbstractMicroservice:
23
+ """Base class of the components of the box.
24
+
25
+ Takes care of the config, the pubsub client, the logger and the interactive command line, and provides the life
26
+ cycle (:meth:`start`, :meth:`stop`, :meth:`run`).
27
+
28
+ Subclasses implement the hooks
29
+
30
+ * :meth:`_start` / :meth:`_stop` - start/stop the service specific parts,
31
+ * :meth:`_get_schema` (and optionally :meth:`_get_schema_definitions`) - JSON schema of their config section,
32
+ * :meth:`_get_description` - one line description for ``--help`` and the command line,
33
+
34
+ and set the class attribute ``_version``. Internal loops must end as soon as ``_stop_service`` is set.
35
+
36
+ ``_is_started`` / ``_is_stopped`` reflect the state; both are cleared while starting/stopping.
37
+ """
38
+
39
+ _version: ClassVar[str] = "0.0.0"
40
+
41
+ def __init__(
42
+ self,
43
+ config: Mapping[str, Any],
44
+ config_class_root_node_name: str,
45
+ pubsub_client: PubSubClient | None = None,
46
+ logger: logging.Logger | None = None,
47
+ logger_name: str | None = None,
48
+ stdout_log_level: str | None = "WARNING",
49
+ no_gui: bool | None = False,
50
+ ) -> None:
51
+ """
52
+ :param config: complete config (all sections).
53
+ :param config_class_root_node_name: name of the config section of this service.
54
+ :param pubsub_client: shared pubsub client; if ``None``, a :class:`LocalQueue` is created and
55
+ connected/disconnected by this service.
56
+ :param logger: parent logger; if ``None``, a logger is created from section ``logger``.
57
+ :param logger_name: name of the (child) logger (default: module name).
58
+ :param stdout_log_level: level of the console log handler (only if ``logger`` is ``None``); empty = quiet.
59
+ :param no_gui: if true, no interactive command line is started.
60
+ """
61
+ self._config: Any = config[config_class_root_node_name]
62
+ self._full_config = config
63
+ logger_name = logger_name or __name__
64
+
65
+ if logger is None:
66
+ self._logger = create_logger(config["logger"], logger_name)
67
+ add_stdout_handler("WARNING" if stdout_log_level is None else stdout_log_level)
68
+ else:
69
+ self._logger = logger.getChild(logger_name)
70
+
71
+ self._logger.info("%s.__init__ - initializing", type(self).__name__)
72
+ self._logger.debug("%s.__init__ - config: %s", type(self).__name__, self._config)
73
+
74
+ self._is_stopped = threading.Event()
75
+ self._is_stopped.set()
76
+ self._is_started = threading.Event()
77
+ self._stop_service = threading.Event()
78
+ self._startstop_lock = threading.Lock()
79
+
80
+ self._manage_ui = not no_gui
81
+ self._UI: UI | None = None
82
+ self._ui_commands: dict[str, Command] = {}
83
+
84
+ self._pubsub_client: PubSubClient
85
+ if pubsub_client is None:
86
+ self._pubsub_client = LocalQueue(self._logger)
87
+ self._stop_pubsub_client = True
88
+ self._add_ui_command("pubsub_stats", self._cmd_pubsub_stats)
89
+ self._add_ui_command("show_config", self._cmd_show_config)
90
+ else:
91
+ self._pubsub_client = pubsub_client
92
+ self._stop_pubsub_client = False
93
+
94
+ self._add_ui_command("debug_threads", self._cmd_debug_threads)
95
+ self._logger.info("%s.__init__ - done", type(self).__name__)
96
+
97
+ # --- public properties --------------------------------------------------------------------------------------
98
+
99
+ @property
100
+ def config(self) -> Any:
101
+ """The config section of this service."""
102
+ return self._config
103
+
104
+ @property
105
+ def ui_commands(self) -> dict[str, Command]:
106
+ """Commands for the interactive command line - another service can offer them in its own command line."""
107
+ return dict(self._ui_commands)
108
+
109
+ @property
110
+ def version(self) -> str:
111
+ return str(self._version)
112
+
113
+ @property
114
+ def is_started(self) -> bool:
115
+ return self._is_started.is_set()
116
+
117
+ def healthy(self) -> bool:
118
+ """Whether the service does what it was started for - asked by the health check of a new version
119
+ (:mod:`tinyjukebox.slots`)."""
120
+ return bool(self.is_started)
121
+
122
+ @property
123
+ def is_stopped(self) -> bool:
124
+ return self._is_stopped.is_set()
125
+
126
+ # --- life cycle ---------------------------------------------------------------------------------------------
127
+
128
+ def start(self) -> None:
129
+ """Connect the pubsub client (if necessary), start the command line, call :meth:`_start`.
130
+
131
+ If starting fails, the parts started so far are stopped (:meth:`_stop` must therefore cope with a partial
132
+ start) before the exception is re-raised - threads left running would keep the process alive.
133
+ """
134
+ self._logger.info("%s - starting", type(self).__name__)
135
+ with self._startstop_lock:
136
+ self._is_stopped.clear()
137
+ self._stop_service.clear()
138
+ try:
139
+ if not self._pubsub_client.is_connected.is_set():
140
+ self._pubsub_client.connect()
141
+ self._start_ui()
142
+ self._start()
143
+ except BaseException:
144
+ self._logger.error("%s - start failed - stopping the parts started so far", type(self).__name__)
145
+ self._stop_after_failed_start()
146
+ raise
147
+ self._is_started.set()
148
+ self._logger.info("%s - started", type(self).__name__)
149
+
150
+ def _stop_after_failed_start(self) -> None:
151
+ self._stop_service.set()
152
+ for step in (self._stop_ui, self._stop, self._disconnect_own_client):
153
+ try:
154
+ step()
155
+ except Exception:
156
+ self._logger.exception("%s - %s after failed start failed", type(self).__name__, step.__name__)
157
+ self._is_stopped.set()
158
+
159
+ def _disconnect_own_client(self) -> None:
160
+ if self._stop_pubsub_client:
161
+ self._pubsub_client.disconnect()
162
+
163
+ def stop(self) -> None:
164
+ """Set ``_stop_service``, stop the command line, call :meth:`_stop` and - if owned - stop the pubsub client."""
165
+ self._logger.info("%s - stopping", type(self).__name__)
166
+ with self._startstop_lock:
167
+ self._is_started.clear()
168
+ self._stop_service.set()
169
+ try:
170
+ self._stop_ui()
171
+ self._stop()
172
+ finally:
173
+ # also after a stop that failed: the own queue is disconnected and the service counts as stopped -
174
+ # otherwise run() would wait for it forever
175
+ self._disconnect_own_client()
176
+ self._is_stopped.set()
177
+ self._logger.info("%s - stopped", type(self).__name__)
178
+
179
+ def asyncstart(self) -> None:
180
+ """Start the service in a separate thread and return immediately."""
181
+ LoggerThread(target=self.start, name=f"{type(self).__name__}.asyncstart", logger=self._logger).start()
182
+
183
+ def asyncstop(self) -> None:
184
+ """Stop the service in a separate thread and return immediately (e.g. from within a message handler)."""
185
+ LoggerThread(target=self.stop, name=f"{type(self).__name__}.asyncstop", logger=self._logger).start()
186
+
187
+ def run(self) -> None:
188
+ """Start the service and block until it has been stopped (``Ctrl-C`` stops it).
189
+
190
+ The interrupt may arrive at any moment, also while the service is still starting: a start that it breaks
191
+ off has cleaned up already (:meth:`start`); one that it hits at its very end has not, and is stopped here.
192
+ Otherwise the threads of the service would keep the process alive."""
193
+ try:
194
+ self.start()
195
+ while not self._is_stopped.wait(0.25): # timeout keeps the main thread responsive for Ctrl-C
196
+ pass
197
+ except KeyboardInterrupt:
198
+ self._logger.info("KeyboardInterrupt")
199
+ if not self._is_stopped.is_set():
200
+ self.stop()
201
+
202
+ # --- hooks for subclasses -----------------------------------------------------------------------------------
203
+
204
+ def _start(self) -> None:
205
+ """Start the service specific parts - to be implemented by subclasses."""
206
+ raise NotImplementedError
207
+
208
+ def _stop(self) -> None:
209
+ """Stop the service specific parts - to be implemented by subclasses."""
210
+ raise NotImplementedError
211
+
212
+ @classmethod
213
+ def _get_schema(cls) -> dict[str, Any]:
214
+ """JSON schema(s) of the service specific config section(s): ``{section_name: schema}``."""
215
+ raise NotImplementedError
216
+
217
+ @classmethod
218
+ def _get_schema_definitions(cls) -> dict[str, Any]:
219
+ """Shared schema definitions (referenced via ``#/definitions/<name>``); none by default."""
220
+ return {}
221
+
222
+ @classmethod
223
+ def _get_description(cls) -> str:
224
+ """Short description of the service (command line help)."""
225
+ raise NotImplementedError
226
+
227
+ # --- schema / config / command line -------------------------------------------------------------------------
228
+
229
+ @classmethod
230
+ def get_schema(cls) -> dict[str, Any]:
231
+ """Complete JSON schema of a config file for this service."""
232
+ return tinyjukebox.core.schema.microservice.get_schema(cls._get_schema(), cls._get_schema_definitions())
233
+
234
+ @classmethod
235
+ def dump_schema(cls, filename: str | Path) -> None:
236
+ """Write :meth:`get_schema` to ``filename`` (only if the content changed)."""
237
+ if tinyjukebox.core.schema.microservice.dump_schema(cls.get_schema(), filename):
238
+ print(f"updated {filename} to latest schema.")
239
+
240
+ @classmethod
241
+ def _argument_parser(cls) -> argparse.ArgumentParser:
242
+ """Command line arguments; subclasses may extend the parser."""
243
+ parser = argparse.ArgumentParser(description=cls._get_description())
244
+ parser.add_argument("-c", "--config", type=str, help="yaml config file", required=True)
245
+ verbosity = parser.add_mutually_exclusive_group()
246
+ verbosity.add_argument("-q", "--quiet", help="suppress all console output", action="store_true")
247
+ verbosity.add_argument("-v", "--verbose", help="more console output", action="store_true")
248
+ parser.add_argument("--no_gui", help="do not start the interactive command line", action="store_true")
249
+ parser.add_argument("--version", action="version", version=f"%(prog)s {cls._version}")
250
+ return parser
251
+
252
+ @classmethod
253
+ def _read_config(cls, config_filename: str | Path) -> dict[str, Any]:
254
+ """Read the config file and validate it against :meth:`get_schema`.
255
+
256
+ :raises ValueError: if the config is invalid.
257
+ """
258
+ config = core_config.read_config(config_filename)
259
+ validation_result = core_config.validate_config(config, cls.get_schema())
260
+ if validation_result:
261
+ raise ValueError(f"Validation of config file failed: {validation_result}")
262
+ return config
263
+
264
+ # --- interactive command line -------------------------------------------------------------------------------
265
+
266
+ def _add_ui_command(self, name: str, function: Command) -> None:
267
+ """Register a command for the interactive command line (``do_<name>``); the docstring is its help text."""
268
+ if name in self._ui_commands:
269
+ self._logger.info("_add_ui_command - command '%s' already defined", name)
270
+ return
271
+ self._ui_commands[name] = function
272
+ if self._UI is not None:
273
+ self._UI.add_command(name, function)
274
+
275
+ def _start_ui(self) -> None:
276
+ if not self._manage_ui:
277
+ return
278
+ name = type(self).__name__
279
+ intro = f"{name} v{self._version}\n{self._get_description()}\n Type help or ? to list commands.\n"
280
+ self._UI = UI(intro, f"({name.lower()}) ", self.stop, self._stop_service, self._logger, self._ui_commands)
281
+ self._UI.start()
282
+
283
+ def _stop_ui(self) -> None:
284
+ if self._UI is not None:
285
+ self._UI.stop()
286
+ self._UI = None
287
+
288
+ def _cmd_debug_threads(self, args: str) -> None:
289
+ """debug_threads - list all active threads: DEBUG_THREADS"""
290
+ text = f"active threads ({threading.active_count()}):\n"
291
+ for thread in threading.enumerate():
292
+ text += f" - {thread.name} (alive: {thread.is_alive()}, daemon: {thread.daemon})\n"
293
+ more(text)
294
+
295
+ def _cmd_pubsub_stats(self, args: str) -> None:
296
+ """pubsub_stats - show the pubsub client statistics: PUBSUB_STATS"""
297
+ more(pprint.pformat(self._pubsub_client.get_stats(), indent=2))
298
+
299
+ def _cmd_show_config(self, args: str) -> None:
300
+ """show_config - show the current config (credentials masked): SHOW_CONFIG"""
301
+ masked = core_config.lowercase_keys(self._full_config)
302
+ core_config.mask_entries(masked)
303
+ more(pprint.pformat(masked, indent=2))
@@ -0,0 +1,15 @@
1
+ """Publish/subscribe within the process: :class:`LocalQueue`."""
2
+
3
+ from tinyjukebox.core.pubsub.base import MessageHandler, NotConnectedError, Payload, PubSubClient, PubSubError
4
+ from tinyjukebox.core.pubsub.localqueue import LocalQueue
5
+ from tinyjukebox.core.pubsub.statistics import Statistics
6
+
7
+ __all__ = [
8
+ "LocalQueue",
9
+ "MessageHandler",
10
+ "NotConnectedError",
11
+ "Payload",
12
+ "PubSubClient",
13
+ "PubSubError",
14
+ "Statistics",
15
+ ]