py-app-runner 0.5.49.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- py_app_runner/__init__.py +11 -0
- py_app_runner/audit/__init__.py +29 -0
- py_app_runner/audit/_service.py +91 -0
- py_app_runner/audit/_service_args.py +44 -0
- py_app_runner/audit/audit.py +319 -0
- py_app_runner/audit/commands.py +151 -0
- py_app_runner/audit/diff.py +202 -0
- py_app_runner/audit/errors.py +8 -0
- py_app_runner/audit/event.py +130 -0
- py_app_runner/audit/store.py +134 -0
- py_app_runner/bridge/__init__.py +0 -0
- py_app_runner/bridge/_service.py +265 -0
- py_app_runner/bridge/_service_args.py +24 -0
- py_app_runner/bridge/api.py +138 -0
- py_app_runner/bridge/encoders/__init__.py +5 -0
- py_app_runner/bridge/encoders/base.py +24 -0
- py_app_runner/bridge/encoders/json_encoder.py +26 -0
- py_app_runner/bridge/encoders/msgpack_encoder.py +58 -0
- py_app_runner/bridge/web_app.py +31 -0
- py_app_runner/bridge/websocket.py +313 -0
- py_app_runner/colors.py +73 -0
- py_app_runner/config.py +132 -0
- py_app_runner/crypto/__init__.py +14 -0
- py_app_runner/crypto/_service.py +75 -0
- py_app_runner/crypto/_service_args.py +54 -0
- py_app_runner/crypto/commands.py +164 -0
- py_app_runner/crypto/envelope.py +144 -0
- py_app_runner/crypto/errors.py +8 -0
- py_app_runner/crypto/fields.py +300 -0
- py_app_runner/crypto/passwords.py +66 -0
- py_app_runner/db_pools.py +20 -0
- py_app_runner/http_exception.py +31 -0
- py_app_runner/logger_handlers.py +167 -0
- py_app_runner/migrations/__init__.py +5 -0
- py_app_runner/migrations/_service.py +296 -0
- py_app_runner/migrations/_service_args.py +91 -0
- py_app_runner/migrations/commands.py +386 -0
- py_app_runner/migrations/discovery.py +108 -0
- py_app_runner/migrations/states.py +63 -0
- py_app_runner/migrations/tracker.py +141 -0
- py_app_runner/py.typed +0 -0
- py_app_runner/pybridge.py +64 -0
- py_app_runner/queue/__init__.py +25 -0
- py_app_runner/queue/_service.py +231 -0
- py_app_runner/queue/_service_args.py +67 -0
- py_app_runner/queue/commands.py +180 -0
- py_app_runner/queue/driver_pg.py +464 -0
- py_app_runner/queue/driver_redis.py +613 -0
- py_app_runner/queue/handler.py +90 -0
- py_app_runner/queue/interface.py +63 -0
- py_app_runner/queue/job.py +46 -0
- py_app_runner/queue/worker.py +221 -0
- py_app_runner/registry.py +54 -0
- py_app_runner/request_handler/__init__.py +0 -0
- py_app_runner/request_handler/auth_service.py +123 -0
- py_app_runner/request_handler/decorators.py +304 -0
- py_app_runner/request_handler/handlers.py +604 -0
- py_app_runner/request_handler/pagination.py +24 -0
- py_app_runner/return_model.py +78 -0
- py_app_runner/runner.py +182 -0
- py_app_runner/throttle/__init__.py +5 -0
- py_app_runner/throttle/throttle.py +217 -0
- py_app_runner/tick_service.py +308 -0
- py_app_runner/timer.py +289 -0
- py_app_runner/utils.py +346 -0
- py_app_runner/wbcm/__init__.py +0 -0
- py_app_runner/wbcm/device_connections.py +89 -0
- py_app_runner/wbcm/factory.py +113 -0
- py_app_runner/wbcm/wb_connection_manager.py +333 -0
- py_app_runner/wbcm/ws_interface.py +56 -0
- py_app_runner-0.5.49.dev0.dist-info/METADATA +134 -0
- py_app_runner-0.5.49.dev0.dist-info/RECORD +75 -0
- py_app_runner-0.5.49.dev0.dist-info/WHEEL +5 -0
- py_app_runner-0.5.49.dev0.dist-info/licenses/LICENSE +21 -0
- py_app_runner-0.5.49.dev0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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
|