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,196 @@
|
|
|
1
|
+
"""Common base class of all pubsub clients."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import threading
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from tinyjukebox.core import unexpected
|
|
10
|
+
from tinyjukebox.core.logging import get_child
|
|
11
|
+
from tinyjukebox.core.pubsub.statistics import Statistics
|
|
12
|
+
|
|
13
|
+
__all__ = ["MessageHandler", "NotConnectedError", "Payload", "PubSubClient", "PubSubError"]
|
|
14
|
+
|
|
15
|
+
Payload = object
|
|
16
|
+
"""Any (immutable) Python object - payloads are passed to the handlers unchanged (see :mod:`tinyjukebox.messages`)."""
|
|
17
|
+
|
|
18
|
+
MessageHandler = Callable[[Any], object]
|
|
19
|
+
"""A message handler receives the payload of a message."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class PubSubError(RuntimeError):
|
|
23
|
+
"""Base class for all errors raised by pubsub clients."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class NotConnectedError(PubSubError):
|
|
27
|
+
"""Raised if a message should be published while the client is not connected."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PubSubClient(ABC):
|
|
31
|
+
"""Topic/handler registry shared by all pubsub implementations.
|
|
32
|
+
|
|
33
|
+
Any number of handlers can be registered per topic; every handler receives the payload of each
|
|
34
|
+
message published to a matching topic (wildcards: ``+`` one level, ``#`` all remaining levels). Subscriptions
|
|
35
|
+
are independent of the connection state: they can be added before :meth:`connect` and are re-activated on every
|
|
36
|
+
(re)connect.
|
|
37
|
+
|
|
38
|
+
``is_connected`` and ``is_disconnected`` are ``threading.Event`` instances that reflect the connection state.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, logger: logging.Logger, logger_name: str) -> None:
|
|
42
|
+
self._logger = get_child(logger, logger_name)
|
|
43
|
+
self._logger.info("__init__ - initializing")
|
|
44
|
+
self.is_connected = threading.Event()
|
|
45
|
+
self.is_disconnected = threading.Event()
|
|
46
|
+
self.is_disconnected.set()
|
|
47
|
+
# _lock_client serializes (dis)connect/(un)subscribe.
|
|
48
|
+
# _handlers_lock guards the registry only and is held briefly; the thread dispatching messages must never
|
|
49
|
+
# wait for _lock_client (dead-lock while e.g. disconnect() waits for the dispatch thread).
|
|
50
|
+
self._lock_client = threading.Lock()
|
|
51
|
+
self._handlers_lock = threading.Lock()
|
|
52
|
+
self._topic_handler: dict[str, list[MessageHandler]] = {}
|
|
53
|
+
self.stats = Statistics()
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def logger(self) -> logging.Logger:
|
|
57
|
+
"""The logger of this client."""
|
|
58
|
+
return self._logger
|
|
59
|
+
|
|
60
|
+
@abstractmethod
|
|
61
|
+
def connect(self) -> None:
|
|
62
|
+
"""Connect and activate all registered subscriptions."""
|
|
63
|
+
|
|
64
|
+
@abstractmethod
|
|
65
|
+
def disconnect(self) -> None:
|
|
66
|
+
"""Disconnect; the registered subscriptions are kept."""
|
|
67
|
+
|
|
68
|
+
@abstractmethod
|
|
69
|
+
def publish(self, topic: str, msg: Payload) -> None:
|
|
70
|
+
"""Publish ``msg`` to ``topic``.
|
|
71
|
+
|
|
72
|
+
:raises NotConnectedError: if the client is not connected.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def published_at(self) -> float | None:
|
|
76
|
+
"""``time.monotonic()`` of the publication of the message that is currently being dispatched (to be called
|
|
77
|
+
by message handlers); ``None`` if the client does not know it."""
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
@abstractmethod
|
|
81
|
+
def _activate_subscription(self, topic: str) -> None:
|
|
82
|
+
"""Called (with ``_lock_client`` held) after the first handler for ``topic`` has been registered.
|
|
83
|
+
|
|
84
|
+
Implementations must not access the handler registry directly.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
@abstractmethod
|
|
88
|
+
def _deactivate_subscriptions(self, topics: list[str]) -> None:
|
|
89
|
+
"""Called (with ``_lock_client`` held) after the last handler of each of ``topics`` has been removed."""
|
|
90
|
+
|
|
91
|
+
def subscribe(self, topic: str, handler: MessageHandler, ignore_duplicate: bool = False) -> None:
|
|
92
|
+
"""Register ``handler`` for ``topic``.
|
|
93
|
+
|
|
94
|
+
:param ignore_duplicate: if false, registering the same topic/handler pair twice raises a ``ValueError``;
|
|
95
|
+
if true, the second registration is silently ignored.
|
|
96
|
+
"""
|
|
97
|
+
self._logger.info("subscribe - topic '%s', handler '%s'.", topic, _handler_name(handler))
|
|
98
|
+
with self._lock_client:
|
|
99
|
+
with self._handlers_lock:
|
|
100
|
+
handlers = self._topic_handler.get(topic)
|
|
101
|
+
if handlers is not None:
|
|
102
|
+
if handler not in handlers:
|
|
103
|
+
handlers.append(handler)
|
|
104
|
+
elif not ignore_duplicate:
|
|
105
|
+
raise ValueError(f"subscribe - topic/handler pair already added. ({topic}/{handler})")
|
|
106
|
+
return
|
|
107
|
+
self._topic_handler[topic] = [handler]
|
|
108
|
+
self._activate_subscription(topic)
|
|
109
|
+
|
|
110
|
+
def unsubscribe(self, topic: str, handler: MessageHandler, ignore_not_found: bool = False) -> None:
|
|
111
|
+
"""Remove a topic/handler pair (the reverse of :meth:`subscribe`).
|
|
112
|
+
|
|
113
|
+
Other handlers of the same topic stay registered. The topic itself is unsubscribed as soon as no handler
|
|
114
|
+
is left.
|
|
115
|
+
|
|
116
|
+
:param ignore_not_found: if false, unknown topic/handler pairs raise a ``ValueError``.
|
|
117
|
+
"""
|
|
118
|
+
self._logger.info("unsubscribe - topic '%s', handler '%s'.", topic, _handler_name(handler))
|
|
119
|
+
with self._lock_client:
|
|
120
|
+
with self._handlers_lock:
|
|
121
|
+
handlers = self._topic_handler.get(topic)
|
|
122
|
+
if handlers is None or handler not in handlers:
|
|
123
|
+
if ignore_not_found:
|
|
124
|
+
return
|
|
125
|
+
raise ValueError(f"unsubscribe - unknown topic/handler pair ({topic}/{handler}).")
|
|
126
|
+
handlers.remove(handler)
|
|
127
|
+
if handlers:
|
|
128
|
+
return
|
|
129
|
+
del self._topic_handler[topic]
|
|
130
|
+
self._deactivate_subscriptions([topic])
|
|
131
|
+
|
|
132
|
+
def unsubscribe_all(self) -> None:
|
|
133
|
+
"""Remove all subscriptions."""
|
|
134
|
+
with self._lock_client:
|
|
135
|
+
with self._handlers_lock:
|
|
136
|
+
topics = list(self._topic_handler)
|
|
137
|
+
self._topic_handler.clear()
|
|
138
|
+
self._logger.info("unsubscribe_all - topics %s", topics)
|
|
139
|
+
if topics:
|
|
140
|
+
self._deactivate_subscriptions(topics)
|
|
141
|
+
|
|
142
|
+
def subscribed_topics(self) -> dict[str, list[str]]:
|
|
143
|
+
"""All subscribed topics with the (qualified) names of their handlers."""
|
|
144
|
+
with self._handlers_lock:
|
|
145
|
+
return {topic: [_handler_name(h) for h in handlers] for topic, handlers in self._topic_handler.items()}
|
|
146
|
+
|
|
147
|
+
def _topics(self) -> list[str]:
|
|
148
|
+
"""All subscribed topics (snapshot)."""
|
|
149
|
+
with self._handlers_lock:
|
|
150
|
+
return list(self._topic_handler)
|
|
151
|
+
|
|
152
|
+
def get_stats(self) -> dict[str, Any]:
|
|
153
|
+
"""Connection state, message statistics and subscriptions."""
|
|
154
|
+
return {
|
|
155
|
+
"connected": self.is_connected.is_set(),
|
|
156
|
+
"message_stats": self.stats.get_stats(),
|
|
157
|
+
"subscribed_topics": self.subscribed_topics(),
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
def _handlers_for(self, topic: str) -> list[MessageHandler]:
|
|
161
|
+
"""All handlers whose subscription matches ``topic`` (incl. wildcard subscriptions)."""
|
|
162
|
+
with self._handlers_lock:
|
|
163
|
+
handlers = list(self._topic_handler.get(topic, ()))
|
|
164
|
+
for subscription, subscription_handlers in self._topic_handler.items():
|
|
165
|
+
if subscription != topic and _is_wildcard(subscription) and topic_matches(subscription, topic):
|
|
166
|
+
handlers.extend(h for h in subscription_handlers if h not in handlers)
|
|
167
|
+
return handlers
|
|
168
|
+
|
|
169
|
+
def _dispatch(self, topic: str, payload: Payload) -> None:
|
|
170
|
+
"""Call all handlers registered for ``topic``. Exceptions of handlers are logged and swallowed."""
|
|
171
|
+
self.stats.recv(topic)
|
|
172
|
+
for handler in self._handlers_for(topic):
|
|
173
|
+
try:
|
|
174
|
+
handler(payload)
|
|
175
|
+
except Exception as error:
|
|
176
|
+
self._logger.exception("message handler '%s' for topic '%s' failed.", _handler_name(handler), topic)
|
|
177
|
+
unexpected.report(error, f"handler {_handler_name(handler)} of the topic {topic}")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def topic_matches(subscription: str, topic: str) -> bool:
|
|
181
|
+
"""True if ``topic`` matches ``subscription`` (``+``: exactly one level, ``#``: all remaining levels, also none)."""
|
|
182
|
+
levels = topic.split("/")
|
|
183
|
+
for index, part in enumerate(subscription.split("/")):
|
|
184
|
+
if part == "#":
|
|
185
|
+
return True
|
|
186
|
+
if index >= len(levels) or part not in ("+", levels[index]):
|
|
187
|
+
return False
|
|
188
|
+
return len(subscription.split("/")) == len(levels)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _is_wildcard(topic: str) -> bool:
|
|
192
|
+
return "+" in topic or "#" in topic
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _handler_name(handler: MessageHandler) -> str:
|
|
196
|
+
return getattr(handler, "__qualname__", repr(handler))
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""In-process pubsub client."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import queue
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
from tinyjukebox.core.pubsub.base import NotConnectedError, Payload, PubSubClient
|
|
9
|
+
from tinyjukebox.core.threads import LoggerThread
|
|
10
|
+
|
|
11
|
+
__all__ = ["LocalQueue"]
|
|
12
|
+
|
|
13
|
+
_Item = tuple[str, Payload, float] | None # topic, payload, time.monotonic() of the publication
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class LocalQueue(PubSubClient):
|
|
17
|
+
"""Pubsub client that delivers messages within the process via a ``queue.Queue``.
|
|
18
|
+
|
|
19
|
+
Messages are delivered asynchronously and in order by a single worker thread - handlers must therefore never
|
|
20
|
+
block. Handlers receive the published object itself (no copy).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, logger: logging.Logger) -> None:
|
|
24
|
+
super().__init__(logger, "LocalQueue")
|
|
25
|
+
self._message_queue: queue.Queue[_Item] = queue.Queue() # a new one with every connection
|
|
26
|
+
self._worker: LoggerThread | None = None # the worker of the connection
|
|
27
|
+
# all running workers: a handler may disconnect - its worker then runs until the handler returns. An own
|
|
28
|
+
# lock: a worker must never wait for _lock_client
|
|
29
|
+
self._workers: set[threading.Thread] = set()
|
|
30
|
+
self._workers_lock = threading.Lock()
|
|
31
|
+
self._local = threading.local() # published_at of the message a worker dispatches
|
|
32
|
+
|
|
33
|
+
def _work(self, messages: "queue.Queue[_Item]") -> None:
|
|
34
|
+
try:
|
|
35
|
+
while (item := messages.get()) is not None:
|
|
36
|
+
topic, payload, self._local.published_at = item
|
|
37
|
+
# nothing after a disconnect: neither while the client is disconnected nor once it has connected again
|
|
38
|
+
if messages is self._message_queue and self.is_connected.is_set():
|
|
39
|
+
self._dispatch(topic, payload)
|
|
40
|
+
self._local.published_at = None
|
|
41
|
+
messages.task_done()
|
|
42
|
+
messages.task_done()
|
|
43
|
+
finally:
|
|
44
|
+
with self._workers_lock:
|
|
45
|
+
self._workers.discard(threading.current_thread())
|
|
46
|
+
|
|
47
|
+
def connect(self) -> None:
|
|
48
|
+
with self._lock_client:
|
|
49
|
+
if self.is_connected.is_set():
|
|
50
|
+
self._logger.info("connect - already connected.")
|
|
51
|
+
return
|
|
52
|
+
self._message_queue = queue.Queue()
|
|
53
|
+
worker = LoggerThread(
|
|
54
|
+
target=self._work, args=(self._message_queue,), name="LocalQueue", logger=self._logger, daemon=True
|
|
55
|
+
)
|
|
56
|
+
self._worker = worker
|
|
57
|
+
with self._workers_lock:
|
|
58
|
+
self._workers.add(worker)
|
|
59
|
+
worker.start()
|
|
60
|
+
self.is_disconnected.clear()
|
|
61
|
+
self.is_connected.set()
|
|
62
|
+
|
|
63
|
+
def disconnect(self) -> None:
|
|
64
|
+
with self._lock_client:
|
|
65
|
+
worker = self._worker
|
|
66
|
+
if worker is None:
|
|
67
|
+
return
|
|
68
|
+
self.is_connected.clear()
|
|
69
|
+
self._message_queue.put(None)
|
|
70
|
+
self._worker = None
|
|
71
|
+
if worker is not threading.current_thread(): # disconnect might be called by a message handler
|
|
72
|
+
worker.join()
|
|
73
|
+
self.is_disconnected.set()
|
|
74
|
+
|
|
75
|
+
def publish(self, topic: str, msg: Payload) -> None:
|
|
76
|
+
self._logger.debug("publish - topic '%s', message '%s'.", topic, msg)
|
|
77
|
+
if not self.is_connected.is_set():
|
|
78
|
+
raise NotConnectedError("publish - trying to publish while not being connected.")
|
|
79
|
+
self.stats.sent(topic)
|
|
80
|
+
self._message_queue.put((topic, msg, time.monotonic()))
|
|
81
|
+
|
|
82
|
+
def wait_until_delivered(self) -> None:
|
|
83
|
+
"""Block until all published messages have been delivered to their handlers.
|
|
84
|
+
|
|
85
|
+
Returns immediately if called by a message handler - the worker would wait for itself.
|
|
86
|
+
"""
|
|
87
|
+
if self._is_worker():
|
|
88
|
+
return
|
|
89
|
+
self._message_queue.join()
|
|
90
|
+
|
|
91
|
+
def published_at(self) -> float | None:
|
|
92
|
+
return getattr(self._local, "published_at", None)
|
|
93
|
+
|
|
94
|
+
def _is_worker(self) -> bool:
|
|
95
|
+
with self._workers_lock:
|
|
96
|
+
return threading.current_thread() in self._workers
|
|
97
|
+
|
|
98
|
+
def _activate_subscription(self, topic: str) -> None:
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
def _deactivate_subscriptions(self, topics: list[str]) -> None:
|
|
102
|
+
pass
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Message counters per topic."""
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
from collections import defaultdict
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, NamedTuple
|
|
7
|
+
|
|
8
|
+
__all__ = ["StatEntry", "Statistics", "Totals"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class StatEntry:
|
|
13
|
+
"""Counter for sent and received messages of one topic."""
|
|
14
|
+
|
|
15
|
+
sent_messages: int = 0
|
|
16
|
+
received_messages: int = 0
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Totals(NamedTuple):
|
|
20
|
+
received_messages: int
|
|
21
|
+
sent_messages: int
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Statistics:
|
|
25
|
+
"""Thread-safe sent/received counters for all topics."""
|
|
26
|
+
|
|
27
|
+
def __init__(self) -> None:
|
|
28
|
+
self.stats: defaultdict[str, StatEntry] = defaultdict(StatEntry)
|
|
29
|
+
self._lock = threading.Lock()
|
|
30
|
+
|
|
31
|
+
def sent(self, topic: str) -> None:
|
|
32
|
+
"""Increase the sent message counter of ``topic``."""
|
|
33
|
+
with self._lock:
|
|
34
|
+
self.stats[topic].sent_messages += 1
|
|
35
|
+
|
|
36
|
+
def recv(self, topic: str) -> None:
|
|
37
|
+
"""Increase the received message counter of ``topic``."""
|
|
38
|
+
with self._lock:
|
|
39
|
+
self.stats[topic].received_messages += 1
|
|
40
|
+
|
|
41
|
+
def get_totals(self) -> Totals:
|
|
42
|
+
"""Sum of all counters."""
|
|
43
|
+
with self._lock:
|
|
44
|
+
return Totals(
|
|
45
|
+
received_messages=sum(s.received_messages for s in self.stats.values()),
|
|
46
|
+
sent_messages=sum(s.sent_messages for s in self.stats.values()),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
def get_stats(self) -> dict[str, Any]:
|
|
50
|
+
"""Totals and per-topic counters as a JSON-serializable dict."""
|
|
51
|
+
received, sent = self.get_totals()
|
|
52
|
+
with self._lock:
|
|
53
|
+
topics = {
|
|
54
|
+
topic: {"messages-received": s.received_messages, "messages-sent": s.sent_messages}
|
|
55
|
+
for topic, s in self.stats.items()
|
|
56
|
+
}
|
|
57
|
+
return {"received": received, "sent": sent, "topics": topics}
|
|
File without changes
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Schema of the ``logger`` config section."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
KEY = "logger"
|
|
6
|
+
|
|
7
|
+
LOG_LEVELS = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def get_schema() -> tuple[str, dict[str, Any]]:
|
|
11
|
+
schema = {
|
|
12
|
+
"description": "Logger configuration",
|
|
13
|
+
"type": "object",
|
|
14
|
+
"properties": {
|
|
15
|
+
"log-level": {"description": "Log level to be used.", "type": "string", "enum": LOG_LEVELS},
|
|
16
|
+
"log-file": {"description": "File name for the logger.", "type": "string"},
|
|
17
|
+
"log-rotation": {
|
|
18
|
+
"description": "Parameters for log rotation (optional).",
|
|
19
|
+
"type": "object",
|
|
20
|
+
"properties": {
|
|
21
|
+
"maxbytes": {
|
|
22
|
+
"type": "integer",
|
|
23
|
+
"minimum": 1,
|
|
24
|
+
"description": "Rollover occurs whenever the current log file is nearly maxbytes in length.",
|
|
25
|
+
},
|
|
26
|
+
"backupcount": {
|
|
27
|
+
"type": "integer",
|
|
28
|
+
"minimum": 1,
|
|
29
|
+
"description": "With a backupcount of 5 and a base file name of app.log, you would get "
|
|
30
|
+
"app.log, app.log.1, app.log.2, up to app.log.5.",
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
"required": ["maxbytes", "backupcount"],
|
|
34
|
+
"additionalProperties": False,
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
"required": ["log-level", "log-file"],
|
|
38
|
+
"additionalProperties": False,
|
|
39
|
+
}
|
|
40
|
+
return KEY, schema
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Schema of a complete microservice config file."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from tinyjukebox.core.schema import logger
|
|
9
|
+
|
|
10
|
+
__all__ = ["dump_schema", "get_schema"]
|
|
11
|
+
|
|
12
|
+
JSON_SCHEMA_DRAFT = "http://json-schema.org/draft-07/schema#"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_schema(sub_schema: Mapping[str, Any], definitions: Mapping[str, Any] | None = None) -> dict[str, Any]:
|
|
16
|
+
"""Combine the service specific ``sub_schema`` with the section ``logger``.
|
|
17
|
+
|
|
18
|
+
:param sub_schema: mapping of root keys to their schemas; all of them are required.
|
|
19
|
+
:param definitions: shared schema definitions (referenced via ``#/definitions/<name>``).
|
|
20
|
+
"""
|
|
21
|
+
schema: dict[str, Any] = {
|
|
22
|
+
"$schema": JSON_SCHEMA_DRAFT,
|
|
23
|
+
"title": "Configuration of the Tiny Jukebox box.",
|
|
24
|
+
"type": "object",
|
|
25
|
+
"properties": dict(sub_schema),
|
|
26
|
+
"required": list(sub_schema),
|
|
27
|
+
"definitions": dict(definitions or {}),
|
|
28
|
+
}
|
|
29
|
+
key, sub = logger.get_schema()
|
|
30
|
+
schema["properties"][key] = sub
|
|
31
|
+
schema["required"].append(key)
|
|
32
|
+
return schema
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def dump_schema(schema: Mapping[str, Any], filename: str | Path) -> bool:
|
|
36
|
+
"""Write ``schema`` as JSON to ``filename`` if the content differs from the file.
|
|
37
|
+
|
|
38
|
+
:return: ``True`` if the file has been (re)written.
|
|
39
|
+
"""
|
|
40
|
+
path = Path(filename)
|
|
41
|
+
new_content = json.dumps(schema, indent=4)
|
|
42
|
+
try:
|
|
43
|
+
if path.read_text(encoding="utf-8") == new_content:
|
|
44
|
+
return False
|
|
45
|
+
except OSError:
|
|
46
|
+
pass
|
|
47
|
+
path.write_text(new_content, encoding="utf-8")
|
|
48
|
+
return True
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Threading helpers."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import threading
|
|
5
|
+
from collections.abc import Callable, Iterable, Mapping
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from tinyjukebox.core import unexpected
|
|
9
|
+
|
|
10
|
+
__all__ = ["LoggerThread"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class LoggerThread(threading.Thread):
|
|
14
|
+
"""``threading.Thread`` that logs uncaught exceptions of its target before re-raising them.
|
|
15
|
+
|
|
16
|
+
Exceptions in plain threads only end up on stderr. Services running as daemons should have them in the log file.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
group: None = None,
|
|
22
|
+
target: Callable[..., object] | None = None,
|
|
23
|
+
name: str | None = None,
|
|
24
|
+
args: Iterable[Any] = (),
|
|
25
|
+
kwargs: Mapping[str, Any] | None = None,
|
|
26
|
+
*,
|
|
27
|
+
daemon: bool | None = None,
|
|
28
|
+
logger: logging.Logger | None = None,
|
|
29
|
+
) -> None:
|
|
30
|
+
super().__init__(group=group, target=target, name=name, args=args, kwargs=kwargs, daemon=daemon)
|
|
31
|
+
self._logger = logger if logger is not None else logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
def run(self) -> None:
|
|
34
|
+
try:
|
|
35
|
+
super().run()
|
|
36
|
+
except Exception as error:
|
|
37
|
+
self._logger.exception("uncaught exception in thread '%s'", self.name)
|
|
38
|
+
unexpected.report(error, f"thread {self.name}")
|
|
39
|
+
raise
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Interactive command line interface running in its own thread."""
|
|
2
|
+
|
|
3
|
+
import cmd
|
|
4
|
+
import contextlib
|
|
5
|
+
import logging
|
|
6
|
+
import threading
|
|
7
|
+
from collections.abc import Callable, Mapping
|
|
8
|
+
|
|
9
|
+
from tinyjukebox.core.logging import get_child
|
|
10
|
+
from tinyjukebox.core.threads import LoggerThread
|
|
11
|
+
|
|
12
|
+
__all__ = ["UI", "Command"]
|
|
13
|
+
|
|
14
|
+
Command = Callable[[str], object]
|
|
15
|
+
"""A command receives the rest of the input line as argument. Its docstring is shown by ``help``."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class UI(cmd.Cmd):
|
|
19
|
+
"""Command line of a service (``help`` lists all commands, ``exit``/``e``/``q`` stops the service).
|
|
20
|
+
|
|
21
|
+
The end of the input (``Ctrl-D``) stops the service only on a terminal - without one (e.g. ``< /dev/null``) it
|
|
22
|
+
ends only the command line.
|
|
23
|
+
|
|
24
|
+
:param intro: text shown at start.
|
|
25
|
+
:param prompt: prompt string.
|
|
26
|
+
:param stop_service: called (in a separate thread) if the user leaves the command line.
|
|
27
|
+
:param service_is_stopping: event that is set while the service is stopping (then ``stop_service`` is not
|
|
28
|
+
called again).
|
|
29
|
+
:param logger: parent logger.
|
|
30
|
+
:param commands: additional commands - name to callable.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
intro: str,
|
|
36
|
+
prompt: str,
|
|
37
|
+
stop_service: Callable[[], object],
|
|
38
|
+
service_is_stopping: threading.Event,
|
|
39
|
+
logger: logging.Logger,
|
|
40
|
+
commands: Mapping[str, Command] | None = None,
|
|
41
|
+
) -> None:
|
|
42
|
+
super().__init__()
|
|
43
|
+
self._logger = get_child(logger, type(self).__name__)
|
|
44
|
+
self.intro = intro
|
|
45
|
+
self.prompt = prompt
|
|
46
|
+
self._stop_service = stop_service
|
|
47
|
+
self._service_is_stopping = service_is_stopping
|
|
48
|
+
self._closed = False # stopped by the service: a line typed afterwards is not executed
|
|
49
|
+
self._end_of_input = False
|
|
50
|
+
self._thread = LoggerThread(target=self._loop, name="UI", logger=self._logger, daemon=True)
|
|
51
|
+
for name, function in (commands or {}).items():
|
|
52
|
+
self.add_command(name, function)
|
|
53
|
+
|
|
54
|
+
def add_command(self, name: str, function: Command) -> None:
|
|
55
|
+
"""Add the command ``name``.
|
|
56
|
+
|
|
57
|
+
:raises ValueError: if a command with this name exists already.
|
|
58
|
+
:raises TypeError: if ``function`` is not callable.
|
|
59
|
+
"""
|
|
60
|
+
if hasattr(self, "do_" + name):
|
|
61
|
+
raise ValueError(f"command '{name}' already defined")
|
|
62
|
+
if not callable(function):
|
|
63
|
+
raise TypeError("parameter function must be callable")
|
|
64
|
+
setattr(self, "do_" + name, function)
|
|
65
|
+
|
|
66
|
+
def get_names(self) -> list[str]:
|
|
67
|
+
# cmd.Cmd only inspects the class - include the commands added to this instance
|
|
68
|
+
return dir(self)
|
|
69
|
+
|
|
70
|
+
def start(self) -> None:
|
|
71
|
+
self._thread.start()
|
|
72
|
+
|
|
73
|
+
def stop(self) -> None:
|
|
74
|
+
"""End the command line (called by the service when it stops)."""
|
|
75
|
+
self._closed = True
|
|
76
|
+
if self._thread.is_alive() and self._thread is not threading.current_thread():
|
|
77
|
+
# cmdloop blocks in input() until the next line; the thread is a daemon and must not block the shutdown
|
|
78
|
+
# of the service
|
|
79
|
+
self._thread.join(timeout=0.5)
|
|
80
|
+
|
|
81
|
+
def _loop(self) -> None:
|
|
82
|
+
with contextlib.suppress(EOFError):
|
|
83
|
+
self.cmdloop()
|
|
84
|
+
if self._closed or self._service_is_stopping.is_set():
|
|
85
|
+
return
|
|
86
|
+
if self._end_of_input and not self._interactive():
|
|
87
|
+
self._logger.warning("the input has ended and is no terminal - the console ends, the service goes on")
|
|
88
|
+
return
|
|
89
|
+
LoggerThread(target=self._stop_service, name="UI.stop_service", logger=self._logger, daemon=True).start()
|
|
90
|
+
|
|
91
|
+
def _interactive(self) -> bool:
|
|
92
|
+
try:
|
|
93
|
+
return bool(self.stdin.isatty())
|
|
94
|
+
except (AttributeError, OSError, ValueError): # no stdin, or a closed one
|
|
95
|
+
return False
|
|
96
|
+
|
|
97
|
+
def onecmd(self, line: str) -> bool:
|
|
98
|
+
if self._closed:
|
|
99
|
+
return True # the service has stopped the command line meanwhile
|
|
100
|
+
try:
|
|
101
|
+
return bool(super().onecmd(line))
|
|
102
|
+
except Exception as e:
|
|
103
|
+
self._logger.exception("command '%s' failed", line)
|
|
104
|
+
print(f"Caught exception: {e}")
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
def emptyline(self) -> bool:
|
|
108
|
+
return False # do not repeat the last command
|
|
109
|
+
|
|
110
|
+
def do_exit(self, arg: str) -> bool:
|
|
111
|
+
"""exit - stop the service: EXIT"""
|
|
112
|
+
return True
|
|
113
|
+
|
|
114
|
+
def do_EOF(self, arg: str) -> bool: # noqa: N802 - name defined by cmd.Cmd
|
|
115
|
+
"""Ctrl-D - stop the service"""
|
|
116
|
+
self._end_of_input = True
|
|
117
|
+
return True
|
|
118
|
+
|
|
119
|
+
do_e = do_exit
|
|
120
|
+
do_q = do_exit
|