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,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,5 @@
1
+ """JSON schemas of the common config sections (``logger``) and of the complete config file."""
2
+
3
+ from tinyjukebox.core.schema.microservice import get_schema
4
+
5
+ __all__ = ["get_schema"]
@@ -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,5 @@
1
+ """Interactive command line of a running service (based on :mod:`cmd`)."""
2
+
3
+ from tinyjukebox.core.ui.cli import UI, Command
4
+
5
+ __all__ = ["UI", "Command"]
@@ -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