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,386 @@
1
+ import pathlib
2
+ import time
3
+ from collections.abc import Callable
4
+ from datetime import datetime
5
+
6
+ import psycopg
7
+
8
+ from py_app_runner.migrations.discovery import (
9
+ MigrationError,
10
+ count_statements,
11
+ discover,
12
+ find_meta_commands,
13
+ load_migration,
14
+ new_filename,
15
+ )
16
+ from py_app_runner.migrations.states import MigrationState, State, blocking, compute_states, pending
17
+ from py_app_runner.migrations.tracker import Tracker
18
+
19
+ Out = Callable[[str], None]
20
+
21
+
22
+ async def _load_states(
23
+ conn: psycopg.AsyncConnection, directory: pathlib.Path, table: str
24
+ ) -> tuple[Tracker, list[MigrationState]]:
25
+ tracker = Tracker(conn, table)
26
+ await tracker.ensure_table()
27
+ files = discover(directory)
28
+ rows = await tracker.applied_rows()
29
+
30
+ return tracker, compute_states(files, rows)
31
+
32
+
33
+ def _report_blocking(states: list[MigrationState], table: str, out: Out) -> None:
34
+ """The two blocking states need different advice. `repair` re-reads the file to re-stamp
35
+ its checksum, so it is the DRIFT remedy and is useless for MISSING - pointing an operator
36
+ at it there just earns them "no such migration file"."""
37
+
38
+ blocked = blocking(states)
39
+ for state in blocked:
40
+ if state.state is State.DRIFT:
41
+ out(f" {state.state:<8} {state.name} (file changed after it was applied)")
42
+ else:
43
+ out(f" {state.state:<8} {state.name} (applied, but the file is gone)")
44
+
45
+ out("")
46
+ if any(state.state is State.DRIFT for state in blocked):
47
+ out("DRIFT: revert the edit, or run `migrations repair <name>` if the edit was deliberate.")
48
+
49
+ for state in blocked:
50
+ if state.state is not State.MISSING:
51
+ continue
52
+
53
+ out(f"MISSING: {state.name} is gone, so `migrations repair` cannot help. Restore the file")
54
+ out("from version control, or - if it is gone for good and its schema change is known to")
55
+ out(f"be in place - drop the tracking row: DELETE FROM {table} WHERE name = '{state.name}';")
56
+
57
+
58
+ async def cmd_status(
59
+ conn: psycopg.AsyncConnection,
60
+ directory: pathlib.Path,
61
+ table: str,
62
+ check: bool,
63
+ out: Out,
64
+ ) -> int:
65
+ try:
66
+ _tracker, states = await _load_states(conn, directory, table)
67
+ except MigrationError as e:
68
+ out(f"error: {e}")
69
+ return 1
70
+
71
+ if not states:
72
+ out("No migrations found.")
73
+ return 0
74
+
75
+ for state in states:
76
+ applied_at = state.row.applied_at.strftime("%Y-%m-%d %H:%M") if state.row else ""
77
+ out(f" {state.state:<8} {state.name:<52} {applied_at}")
78
+
79
+ blocked = blocking(states)
80
+ waiting = pending(states)
81
+
82
+ out("")
83
+ out(f"{len(states) - len(waiting) - len(blocked)} applied, {len(waiting)} pending, {len(blocked)} blocked")
84
+
85
+ if check and (waiting or blocked):
86
+ return 1
87
+
88
+ return 0
89
+
90
+
91
+ async def cmd_apply(
92
+ conn: psycopg.AsyncConnection,
93
+ directory: pathlib.Path,
94
+ table: str,
95
+ dry_run: bool,
96
+ to: str | None,
97
+ applied_by: str,
98
+ out: Out,
99
+ ) -> int:
100
+ """Apply pending migrations to `conn`.
101
+
102
+ `conn` must be in autocommit mode. `tracker.lock()`'s advisory-lock statement would
103
+ otherwise implicitly open a transaction, so each migration's `conn.transaction()`
104
+ degrades to a SAVEPOINT instead of a real BEGIN/COMMIT - a mid-run failure would then
105
+ roll the whole outer transaction back on unlock, undoing migrations this function
106
+ already reported as applied. `CREATE INDEX CONCURRENTLY` in the no-transaction branch
107
+ also requires autocommit outright.
108
+ """
109
+
110
+ if not conn.autocommit:
111
+ out("error: cmd_apply requires an autocommit connection")
112
+ return 1
113
+
114
+ tracker = Tracker(conn, table)
115
+
116
+ async with tracker.lock():
117
+ try:
118
+ _tracker, states = await _load_states(conn, directory, table)
119
+ except MigrationError as e:
120
+ out(f"error: {e}")
121
+ return 1
122
+
123
+ if blocking(states):
124
+ _report_blocking(states, table, out)
125
+ return 1
126
+
127
+ queue = pending(states)
128
+ if to is not None:
129
+ # blocking(states) was empty, so no MISSING state remains here and every
130
+ # state has a file - the `is not None` filter is structural narrowing for
131
+ # pyrefly, not a real behavioural filter.
132
+ known = {state.file.prefix for state in states if state.file is not None}
133
+ if to not in known:
134
+ out(f"error: no migration with prefix {to!r}")
135
+ return 1
136
+
137
+ queue = [state for state in queue if state.file is not None and state.file.prefix <= to]
138
+
139
+ if not queue:
140
+ out("Database is up to date; nothing to apply.")
141
+ return 0
142
+
143
+ for state in queue:
144
+ assert state.file is not None
145
+ meta = find_meta_commands(state.file.sql)
146
+ if meta:
147
+ out(f"error: {state.name} contains psql meta-command(s) psycopg cannot execute:")
148
+ for line_number, line in meta:
149
+ out(f" line {line_number}: {line}")
150
+
151
+ out("Strip them (pg_dump emits \\restrict / \\unrestrict) and try again.")
152
+ return 1
153
+
154
+ # Postgres wraps a multi-statement simple-Query message in an *implicit*
155
+ # transaction, so a no-transaction file only genuinely runs outside one when it
156
+ # holds exactly one statement. Since the whole file goes to `cur.execute()` in a
157
+ # single call with no statement splitter, the only honest answer is to refuse.
158
+ if state.file.no_transaction and count_statements(state.file.sql) > 1:
159
+ out(f"error: {state.name} is marked `-- migrations:no-transaction` but contains")
160
+ out("more than one statement. Postgres runs a multi-statement send inside an")
161
+ out("implicit transaction, which would defeat the directive. Split the file so")
162
+ out("each no-transaction migration contains exactly one statement.")
163
+ return 1
164
+
165
+ for state in queue:
166
+ assert state.file is not None
167
+ if dry_run:
168
+ out(f"would apply {state.name}")
169
+ continue
170
+
171
+ started = time.monotonic()
172
+ duration_ms = 0
173
+ try:
174
+ if state.file.no_transaction:
175
+ async with conn.cursor() as cur:
176
+ await cur.execute(state.file.sql.encode(conn.info.encoding))
177
+
178
+ duration_ms = int((time.monotonic() - started) * 1000)
179
+ await tracker.record(state.name, state.file.checksum, duration_ms, applied_by)
180
+ else:
181
+ # The tracking row is written inside the same transaction as the
182
+ # migration, so a file either fully lands and is recorded, or neither.
183
+ async with conn.transaction():
184
+ async with conn.cursor() as cur:
185
+ await cur.execute(state.file.sql.encode(conn.info.encoding))
186
+
187
+ duration_ms = int((time.monotonic() - started) * 1000)
188
+ await tracker.record(state.name, state.file.checksum, duration_ms, applied_by)
189
+
190
+ except Exception as e:
191
+ out(f"FAILED {state.name}: {e}")
192
+ if state.file.no_transaction:
193
+ # No transaction means no rollback: a failed CREATE INDEX CONCURRENTLY
194
+ # leaves an invalid index behind, and a re-run then dies on "relation
195
+ # already exists". Claiming a clean stop here is what strands a deploy.
196
+ out("This file ran OUTSIDE a transaction, so it may have PARTIALLY applied.")
197
+ out("It was not recorded as applied. Inspect the database and undo whatever")
198
+ out("landed (a failed CREATE INDEX CONCURRENTLY leaves an invalid index)")
199
+ out("before re-running.")
200
+ else:
201
+ out("Stopped. Nothing after this migration was applied.")
202
+
203
+ return 1
204
+
205
+ out(f"applied {state.name} ({duration_ms} ms)")
206
+
207
+ if dry_run:
208
+ out(f"{len(queue)} migration(s) would be applied; nothing was changed.")
209
+ else:
210
+ out(f"Applied {len(queue)} migration(s).")
211
+
212
+ return 0
213
+
214
+
215
+ _NEW_FILE_TEMPLATE = """-- {name}
216
+ --
217
+ -- Runs in a transaction. Add `-- migrations:no-transaction` as the very first line if this
218
+ -- file needs CREATE INDEX CONCURRENTLY or anything else Postgres refuses inside one.
219
+ --
220
+ -- A no-transaction file must contain exactly ONE statement. Postgres wraps a
221
+ -- multi-statement send in an implicit transaction, which would defeat the directive, so
222
+ -- `apply` refuses such a file. Split it into one file per statement.
223
+ """
224
+
225
+
226
+ async def cmd_baseline(
227
+ conn: psycopg.AsyncConnection,
228
+ directory: pathlib.Path,
229
+ table: str,
230
+ to: str | None,
231
+ assume_yes: bool,
232
+ applied_by: str,
233
+ prompt: Callable[[str], str],
234
+ out: Out,
235
+ ) -> int:
236
+ """Writes tracking rows without executing anything - the adoption path for a database
237
+ that already has the schema. Nothing is written until every answer is in, so `q` really
238
+ does leave the table untouched.
239
+
240
+ `conn` must be in autocommit mode, for the same reason as `cmd_apply`: `tracker.record()`
241
+ issues its own INSERT per row with no wrapping transaction and no explicit commit, so on a
242
+ non-autocommit connection those rows would sit uncommitted - silently discarded if the
243
+ caller never commits, rather than genuinely "written".
244
+ """
245
+
246
+ if not conn.autocommit:
247
+ out("error: cmd_baseline requires an autocommit connection")
248
+ return 1
249
+
250
+ tracker = Tracker(conn, table)
251
+
252
+ async with tracker.lock():
253
+ try:
254
+ _tracker, states = await _load_states(conn, directory, table)
255
+ except MigrationError as e:
256
+ out(f"error: {e}")
257
+ return 1
258
+
259
+ candidates = pending(states)
260
+ if to is not None:
261
+ # Unlike cmd_apply, this runs without a blocking() guard in front of it, so a
262
+ # MISSING state (file is None) can still be in `states` here.
263
+ known = {state.file.prefix for state in states if state.file is not None}
264
+ if to not in known:
265
+ out(f"error: no migration with prefix {to!r}")
266
+ return 1
267
+
268
+ candidates = [state for state in candidates if state.file is not None and state.file.prefix <= to]
269
+
270
+ if not candidates:
271
+ out("Nothing to baseline; every migration on disk is already recorded.")
272
+ return 0
273
+
274
+ chosen = []
275
+ stamp_rest = assume_yes
276
+ for state in candidates:
277
+ if stamp_rest:
278
+ chosen.append(state)
279
+ continue
280
+
281
+ answer = prompt(f" {state.name:<52} mark as already applied? [y/N/a/q] ").strip().lower()
282
+ if answer == "q":
283
+ out("Aborted; nothing was written.")
284
+ return 1
285
+
286
+ if answer == "a":
287
+ stamp_rest = True
288
+ chosen.append(state)
289
+ elif answer == "y":
290
+ chosen.append(state)
291
+
292
+ try:
293
+ # One transaction for the whole write step: `chosen` was decided in memory
294
+ # above, so partway-through failure here must not leave some of those decisions
295
+ # stamped and others not - that would put `apply` in exactly the position
296
+ # baseline exists to avoid, half-recognising a schema it should treat as known.
297
+ async with conn.transaction():
298
+ for state in chosen:
299
+ assert state.file is not None
300
+ await tracker.record(state.name, state.file.checksum, 0, applied_by)
301
+ except Exception as e:
302
+ out(f"error: failed to write tracking rows: {e}")
303
+ out("Nothing was written.")
304
+ return 1
305
+
306
+ out("")
307
+ out(
308
+ f"stamped {len(chosen)} migration(s) as applied (not executed); "
309
+ f"{len(candidates) - len(chosen)} left pending"
310
+ )
311
+
312
+ return 0
313
+
314
+
315
+ def cmd_new(directory: pathlib.Path, name: str, now: datetime, out: Out) -> int:
316
+ try:
317
+ filename = new_filename(name, now)
318
+ except MigrationError as e:
319
+ out(f"error: {e}")
320
+ return 1
321
+
322
+ if not directory.is_dir():
323
+ out(f"error: migrations directory does not exist: {directory}")
324
+ return 1
325
+
326
+ path = directory / filename
327
+ if path.exists():
328
+ out(f"error: {path} already exists")
329
+ return 1
330
+
331
+ path.write_text(_NEW_FILE_TEMPLATE.format(name=name))
332
+ out(f"created {path}")
333
+
334
+ return 0
335
+
336
+
337
+ async def cmd_repair(
338
+ conn: psycopg.AsyncConnection,
339
+ directory: pathlib.Path,
340
+ table: str,
341
+ name: str,
342
+ out: Out,
343
+ ) -> int:
344
+ """Re-stamps one migration's checksum after a deliberate edit. The only way out of DRIFT
345
+ short of reverting the file.
346
+
347
+ `conn` must be in autocommit mode, for the same reason as `cmd_baseline`: `update_checksum`
348
+ is a single UPDATE with no explicit commit of its own.
349
+ """
350
+
351
+ if not conn.autocommit:
352
+ out("error: cmd_repair requires an autocommit connection")
353
+ return 1
354
+
355
+ # A bare basename only. `directory / "../elsewhere/x.sql"` resolves outside the migrations
356
+ # directory, and stamping that file's checksum under its basename would leave the real
357
+ # migration permanently in DRIFT while reporting a successful repair.
358
+ if name != pathlib.PurePath(name).name:
359
+ out(f"error: {name!r} must be a plain migration filename, not a path")
360
+ return 1
361
+
362
+ path = directory / name
363
+ if not path.is_file():
364
+ out(f"error: no such migration file: {path}")
365
+ return 1
366
+
367
+ try:
368
+ migration = load_migration(path)
369
+ except MigrationError as e:
370
+ out(f"error: {e}")
371
+ return 1
372
+
373
+ tracker = Tracker(conn, table)
374
+ try:
375
+ await tracker.ensure_table()
376
+ except MigrationError as e:
377
+ out(f"error: {e}")
378
+ return 1
379
+
380
+ if not await tracker.update_checksum(migration.name, migration.checksum):
381
+ out(f"error: {migration.name} has no tracking row - it was never applied, so there is nothing to repair")
382
+ return 1
383
+
384
+ out(f"repaired {migration.name}: checksum re-stamped to {migration.checksum[:12]}...")
385
+
386
+ return 0
@@ -0,0 +1,108 @@
1
+ import hashlib
2
+ import pathlib
3
+ import re
4
+ from dataclasses import dataclass
5
+ from datetime import datetime
6
+
7
+ MIGRATION_FILENAME_RE = re.compile(r"^(\d{4}-\d{2}-\d{2}-\d{6})-([a-z0-9-]+)\.sql$")
8
+ NO_TRANSACTION_DIRECTIVE = "-- migrations:no-transaction"
9
+
10
+ # psql meta-commands. pg_dump emits \restrict / \unrestrict, and psycopg sends SQL to the
11
+ # server directly - there is no psql to interpret these, so they arrive as syntax errors.
12
+ _META_COMMAND_RE = re.compile(r"^\s*\\")
13
+
14
+ _LINE_COMMENT_RE = re.compile(r"--[^\n]*")
15
+
16
+
17
+ class MigrationError(Exception):
18
+ """Anything wrong with the migration files themselves, as opposed to the SQL failing."""
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class MigrationFile:
23
+ name: str
24
+ prefix: str
25
+ path: pathlib.Path
26
+ sql: str
27
+ checksum: str
28
+ no_transaction: bool
29
+
30
+
31
+ def checksum_bytes(data: bytes) -> str:
32
+ return hashlib.sha256(data).hexdigest()
33
+
34
+
35
+ def load_migration(path: pathlib.Path) -> MigrationFile:
36
+ match = MIGRATION_FILENAME_RE.match(path.name)
37
+ if not match:
38
+ raise MigrationError(f"Bad migration filename {path.name!r}: expected YYYY-MM-DD-HHMMSS-kebab-name.sql")
39
+
40
+ raw = path.read_bytes()
41
+ try:
42
+ sql = raw.decode("utf-8")
43
+ except UnicodeDecodeError as exc:
44
+ raise MigrationError(f"Migration {path.name!r} is not valid UTF-8: {exc}") from exc
45
+
46
+ first_line = sql.split("\n", 1)[0].strip()
47
+
48
+ return MigrationFile(
49
+ name=path.name,
50
+ prefix=match.group(1),
51
+ path=path,
52
+ sql=sql,
53
+ checksum=checksum_bytes(raw),
54
+ no_transaction=first_line == NO_TRANSACTION_DIRECTIVE,
55
+ )
56
+
57
+
58
+ def discover(directory: pathlib.Path) -> list[MigrationFile]:
59
+ """Every *.sql in `directory`, chronological. Filenames sort chronologically as plain
60
+ text, which is the whole point of the timestamp prefix."""
61
+
62
+ if not directory.is_dir():
63
+ raise MigrationError(f"Migrations directory does not exist: {directory}")
64
+
65
+ migrations = [load_migration(path) for path in sorted(directory.glob("*.sql"))]
66
+
67
+ seen: dict[str, str] = {}
68
+ for migration in migrations:
69
+ if migration.prefix in seen:
70
+ raise MigrationError(
71
+ f"Duplicate migration timestamp {migration.prefix}: {seen[migration.prefix]} and {migration.name}"
72
+ )
73
+
74
+ seen[migration.prefix] = migration.name
75
+
76
+ return migrations
77
+
78
+
79
+ def find_meta_commands(sql: str) -> list[tuple[int, str]]:
80
+ """1-indexed line numbers of psql meta-commands. A backslash inside a string literal is
81
+ not one, and only a line that *starts* with a backslash can be."""
82
+
83
+ return [
84
+ (number, line.rstrip())
85
+ for number, line in enumerate(sql.split("\n"), start=1)
86
+ if _META_COMMAND_RE.match(line)
87
+ ]
88
+
89
+
90
+ def count_statements(sql: str) -> int:
91
+ """Crude statement count: strip `--` comments, split on `;`, count non-empty remainders.
92
+
93
+ A semicolon inside a dollar-quoted block or a string literal is counted as a separator
94
+ here, so such a file is over-counted. That is acceptable because the only caller uses this
95
+ to *refuse* a multi-statement no-transaction file before anything executes - the
96
+ false positive is a loud refusal at scan time, and the operations the no-transaction
97
+ directive exists for (CREATE INDEX CONCURRENTLY and friends) are never dollar-quoted.
98
+ """
99
+
100
+ return len([part for part in _LINE_COMMENT_RE.sub("", sql).split(";") if part.strip()])
101
+
102
+
103
+ def new_filename(name: str, now: datetime) -> str:
104
+ slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
105
+ if not slug:
106
+ raise MigrationError(f"Migration name {name!r} contains no usable characters")
107
+
108
+ return f"{now:%Y-%m-%d-%H%M%S}-{slug}.sql"
@@ -0,0 +1,63 @@
1
+ from dataclasses import dataclass
2
+ from datetime import datetime
3
+ from enum import StrEnum
4
+
5
+ from py_app_runner.migrations.discovery import MigrationFile
6
+
7
+
8
+ class State(StrEnum):
9
+ APPLIED = "applied"
10
+ PENDING = "PENDING"
11
+ DRIFT = "DRIFT"
12
+ MISSING = "MISSING"
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class AppliedRow:
17
+ name: str
18
+ checksum: str
19
+ applied_at: datetime
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class MigrationState:
24
+ name: str
25
+ state: State
26
+ file: MigrationFile | None
27
+ row: AppliedRow | None
28
+
29
+
30
+ def compute_states(files: list[MigrationFile], rows: list[AppliedRow]) -> list[MigrationState]:
31
+ """Union of disk and table, ordered by name. A row with no file (MISSING) sorts in
32
+ among the files rather than being appended, so `status` reads chronologically."""
33
+
34
+ by_name = {file.name: file for file in files}
35
+ rows_by_name = {row.name: row for row in rows}
36
+
37
+ states = []
38
+ for name in sorted(by_name.keys() | rows_by_name.keys()):
39
+ file = by_name.get(name)
40
+ row = rows_by_name.get(name)
41
+
42
+ if file is None:
43
+ state = State.MISSING
44
+ elif row is None:
45
+ state = State.PENDING
46
+ elif row.checksum != file.checksum:
47
+ state = State.DRIFT
48
+ else:
49
+ state = State.APPLIED
50
+
51
+ states.append(MigrationState(name=name, state=state, file=file, row=row))
52
+
53
+ return states
54
+
55
+
56
+ def pending(states: list[MigrationState]) -> list[MigrationState]:
57
+ return [state for state in states if state.state is State.PENDING]
58
+
59
+
60
+ def blocking(states: list[MigrationState]) -> list[MigrationState]:
61
+ """States that must be resolved before anything may be applied."""
62
+
63
+ return [state for state in states if state.state in (State.DRIFT, State.MISSING)]
@@ -0,0 +1,141 @@
1
+ import contextlib
2
+ from collections.abc import AsyncIterator
3
+
4
+ import psycopg
5
+ from psycopg import sql
6
+ from psycopg.pq import TransactionStatus
7
+
8
+ from py_app_runner.migrations.discovery import MigrationError
9
+ from py_app_runner.migrations.states import AppliedRow
10
+
11
+ # One fixed key for the whole system. Two processes applying migrations at once would
12
+ # interleave DDL and duplicate tracking rows, so `apply` serialises on this.
13
+ ADVISORY_LOCK_KEY = 4829173465872001
14
+
15
+ EXPECTED_COLUMNS = frozenset({"id", "name", "checksum", "applied_at", "duration_ms", "applied_by"})
16
+
17
+ # The schema is resolved from the oid rather than matched on `table_name` alone, so a
18
+ # same-named table in another schema cannot answer for the one search_path actually picks.
19
+ _COLUMNS_SQL = """
20
+ SELECT column_name
21
+ FROM information_schema.columns
22
+ WHERE (table_schema, table_name) = (
23
+ SELECT n.nspname, c.relname
24
+ FROM pg_class c
25
+ JOIN pg_namespace n ON n.oid = c.relnamespace
26
+ WHERE c.oid = to_regclass(%s)
27
+ )
28
+ """
29
+
30
+
31
+ class Tracker:
32
+ """Reads and writes the migration tracking table.
33
+
34
+ The table bootstraps itself on every invocation rather than being migration zero -
35
+ otherwise there is nothing to record the fact that it was created.
36
+ """
37
+
38
+ def __init__(self, conn: "psycopg.AsyncConnection", table: str = "migrations") -> None:
39
+ self.conn = conn
40
+ self.table_name = table
41
+ self.table = sql.Identifier(table)
42
+
43
+ async def ensure_table(self) -> None:
44
+ await self._create_table()
45
+ await self._verify_columns()
46
+
47
+ async def _create_table(self) -> None:
48
+ statement = sql.SQL(
49
+ """
50
+ CREATE TABLE IF NOT EXISTS {table} (
51
+ id INT8 GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
52
+ name TEXT NOT NULL UNIQUE,
53
+ checksum TEXT NOT NULL,
54
+ applied_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
55
+ duration_ms INT4 NOT NULL,
56
+ applied_by TEXT NOT NULL
57
+ )
58
+ """
59
+ ).format(table=self.table)
60
+
61
+ async with self.conn.cursor() as cur:
62
+ await cur.execute(statement)
63
+
64
+ async def _verify_columns(self) -> None:
65
+ """`CREATE TABLE IF NOT EXISTS` matches on name alone, and `migrations` is about as
66
+ generic a table name as exists. Without this check an unrelated pre-existing table is
67
+ silently adopted and the next query fails with a bare `column "name" does not exist`,
68
+ with nothing pointing at this tool - exactly when an operator is adopting a database.
69
+ """
70
+
71
+ async with self.conn.cursor() as cur:
72
+ await cur.execute(_COLUMNS_SQL, (self.table.as_string(self.conn),))
73
+ found = {row[0] for row in await cur.fetchall()}
74
+
75
+ missing = EXPECTED_COLUMNS - found
76
+ if not missing:
77
+ return
78
+
79
+ raise MigrationError(
80
+ f"Table {self.table_name!r} exists but is not a migration tracking table: "
81
+ f"missing column(s) {', '.join(sorted(missing))}; it has {', '.join(sorted(found)) or 'no columns'}. "
82
+ f'Point the tracker at a free name via config["migrations"]["table"], '
83
+ f"or drop/rename the existing table if it is not in use."
84
+ )
85
+
86
+ async def applied_rows(self) -> list[AppliedRow]:
87
+ statement = sql.SQL("SELECT name, checksum, applied_at FROM {table} ORDER BY name").format(
88
+ table=self.table
89
+ )
90
+
91
+ async with self.conn.cursor() as cur:
92
+ await cur.execute(statement)
93
+ return [AppliedRow(name=row[0], checksum=row[1], applied_at=row[2]) for row in await cur.fetchall()]
94
+
95
+ async def record(self, name: str, checksum: str, duration_ms: int, applied_by: str) -> None:
96
+ statement = sql.SQL(
97
+ "INSERT INTO {table} (name, checksum, duration_ms, applied_by) VALUES (%s, %s, %s, %s)"
98
+ ).format(table=self.table)
99
+
100
+ async with self.conn.cursor() as cur:
101
+ await cur.execute(statement, (name, checksum, duration_ms, applied_by))
102
+
103
+ async def update_checksum(self, name: str, checksum: str) -> bool:
104
+ """False when no such row exists - the caller reports that rather than claiming a
105
+ repair that never happened."""
106
+
107
+ statement = sql.SQL("UPDATE {table} SET checksum = %s WHERE name = %s").format(table=self.table)
108
+
109
+ async with self.conn.cursor() as cur:
110
+ await cur.execute(statement, (checksum, name))
111
+ return cur.rowcount == 1
112
+
113
+ @contextlib.asynccontextmanager
114
+ async def lock(self) -> AsyncIterator[None]:
115
+ """Session-level advisory lock held for the whole run. A crashed process drops its
116
+ connection, and Postgres releases the lock with it.
117
+
118
+ The lock will wrap real migration execution, so a failing body may leave a
119
+ non-autocommit connection in an aborted transaction. Releasing the lock then needs a
120
+ rollback first, and its own failure must never replace the body's real exception as
121
+ what the caller sees.
122
+ """
123
+
124
+ async with self.conn.cursor() as cur:
125
+ await cur.execute("SELECT pg_advisory_lock(%s)", (ADVISORY_LOCK_KEY,))
126
+
127
+ try:
128
+ yield
129
+ except BaseException:
130
+ with contextlib.suppress(Exception):
131
+ await self._unlock()
132
+ raise
133
+ else:
134
+ await self._unlock()
135
+
136
+ async def _unlock(self) -> None:
137
+ if self.conn.info.transaction_status == TransactionStatus.INERROR:
138
+ await self.conn.rollback()
139
+
140
+ async with self.conn.cursor() as cur:
141
+ await cur.execute("SELECT pg_advisory_unlock(%s)", (ADVISORY_LOCK_KEY,))
py_app_runner/py.typed ADDED
File without changes