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.
Files changed (75) hide show
  1. py_app_runner/__init__.py +11 -0
  2. py_app_runner/audit/__init__.py +29 -0
  3. py_app_runner/audit/_service.py +91 -0
  4. py_app_runner/audit/_service_args.py +44 -0
  5. py_app_runner/audit/audit.py +319 -0
  6. py_app_runner/audit/commands.py +151 -0
  7. py_app_runner/audit/diff.py +202 -0
  8. py_app_runner/audit/errors.py +8 -0
  9. py_app_runner/audit/event.py +130 -0
  10. py_app_runner/audit/store.py +134 -0
  11. py_app_runner/bridge/__init__.py +0 -0
  12. py_app_runner/bridge/_service.py +265 -0
  13. py_app_runner/bridge/_service_args.py +24 -0
  14. py_app_runner/bridge/api.py +138 -0
  15. py_app_runner/bridge/encoders/__init__.py +5 -0
  16. py_app_runner/bridge/encoders/base.py +24 -0
  17. py_app_runner/bridge/encoders/json_encoder.py +26 -0
  18. py_app_runner/bridge/encoders/msgpack_encoder.py +58 -0
  19. py_app_runner/bridge/web_app.py +31 -0
  20. py_app_runner/bridge/websocket.py +313 -0
  21. py_app_runner/colors.py +73 -0
  22. py_app_runner/config.py +132 -0
  23. py_app_runner/crypto/__init__.py +14 -0
  24. py_app_runner/crypto/_service.py +75 -0
  25. py_app_runner/crypto/_service_args.py +54 -0
  26. py_app_runner/crypto/commands.py +164 -0
  27. py_app_runner/crypto/envelope.py +144 -0
  28. py_app_runner/crypto/errors.py +8 -0
  29. py_app_runner/crypto/fields.py +300 -0
  30. py_app_runner/crypto/passwords.py +66 -0
  31. py_app_runner/db_pools.py +20 -0
  32. py_app_runner/http_exception.py +31 -0
  33. py_app_runner/logger_handlers.py +167 -0
  34. py_app_runner/migrations/__init__.py +5 -0
  35. py_app_runner/migrations/_service.py +296 -0
  36. py_app_runner/migrations/_service_args.py +91 -0
  37. py_app_runner/migrations/commands.py +386 -0
  38. py_app_runner/migrations/discovery.py +108 -0
  39. py_app_runner/migrations/states.py +63 -0
  40. py_app_runner/migrations/tracker.py +141 -0
  41. py_app_runner/py.typed +0 -0
  42. py_app_runner/pybridge.py +64 -0
  43. py_app_runner/queue/__init__.py +25 -0
  44. py_app_runner/queue/_service.py +231 -0
  45. py_app_runner/queue/_service_args.py +67 -0
  46. py_app_runner/queue/commands.py +180 -0
  47. py_app_runner/queue/driver_pg.py +464 -0
  48. py_app_runner/queue/driver_redis.py +613 -0
  49. py_app_runner/queue/handler.py +90 -0
  50. py_app_runner/queue/interface.py +63 -0
  51. py_app_runner/queue/job.py +46 -0
  52. py_app_runner/queue/worker.py +221 -0
  53. py_app_runner/registry.py +54 -0
  54. py_app_runner/request_handler/__init__.py +0 -0
  55. py_app_runner/request_handler/auth_service.py +123 -0
  56. py_app_runner/request_handler/decorators.py +304 -0
  57. py_app_runner/request_handler/handlers.py +604 -0
  58. py_app_runner/request_handler/pagination.py +24 -0
  59. py_app_runner/return_model.py +78 -0
  60. py_app_runner/runner.py +182 -0
  61. py_app_runner/throttle/__init__.py +5 -0
  62. py_app_runner/throttle/throttle.py +217 -0
  63. py_app_runner/tick_service.py +308 -0
  64. py_app_runner/timer.py +289 -0
  65. py_app_runner/utils.py +346 -0
  66. py_app_runner/wbcm/__init__.py +0 -0
  67. py_app_runner/wbcm/device_connections.py +89 -0
  68. py_app_runner/wbcm/factory.py +113 -0
  69. py_app_runner/wbcm/wb_connection_manager.py +333 -0
  70. py_app_runner/wbcm/ws_interface.py +56 -0
  71. py_app_runner-0.5.49.dev0.dist-info/METADATA +134 -0
  72. py_app_runner-0.5.49.dev0.dist-info/RECORD +75 -0
  73. py_app_runner-0.5.49.dev0.dist-info/WHEEL +5 -0
  74. py_app_runner-0.5.49.dev0.dist-info/licenses/LICENSE +21 -0
  75. 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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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