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.
- tinyjukebox/__init__.py +7 -0
- tinyjukebox/albumplayer/__init__.py +1 -0
- tinyjukebox/albumplayer/aalbumplayer.py +402 -0
- tinyjukebox/albumplayer/clock.py +39 -0
- tinyjukebox/albumplayer/dummyplayer.py +49 -0
- tinyjukebox/albumplayer/player_interface.py +97 -0
- tinyjukebox/albumplayer/radioplayer.py +391 -0
- tinyjukebox/albumplayer/simpleplayer.py +419 -0
- tinyjukebox/albumplayer/spotifyplayer.py +671 -0
- tinyjukebox/backup.py +176 -0
- tinyjukebox/backups.py +340 -0
- tinyjukebox/cli.py +1076 -0
- tinyjukebox/control.py +1386 -0
- tinyjukebox/core/__init__.py +1 -0
- tinyjukebox/core/config.py +100 -0
- tinyjukebox/core/logging/__init__.py +113 -0
- tinyjukebox/core/microservice.py +303 -0
- tinyjukebox/core/pubsub/__init__.py +15 -0
- tinyjukebox/core/pubsub/base.py +196 -0
- tinyjukebox/core/pubsub/localqueue.py +102 -0
- tinyjukebox/core/pubsub/statistics.py +57 -0
- tinyjukebox/core/py.typed +0 -0
- tinyjukebox/core/schema/__init__.py +5 -0
- tinyjukebox/core/schema/logger.py +40 -0
- tinyjukebox/core/schema/microservice.py +48 -0
- tinyjukebox/core/threads.py +39 -0
- tinyjukebox/core/ui/__init__.py +5 -0
- tinyjukebox/core/ui/cli.py +120 -0
- tinyjukebox/core/ui/tools.py +73 -0
- tinyjukebox/core/unexpected.py +46 -0
- tinyjukebox/data/__init__.py +0 -0
- tinyjukebox/data/activities.py +175 -0
- tinyjukebox/data/activity.py +138 -0
- tinyjukebox/data/common.py +91 -0
- tinyjukebox/data/degraded.py +39 -0
- tinyjukebox/data/jsondata.py +218 -0
- tinyjukebox/data/sanitycheck.py +71 -0
- tinyjukebox/data/tag.py +98 -0
- tinyjukebox/data/tags.py +160 -0
- tinyjukebox/data/target.py +568 -0
- tinyjukebox/data/targetalbumtools.py +88 -0
- tinyjukebox/data/targets.py +256 -0
- tinyjukebox/data/volumelimits.py +68 -0
- tinyjukebox/data_folder.py +180 -0
- tinyjukebox/devices/__init__.py +1 -0
- tinyjukebox/devices/baseclasses/__init__.py +1 -0
- tinyjukebox/devices/baseclasses/adriver.py +115 -0
- tinyjukebox/devices/baseclasses/aevents.py +47 -0
- tinyjukebox/devices/baseclasses/gpiolevel.py +16 -0
- tinyjukebox/devices/bluetooth/__init__.py +1 -0
- tinyjukebox/devices/bluetooth/avrcp_input.py +118 -0
- tinyjukebox/devices/bluetooth/bluez.py +446 -0
- tinyjukebox/devices/bluetooth/link.py +396 -0
- tinyjukebox/devices/bluetooth/virtual.py +155 -0
- tinyjukebox/devices/drivermanager/__init__.py +1 -0
- tinyjukebox/devices/drivermanager/driverfactory.py +75 -0
- tinyjukebox/devices/drivermanager/drivermanager.py +151 -0
- tinyjukebox/devices/drivers/__init__.py +31 -0
- tinyjukebox/devices/drivers/bluetooth.py +257 -0
- tinyjukebox/devices/drivers/input.py +165 -0
- tinyjukebox/devices/drivers/rfid.py +297 -0
- tinyjukebox/devices/drivers/rgbled.py +215 -0
- tinyjukebox/devices/py.typed +0 -0
- tinyjukebox/devices/schema/__init__.py +1 -0
- tinyjukebox/devices/schema/adriver.py +33 -0
- tinyjukebox/devices/schema/bluetooth.py +31 -0
- tinyjukebox/devices/schema/drivermanager.py +41 -0
- tinyjukebox/devices/schema/input.py +23 -0
- tinyjukebox/devices/schema/rfid.py +44 -0
- tinyjukebox/devices/schema/rgbled.py +26 -0
- tinyjukebox/diagnostics.py +179 -0
- tinyjukebox/errors.py +91 -0
- tinyjukebox/headset.py +296 -0
- tinyjukebox/images.py +133 -0
- tinyjukebox/latency.py +43 -0
- tinyjukebox/library/__init__.py +2 -0
- tinyjukebox/library/diagnostics.py +183 -0
- tinyjukebox/library/journal.py +94 -0
- tinyjukebox/library/releases.py +348 -0
- tinyjukebox/library/schema.py +98 -0
- tinyjukebox/library/service.py +1115 -0
- tinyjukebox/library/statistics.py +288 -0
- tinyjukebox/library/store.py +1386 -0
- tinyjukebox/messages.py +202 -0
- tinyjukebox/metadata.py +133 -0
- tinyjukebox/py.typed +0 -0
- tinyjukebox/restore.py +391 -0
- tinyjukebox/rgbled.py +158 -0
- tinyjukebox/schema/__init__.py +1 -0
- tinyjukebox/schema/activities.py +27 -0
- tinyjukebox/schema/tags.py +54 -0
- tinyjukebox/schema/targets.py +83 -0
- tinyjukebox/schema/tinyjukebox.py +217 -0
- tinyjukebox/schema/volume.py +28 -0
- tinyjukebox/slots.py +234 -0
- tinyjukebox/spotify/__init__.py +1 -0
- tinyjukebox/spotify/account.py +647 -0
- tinyjukebox/spotify/engine.py +568 -0
- tinyjukebox/spotify/oembed.py +65 -0
- tinyjukebox/spotify/soloist.py +198 -0
- tinyjukebox/spotify/sound.py +316 -0
- tinyjukebox/stm/__init__.py +0 -0
- tinyjukebox/stm/base.py +174 -0
- tinyjukebox/stm/playradio.py +237 -0
- tinyjukebox/stm/playspotify.py +418 -0
- tinyjukebox/stm/stmoperation.py +769 -0
- tinyjukebox/stm/stmplayalbum.py +655 -0
- tinyjukebox/stm/stmvolume.py +221 -0
- tinyjukebox/sync/__init__.py +42 -0
- tinyjukebox/sync/box.py +486 -0
- tinyjukebox/sync/client.py +189 -0
- tinyjukebox/sync/data.py +489 -0
- tinyjukebox/sync/files.py +447 -0
- tinyjukebox/sync/handshake.py +42 -0
- tinyjukebox/sync/link.py +379 -0
- tinyjukebox/sync/manifest.py +162 -0
- tinyjukebox/sync/util.py +137 -0
- tinyjukebox/tinyjukebox.py +680 -0
- tinyjukebox/tools/__init__.py +1 -0
- tinyjukebox/tools/backup.py +173 -0
- tinyjukebox/tools/fill_library.py +120 -0
- tinyjukebox/tools/import_cards.py +274 -0
- tinyjukebox/tools/import_covers.py +138 -0
- tinyjukebox/tools/migrate_box01.py +151 -0
- tinyjukebox/volumemanager.py +355 -0
- tinyjukebox/web/__init__.py +1 -0
- tinyjukebox/web/auth.py +373 -0
- tinyjukebox/web/certificates.py +111 -0
- tinyjukebox/web/logbuffer.py +62 -0
- tinyjukebox/web/server.py +1237 -0
- tinyjukebox/web/uploads.py +262 -0
- tinyjukebox/web/users.py +106 -0
- tinyjukebox/web/web/app.js +3050 -0
- tinyjukebox/web/web/favicon.svg +6 -0
- tinyjukebox/web/web/help/de/admin.html +714 -0
- tinyjukebox/web/web/help/de/manual.html +1147 -0
- tinyjukebox/web/web/help/de/quick-guide.html +79 -0
- tinyjukebox/web/web/help/en/admin.html +701 -0
- tinyjukebox/web/web/help/en/manual.html +1126 -0
- tinyjukebox/web/web/help/en/quick-guide.html +79 -0
- tinyjukebox/web/web/help/help.css +25 -0
- tinyjukebox/web/web/help/images/box-2026.jpg +0 -0
- tinyjukebox/web/web/help/images/box-back.jpg +0 -0
- tinyjukebox/web/web/help/images/lid-top.jpg +0 -0
- tinyjukebox/web/web/help/images/logo-mark.svg +38 -0
- tinyjukebox/web/web/index.html +867 -0
- tinyjukebox/web/web/style.css +412 -0
- tinyjukebox/web/web/texts/de.json +892 -0
- tinyjukebox/web/web/texts/en.json +892 -0
- tinyjukebox-0.47.0.dist-info/METADATA +236 -0
- tinyjukebox-0.47.0.dist-info/RECORD +154 -0
- tinyjukebox-0.47.0.dist-info/WHEEL +4 -0
- tinyjukebox-0.47.0.dist-info/entry_points.txt +9 -0
- 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
|
+
]
|