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,464 @@
1
+ """The PostgreSQL driver.
2
+
3
+ This is the one to reach for first, and the reason is the push: it is an INSERT, so it
4
+ joins the transaction that caused it. Queue the confirmation email inside the transaction
5
+ that writes the payment and either both happen or neither does. No other backend can offer
6
+ that, and it is the entire argument for keeping jobs in the application's own database
7
+ rather than somewhere faster.
8
+ """
9
+
10
+ import datetime
11
+ import json
12
+ import re
13
+ from typing import Any
14
+
15
+ import psycopg
16
+ from psycopg import sql
17
+
18
+ from py_app_runner.queue.job import Job, QueueError
19
+
20
+ # Table names reach SQL as identifiers, which cannot be bound. Quoted by Identifier, but
21
+ # whitelisted first for the same reason the audit table is.
22
+ _TABLE_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?$")
23
+
24
+ # A claim can be lost to another worker between the candidate select and the guarded
25
+ # update. Retrying a few times is cheaper than sleeping the whole poll interval when there
26
+ # is obviously work; giving up after five stops a hot queue from spinning here forever.
27
+ CLAIM_ROUNDS = 5
28
+
29
+ # Errors are cut rather than rejected. A traceback that will not fit must not be the reason
30
+ # a job cannot be recorded as failed.
31
+ MAX_ERROR_BYTES = 60000
32
+
33
+ _JOB_COLUMNS = "id, queue, name, payload, attempts, max_attempts, priority, unique_key"
34
+
35
+
36
+ def assert_table_name(table: str) -> str:
37
+ if _TABLE_RE.match(table) is None:
38
+ raise QueueError(f"{table!r} is not a plain table name")
39
+
40
+ return table
41
+
42
+
43
+ def _qualified(table: str) -> sql.Composed:
44
+ return sql.SQL(".").join(sql.Identifier(part) for part in table.split("."))
45
+
46
+
47
+ def fit_error(error: str) -> str:
48
+ raw = error.encode("utf-8")
49
+ if len(raw) <= MAX_ERROR_BYTES:
50
+ return error
51
+
52
+ # Cut on a character boundary: slicing bytes can split a multi-byte sequence and leave
53
+ # a value Postgres refuses as invalid UTF-8, which would turn a failed job into a
54
+ # failed *write*.
55
+ return raw[:MAX_ERROR_BYTES].decode("utf-8", errors="ignore") + "\n... truncated"
56
+
57
+
58
+ def encode_payload(payload: dict[str, Any]) -> str:
59
+ try:
60
+ return json.dumps(payload, ensure_ascii=False)
61
+ except (TypeError, ValueError) as e:
62
+ raise QueueError(f"A job payload has to be JSON encodable: {e}") from e
63
+
64
+
65
+ def column_names(cur: psycopg.AsyncCursor) -> list[str]:
66
+ """Column names of the last result set.
67
+
68
+ `description` is None for a statement that returned no result set at all. Every caller
69
+ here has just run a SELECT, so that is an invariant rather than a case - but reading it
70
+ unguarded is how a refactor that moves one of these onto a non-returning statement gets
71
+ a `NoneType is not iterable` a long way from the cause.
72
+ """
73
+
74
+ if cur.description is None:
75
+ raise QueueError("Expected a result set from the queue, and the statement returned none.")
76
+
77
+ return [d.name for d in cur.description]
78
+
79
+
80
+ def now_utc() -> datetime.datetime:
81
+ """Every time comparison binds a value computed here rather than using now().
82
+
83
+ The worker's clock and the database's are allowed to differ; what must not happen is
84
+ the two being mixed inside one comparison, which is what makes a job's due time depend
85
+ on which machine asked.
86
+ """
87
+
88
+ return datetime.datetime.now(datetime.UTC)
89
+
90
+
91
+ class PgQueue:
92
+ def __init__(
93
+ self,
94
+ conn: psycopg.AsyncConnection,
95
+ table: str = "queue_jobs",
96
+ failed_table: str = "queue_failed_jobs",
97
+ ) -> None:
98
+ self.conn = conn
99
+ self.table = assert_table_name(table)
100
+ self.failed_table = assert_table_name(failed_table)
101
+
102
+ ############
103
+ ### Push ###
104
+ ############
105
+
106
+ async def push(
107
+ self,
108
+ name: str,
109
+ payload: dict[str, Any] | None = None,
110
+ delay: int = 0,
111
+ queue: str = "default",
112
+ priority: int = 0,
113
+ unique: str | None = None,
114
+ max_attempts: int = 3,
115
+ ) -> int:
116
+ """Queue a job. Returns its id, or the id of the job already holding `unique`.
117
+
118
+ Deliberately not wrapped in a transaction of its own: the whole point is that it
119
+ joins whatever the caller already has open.
120
+ """
121
+
122
+ moment = now_utc()
123
+ key = unique or None
124
+
125
+ if key is not None:
126
+ existing = await self._id_for_unique(key)
127
+ if existing is not None:
128
+ return existing
129
+
130
+ row = {
131
+ "queue": queue or "default",
132
+ "name": name,
133
+ "payload": encode_payload(payload or {}),
134
+ "attempts": 0,
135
+ "max_attempts": max(1, max_attempts),
136
+ "priority": priority,
137
+ "unique_key": key,
138
+ "available_at": moment + datetime.timedelta(seconds=max(0, delay)),
139
+ "last_error": "",
140
+ "created_at": moment,
141
+ }
142
+
143
+ columns = list(row.keys())
144
+ statement = sql.SQL("INSERT INTO {rel} ({cols}) VALUES ({vals}) RETURNING id").format(
145
+ rel=_qualified(self.table),
146
+ cols=sql.SQL(", ").join(sql.Identifier(c) for c in columns),
147
+ vals=sql.SQL(", ").join(sql.Placeholder() for _ in columns),
148
+ )
149
+
150
+ try:
151
+ async with self.conn.cursor() as cur:
152
+ await cur.execute(statement, tuple(row[c] for c in columns))
153
+ returned = await cur.fetchone()
154
+ if returned is None:
155
+ # RETURNING on a successful single-row INSERT always yields a row, so
156
+ # this is an invariant rather than a case - but silently returning 0
157
+ # would hand the caller a job id that refers to nothing.
158
+ raise QueueError("The queue insert reported no id; the job may not have been queued.")
159
+
160
+ return int(returned[0])
161
+ except psycopg.errors.UniqueViolation:
162
+ # Lost the race for the unique key. On Postgres this aborts the surrounding
163
+ # transaction, so the caller has to decide what to do next - but reporting the
164
+ # winner's id is still the honest answer to "what is queued under this key".
165
+ existing = await self._id_for_unique(key) if key is not None else None
166
+ if existing is not None:
167
+ return existing
168
+
169
+ raise
170
+
171
+ async def _id_for_unique(self, key: str) -> int | None:
172
+ statement = sql.SQL("SELECT id FROM {rel} WHERE unique_key = %s").format(rel=_qualified(self.table))
173
+
174
+ async with self.conn.cursor() as cur:
175
+ await cur.execute(statement, (key,))
176
+ row = await cur.fetchone()
177
+ return int(row[0]) if row else None
178
+
179
+ ###############
180
+ ### Reserve ###
181
+ ###############
182
+
183
+ async def reserve(self, queues: list[str], timeout: int, worker: str) -> Job | None:
184
+ """Claim the next due job, or None.
185
+
186
+ `queues` is a precedence order, not a merged sort: ["high", "default"] drains high
187
+ completely first. That is what "priority queue" means to somebody running two of
188
+ them, and a merged sort would quietly make `high` mean "slightly sooner".
189
+ """
190
+
191
+ for queue in queues:
192
+ job = await self._reserve_from(queue, timeout, worker)
193
+ if job is not None:
194
+ return job
195
+
196
+ return None
197
+
198
+ async def _reserve_from(self, queue: str, timeout: int, worker: str) -> Job | None:
199
+ for _ in range(CLAIM_ROUNDS):
200
+ moment = now_utc()
201
+ until = moment + datetime.timedelta(seconds=max(1, timeout))
202
+
203
+ # One transaction around candidate-and-claim, so the FOR UPDATE lock still holds
204
+ # when the UPDATE runs. It ends before the handler is called: holding a
205
+ # transaction open for the length of a job is how a queue takes a database down
206
+ # with it.
207
+ async with self.conn.transaction():
208
+ candidate = await self._candidate(queue, moment)
209
+ if candidate is None:
210
+ return None
211
+
212
+ claimed = await self._claim(candidate, until, worker, moment)
213
+ if not claimed:
214
+ # Another worker got it between the select and the update. The guard did
215
+ # its job; try for a different one.
216
+ continue
217
+
218
+ row = await self._read(candidate)
219
+
220
+ if row is None:
221
+ continue
222
+
223
+ job = self._to_job(row)
224
+ if job is None:
225
+ # Undecodable payload. It is not going to decode on the next attempt either,
226
+ # so it goes straight to failed rather than burning its budget first.
227
+ await self._fail_row(row, "Payload is not valid JSON, so no handler could be given it.")
228
+ continue
229
+
230
+ return job
231
+
232
+ return None
233
+
234
+ async def _candidate(self, queue: str, moment: datetime.datetime) -> int | None:
235
+ statement = sql.SQL(
236
+ "SELECT id FROM {rel} "
237
+ "WHERE queue = %s AND available_at <= %s "
238
+ " AND (reserved_until IS NULL OR reserved_until <= %s) "
239
+ "ORDER BY priority DESC, available_at, id "
240
+ "LIMIT 1 FOR UPDATE SKIP LOCKED"
241
+ ).format(rel=_qualified(self.table))
242
+
243
+ async with self.conn.cursor() as cur:
244
+ await cur.execute(statement, (queue, moment, moment))
245
+ row = await cur.fetchone()
246
+ return int(row[0]) if row else None
247
+
248
+ async def _claim(self, job_id: int, until: datetime.datetime, worker: str, moment: datetime.datetime) -> bool:
249
+ """The guarded update.
250
+
251
+ The guard in the WHERE - not SKIP LOCKED - is what makes the claim safe. SKIP LOCKED
252
+ only stops workers queueing behind each other; remove the guard and two workers that
253
+ both read the same candidate would both believe they hold it. This is exactly the
254
+ line a later simplification would delete, so it is spelled out here.
255
+ """
256
+
257
+ statement = sql.SQL(
258
+ "UPDATE {rel} SET reserved_until = %s, reserved_by = %s, attempts = attempts + 1 "
259
+ "WHERE id = %s AND (reserved_until IS NULL OR reserved_until <= %s)"
260
+ ).format(rel=_qualified(self.table))
261
+
262
+ async with self.conn.cursor() as cur:
263
+ await cur.execute(statement, (until, worker[:64], job_id, moment))
264
+ return cur.rowcount == 1
265
+
266
+ async def _read(self, job_id: int) -> dict[str, Any] | None:
267
+ statement = sql.SQL("SELECT " + _JOB_COLUMNS + " FROM {rel} WHERE id = %s").format(rel=_qualified(self.table))
268
+
269
+ async with self.conn.cursor() as cur:
270
+ await cur.execute(statement, (job_id,))
271
+ row = await cur.fetchone()
272
+ if row is None:
273
+ return None
274
+
275
+ return dict(zip(column_names(cur), row, strict=True))
276
+
277
+ def _to_job(self, row: dict[str, Any]) -> Job | None:
278
+ try:
279
+ payload = json.loads(row["payload"])
280
+ except (TypeError, ValueError):
281
+ return None
282
+
283
+ if not isinstance(payload, dict):
284
+ return None
285
+
286
+ return Job(
287
+ id=int(row["id"]),
288
+ queue=row["queue"],
289
+ name=row["name"],
290
+ payload=payload,
291
+ payload_json=row["payload"],
292
+ attempts=int(row["attempts"]),
293
+ max_attempts=int(row["max_attempts"]),
294
+ )
295
+
296
+ ##################
297
+ ### Completion ###
298
+ ##################
299
+
300
+ async def delete(self, job: Job) -> None:
301
+ statement = sql.SQL("DELETE FROM {rel} WHERE id = %s").format(rel=_qualified(self.table))
302
+
303
+ async with self.conn.cursor() as cur:
304
+ await cur.execute(statement, (job.id,))
305
+
306
+ async def release(self, job: Job, delay: int = 0, error: str = "") -> None:
307
+ """Put a job back. `attempts` is untouched - the claim already counted it."""
308
+
309
+ statement = sql.SQL(
310
+ "UPDATE {rel} SET reserved_until = NULL, reserved_by = NULL, available_at = %s, last_error = %s "
311
+ "WHERE id = %s"
312
+ ).format(rel=_qualified(self.table))
313
+
314
+ available = now_utc() + datetime.timedelta(seconds=max(0, delay))
315
+
316
+ async with self.conn.cursor() as cur:
317
+ await cur.execute(statement, (available, fit_error(error), job.id))
318
+
319
+ async def fail(self, job: Job, error: str) -> None:
320
+ await self._move_to_failed(
321
+ job_id=job.id,
322
+ queue=job.queue,
323
+ name=job.name,
324
+ payload=job.payload_json,
325
+ attempts=job.attempts,
326
+ error=error,
327
+ )
328
+
329
+ async def _fail_row(self, row: dict[str, Any], error: str) -> None:
330
+ await self._move_to_failed(
331
+ job_id=int(row["id"]),
332
+ queue=row["queue"],
333
+ name=row["name"],
334
+ payload=row["payload"],
335
+ attempts=int(row["attempts"]),
336
+ error=error,
337
+ )
338
+
339
+ async def _move_to_failed(
340
+ self, job_id: int, queue: str, name: str, payload: str, attempts: int, error: str
341
+ ) -> None:
342
+ insert = sql.SQL(
343
+ "INSERT INTO {rel} (queue, name, payload, attempts, error, failed_at) VALUES (%s, %s, %s, %s, %s, %s)"
344
+ ).format(rel=_qualified(self.failed_table))
345
+ delete = sql.SQL("DELETE FROM {rel} WHERE id = %s").format(rel=_qualified(self.table))
346
+
347
+ # One transaction so a job cannot be in both tables or neither.
348
+ async with self.conn.transaction():
349
+ async with self.conn.cursor() as cur:
350
+ await cur.execute(insert, (queue, name, payload, attempts, fit_error(error), now_utc()))
351
+ await cur.execute(delete, (job_id,))
352
+
353
+ ###############
354
+ ### Reports ###
355
+ ###############
356
+
357
+ async def pending(self, queue: str | None = None) -> int:
358
+ """How many could be picked up right now: excludes delayed jobs that are not due and
359
+ jobs another worker currently holds."""
360
+
361
+ moment = now_utc()
362
+ condition = sql.SQL("available_at <= %s AND (reserved_until IS NULL OR reserved_until <= %s)")
363
+ params: tuple[Any, ...] = (moment, moment)
364
+
365
+ if queue is not None:
366
+ condition = sql.SQL("queue = %s AND ") + condition
367
+ params = (queue, *params)
368
+
369
+ statement = sql.SQL("SELECT count(*) FROM {rel} WHERE {cond}").format(
370
+ rel=_qualified(self.table), cond=condition
371
+ )
372
+
373
+ async with self.conn.cursor() as cur:
374
+ await cur.execute(statement, params)
375
+ row = await cur.fetchone()
376
+ return int(row[0]) if row else 0
377
+
378
+ async def stats(self) -> list[dict[str, Any]]:
379
+ moment = now_utc()
380
+ statement = sql.SQL(
381
+ "SELECT queue,"
382
+ " count(*) FILTER (WHERE available_at <= %s"
383
+ " AND (reserved_until IS NULL OR reserved_until <= %s)) AS pending,"
384
+ " count(*) FILTER (WHERE available_at > %s) AS delayed,"
385
+ " count(*) FILTER (WHERE reserved_until > %s) AS reserved,"
386
+ " count(*) AS total "
387
+ "FROM {rel} GROUP BY queue ORDER BY queue"
388
+ ).format(rel=_qualified(self.table))
389
+
390
+ async with self.conn.cursor() as cur:
391
+ await cur.execute(statement, (moment, moment, moment, moment))
392
+ names = column_names(cur)
393
+ return [dict(zip(names, row, strict=True)) for row in await cur.fetchall()]
394
+
395
+ async def failed_count(self) -> int:
396
+ statement = sql.SQL("SELECT count(*) FROM {rel}").format(rel=_qualified(self.failed_table))
397
+
398
+ async with self.conn.cursor() as cur:
399
+ await cur.execute(statement)
400
+ row = await cur.fetchone()
401
+ return int(row[0]) if row else 0
402
+
403
+ async def failed_rows(self, limit: int) -> list[dict[str, Any]]:
404
+ statement = sql.SQL(
405
+ "SELECT id, failed_at, queue, name, attempts, error FROM {rel} ORDER BY failed_at DESC, id DESC LIMIT %s"
406
+ ).format(rel=_qualified(self.failed_table))
407
+
408
+ async with self.conn.cursor() as cur:
409
+ await cur.execute(statement, (max(1, limit),))
410
+ names = column_names(cur)
411
+ return [dict(zip(names, row, strict=True)) for row in await cur.fetchall()]
412
+
413
+ async def retry_failed(self, job_id: int | None, max_attempts: int) -> int:
414
+ """Move failed jobs back onto the queue. Returns how many were requeued.
415
+
416
+ One transaction per job rather than one for the batch: four hundred failed jobs
417
+ should not be an all-or-nothing operation, and an interrupted run leaves the ones it
418
+ already moved on the queue.
419
+ """
420
+
421
+ select = sql.SQL("SELECT id, queue, name, payload FROM {rel}").format(rel=_qualified(self.failed_table))
422
+ params: tuple[Any, ...] = ()
423
+ if job_id is not None:
424
+ select = select + sql.SQL(" WHERE id = %s")
425
+ params = (job_id,)
426
+
427
+ async with self.conn.cursor() as cur:
428
+ await cur.execute(select + sql.SQL(" ORDER BY id"), params)
429
+ rows = await cur.fetchall()
430
+
431
+ insert = sql.SQL(
432
+ "INSERT INTO {rel} (queue, name, payload, attempts, max_attempts, priority, unique_key,"
433
+ " available_at, last_error, created_at) "
434
+ "VALUES (%s, %s, %s, 0, %s, 0, NULL, %s, '', %s)"
435
+ ).format(rel=_qualified(self.table))
436
+ delete = sql.SQL("DELETE FROM {rel} WHERE id = %s").format(rel=_qualified(self.failed_table))
437
+
438
+ requeued = 0
439
+ for failed_id, queue, name, payload in rows:
440
+ moment = now_utc()
441
+ async with self.conn.transaction():
442
+ async with self.conn.cursor() as cur:
443
+ await cur.execute(insert, (queue, name, payload, max(1, max_attempts), moment, moment))
444
+ await cur.execute(delete, (failed_id,))
445
+ requeued += 1
446
+
447
+ return requeued
448
+
449
+ async def forget_failed(self, job_id: int | None, before: str | None) -> int:
450
+ statement = sql.SQL("DELETE FROM {rel}").format(rel=_qualified(self.failed_table))
451
+ params: tuple[Any, ...] = ()
452
+
453
+ if job_id is not None:
454
+ statement = statement + sql.SQL(" WHERE id = %s")
455
+ params = (job_id,)
456
+ elif before is not None:
457
+ statement = statement + sql.SQL(" WHERE failed_at < %s")
458
+ params = (before,)
459
+
460
+ # With neither argument this deletes everything, which is what --all means. The CLI
461
+ # is what insists one of the three was given.
462
+ async with self.conn.cursor() as cur:
463
+ await cur.execute(statement, params)
464
+ return cur.rowcount