py-app-runner 0.5.49.dev0__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.
- py_app_runner/__init__.py +11 -0
- py_app_runner/audit/__init__.py +29 -0
- py_app_runner/audit/_service.py +91 -0
- py_app_runner/audit/_service_args.py +44 -0
- py_app_runner/audit/audit.py +319 -0
- py_app_runner/audit/commands.py +151 -0
- py_app_runner/audit/diff.py +202 -0
- py_app_runner/audit/errors.py +8 -0
- py_app_runner/audit/event.py +130 -0
- py_app_runner/audit/store.py +134 -0
- py_app_runner/bridge/__init__.py +0 -0
- py_app_runner/bridge/_service.py +265 -0
- py_app_runner/bridge/_service_args.py +24 -0
- py_app_runner/bridge/api.py +138 -0
- py_app_runner/bridge/encoders/__init__.py +5 -0
- py_app_runner/bridge/encoders/base.py +24 -0
- py_app_runner/bridge/encoders/json_encoder.py +26 -0
- py_app_runner/bridge/encoders/msgpack_encoder.py +58 -0
- py_app_runner/bridge/web_app.py +31 -0
- py_app_runner/bridge/websocket.py +313 -0
- py_app_runner/colors.py +73 -0
- py_app_runner/config.py +132 -0
- py_app_runner/crypto/__init__.py +14 -0
- py_app_runner/crypto/_service.py +75 -0
- py_app_runner/crypto/_service_args.py +54 -0
- py_app_runner/crypto/commands.py +164 -0
- py_app_runner/crypto/envelope.py +144 -0
- py_app_runner/crypto/errors.py +8 -0
- py_app_runner/crypto/fields.py +300 -0
- py_app_runner/crypto/passwords.py +66 -0
- py_app_runner/db_pools.py +20 -0
- py_app_runner/http_exception.py +31 -0
- py_app_runner/logger_handlers.py +167 -0
- py_app_runner/migrations/__init__.py +5 -0
- py_app_runner/migrations/_service.py +296 -0
- py_app_runner/migrations/_service_args.py +91 -0
- py_app_runner/migrations/commands.py +386 -0
- py_app_runner/migrations/discovery.py +108 -0
- py_app_runner/migrations/states.py +63 -0
- py_app_runner/migrations/tracker.py +141 -0
- py_app_runner/py.typed +0 -0
- py_app_runner/pybridge.py +64 -0
- py_app_runner/queue/__init__.py +25 -0
- py_app_runner/queue/_service.py +231 -0
- py_app_runner/queue/_service_args.py +67 -0
- py_app_runner/queue/commands.py +180 -0
- py_app_runner/queue/driver_pg.py +464 -0
- py_app_runner/queue/driver_redis.py +613 -0
- py_app_runner/queue/handler.py +90 -0
- py_app_runner/queue/interface.py +63 -0
- py_app_runner/queue/job.py +46 -0
- py_app_runner/queue/worker.py +221 -0
- py_app_runner/registry.py +54 -0
- py_app_runner/request_handler/__init__.py +0 -0
- py_app_runner/request_handler/auth_service.py +123 -0
- py_app_runner/request_handler/decorators.py +304 -0
- py_app_runner/request_handler/handlers.py +604 -0
- py_app_runner/request_handler/pagination.py +24 -0
- py_app_runner/return_model.py +78 -0
- py_app_runner/runner.py +182 -0
- py_app_runner/throttle/__init__.py +5 -0
- py_app_runner/throttle/throttle.py +217 -0
- py_app_runner/tick_service.py +308 -0
- py_app_runner/timer.py +289 -0
- py_app_runner/utils.py +346 -0
- py_app_runner/wbcm/__init__.py +0 -0
- py_app_runner/wbcm/device_connections.py +89 -0
- py_app_runner/wbcm/factory.py +113 -0
- py_app_runner/wbcm/wb_connection_manager.py +333 -0
- py_app_runner/wbcm/ws_interface.py +56 -0
- py_app_runner-0.5.49.dev0.dist-info/METADATA +134 -0
- py_app_runner-0.5.49.dev0.dist-info/RECORD +75 -0
- py_app_runner-0.5.49.dev0.dist-info/WHEEL +5 -0
- py_app_runner-0.5.49.dev0.dist-info/licenses/LICENSE +21 -0
- py_app_runner-0.5.49.dev0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
import threading
|
|
4
|
+
from collections.abc import AsyncGenerator
|
|
5
|
+
from contextlib import asynccontextmanager
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from database_wrapper_redis import RedisDbAsync
|
|
9
|
+
|
|
10
|
+
from py_app_runner.registry import AppRegistry
|
|
11
|
+
from py_app_runner.tick_service import TickService
|
|
12
|
+
from py_app_runner.utils import json_decode
|
|
13
|
+
from py_app_runner.wbcm.device_connections import DeviceConnections
|
|
14
|
+
from py_app_runner.wbcm.factory import UserConnections
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class WbConnectionManager:
|
|
18
|
+
logger: logging.Logger
|
|
19
|
+
tick_service: TickService
|
|
20
|
+
|
|
21
|
+
cache_db: RedisDbAsync
|
|
22
|
+
user_connections: UserConnections
|
|
23
|
+
device_connections: DeviceConnections
|
|
24
|
+
|
|
25
|
+
# Plain XREAD position (not a consumer group): every manager instance reads the
|
|
26
|
+
# full stream, so forked workers each deliver to their own sockets. "$" = only
|
|
27
|
+
# messages arriving after startup; missed history is useless to dead sockets.
|
|
28
|
+
stream_last_id: str = "$"
|
|
29
|
+
|
|
30
|
+
shutdown_lock: threading.Lock
|
|
31
|
+
|
|
32
|
+
def __init__(self, cache_db: RedisDbAsync, debug: bool = False) -> None:
|
|
33
|
+
assert cache_db is not None, "cache_db is required"
|
|
34
|
+
|
|
35
|
+
loggerName = f"{__name__}.{self.__class__.__name__}"
|
|
36
|
+
self.logger = logging.getLogger(loggerName)
|
|
37
|
+
self.tick_service = TickService(1, self.tick, self.shutdown)
|
|
38
|
+
|
|
39
|
+
self.cache_db = cache_db
|
|
40
|
+
self.user_connections = UserConnections()
|
|
41
|
+
self.device_connections = DeviceConnections()
|
|
42
|
+
self.stream_last_id = "$"
|
|
43
|
+
|
|
44
|
+
self.shutdown_lock = threading.Lock()
|
|
45
|
+
|
|
46
|
+
debug_level = logging.DEBUG if debug else logging.INFO
|
|
47
|
+
self.logger.setLevel(debug_level)
|
|
48
|
+
self.tick_service.logger.setLevel(debug_level)
|
|
49
|
+
self.tick_service.timer.logger.setLevel(debug_level)
|
|
50
|
+
|
|
51
|
+
def __del__(self) -> None:
|
|
52
|
+
self.logger.debug("Deleting WbConnectionManager")
|
|
53
|
+
|
|
54
|
+
####################
|
|
55
|
+
### Process loop ###
|
|
56
|
+
####################
|
|
57
|
+
|
|
58
|
+
def start_in_new_loop(self) -> None:
|
|
59
|
+
self.logger.debug("Starting WbConnectionManager in a new loop")
|
|
60
|
+
self.tick_service.skip_signal_handling = True
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
asyncio.run(self.start())
|
|
64
|
+
except (KeyboardInterrupt, asyncio.CancelledError):
|
|
65
|
+
... # Ignore this exception
|
|
66
|
+
|
|
67
|
+
async def start(self):
|
|
68
|
+
self.logger.info("Starting")
|
|
69
|
+
await self.tick_service.run_loop()
|
|
70
|
+
|
|
71
|
+
async def stop(self):
|
|
72
|
+
self.logger.info("Stopping")
|
|
73
|
+
await self.tick_service.stop_loop()
|
|
74
|
+
|
|
75
|
+
async def shutdown(self):
|
|
76
|
+
self.logger.debug("Stop the event loop")
|
|
77
|
+
|
|
78
|
+
# We run on the manager's background thread; the sockets belong to the web
|
|
79
|
+
# server's loop. Closing them directly is not thread safe, and iterating the
|
|
80
|
+
# live dict would break as on_close() removes entries from it.
|
|
81
|
+
for _conn_id, conn in list(self.user_connections.items()):
|
|
82
|
+
try:
|
|
83
|
+
conn.loop.call_soon_threadsafe(conn.close)
|
|
84
|
+
except Exception:
|
|
85
|
+
pass
|
|
86
|
+
|
|
87
|
+
for _conn_id, conn in list(self.device_connections.items()):
|
|
88
|
+
try:
|
|
89
|
+
conn.loop.call_soon_threadsafe(conn.close)
|
|
90
|
+
except Exception:
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
self.user_connections.reset()
|
|
94
|
+
self.device_connections.reset()
|
|
95
|
+
|
|
96
|
+
async def tick(self, tick_count: int, tick_time: float):
|
|
97
|
+
try:
|
|
98
|
+
async with self.connect_dbs():
|
|
99
|
+
with self.tick_service.timer.enter("Get data"):
|
|
100
|
+
await self.process_messages()
|
|
101
|
+
|
|
102
|
+
except Exception as e:
|
|
103
|
+
self.logger.exception(f"Unknown error: {e}")
|
|
104
|
+
|
|
105
|
+
#############
|
|
106
|
+
### Utils ###
|
|
107
|
+
#############
|
|
108
|
+
|
|
109
|
+
@asynccontextmanager
|
|
110
|
+
async def connect_dbs(self) -> AsyncGenerator[bool]:
|
|
111
|
+
try:
|
|
112
|
+
self.logger.debug("Opening DB connections")
|
|
113
|
+
await self.cache_db.open()
|
|
114
|
+
|
|
115
|
+
yield True
|
|
116
|
+
|
|
117
|
+
finally:
|
|
118
|
+
self.logger.debug("Closing DB connections")
|
|
119
|
+
await self.cache_db.close()
|
|
120
|
+
|
|
121
|
+
async def read_stream_data(self, stream_name: str) -> Any:
|
|
122
|
+
self.logger.debug(f"Reading stream data after {self.stream_last_id}")
|
|
123
|
+
return await self.cache_db.connection.xread(
|
|
124
|
+
streams={stream_name: self.stream_last_id},
|
|
125
|
+
count=500,
|
|
126
|
+
block=2000,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
def send_message(
|
|
130
|
+
self,
|
|
131
|
+
user_id: str,
|
|
132
|
+
message: bytes | str | dict[str, Any] | list[Any],
|
|
133
|
+
source_uid: str,
|
|
134
|
+
) -> None:
|
|
135
|
+
connections = self.user_connections.get_connections_by_user_id(user_id)
|
|
136
|
+
if not connections:
|
|
137
|
+
self.logger.debug(f"No connections for user {user_id}")
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
self.logger.debug(f"Sending message to {user_id}")
|
|
141
|
+
for conn in connections:
|
|
142
|
+
# Skip the source connection
|
|
143
|
+
if conn.uid == source_uid:
|
|
144
|
+
self.logger.debug(f"Skipping source connection {source_uid}")
|
|
145
|
+
continue
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
self.logger.debug(f"Sending message to {conn.uid} for user {user_id}")
|
|
149
|
+
conn.loop.call_soon_threadsafe(
|
|
150
|
+
conn.write_custom_message,
|
|
151
|
+
message,
|
|
152
|
+
False,
|
|
153
|
+
)
|
|
154
|
+
except Exception as e:
|
|
155
|
+
self.logger.warning(f"Removing connection {conn.uid} for exception {e}")
|
|
156
|
+
self.user_connections.remove_connection(conn)
|
|
157
|
+
continue
|
|
158
|
+
|
|
159
|
+
def send_message_to_device(
|
|
160
|
+
self,
|
|
161
|
+
device_id: str,
|
|
162
|
+
message: bytes | str | dict[str, Any] | list[Any],
|
|
163
|
+
source_uid: str,
|
|
164
|
+
) -> None:
|
|
165
|
+
connections = self.device_connections.get_connections_by_device_id(device_id)
|
|
166
|
+
if not connections:
|
|
167
|
+
self.logger.debug("No connections for device %s", device_id)
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
self.logger.debug("Sending message to device %s", device_id)
|
|
171
|
+
for conn in connections:
|
|
172
|
+
if conn.uid == source_uid:
|
|
173
|
+
continue
|
|
174
|
+
try:
|
|
175
|
+
conn.loop.call_soon_threadsafe(
|
|
176
|
+
conn.write_custom_message,
|
|
177
|
+
message,
|
|
178
|
+
False,
|
|
179
|
+
)
|
|
180
|
+
except Exception as e:
|
|
181
|
+
self.logger.warning("Removing device connection %s for exception %s", conn.uid, e)
|
|
182
|
+
self.device_connections.remove_connection(conn)
|
|
183
|
+
continue
|
|
184
|
+
|
|
185
|
+
def close_sessions(
|
|
186
|
+
self,
|
|
187
|
+
user_id: str,
|
|
188
|
+
source_uid: str,
|
|
189
|
+
target_sid: str | None = None,
|
|
190
|
+
) -> None:
|
|
191
|
+
"""Hard-close revoked live sockets.
|
|
192
|
+
|
|
193
|
+
Closing (not just notifying) is what makes a revoke enforceable: a client
|
|
194
|
+
that ignores a notification must not keep an authenticated socket. With
|
|
195
|
+
`target_sid` only that one session's socket is closed; otherwise all of
|
|
196
|
+
the user's sockets except `source_uid`."""
|
|
197
|
+
|
|
198
|
+
connections = self.user_connections.get_connections_by_user_id(user_id)
|
|
199
|
+
for conn in connections:
|
|
200
|
+
if conn.uid == source_uid:
|
|
201
|
+
continue
|
|
202
|
+
if target_sid is not None and conn.current_session_sid != target_sid:
|
|
203
|
+
continue
|
|
204
|
+
|
|
205
|
+
try:
|
|
206
|
+
self.logger.debug(f"Closing connection {conn.uid} for user {user_id}")
|
|
207
|
+
conn.loop.call_soon_threadsafe(conn.close)
|
|
208
|
+
except Exception as e:
|
|
209
|
+
self.logger.warning(f"Failed to close connection {conn.uid}: {e}")
|
|
210
|
+
|
|
211
|
+
def send_message_to_all(
|
|
212
|
+
self,
|
|
213
|
+
message: bytes | str | dict[str, Any] | list[Any],
|
|
214
|
+
source_uid: str,
|
|
215
|
+
) -> None:
|
|
216
|
+
self.logger.debug("Sending message to all")
|
|
217
|
+
# Snapshot: the loop below removes dead connections from the same dict.
|
|
218
|
+
for _user_id, conn in list(self.user_connections.items()):
|
|
219
|
+
# Skip the source connection
|
|
220
|
+
if conn.uid == source_uid:
|
|
221
|
+
self.logger.debug(f"Skipping source connection {source_uid}")
|
|
222
|
+
continue
|
|
223
|
+
|
|
224
|
+
try:
|
|
225
|
+
self.logger.debug(f"Sending message to {conn.uid}")
|
|
226
|
+
conn.loop.call_soon_threadsafe(
|
|
227
|
+
conn.write_custom_message,
|
|
228
|
+
message,
|
|
229
|
+
False,
|
|
230
|
+
)
|
|
231
|
+
except Exception as e:
|
|
232
|
+
self.logger.warning(f"Removing connection {conn.uid} for exception {e}")
|
|
233
|
+
self.user_connections.remove_connection(conn)
|
|
234
|
+
continue
|
|
235
|
+
|
|
236
|
+
########################
|
|
237
|
+
### Process messages ###
|
|
238
|
+
########################
|
|
239
|
+
|
|
240
|
+
async def process_messages(self) -> None:
|
|
241
|
+
self.logger.debug("Find new messages in the stream")
|
|
242
|
+
stream_name = AppRegistry.redis_channel()
|
|
243
|
+
cached_data = await self.read_stream_data(stream_name)
|
|
244
|
+
if not cached_data or stream_name not in cached_data or not cached_data[stream_name][0]:
|
|
245
|
+
self.logger.debug("No records found")
|
|
246
|
+
return
|
|
247
|
+
|
|
248
|
+
items_found_count: int = 0
|
|
249
|
+
message_data = cached_data[stream_name][0]
|
|
250
|
+
|
|
251
|
+
self.logger.info(f"Processing {len(message_data)} records")
|
|
252
|
+
for message_id, msg_data in message_data:
|
|
253
|
+
# A raising message must never hold up the stream position, otherwise the
|
|
254
|
+
# same batch is re-read on every tick forever.
|
|
255
|
+
try:
|
|
256
|
+
if self.work(msg_data):
|
|
257
|
+
items_found_count += 1
|
|
258
|
+
except Exception:
|
|
259
|
+
self.logger.exception(f"Failed to process message {message_id!r}, skipping it")
|
|
260
|
+
|
|
261
|
+
self.stream_last_id = message_id
|
|
262
|
+
|
|
263
|
+
self.logger.info(f"Processed {items_found_count} messages")
|
|
264
|
+
|
|
265
|
+
def work(self, message_data: dict[str, Any]) -> bool:
|
|
266
|
+
self.logger.debug(f"Processing message {message_data}")
|
|
267
|
+
|
|
268
|
+
if "service" not in message_data:
|
|
269
|
+
self.logger.error(f"Message data does not contain service: {message_data!r}")
|
|
270
|
+
return False
|
|
271
|
+
|
|
272
|
+
if "type" not in message_data:
|
|
273
|
+
self.logger.error(f"Message data does not contain type: {message_data!r}")
|
|
274
|
+
return False
|
|
275
|
+
|
|
276
|
+
if "data" not in message_data:
|
|
277
|
+
self.logger.error(f"Message data does not contain data: {message_data!r}")
|
|
278
|
+
return False
|
|
279
|
+
|
|
280
|
+
if "source_uid" not in message_data:
|
|
281
|
+
self.logger.error(f"Message data does not contain source_uid: {message_data!r}")
|
|
282
|
+
return False
|
|
283
|
+
|
|
284
|
+
source_uid: str = message_data.get("source_uid", "")
|
|
285
|
+
data = message_data.get("data", None)
|
|
286
|
+
if data is not None and isinstance(data, str):
|
|
287
|
+
try:
|
|
288
|
+
message_data["data"] = json_decode(data)
|
|
289
|
+
except Exception:
|
|
290
|
+
self.logger.exception(f"Message data is not valid JSON, dropping: {message_data!r}")
|
|
291
|
+
return False
|
|
292
|
+
|
|
293
|
+
msg_type: str = message_data.get("type", "")
|
|
294
|
+
|
|
295
|
+
# Session revocation closes sockets instead of forwarding a payload.
|
|
296
|
+
if msg_type == "session_revoked":
|
|
297
|
+
user_id = message_data.get("user_id", None)
|
|
298
|
+
if user_id is None:
|
|
299
|
+
self.logger.error(f"session_revoked without user_id: {message_data!r}")
|
|
300
|
+
return False
|
|
301
|
+
|
|
302
|
+
self.close_sessions(
|
|
303
|
+
user_id,
|
|
304
|
+
source_uid,
|
|
305
|
+
message_data.get("target_sid", None) or None,
|
|
306
|
+
)
|
|
307
|
+
return True
|
|
308
|
+
|
|
309
|
+
# Session relay: route to device or user based on target_type
|
|
310
|
+
if msg_type == "session_relay":
|
|
311
|
+
target_type = message_data.get("target_type", "")
|
|
312
|
+
target_id = message_data.get("target_id", "")
|
|
313
|
+
if target_type == "device":
|
|
314
|
+
self.send_message_to_device(target_id, message_data, source_uid)
|
|
315
|
+
elif target_type == "user":
|
|
316
|
+
self.send_message(target_id, message_data, source_uid)
|
|
317
|
+
else:
|
|
318
|
+
self.logger.error("session_relay with unknown target_type: %s", target_type)
|
|
319
|
+
return False
|
|
320
|
+
return True
|
|
321
|
+
|
|
322
|
+
# Default behavior: route by user_id
|
|
323
|
+
if "user_id" not in message_data:
|
|
324
|
+
self.logger.error(f"Message data does not contain user_id: {message_data!r}")
|
|
325
|
+
return False
|
|
326
|
+
|
|
327
|
+
user_id: str | None = message_data.get("user_id", None)
|
|
328
|
+
if user_id is None:
|
|
329
|
+
self.send_message_to_all(message_data, source_uid)
|
|
330
|
+
else:
|
|
331
|
+
self.send_message(user_id, message_data, source_uid)
|
|
332
|
+
|
|
333
|
+
return True
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from asyncio import AbstractEventLoop, Future
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class WebSocketHandlerInterface(ABC):
|
|
7
|
+
uid: str | None
|
|
8
|
+
msg_id: int | None
|
|
9
|
+
service: str | None
|
|
10
|
+
current_session_sid: str | None
|
|
11
|
+
|
|
12
|
+
loop: AbstractEventLoop
|
|
13
|
+
|
|
14
|
+
@abstractmethod
|
|
15
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
16
|
+
self.uid = None
|
|
17
|
+
self.msg_id = None
|
|
18
|
+
self.service = None
|
|
19
|
+
self.current_session_sid = None
|
|
20
|
+
|
|
21
|
+
@abstractmethod
|
|
22
|
+
def error_message(self, msg: str, code: int = -1, **kwargs: Any) -> None:
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
@abstractmethod
|
|
26
|
+
def write_custom_message(
|
|
27
|
+
self,
|
|
28
|
+
message: bytes | str | dict[str, Any] | list[Any],
|
|
29
|
+
wrap_in_data: bool = True,
|
|
30
|
+
**kwargs: Any,
|
|
31
|
+
) -> Future[None]:
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
@abstractmethod
|
|
35
|
+
def check_origin(self, origin: str) -> bool:
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
@abstractmethod
|
|
39
|
+
async def open(self, *args: str, **kwargs: str) -> None:
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
@abstractmethod
|
|
43
|
+
def ping(self, data: str | bytes = b"") -> None:
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
@abstractmethod
|
|
47
|
+
async def on_message(self, message: str | bytes) -> None:
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
@abstractmethod
|
|
51
|
+
def close(self, code: int | None = None, reason: str | None = None) -> None:
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
@abstractmethod
|
|
55
|
+
def on_close(self) -> None:
|
|
56
|
+
pass
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: py_app_runner
|
|
3
|
+
Version: 0.5.49.dev0
|
|
4
|
+
Summary: Async service framework: Tornado HTTP/WS bridge, service loader, Redis WS connection manager
|
|
5
|
+
Author-email: Gints Murans <gm@gm.lv>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/4Apps/py_app_runner
|
|
8
|
+
Project-URL: Documentation, https://github.com/4Apps/py_app_runner/blob/master/AGENTS.md
|
|
9
|
+
Project-URL: Code, https://github.com/4Apps/py_app_runner
|
|
10
|
+
Project-URL: Issue Tracker, https://github.com/4Apps/py_app_runner/issues
|
|
11
|
+
Project-URL: Download, https://pypi.org/project/py-app-runner/
|
|
12
|
+
Keywords: async,tornado,websocket,framework,service,redis,postgresql,migrations,queue
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Framework :: AsyncIO
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Operating System :: POSIX
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
22
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
23
|
+
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
24
|
+
Classifier: Typing :: Typed
|
|
25
|
+
Requires-Python: >=3.11
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
License-File: LICENSE
|
|
28
|
+
Requires-Dist: sentry-sdk
|
|
29
|
+
Requires-Dist: python-dotenv
|
|
30
|
+
Requires-Dist: uvloop
|
|
31
|
+
Requires-Dist: msgspec
|
|
32
|
+
Requires-Dist: msgpack
|
|
33
|
+
Requires-Dist: tornado
|
|
34
|
+
Requires-Dist: database_wrapper[pgsql,redis]==0.3.146
|
|
35
|
+
Requires-Dist: psycopg>=3.2.0
|
|
36
|
+
Requires-Dist: psycopg[pool]>=3.2.0
|
|
37
|
+
Requires-Dist: redis
|
|
38
|
+
Requires-Dist: pyjwt
|
|
39
|
+
Requires-Dist: cryptography>=44
|
|
40
|
+
Requires-Dist: bcrypt
|
|
41
|
+
Provides-Extra: dev
|
|
42
|
+
Requires-Dist: pyrefly; extra == "dev"
|
|
43
|
+
Requires-Dist: ruff; extra == "dev"
|
|
44
|
+
Requires-Dist: pytest; extra == "dev"
|
|
45
|
+
Requires-Dist: pytest-asyncio; extra == "dev"
|
|
46
|
+
Dynamic: license-file
|
|
47
|
+
|
|
48
|
+
# py_app_runner
|
|
49
|
+
|
|
50
|
+
Async Python service framework: Tornado HTTP/WS bridge, PyBridge service loader, Redis WebSocket connection manager.
|
|
51
|
+
|
|
52
|
+
## Install
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pip install py_app_runner
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Every push to `develop` publishes a pre-release, `X.Y.<commit count>.dev0`. pip hides those
|
|
59
|
+
unless you ask for them:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
pip install --pre py_app_runner # newest, including dev builds
|
|
63
|
+
pip install py_app_runner==0.4.50.dev0 # a specific dev build
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Development
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
docker-compose up develop
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Migrations
|
|
73
|
+
|
|
74
|
+
Built-in service that applies tracked SQL files to one or more configured databases. Add
|
|
75
|
+
`migrations` to `SERVICES` to enable it.
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
python3 src/app.py migrations status [--check] [--target NAME]
|
|
79
|
+
python3 src/app.py migrations apply [--dry-run] [--to PREFIX] [--target NAME]
|
|
80
|
+
python3 src/app.py migrations baseline [--to PREFIX] [--yes] [--target NAME]
|
|
81
|
+
python3 src/app.py migrations new <name> [--target NAME]
|
|
82
|
+
python3 src/app.py migrations repair <filename> [--target NAME]
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
By default there is a single target, `main`, against `config["db"]["main"]`: files live in
|
|
86
|
+
`config["migrations"]["dir"]` (default `data/migrations`) and are tracked in
|
|
87
|
+
`config["migrations"]["table"]` (default `migrations`). **No project needs to change
|
|
88
|
+
anything to keep this working** - an absent `migrations` key, an empty one, and this flat
|
|
89
|
+
shape all resolve to that same single target.
|
|
90
|
+
|
|
91
|
+
To migrate more than one database, opt in with `config["migrations"]["targets"]`:
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
"migrations": {
|
|
95
|
+
"targets": {
|
|
96
|
+
"main": {"db": "main", "dir": "data/migrations", "table": "migrations"},
|
|
97
|
+
"gis": {"db": "gis", "dir": "data/migrations_gis", "table": "gis_migrations"},
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Each target's `db` names a key under `config["db"]` and defaults to the target's own name;
|
|
103
|
+
`dir` and `table` default as above. `status` and `apply` with no `--target` run every
|
|
104
|
+
target in declared order (`apply` stops at the first one that fails; `status --check`
|
|
105
|
+
reports each target rather than stopping at the first with pending work, and exits 1 if any
|
|
106
|
+
is pending or blocked). `new`, `repair` and `baseline` require `--target` once more than one
|
|
107
|
+
target is configured, and an unknown `--target` exits 1 - both name the configured targets.
|
|
108
|
+
Output gets a `[name] ` prefix only when more than one target is processed, so single-target
|
|
109
|
+
output is unchanged.
|
|
110
|
+
|
|
111
|
+
Targets may share a database, but not a database *and* a tracking table - each would then
|
|
112
|
+
report the other's migrations as missing, so that config is refused up front, naming both
|
|
113
|
+
targets. `table` defaults to `migrations` for every target, so two targets on one database
|
|
114
|
+
need an explicit `table` on at least one of them.
|
|
115
|
+
|
|
116
|
+
- Migrations no longer need to be idempotent - each file runs in its own transaction with
|
|
117
|
+
its tracking row written inside it, so what already ran is always known.
|
|
118
|
+
- `-- migrations:no-transaction` on line 1 runs a file outside a transaction (for
|
|
119
|
+
`CREATE INDEX CONCURRENTLY` and friends). Such a file must contain **exactly one
|
|
120
|
+
statement**: Postgres wraps a multi-statement send in an implicit transaction, which would
|
|
121
|
+
defeat the directive, so `apply` refuses it up front. A no-transaction file that fails
|
|
122
|
+
cannot roll back and is not recorded - `apply` says so and tells you to inspect the
|
|
123
|
+
database before re-running.
|
|
124
|
+
- Editing an applied file is detected as drift, and a tracked file that has since been
|
|
125
|
+
deleted shows as missing; both block `apply` until resolved. `repair` fixes drift only;
|
|
126
|
+
a missing file is fixed by restoring it, or by deleting its tracking row by hand
|
|
127
|
+
(`apply` prints the exact `DELETE` when it blocks).
|
|
128
|
+
- Files must not contain psql meta-commands (`\restrict` / `\unrestrict`, as emitted by
|
|
129
|
+
`pg_dump`) - psycopg cannot execute them, and `apply` refuses such a file up front.
|
|
130
|
+
- `baseline` adopts an existing database into the system: it writes tracking rows without
|
|
131
|
+
executing anything.
|
|
132
|
+
- Every subcommand exits non-zero on failure, including a misconfigured `targets` block.
|
|
133
|
+
`status --check` exits 1 if anything is pending, drifted or missing, so a deploy script
|
|
134
|
+
can halt before restarting services against a half-migrated database.
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
py_app_runner/__init__.py,sha256=-06k2fPyJBVR2E23A54_ip7-C7Lv0L_YCjkTvk67qg8,310
|
|
2
|
+
py_app_runner/colors.py,sha256=fwgl_PHeMAryyu6saydHOK7SYF_bqmWG5BiEgDcmYHs,2162
|
|
3
|
+
py_app_runner/config.py,sha256=G5ZMY-RVhdB0mnwmjoogLdnsTqoQ-xTnG_tAFj3q-9Y,4057
|
|
4
|
+
py_app_runner/db_pools.py,sha256=yk8OkfLFZ5-BvU_pWgfr-fCKTMDS4UYTy1fDOm8ZP0g,546
|
|
5
|
+
py_app_runner/http_exception.py,sha256=Kpjmk54HYNC2PZH9RIYuiQSTJar1qYp35LR6jT44q2k,767
|
|
6
|
+
py_app_runner/logger_handlers.py,sha256=Pd45xLPbhEEYgqlHg2il76NX0CyXd1y4rtV_jmNA6-c,4937
|
|
7
|
+
py_app_runner/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
py_app_runner/pybridge.py,sha256=1MwazLm9oeXMYcrjB0J4DwH7w-edPguIM8F6LNHJGGc,2541
|
|
9
|
+
py_app_runner/registry.py,sha256=P6QCg5ivyr_S9JO0W3Tv7xO5n_OEmaUV2ffCNn3Ckno,1531
|
|
10
|
+
py_app_runner/return_model.py,sha256=9V8iCuFvtskSR-MT3qh0oGjZENmtIDe55hJAEw_b3Ac,1680
|
|
11
|
+
py_app_runner/runner.py,sha256=GCwpuChgHQHcG3B0Ss3PXqlW58BC9uNIV7AbO4Y9ZDQ,5596
|
|
12
|
+
py_app_runner/tick_service.py,sha256=wqEA8cU9mCu3KOgCpk5_LmkSJifZG1ewlNabLodeHH0,11010
|
|
13
|
+
py_app_runner/timer.py,sha256=8kXMvLA--NPhhzHR53BiIwol6ts4YRq7ywr6DyKz-cQ,9155
|
|
14
|
+
py_app_runner/utils.py,sha256=qOzTNAyrlRhn_4gb_-Go0GtxwWkLkn0WsplQwaKb6N0,10139
|
|
15
|
+
py_app_runner/audit/__init__.py,sha256=2idlte6sD9rf5QZGGqKJN0_BrJ4_kxe_wlX8CIgjE7s,582
|
|
16
|
+
py_app_runner/audit/_service.py,sha256=qKGOBEx2PL3DwJv9PKNOKZ81L-oUycCk_2dJmdxwM3U,3341
|
|
17
|
+
py_app_runner/audit/_service_args.py,sha256=Wv_2CGXrSjZd4JRCLpibjHpY60LZZ9xAL9k9sHIfHcM,1898
|
|
18
|
+
py_app_runner/audit/audit.py,sha256=xgShwmCTW28DDPvZAO-0e-aFcV0k4FKN0azTP4Qypgs,11725
|
|
19
|
+
py_app_runner/audit/commands.py,sha256=z8ApHQSDVMQGT5vH7jYjio75Q4yNbjsCYrUvva0BoB8,4449
|
|
20
|
+
py_app_runner/audit/diff.py,sha256=VDY4SVP1Acnkdb_C5LEpkDGdiXH85SahaelDPAgzhpY,6893
|
|
21
|
+
py_app_runner/audit/errors.py,sha256=bej4Jqapd0vb_rBEjZwCCvE-5_5nfRApiBzGcSxCizI,369
|
|
22
|
+
py_app_runner/audit/event.py,sha256=vJXDA13wqLf-73WzDLYT7xRL5msFXEGBnxq2KL_BzDU,3994
|
|
23
|
+
py_app_runner/audit/store.py,sha256=NriBE9mTA9hnxQ5uvsNiOqA79uCImyxk2eJ-Ueshkoc,4277
|
|
24
|
+
py_app_runner/bridge/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
25
|
+
py_app_runner/bridge/_service.py,sha256=-NNYqDpsff8p7tFbXf0DNNhQXTwQkziHxKl3T8ajIno,9684
|
|
26
|
+
py_app_runner/bridge/_service_args.py,sha256=P0pLcaPXPiPjDTWhj1YCzwHzbQJBz7K0zFMjgHqVy9g,828
|
|
27
|
+
py_app_runner/bridge/api.py,sha256=lepe1wfAR7GfI_XfL2sCAg3ah1hh7YbUvxfIcdK_wYM,5489
|
|
28
|
+
py_app_runner/bridge/web_app.py,sha256=XOMKMCBvJRwX5wirMYLI_sHkeYKVilqBJ3vcObbIICo,1261
|
|
29
|
+
py_app_runner/bridge/websocket.py,sha256=NaMeC3X5RnbxWqgEOW__4IRVDxUFc2Um6dmHXCojqDw,11779
|
|
30
|
+
py_app_runner/bridge/encoders/__init__.py,sha256=F4pe2wNdkgUaPEraDOEjOIdUtIkiavQPngYKsrlyn9A,178
|
|
31
|
+
py_app_runner/bridge/encoders/base.py,sha256=LsGwu5_v09ZATY3c8jRY9_eLt_u-bP1tBwBMbhoHkFs,724
|
|
32
|
+
py_app_runner/bridge/encoders/json_encoder.py,sha256=apYWHO_z5TtBBdyE-fQlI0B91iMPuGdSCh5gXdsfHi8,612
|
|
33
|
+
py_app_runner/bridge/encoders/msgpack_encoder.py,sha256=nLojvRDJeHgbANTu3imCYpHaISlknFICPPZAPWCsOG4,1492
|
|
34
|
+
py_app_runner/crypto/__init__.py,sha256=d25Dza7wGNIMFi2-W0dKKMeBvFsrk37kLsSVgIO5cd0,408
|
|
35
|
+
py_app_runner/crypto/_service.py,sha256=G0MOKmPpfibUuhPfhuCqxXnZDPhEs0tIO1Rp_mM1_Ag,2988
|
|
36
|
+
py_app_runner/crypto/_service_args.py,sha256=397lCCyYhWfPgA_ZMAq4yQSc_wEJw6f3JEkmDU6AYy0,1952
|
|
37
|
+
py_app_runner/crypto/commands.py,sha256=iha0_ftmqdYe-yM_0D-K1TsXiCT1WkQzSKXqpayrvGg,5561
|
|
38
|
+
py_app_runner/crypto/envelope.py,sha256=8amdymB8SpSuyNyB8klGmx38IPwJd2GgnsRJLVvAAyQ,5572
|
|
39
|
+
py_app_runner/crypto/errors.py,sha256=8kukvUKqB4-pybylg9qAaUkBoLPahmhFxQKktP_1LhI,457
|
|
40
|
+
py_app_runner/crypto/fields.py,sha256=QcWBLaJdvseR0nlgcbWHt4HhU1D9-atiNLQZEYPguIg,11137
|
|
41
|
+
py_app_runner/crypto/passwords.py,sha256=R4sxYzDlIb7jWiE1FBqd8VX30FRlI0nX5ZAq0inif-g,2550
|
|
42
|
+
py_app_runner/migrations/__init__.py,sha256=EtB9skFtbRWpYCTOzBwf39Bx_iUXVT4bdqFdLHGALv8,173
|
|
43
|
+
py_app_runner/migrations/_service.py,sha256=SiV-PispLQSCNaGuLhpdGk_LIFYyHZK8ZuBUkxzthhQ,13361
|
|
44
|
+
py_app_runner/migrations/_service_args.py,sha256=LusJM-jh9F0SdmWohYYzzrG2So8bbT3z7YVONr1imfM,3412
|
|
45
|
+
py_app_runner/migrations/commands.py,sha256=_myCf5Xb8hth_GYxO4UhBcY-L9-WixNNKQkdzWQYiRE,14302
|
|
46
|
+
py_app_runner/migrations/discovery.py,sha256=L3cXuNa_P7-8tKSAaAd412weoiwCLgInT28y0y1Ry_c,3686
|
|
47
|
+
py_app_runner/migrations/states.py,sha256=2t7WV4jQhunp3WMCfdqBZQqNsh1F5iJWYtbZ3-ngbLU,1741
|
|
48
|
+
py_app_runner/migrations/tracker.py,sha256=sW34evKuMTyIOtr8xGn0BQQ6t967a7kquf9O3Q39GM0,5704
|
|
49
|
+
py_app_runner/queue/__init__.py,sha256=2iL8thHW0Sz8XAHx3eMBkSAytVXErMpt5-xykxULq80,766
|
|
50
|
+
py_app_runner/queue/_service.py,sha256=E885H0713Btc0q1NgfkO-_ZX9E5hcPzCZoFBJTK53Nc,8373
|
|
51
|
+
py_app_runner/queue/_service_args.py,sha256=ZEhFdtKZdo8Yvla1Uiw_HsBJLCJ5YBduv7SX9QBRL1w,3244
|
|
52
|
+
py_app_runner/queue/commands.py,sha256=pD3HWIS4G6ERIYgbWH5QqmIAd7tIi4t7WqL7V2IRjUo,5405
|
|
53
|
+
py_app_runner/queue/driver_pg.py,sha256=nTMAmTl5izMHtEKUqBwh8vHLnIbNXcIS__lJzww-3Ws,18448
|
|
54
|
+
py_app_runner/queue/driver_redis.py,sha256=n3RLDOsrr9lRJYbu3Qw412yJuf17BAt4Msdi1j_nKoE,21705
|
|
55
|
+
py_app_runner/queue/handler.py,sha256=IrCvER7rkAc-SDv_BvGo2bWvbeteOqX2FeAjqauCekw,3386
|
|
56
|
+
py_app_runner/queue/interface.py,sha256=rm4KmGl3E-eJOcN1cy_LN6Eh3CIxkeIH0REB8AE6GfA,2253
|
|
57
|
+
py_app_runner/queue/job.py,sha256=bSAYQ03DuV_qeuB7U69s8Vqa8IwI4sulL_nhQMuAm50,1522
|
|
58
|
+
py_app_runner/queue/worker.py,sha256=suvz_KJxDPsK_G7O1iPa0GcexMGCM-oPOKGUrEnVYoc,8040
|
|
59
|
+
py_app_runner/request_handler/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
60
|
+
py_app_runner/request_handler/auth_service.py,sha256=0sT9yx-d_umee-drEZrZaEIA8Bo1PJejGzA9tdKW_g8,3968
|
|
61
|
+
py_app_runner/request_handler/decorators.py,sha256=hzHCUla1jmbvmOVHxGv6n9EG2v7fcnQ17zScsSkMOHk,11916
|
|
62
|
+
py_app_runner/request_handler/handlers.py,sha256=PYy7ShoOQiYlCccRDozVFdLFFHBZcZo8vBkzPdLOrgQ,20555
|
|
63
|
+
py_app_runner/request_handler/pagination.py,sha256=kwTIESjGBNVUTw_WMO5hQmX2wslOjtPduTEee5YEnr0,655
|
|
64
|
+
py_app_runner/throttle/__init__.py,sha256=Nro3YzxTNurUXjijicF06_B8oI_jYLB2zeWBl4lpX24,141
|
|
65
|
+
py_app_runner/throttle/throttle.py,sha256=slIBhi5Nn8_UlQDX5v_t3PGdlwpPJbMW8lD16WrRoO0,8273
|
|
66
|
+
py_app_runner/wbcm/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
67
|
+
py_app_runner/wbcm/device_connections.py,sha256=GvHzqBGGpgoWuGVnkMzWKrxbWKVxzd-VGG9XUIcI0Vk,3288
|
|
68
|
+
py_app_runner/wbcm/factory.py,sha256=4lLIuVGFgvY8g0gYQShitN9kRXAJJPDpMExuSbJugoU,4046
|
|
69
|
+
py_app_runner/wbcm/wb_connection_manager.py,sha256=TAwyXA8FNywWK2DTkcx-zsT6RfFWQs9iccf4YnS1VSw,12315
|
|
70
|
+
py_app_runner/wbcm/ws_interface.py,sha256=HiULD518DuUnF0xvdL5roTQsVzJQEoHOlz1gN7s7zTE,1348
|
|
71
|
+
py_app_runner-0.5.49.dev0.dist-info/licenses/LICENSE,sha256=NClrlP01PpaS6SoxF7FRZQOIFOZoefTsJoW0Y9MzOKs,1080
|
|
72
|
+
py_app_runner-0.5.49.dev0.dist-info/METADATA,sha256=VnIWMNe3jTk9y2G_zZKYrY-bZah5zcRF9q_q01twuJo,6058
|
|
73
|
+
py_app_runner-0.5.49.dev0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
74
|
+
py_app_runner-0.5.49.dev0.dist-info/top_level.txt,sha256=-HU5naBcGqnM2P9ovTpBouINvi6MPL-vi_72PJzrWQw,14
|
|
75
|
+
py_app_runner-0.5.49.dev0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Gints Murāns
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
py_app_runner
|