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,11 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ from py_app_runner.registry import AppRegistry
4
+
5
+ __all__ = ["AppRegistry"]
6
+
7
+ try:
8
+ __version__ = version("py_app_runner")
9
+ except PackageNotFoundError:
10
+ # Running straight from a source tree that was never installed
11
+ __version__ = "0.0.dev0"
@@ -0,0 +1,29 @@
1
+ """An append-only trail of who changed what, written explicitly at the call site."""
2
+
3
+ from py_app_runner.audit.audit import Audit
4
+ from py_app_runner.audit.errors import AuditError
5
+ from py_app_runner.audit.event import (
6
+ CREATED,
7
+ DELETED,
8
+ UPDATED,
9
+ Actor,
10
+ AuditEvent,
11
+ RequestContext,
12
+ current_context,
13
+ new_request_id,
14
+ request_context,
15
+ )
16
+
17
+ __all__ = [
18
+ "CREATED",
19
+ "DELETED",
20
+ "UPDATED",
21
+ "Actor",
22
+ "Audit",
23
+ "AuditError",
24
+ "AuditEvent",
25
+ "RequestContext",
26
+ "current_context",
27
+ "new_request_id",
28
+ "request_context",
29
+ ]
@@ -0,0 +1,91 @@
1
+ import asyncio
2
+ import logging
3
+ import pathlib
4
+ from argparse import Namespace
5
+ from datetime import UTC, datetime
6
+
7
+ import psycopg
8
+
9
+ from py_app_runner.audit.commands import Out, cmd_install, cmd_prune
10
+ from py_app_runner.audit.errors import AuditError
11
+ from py_app_runner.migrations._service import connect_kwargs, resolve_targets
12
+ from py_app_runner.pybridge import PyBridge
13
+ from py_app_runner.registry import AppRegistry
14
+
15
+ _DEFAULT_DB = "main"
16
+ _DEFAULT_TABLE = "audit_log"
17
+
18
+
19
+ def _migrations_dir(args: Namespace, config: dict) -> pathlib.Path:
20
+ """Where `install` writes.
21
+
22
+ The file it produces is a migration and belongs wherever the rest of them are, so the
23
+ default comes from the migrations config rather than from an audit key of its own.
24
+ """
25
+
26
+ raw = getattr(args, "dir", None)
27
+ if raw:
28
+ directory = pathlib.Path(raw)
29
+ if directory.is_absolute():
30
+ return directory
31
+
32
+ return pathlib.Path(config.get("current_path") or ".") / directory
33
+
34
+ targets = resolve_targets(config)
35
+ return next(iter(targets.values())).directory
36
+
37
+
38
+ async def init_service(args: Namespace, _pybridge: PyBridge, logger: logging.Logger) -> None:
39
+ out: Out = print
40
+
41
+ # Same reasoning as migrations: runner.py catches Exception around init_service and
42
+ # returns normally, which exits 0. A prune that could not reach the database must not
43
+ # report success to a retention job.
44
+ code = 1
45
+ try:
46
+ config = AppRegistry.config()
47
+ settings = config.get("audit") or {}
48
+ table = getattr(args, "table", None) or settings.get("table") or _DEFAULT_TABLE
49
+
50
+ if args.step == "install":
51
+ raise SystemExit(cmd_install(_migrations_dir(args, config), table, datetime.now(UTC), out))
52
+
53
+ db_name = getattr(args, "db", None) or settings.get("db") or _DEFAULT_DB
54
+ db_config = config.get("db") or {}
55
+ if db_name not in db_config:
56
+ out(
57
+ f'error: no database {db_name!r} in config["db"]; '
58
+ f"configured are: {', '.join(sorted(db_config)) or 'none'}."
59
+ )
60
+ raise SystemExit(2)
61
+
62
+ if args.step == "prune":
63
+ async with await psycopg.AsyncConnection.connect(**connect_kwargs(db_config[db_name])) as conn:
64
+ code = await cmd_prune(
65
+ conn,
66
+ table,
67
+ args.before,
68
+ args.batch,
69
+ getattr(args, "dry_run", False),
70
+ out,
71
+ )
72
+ else:
73
+ out(f"error: unknown audit command {args.step!r}")
74
+ code = 1
75
+
76
+ except AuditError as e:
77
+ # Configuration and refusal messages already say what to do; a stack trace above
78
+ # them would bury it.
79
+ out(f"error: {e}")
80
+ raise SystemExit(1) from None
81
+ except (KeyboardInterrupt, asyncio.CancelledError):
82
+ # Above `except Exception` because both derive from BaseException. An interrupted
83
+ # prune has deleted some batches and not others - safe to resume, but it must not
84
+ # be reported as complete.
85
+ logger.error("audit: interrupted; the prune is partially done")
86
+ raise SystemExit(1) from None
87
+ except Exception:
88
+ logger.exception("audit: unhandled failure")
89
+ raise SystemExit(1) from None
90
+
91
+ raise SystemExit(code)
@@ -0,0 +1,44 @@
1
+ """CLI subparsers for the built-in audit service.
2
+
3
+ python3 src/app.py audit install [--dir PATH] [--table NAME]
4
+ python3 src/app.py audit prune --before YYYY-MM-DD [--batch N] [--dry-run] [--db NAME]
5
+
6
+ `--dry-run` shadows a real top-level flag on runner.py's parser and needs
7
+ `default=SUPPRESS`, the same collision migrations' `apply` has.
8
+ """
9
+
10
+ import logging
11
+ from argparse import SUPPRESS, ArgumentParser, _SubParsersAction # type: ignore
12
+
13
+ from py_app_runner.pybridge import PyBridge
14
+
15
+
16
+ def reg_subparsers(
17
+ subparsers: "_SubParsersAction[ArgumentParser]",
18
+ _pybridge: PyBridge,
19
+ _base_logger: logging.Logger,
20
+ ) -> None:
21
+ """Command line subparsers"""
22
+
23
+ parser = subparsers.add_parser(
24
+ "audit",
25
+ description="Install the audit trail schema and prune old rows",
26
+ help="Audit trail",
27
+ )
28
+ group = parser.add_subparsers(title="command", dest="step", required=True)
29
+
30
+ install_parser = group.add_parser("install", help="Write the audit schema into the migrations directory")
31
+ install_parser.add_argument("--dir", default=None, help="Migrations directory to write into")
32
+ install_parser.add_argument("--table", default=None, help="Audit table (default: from config)")
33
+
34
+ prune_parser = group.add_parser("prune", help="Delete trail rows older than a date")
35
+ prune_parser.add_argument("--before", required=True, help="Delete rows older than this, YYYY-MM-DD")
36
+ prune_parser.add_argument("--batch", type=int, default=10000, help="Rows per statement (default: 10000)")
37
+ prune_parser.add_argument("--table", default=None, help="Audit table (default: from config)")
38
+ prune_parser.add_argument("--db", default=None, help='Entry of config["db"] to prune in (default: main)')
39
+ prune_parser.add_argument(
40
+ "--dry-run",
41
+ action="store_true",
42
+ default=SUPPRESS,
43
+ help="Count what would go, delete nothing",
44
+ )
@@ -0,0 +1,319 @@
1
+ """The call-site API.
2
+
3
+ Every audit row is the result of an explicit call. There is no ORM hook, no middleware and
4
+ no database trigger, because a trail that writes itself is one nobody can reason about at
5
+ the call site - and because a hook that does not fire produces a silent gap rather than a
6
+ loud error.
7
+
8
+ await audit.insert(cur, "people", {"name": "Anna"}, module="hr")
9
+ await audit.update(cur, "people", {"status": "left"}, {"id": 42}, module="hr")
10
+
11
+ `update` and `delete` read the affected rows before writing, so old values are recorded
12
+ without hand-written before-fetches at every call site.
13
+ """
14
+
15
+ import logging
16
+ from typing import Any
17
+
18
+ import psycopg
19
+ from psycopg import sql
20
+
21
+ from py_app_runner.audit import diff
22
+ from py_app_runner.audit.errors import AuditError
23
+ from py_app_runner.audit.event import CREATED, DELETED, UPDATED, AuditEvent, current_context
24
+ from py_app_runner.audit.store import Store, assert_table_name, qualified
25
+
26
+ _logger = logging.getLogger(__name__)
27
+
28
+ _DEFAULTS: dict[str, Any] = {
29
+ "table": "audit_log",
30
+ "strict": True,
31
+ "max_rows": 1000,
32
+ "id_key": "id",
33
+ "exclude": {},
34
+ }
35
+
36
+
37
+ class Audit:
38
+ def __init__(
39
+ self,
40
+ table: str = "audit_log",
41
+ strict: bool = True,
42
+ max_rows: int = 1000,
43
+ id_key: str = "id",
44
+ exclude: dict[str, list[str]] | None = None,
45
+ ) -> None:
46
+ self.store = Store(table)
47
+ self.strict = strict
48
+ self.max_rows = max_rows
49
+ self.id_key = id_key
50
+ self.exclude = self._validated_exclude(exclude or {})
51
+
52
+ @classmethod
53
+ def from_config(cls, config: dict[str, Any]) -> "Audit":
54
+ settings = {**_DEFAULTS, **(config.get("audit") or {})}
55
+
56
+ max_rows = settings["max_rows"]
57
+ if not isinstance(max_rows, int) or isinstance(max_rows, bool):
58
+ raise AuditError(
59
+ f'config["audit"]["max_rows"] must be an int; got {max_rows!r}. '
60
+ f"A string here would compare against a row count and silently never match."
61
+ )
62
+
63
+ return cls(
64
+ table=settings["table"],
65
+ strict=settings["strict"] is not False,
66
+ max_rows=max_rows,
67
+ id_key=settings["id_key"] or "id",
68
+ exclude=settings["exclude"] or {},
69
+ )
70
+
71
+ def _validated_exclude(self, exclude: dict[str, list[str]]) -> dict[str, list[str]]:
72
+ """Reject a malformed exclude block at construction.
73
+
74
+ Redaction fails open by its nature: a lookup that finds nothing to exclude simply
75
+ excludes nothing, so a mistake here leaks exactly the values it was meant to
76
+ withhold, silently and forever. Table names cannot be checked against the database
77
+ from here, but the shape can be, and that catches the common mistake of writing a
78
+ bare list where a mapping belongs.
79
+ """
80
+
81
+ if not isinstance(exclude, dict):
82
+ raise AuditError(
83
+ f'config["audit"]["exclude"] must be a mapping of table name -> list of '
84
+ f"columns; got {type(exclude).__name__}."
85
+ )
86
+
87
+ for table, columns in exclude.items():
88
+ if not isinstance(columns, (list, tuple)) or not all(isinstance(c, str) for c in columns):
89
+ raise AuditError(
90
+ f'config["audit"]["exclude"][{table!r}] must be a list of column names; '
91
+ f"got {columns!r}. Anything unreadable here is a redaction that will not happen."
92
+ )
93
+
94
+ return {table: list(columns) for table, columns in exclude.items()}
95
+
96
+ def excluded(self, table: str) -> list[str]:
97
+ return self.exclude.get(table, [])
98
+
99
+ ################
100
+ ### Recording ##
101
+ ################
102
+
103
+ async def record(self, cur: psycopg.AsyncCursor, event: AuditEvent) -> None:
104
+ """Write one event, on the caller's cursor and inside the caller's transaction."""
105
+
106
+ try:
107
+ await self.store.write(cur, event.with_resolved(current_context()))
108
+ except Exception as e:
109
+ self._fail(e)
110
+
111
+ def _fail(self, error: Exception) -> None:
112
+ if self.strict:
113
+ if isinstance(error, AuditError):
114
+ raise error
115
+
116
+ raise AuditError(f"Audit trail: {error}") from error
117
+
118
+ # Availability over completeness, which is rarely the trade an audit trail wants to
119
+ # make. It is a choice, not a silence: the line is always logged.
120
+ #
121
+ # Note what this cannot do on Postgres: a failed INSERT aborts the surrounding
122
+ # transaction, so swallowing the exception does not rescue the change - it converts
123
+ # a clear failure into an unattributable one at commit. Callers running inside a
124
+ # transaction should use a SAVEPOINT around the change if they set strict=False.
125
+ _logger.warning("Audit trail: %s", error)
126
+
127
+ ################
128
+ ### Wrappers ###
129
+ ################
130
+
131
+ async def insert(
132
+ self,
133
+ cur: psycopg.AsyncCursor,
134
+ table: str,
135
+ data: dict[str, Any],
136
+ module: str = "",
137
+ entity_id: str | None = None,
138
+ tags: list[str] | None = None,
139
+ context: dict[str, Any] | None = None,
140
+ ) -> Any:
141
+ """Insert a row and record it. Returns the inserted key.
142
+
143
+ Always uses RETURNING rather than reading the id back afterwards. Postgres cannot
144
+ answer "what id did I just insert" without being told which sequence to look at, so
145
+ every fallback for it either guesses or returns nothing - and returning nothing here
146
+ quietly empties the column `idx_audit_log_entity` exists to search.
147
+ """
148
+
149
+ assert_table_name(table)
150
+ columns = list(data.keys())
151
+
152
+ statement = sql.SQL("INSERT INTO {rel} ({cols}) VALUES ({vals}) RETURNING {id}").format(
153
+ rel=qualified(table),
154
+ cols=sql.SQL(", ").join(sql.Identifier(c) for c in columns),
155
+ vals=sql.SQL(", ").join(sql.Placeholder() for _ in columns),
156
+ id=sql.Identifier(self.id_key),
157
+ )
158
+
159
+ await cur.execute(statement, tuple(data[c] for c in columns))
160
+ returned = await cur.fetchone()
161
+ new_id = returned[0] if returned else None
162
+
163
+ _, new_values = diff.between(None, data, self.excluded(table))
164
+
165
+ await self.record(
166
+ cur,
167
+ AuditEvent(
168
+ event=CREATED,
169
+ entity_type=table,
170
+ entity_id=str(entity_id if entity_id is not None else (new_id if new_id is not None else "")),
171
+ module=module,
172
+ new_values=new_values,
173
+ tags=tags or [],
174
+ context=context,
175
+ ),
176
+ )
177
+
178
+ return new_id
179
+
180
+ async def update(
181
+ self,
182
+ cur: psycopg.AsyncCursor,
183
+ table: str,
184
+ data: dict[str, Any],
185
+ where: dict[str, Any],
186
+ module: str = "",
187
+ tags: list[str] | None = None,
188
+ context: dict[str, Any] | None = None,
189
+ ) -> int:
190
+ """Update rows and record one event per row that actually changed."""
191
+
192
+ assert_table_name(table)
193
+ rows = await self._rows(cur, table, where)
194
+ self._assert_within_limit(table, len(rows))
195
+
196
+ columns = list(data.keys())
197
+ statement = sql.SQL("UPDATE {rel} SET {sets} WHERE {cond}").format(
198
+ rel=qualified(table),
199
+ sets=sql.SQL(", ").join(sql.SQL("{} = {}").format(sql.Identifier(c), sql.Placeholder()) for c in columns),
200
+ cond=self._condition(where),
201
+ )
202
+
203
+ await cur.execute(statement, tuple(data[c] for c in columns) + tuple(where.values()))
204
+ affected = cur.rowcount
205
+
206
+ if not rows:
207
+ # Not an error, but it is what a mistyped condition looks like.
208
+ _logger.debug("Audit trail: update on %r matched no rows", table)
209
+
210
+ excluded = self.excluded(table)
211
+ for row in rows:
212
+ old_values, new_values = diff.between(row, data, excluded)
213
+ if new_values is None:
214
+ continue
215
+
216
+ await self.record(
217
+ cur,
218
+ AuditEvent(
219
+ event=UPDATED,
220
+ entity_type=table,
221
+ entity_id=self._row_id(row),
222
+ module=module,
223
+ old_values=old_values,
224
+ new_values=new_values,
225
+ tags=tags or [],
226
+ context=context,
227
+ ),
228
+ )
229
+
230
+ return affected
231
+
232
+ async def delete(
233
+ self,
234
+ cur: psycopg.AsyncCursor,
235
+ table: str,
236
+ where: dict[str, Any],
237
+ module: str = "",
238
+ tags: list[str] | None = None,
239
+ context: dict[str, Any] | None = None,
240
+ ) -> int:
241
+ """Delete rows and record each one, carrying the whole row as old values."""
242
+
243
+ assert_table_name(table)
244
+
245
+ if not where:
246
+ # An empty condition builds no WHERE at all, so it deletes the table and audits
247
+ # every row of it. Nothing below this call would refuse that, so it is refused
248
+ # here.
249
+ raise AuditError(
250
+ f"Refusing to delete every row of {table!r} with an empty condition. "
251
+ f"Pass an explicit condition, or run the DELETE yourself and record it with record()."
252
+ )
253
+
254
+ rows = await self._rows(cur, table, where)
255
+ self._assert_within_limit(table, len(rows))
256
+
257
+ statement = sql.SQL("DELETE FROM {rel} WHERE {cond}").format(rel=qualified(table), cond=self._condition(where))
258
+ await cur.execute(statement, tuple(where.values()))
259
+ affected = cur.rowcount
260
+
261
+ excluded = self.excluded(table)
262
+ for row in rows:
263
+ old_values, _ = diff.between(row, None, excluded)
264
+
265
+ await self.record(
266
+ cur,
267
+ AuditEvent(
268
+ event=DELETED,
269
+ entity_type=table,
270
+ entity_id=self._row_id(row),
271
+ module=module,
272
+ old_values=old_values,
273
+ tags=tags or [],
274
+ context=context,
275
+ ),
276
+ )
277
+
278
+ return affected
279
+
280
+ ###############
281
+ ### Helpers ###
282
+ ###############
283
+
284
+ def _condition(self, where: dict[str, Any]) -> sql.Composed:
285
+ return sql.SQL(" AND ").join(sql.SQL("{} = {}").format(sql.Identifier(c), sql.Placeholder()) for c in where)
286
+
287
+ async def _rows(self, cur: psycopg.AsyncCursor, table: str, where: dict[str, Any]) -> list[dict[str, Any]]:
288
+ """Read the rows a change is about to affect, as dicts."""
289
+
290
+ statement = sql.SQL("SELECT * FROM {rel}{cond}").format(
291
+ rel=qualified(table),
292
+ cond=sql.SQL(" WHERE ") + self._condition(where) if where else sql.SQL(""),
293
+ )
294
+
295
+ await cur.execute(statement, tuple(where.values()))
296
+ if cur.description is None:
297
+ return []
298
+
299
+ names = [d.name for d in cur.description]
300
+ return [dict(zip(names, row, strict=True)) for row in await cur.fetchall()]
301
+
302
+ def _row_id(self, row: dict[str, Any]) -> str:
303
+ value = row.get(self.id_key)
304
+ return "" if value is None else str(value)
305
+
306
+ def _assert_within_limit(self, table: str, matched: int) -> None:
307
+ """Checked before the write, so a mistyped condition matching the whole table is
308
+ refused rather than becoming one write plus half a million audit rows."""
309
+
310
+ if self.max_rows < 1 or matched <= self.max_rows:
311
+ return
312
+
313
+ self._fail(
314
+ AuditError(
315
+ f"Refusing to audit {matched} rows of {table!r} in one call; "
316
+ f'config["audit"]["max_rows"] is {self.max_rows}. Narrow the condition, or raise '
317
+ f"the limit."
318
+ )
319
+ )
@@ -0,0 +1,151 @@
1
+ """Command implementations, importable directly for programmatic use."""
2
+
3
+ import datetime
4
+ import pathlib
5
+ import re
6
+ from collections.abc import Callable
7
+
8
+ import psycopg
9
+ from psycopg import sql
10
+
11
+ from py_app_runner.audit.errors import AuditError
12
+ from py_app_runner.audit.store import assert_table_name, qualified
13
+ from py_app_runner.migrations.discovery import MigrationError, new_filename
14
+
15
+ Out = Callable[[str], None]
16
+
17
+ _TEMPLATE = pathlib.Path(__file__).parent / "files" / "install.pgsql.sql"
18
+
19
+ # No relative forms. "yesterday" in a retention job is a question about whose clock, and
20
+ # the answer only ever surfaces once rows are gone.
21
+ _BEFORE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}( \d{2}:\d{2}:\d{2})?$")
22
+
23
+
24
+ def cmd_install(migrations_dir: pathlib.Path, table: str, now: datetime.datetime, out: Out) -> int:
25
+ """Write the schema into the project's own migrations directory.
26
+
27
+ The framework ships no migration of its own and never creates the table at runtime:
28
+ migrations are discovered by filename order and checksummed once applied, so a
29
+ framework-owned file would sort by dependency release date and turn every upgrade into
30
+ checksum drift on a file the application cannot edit.
31
+ """
32
+
33
+ try:
34
+ assert_table_name(table)
35
+ except AuditError as e:
36
+ out(f"error: {e}")
37
+ return 2
38
+
39
+ if not migrations_dir.is_dir():
40
+ out(f"error: no migrations directory at {migrations_dir}")
41
+ return 2
42
+
43
+ try:
44
+ schema = _TEMPLATE.read_text(encoding="utf-8")
45
+ except OSError as e:
46
+ out(f"error: could not read {_TEMPLATE}: {e}")
47
+ return 1
48
+
49
+ if table != "audit_log":
50
+ # Renames the indexes along with the table, so two trails can coexist in one schema
51
+ # without their index names colliding.
52
+ schema = schema.replace("audit_log", table)
53
+
54
+ try:
55
+ target = migrations_dir / new_filename(f"create {table}", now)
56
+ except MigrationError as e:
57
+ out(f"error: {e}")
58
+ return 2
59
+
60
+ if target.exists():
61
+ out(f"error: {target} already exists")
62
+ return 1
63
+
64
+ try:
65
+ target.write_text(schema, encoding="utf-8")
66
+ except OSError as e:
67
+ out(f"error: could not write {target}: {e}")
68
+ return 1
69
+
70
+ out(f"Wrote {target}")
71
+ out("Review it, then: python3 src/app.py migrations apply")
72
+
73
+ return 0
74
+
75
+
76
+ async def cmd_prune(
77
+ conn: psycopg.AsyncConnection,
78
+ table: str,
79
+ before: str,
80
+ batch: int,
81
+ dry_run: bool,
82
+ out: Out,
83
+ ) -> int:
84
+ """Delete trail rows older than a date, in batches.
85
+
86
+ Batched because a single DELETE over a year of a busy trail takes a lock long enough to
87
+ be noticed, and because an interrupted run should leave the work it already did done.
88
+ """
89
+
90
+ try:
91
+ assert_table_name(table)
92
+ except AuditError as e:
93
+ out(f"error: {e}")
94
+ return 2
95
+
96
+ if _BEFORE_RE.match(before) is None:
97
+ out(f"error: --before={before!r} must be YYYY-MM-DD or 'YYYY-MM-DD HH:MM:SS'")
98
+ return 2
99
+
100
+ if batch < 1:
101
+ out("error: --batch must be at least 1")
102
+ return 2
103
+
104
+ relation = qualified(table)
105
+
106
+ try:
107
+ async with conn.cursor() as cur:
108
+ await cur.execute(
109
+ sql.SQL("SELECT count(*) FROM {rel} WHERE created_at < %s").format(rel=relation),
110
+ (before,),
111
+ )
112
+ row = await cur.fetchone()
113
+ total = row[0] if row else 0
114
+ except psycopg.Error as e:
115
+ out(f"error: cannot read {table}: {e}")
116
+ return 1
117
+
118
+ out(f"{total} rows in {table} older than {before}")
119
+
120
+ if total == 0:
121
+ return 0
122
+
123
+ if dry_run:
124
+ out("Nothing deleted (--dry-run).")
125
+ return 0
126
+
127
+ statement = sql.SQL(
128
+ "DELETE FROM {rel} WHERE id IN (SELECT id FROM {rel} WHERE created_at < %s ORDER BY id LIMIT %s)"
129
+ ).format(rel=relation)
130
+
131
+ deleted = 0
132
+ while deleted < total:
133
+ try:
134
+ async with conn.cursor() as cur:
135
+ await cur.execute(statement, (before, batch))
136
+ removed = cur.rowcount
137
+ except psycopg.Error as e:
138
+ out(f"error: cannot delete from {table}: {e}")
139
+ return 1
140
+
141
+ if removed <= 0:
142
+ # Nothing left to take. Breaking rather than looping keeps a miscounted total
143
+ # from spinning forever.
144
+ break
145
+
146
+ deleted += removed
147
+ out(f"Deleted {deleted}/{total}")
148
+
149
+ out(f"Done. {deleted} rows removed.")
150
+
151
+ return 0