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,20 @@
1
+ from database_wrapper_pgsql import PgsqlWithPoolingAsync
2
+ from database_wrapper_redis import RedisDbWithPoolAsync
3
+
4
+
5
+ class DbPools:
6
+ """
7
+ Concrete class to hold database connection pools and wrappers for use
8
+ throughout the application
9
+ """
10
+
11
+ cache_db_pool: RedisDbWithPoolAsync
12
+ main_db_pool: PgsqlWithPoolingAsync
13
+
14
+ def __init__(
15
+ self,
16
+ cache_db_pool: RedisDbWithPoolAsync,
17
+ main_db_pool: PgsqlWithPoolingAsync,
18
+ ):
19
+ self.cache_db_pool = cache_db_pool
20
+ self.main_db_pool = main_db_pool
@@ -0,0 +1,31 @@
1
+ from typing import Any
2
+
3
+
4
+ class HTTPException(Exception):
5
+ message: str
6
+ code: int
7
+ description: str | None
8
+ http_status: int
9
+ logged: bool
10
+
11
+ def __init__(
12
+ self,
13
+ message: str,
14
+ code: int = 1,
15
+ description: str | None = None,
16
+ http_status: int = 400,
17
+ logged: bool = False,
18
+ ) -> None:
19
+ super().__init__(message)
20
+
21
+ self.message = message
22
+ self.code = code
23
+ self.description = description
24
+ self.http_status = http_status
25
+ self.logged = logged
26
+
27
+ def to_dict(self) -> dict[str, Any]:
28
+ result: dict[str, Any] = {"msg": self.message, "code": self.code}
29
+ if self.description:
30
+ result["description"] = self.description
31
+ return result
@@ -0,0 +1,167 @@
1
+ import logging
2
+ import socket
3
+ import sys
4
+ import time
5
+ from argparse import Namespace
6
+ from difflib import SequenceMatcher
7
+ from typing import TypedDict
8
+
9
+ import sentry_sdk
10
+ from sentry_sdk.integrations.redis import RedisIntegration
11
+ from sentry_sdk.integrations.tornado import TornadoIntegration
12
+ from sentry_sdk.types import Event, Hint
13
+
14
+ from py_app_runner.registry import AppRegistry
15
+
16
+ from .colors import Colors
17
+ from .http_exception import HTTPException
18
+
19
+ # Constants
20
+ ERROR_RATE = 60 # Minute
21
+
22
+
23
+ # Types
24
+ class LastError(TypedDict):
25
+ count: int
26
+ time: int
27
+ msg: str
28
+
29
+
30
+ # Globals
31
+ last_error: LastError = {"count": 0, "time": 0, "msg": ""}
32
+ logger = logging.getLogger("meta")
33
+
34
+
35
+ #################
36
+ ### Functions ###
37
+ #################
38
+
39
+
40
+ def RateControl(event: Event, hint: Hint) -> Event | None:
41
+ if "exc_info" in hint:
42
+ _exc_type, exc_value, _tb = hint["exc_info"]
43
+ if isinstance(exc_value, HTTPException):
44
+ return None
45
+
46
+ if "extra" in event and "dispatch" in event["extra"] and not event["extra"]["dispatch"]:
47
+ sys.stderr.write("#### Manually discarded error message meant to be sent via Sentry\n")
48
+ return None
49
+
50
+ now = int(time.time())
51
+ current_event = str(event)
52
+ test = SequenceMatcher(None, last_error["msg"], current_event).ratio()
53
+ if last_error["time"] == 0 or now - int(last_error["time"]) >= ERROR_RATE or test < 0.4:
54
+ sys.stderr.write("#### Allowing Sentry to send error message\n")
55
+ last_error["count"] = 0
56
+ last_error["time"] = now
57
+ last_error["msg"] = current_event
58
+ return event
59
+
60
+ sys.stderr.write("#### Discarded error message meant to be sent to Sentry\n")
61
+ last_error["count"] += 1
62
+ return None
63
+
64
+
65
+ def InitSentry(
66
+ args: Namespace,
67
+ sentry_dns: str,
68
+ release: str,
69
+ environment: str,
70
+ server_name: str | None = None,
71
+ ) -> None:
72
+ config = AppRegistry.config()
73
+ if config.get("environment") != "prod":
74
+ logger.debug(f"Sentry logging disabled in {config['environment']}")
75
+ return
76
+
77
+ logger.debug("Enabling Sentry logging")
78
+ server_ip = socket.gethostbyname(socket.gethostname())
79
+ with sentry_sdk.configure_scope() as scope:
80
+ scope.set_tag("server_ip", server_ip)
81
+ scope.set_level(args.sv)
82
+
83
+ # Tracing/profiling rates come from config when a project sets them; 0 disables.
84
+ rates = (config.get("sentry") or {}).get("rate") or {}
85
+
86
+ sentry_sdk.init(
87
+ sentry_dns,
88
+ release=release,
89
+ environment=environment,
90
+ server_name=server_name,
91
+ attach_stacktrace=True,
92
+ before_send=RateControl,
93
+ integrations=[TornadoIntegration(), RedisIntegration()],
94
+ traces_sample_rate=float(rates.get("performance", 0) or 0),
95
+ profiles_sample_rate=float(rates.get("profiles", 0) or 0),
96
+ ignore_errors=[KeyboardInterrupt, HTTPException],
97
+ # debug=True,
98
+ )
99
+
100
+
101
+ ###############
102
+ ### Classes ###
103
+ ###############
104
+
105
+
106
+ class ConsoleHandler(logging.Handler):
107
+ name_filter: str | None
108
+
109
+ should_buffer: bool
110
+ the_buffer: list[logging.LogRecord]
111
+
112
+ def __init__(self, filter: str | None = None, level: int = logging.NOTSET):
113
+ super().__init__(level)
114
+
115
+ self.name_filter = filter
116
+ self.should_buffer = False
117
+ self.the_buffer = []
118
+
119
+ def emit_buffer(self) -> None:
120
+ self.should_buffer = False
121
+
122
+ for record in self.the_buffer:
123
+ self.emit(record)
124
+ self.the_buffer = []
125
+
126
+ def emit(self, record: logging.LogRecord) -> None:
127
+ if self.name_filter is not None and not record.name.startswith(self.name_filter):
128
+ return # Skip logging for this module
129
+
130
+ if self.should_buffer:
131
+ self.the_buffer.append(record)
132
+ return
133
+
134
+ # Colors
135
+ prepend = ""
136
+ append = ""
137
+ if record.levelno == logging.INFO:
138
+ prepend = Colors.Yellow
139
+ append = Colors.ColorOff
140
+ elif record.levelno == logging.DEBUG:
141
+ prepend = Colors.Cyan
142
+ append = Colors.ColorOff
143
+ elif record.levelno == logging.WARNING:
144
+ prepend = Colors.Purple
145
+ append = Colors.ColorOff
146
+ elif record.levelno == logging.ERROR:
147
+ prepend = Colors.Red
148
+ append = Colors.ColorOff
149
+
150
+ # End char for printing to console
151
+ end: str = getattr(record, "end", "\n")
152
+ prefix: str = getattr(record, "prefix", "")
153
+
154
+ # Date
155
+ date = ""
156
+ clean = getattr(record, "clean", False)
157
+ skip_date = getattr(record, "skip_date", False)
158
+ if not clean:
159
+ date = f"[{record.levelname}] [{record.name}] "
160
+ if not skip_date:
161
+ date = f"[{time.strftime('%d.%m.%Y %H:%M:%S')}] [{record.levelname}] [{record.name}] "
162
+
163
+ # Message
164
+ error_msg = f"{prepend}{date}{self.format(record)}{append}"
165
+
166
+ # Print to console
167
+ sys.stderr.write(f"{prefix}{error_msg}{end}")
@@ -0,0 +1,5 @@
1
+ """Tracked SQL migrations: apply only the files a database has not seen yet."""
2
+
3
+ from py_app_runner.migrations.discovery import MigrationError
4
+
5
+ __all__ = ["MigrationError"]
@@ -0,0 +1,296 @@
1
+ import asyncio
2
+ import logging
3
+ import pathlib
4
+ import socket
5
+ from argparse import Namespace
6
+ from dataclasses import dataclass
7
+ from datetime import UTC, datetime
8
+ from typing import Any
9
+
10
+ import psycopg
11
+
12
+ from py_app_runner.migrations.commands import (
13
+ Out,
14
+ cmd_apply,
15
+ cmd_baseline,
16
+ cmd_new,
17
+ cmd_repair,
18
+ cmd_status,
19
+ )
20
+ from py_app_runner.migrations.discovery import MigrationError
21
+ from py_app_runner.pybridge import PyBridge
22
+ from py_app_runner.registry import AppRegistry
23
+
24
+ _DEFAULT_DIR = "data/migrations"
25
+ _DEFAULT_TABLE = "migrations"
26
+ _DEFAULT_TARGET_NAME = "main"
27
+
28
+
29
+ def connect_kwargs(db_config: dict[str, Any]) -> dict[str, Any]:
30
+ """PgsqlConfig uses hostname/username/database; psycopg wants host/user/dbname.
31
+
32
+ autocommit is on because this service manages transactions per migration file
33
+ explicitly - an implicit outer transaction would swallow the per-file boundaries and
34
+ make the no-transaction directive impossible to honour.
35
+ """
36
+
37
+ return {
38
+ "host": db_config["hostname"],
39
+ "port": db_config.get("port") or 5432,
40
+ "user": db_config["username"],
41
+ "password": db_config["password"],
42
+ "dbname": db_config["database"],
43
+ "sslmode": db_config.get("ssl") or "prefer",
44
+ "autocommit": True,
45
+ }
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class Target:
50
+ name: str
51
+ db: str
52
+ directory: pathlib.Path
53
+ table: str
54
+
55
+
56
+ def _resolve_directory(raw_dir: str | None, config: dict[str, Any]) -> pathlib.Path:
57
+ directory = pathlib.Path(raw_dir or _DEFAULT_DIR)
58
+ if directory.is_absolute():
59
+ return directory
60
+
61
+ current_path = config.get("current_path")
62
+ if current_path is None:
63
+ raise MigrationError(
64
+ f'migrations dir {str(directory)!r} is relative but config has no "current_path" to '
65
+ f"resolve it against; either set current_path or use an absolute dir."
66
+ )
67
+
68
+ return pathlib.Path(current_path) / directory
69
+
70
+
71
+ def _database_identity(db_config: dict[str, Any]) -> tuple[Any, Any, Any]:
72
+ """The physical database a `config["db"]` entry points at.
73
+
74
+ Two different db keys can name the same host/port/dbname - a dev box collapsing "main"
75
+ and "gis" onto one postgres database is the documented case - so the tracking-table
76
+ collision check below has to compare on this, not on the db key. The port default
77
+ mirrors connect_kwargs, so both agree on what an absent port means.
78
+ """
79
+
80
+ return (db_config.get("hostname"), db_config.get("port") or 5432, db_config.get("database"))
81
+
82
+
83
+ def _reject_shared_tracking_tables(targets: dict[str, Target], db_config: dict[str, Any]) -> None:
84
+ """Two targets sharing a database is legal and supported; sharing a database *and* a
85
+ tracking table is not. They would each read the other's rows, see no matching file and
86
+ report MISSING - and the remediation `apply` prints for MISSING is a DELETE of the
87
+ tracking row, which de-registers a genuinely applied migration and re-runs it on the
88
+ next apply. Migrations are explicitly allowed to be non-idempotent, so that is a
89
+ data-corruption path reached by following the tool's own advice."""
90
+
91
+ seen: dict[tuple[Any, Any, Any, str], str] = {}
92
+ for target in targets.values():
93
+ identity = (*_database_identity(db_config[target.db]), target.table)
94
+ clash = seen.get(identity)
95
+ if clash is not None:
96
+ raise MigrationError(
97
+ f"migrations targets {clash!r} and {target.name!r} resolve to the same physical "
98
+ f"database and both track in table {target.table!r}; each would report the other's "
99
+ f'migrations as MISSING. Give at least one of them its own "table" under '
100
+ f'config["migrations"]["targets"]. Sharing a database is fine - sharing a database '
101
+ f"and a tracking table is not."
102
+ )
103
+ seen[identity] = target.name
104
+
105
+
106
+ def resolve_targets(config: dict[str, Any]) -> dict[str, Target]:
107
+ """Resolve `config["migrations"]` into an ordered mapping of target name -> Target.
108
+
109
+ Two shapes are supported and never mixed. The flat shape (today's shape, and what all
110
+ four consuming projects still ship) synthesises a single target named "main" against
111
+ db "main" - this is the backward-compatibility path and must keep working with zero
112
+ config changes. The "targets" shape opts a project into multiple named targets, each
113
+ with its own db/dir/table; presence of the "targets" key alone selects it.
114
+ """
115
+
116
+ settings = config.get("migrations") or {}
117
+
118
+ if "targets" in settings:
119
+ flat_keys = {"dir", "table"} & settings.keys()
120
+ if flat_keys:
121
+ raise MigrationError(
122
+ f'config["migrations"] mixes "targets" with the flat key(s) '
123
+ f"{', '.join(sorted(flat_keys))}; that is ambiguous, not merged. Move dir/table "
124
+ f'into each entry under config["migrations"]["targets"] instead.'
125
+ )
126
+
127
+ raw_targets = settings["targets"]
128
+ if not raw_targets:
129
+ raise MigrationError(
130
+ 'config["migrations"]["targets"] is present but empty; declare at least one target.'
131
+ )
132
+
133
+ db_config = config.get("db") or {}
134
+ targets: dict[str, Target] = {}
135
+ for name, raw_entry in raw_targets.items():
136
+ entry = raw_entry or {}
137
+ db_name = entry.get("db") or name
138
+ if db_name not in db_config:
139
+ raise MigrationError(
140
+ f"migrations target {name!r} names db {db_name!r}, which is not configured under "
141
+ f'config["db"]; configured db keys are: {", ".join(sorted(db_config)) or "none"}.'
142
+ )
143
+ targets[name] = Target(
144
+ name=name,
145
+ db=db_name,
146
+ directory=_resolve_directory(entry.get("dir"), config),
147
+ table=entry.get("table") or _DEFAULT_TABLE,
148
+ )
149
+
150
+ _reject_shared_tracking_tables(targets, db_config)
151
+ return targets
152
+
153
+ directory = _resolve_directory(settings.get("dir"), config)
154
+ table = settings.get("table") or _DEFAULT_TABLE
155
+ target = Target(name=_DEFAULT_TARGET_NAME, db=_DEFAULT_TARGET_NAME, directory=directory, table=table)
156
+ return {_DEFAULT_TARGET_NAME: target}
157
+
158
+
159
+ # status and apply run every configured target unless narrowed with --target: status is a
160
+ # read-only report, and apply's own per-target failure handling (see below) is what keeps a
161
+ # fan-out safe. new/baseline/repair are single-item: baseline in particular writes tracking
162
+ # rows without ever running the SQL, so a mis-stamped baseline against the wrong target
163
+ # produces a permanently green `status` over a database that never got its tables - the
164
+ # worst failure this tool can produce, silently. Guessing which target that should be is not
165
+ # acceptable, so those three refuse outright when more than one target is configured and
166
+ # --target was not given.
167
+ _FAN_OUT_STEPS = frozenset({"status", "apply"})
168
+
169
+
170
+ def _out_for(name: str, multi: bool) -> Out:
171
+ """Single-target output must stay byte-identical to today, so `print` is used directly
172
+ below whenever only one target is in play. In the multi-target case, each line is
173
+ prefixed with its target's name - except blank lines, which commands use as visual
174
+ separators; a `[name] ` prefix on those would just be noise."""
175
+
176
+ if not multi:
177
+ return print
178
+
179
+ def prefixed(line: str) -> None:
180
+ print(f"[{name}] {line}" if line else "")
181
+
182
+ return prefixed
183
+
184
+
185
+ def _select_targets(step: str, requested: str | None, targets: dict[str, Target]) -> list[Target]:
186
+ if requested is not None:
187
+ if requested not in targets:
188
+ print(
189
+ f"error: no migrations target {requested!r}; configured targets are: "
190
+ f"{', '.join(targets) or 'none'}."
191
+ )
192
+ raise SystemExit(1)
193
+
194
+ return [targets[requested]]
195
+
196
+ if step in _FAN_OUT_STEPS or len(targets) == 1:
197
+ return list(targets.values())
198
+
199
+ print(
200
+ f"error: migrations {step!r} needs --target since more than one target is configured; "
201
+ f"configured targets are: {', '.join(targets)}."
202
+ )
203
+ raise SystemExit(1)
204
+
205
+
206
+ async def init_service(args: Namespace, _pybridge: PyBridge, logger: logging.Logger) -> None:
207
+ config = AppRegistry.config()
208
+
209
+ # runner.py catches Exception around init_service, logs it and returns normally - which
210
+ # exits 0. For a long-running service that is deliberate, but here it would tell an
211
+ # unattended playbook that migrations succeeded when the database was merely unreachable
212
+ # (or, for a multi-target config, mis-declared - an unknown `db` key under
213
+ # config["migrations"]["targets"] is exactly what resolve_targets raises on), and the
214
+ # playbook would go on to restart services against an unmigrated schema. Silent success
215
+ # is the one outcome this tool must never produce, so every non-SystemExit failure -
216
+ # config resolution included, not just the database connection - is converted into a
217
+ # non-zero exit here, inside the service, without touching runner.py. SystemExit derives
218
+ # from BaseException, so _select_targets's own refusals and the happy-path exit below are
219
+ # not re-wrapped.
220
+ code = 1
221
+ try:
222
+ targets = resolve_targets(config)
223
+ selected = _select_targets(args.step, getattr(args, "target", None), targets)
224
+ if not selected:
225
+ # resolve_targets always returns at least one target, and _select_targets never
226
+ # returns empty on a path that doesn't already SystemExit - this guards that
227
+ # invariant rather than trusting it. Falling through silently would exit 0 with
228
+ # nothing having run, which is exactly the failure mode this function exists to
229
+ # prevent.
230
+ raise RuntimeError("migrations: no targets selected; this should be unreachable")
231
+
232
+ multi = len(selected) > 1
233
+
234
+ if args.step == "new":
235
+ # Synchronous and needs no database - dispatched before any connection is
236
+ # opened. _select_targets already refused ambiguity above, so exactly one
237
+ # target here.
238
+ target = selected[0]
239
+ raise SystemExit(
240
+ cmd_new(target.directory, args.name, datetime.now(UTC), _out_for(target.name, multi))
241
+ )
242
+
243
+ applied_by = f"{config.get('app_version', 'unknown')} @ {socket.gethostname()}"
244
+
245
+ code = 0
246
+ # One connection per target, processed strictly in sequence: two targets can point at
247
+ # the same physical database (e.g. a dev box collapsing "main" and "gis"), and the
248
+ # advisory lock tracker.lock() takes would contend with itself under concurrency.
249
+ for target in selected:
250
+ logger.debug(f"migrations: target={target.name} dir={target.directory} table={target.table}")
251
+ out = _out_for(target.name, multi)
252
+
253
+ async with await psycopg.AsyncConnection.connect(**connect_kwargs(config["db"][target.db])) as conn:
254
+ if args.step == "status":
255
+ target_code = await cmd_status(conn, target.directory, target.table, args.check, out)
256
+ elif args.step == "apply":
257
+ dry_run = getattr(args, "dry_run", False)
258
+ target_code = await cmd_apply(
259
+ conn, target.directory, target.table, dry_run, args.to, applied_by, out
260
+ )
261
+ elif args.step == "baseline":
262
+ target_code = await cmd_baseline(
263
+ conn, target.directory, target.table, args.to, args.yes, applied_by, input, out
264
+ )
265
+ elif args.step == "repair":
266
+ target_code = await cmd_repair(conn, target.directory, target.table, args.name, out)
267
+ else:
268
+ out(f"error: unknown migrations command {args.step!r}")
269
+ target_code = 1
270
+
271
+ if args.step == "apply":
272
+ # Stop at the first failing target: a broken `main` must never leave a
273
+ # deploy half-migrated across two databases by ploughing on to the next one.
274
+ code = target_code
275
+ if code != 0:
276
+ break
277
+ elif args.step == "status":
278
+ # Every target is checked regardless of earlier results: `--check` must
279
+ # report on all of them, not just the first failure.
280
+ code = code or target_code
281
+ else:
282
+ code = target_code
283
+
284
+ except (KeyboardInterrupt, asyncio.CancelledError):
285
+ # Both derive from BaseException, so the `except Exception` below never sees them:
286
+ # they would sail past init_service into runner.py, which swallows them and returns -
287
+ # exit 0, with no log line at all. `apply` runs one file at a time, so an interrupt
288
+ # between two files leaves a partially migrated database while telling the playbook
289
+ # it succeeded. Must stay above the `except Exception` clause to take effect.
290
+ logger.error("migrations: interrupted; the database may be partially migrated")
291
+ raise SystemExit(1) from None
292
+ except Exception:
293
+ logger.exception("migrations: unhandled failure")
294
+ raise SystemExit(1) from None
295
+
296
+ raise SystemExit(code)
@@ -0,0 +1,91 @@
1
+ """CLI subparsers for the built-in migrations service.
2
+
3
+ python3 src/app.py migrations status [--check] [--target NAME]
4
+ python3 src/app.py migrations apply [--dry-run] [--to PREFIX] [--target NAME]
5
+ python3 src/app.py migrations baseline [--to PREFIX] [--yes] [--target NAME]
6
+ python3 src/app.py migrations new <name> [--target NAME]
7
+ python3 src/app.py migrations repair <filename> [--target NAME]
8
+
9
+ `--target` has no top-level counterpart on `runner.py`'s parser (unlike `apply`'s
10
+ `--dry-run`, which shadows a real top-level flag and needs `default=SUPPRESS` to avoid
11
+ clobbering it - see the collision test in tests/test_migrations_service.py). A plain
12
+ `default=None` is enough here, and `None` is what lets `init_service` tell "no --target
13
+ given" apart from "given as main".
14
+ """
15
+
16
+ import logging
17
+ from argparse import SUPPRESS, ArgumentParser, _SubParsersAction # type: ignore
18
+
19
+ from py_app_runner.pybridge import PyBridge
20
+
21
+
22
+ def _add_target(parser: ArgumentParser) -> None:
23
+ parser.add_argument(
24
+ "--target",
25
+ type=str,
26
+ default=None,
27
+ help='Named migrations target to operate on (see config["migrations"]["targets"])',
28
+ )
29
+
30
+
31
+ def reg_subparsers(
32
+ subparsers: "_SubParsersAction[ArgumentParser]",
33
+ _pybridge: PyBridge,
34
+ _base_logger: logging.Logger,
35
+ ) -> None:
36
+ """Command line subparsers"""
37
+
38
+ parser = subparsers.add_parser(
39
+ "migrations",
40
+ description="Apply tracked SQL migrations to the configured database target(s)",
41
+ help="Database migrations",
42
+ )
43
+ group = parser.add_subparsers(title="command", dest="step", required=True)
44
+
45
+ status_parser = group.add_parser("status", help="Show which migrations are applied and which are pending")
46
+ status_parser.add_argument(
47
+ "--check",
48
+ action="store_true",
49
+ help="Exit 1 if anything is pending or drifted (for deploy assertions)",
50
+ )
51
+ _add_target(status_parser)
52
+
53
+ apply_parser = group.add_parser("apply", help="Apply every pending migration, in order")
54
+ apply_parser.add_argument(
55
+ "--dry-run",
56
+ action="store_true",
57
+ default=SUPPRESS,
58
+ help="List what would run; change nothing",
59
+ )
60
+ apply_parser.add_argument(
61
+ "--to",
62
+ type=str,
63
+ default=None,
64
+ help="Stop after the migration with this YYYY-MM-DD-HHMMSS prefix",
65
+ )
66
+ _add_target(apply_parser)
67
+
68
+ baseline_parser = group.add_parser(
69
+ "baseline",
70
+ help="Record migrations as applied WITHOUT running them (adopting an existing database)",
71
+ )
72
+ baseline_parser.add_argument(
73
+ "--to",
74
+ type=str,
75
+ default=None,
76
+ help="Only offer migrations up to and including this YYYY-MM-DD-HHMMSS prefix",
77
+ )
78
+ baseline_parser.add_argument(
79
+ "--yes",
80
+ action="store_true",
81
+ help="Do not ask; stamp every offered migration (for scripts)",
82
+ )
83
+ _add_target(baseline_parser)
84
+
85
+ new_parser = group.add_parser("new", help="Create an empty, timestamped migration file")
86
+ new_parser.add_argument("name", type=str, help="Short description, e.g. 'add widgets table'")
87
+ _add_target(new_parser)
88
+
89
+ repair_parser = group.add_parser("repair", help="Re-stamp one migration's checksum after a deliberate edit")
90
+ repair_parser.add_argument("name", type=str, help="Migration filename, e.g. 2026-08-04-091530-a.sql")
91
+ _add_target(repair_parser)