sqlpush 0.1.0__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.
sqlpush/__init__.py ADDED
@@ -0,0 +1,78 @@
1
+ from typing import TYPE_CHECKING, Any
2
+
3
+ from sqlpush.types import (
4
+ AppliedOperation,
5
+ CheckResult,
6
+ ConnectFailed,
7
+ DestructiveBlocked,
8
+ MetadataImportError,
9
+ Plan,
10
+ PlannedOperation,
11
+ Report,
12
+ RiskClass,
13
+ SqlpushError,
14
+ )
15
+
16
+ # Static-checker visibility for the PEP 562 lazy exports below: this
17
+ # block never executes at runtime, so the light-import guarantee is
18
+ # untouched (tests/test_annotations.py still guards it).
19
+ if TYPE_CHECKING:
20
+ from sqlpush.api import (
21
+ acheck,
22
+ aensure_schema,
23
+ aplan,
24
+ apush,
25
+ check,
26
+ ensure_schema,
27
+ plan,
28
+ push,
29
+ )
30
+
31
+ __version__ = "0.1.0"
32
+
33
+ # Public API (plan/push/check/ensure_schema + async facade) is exported
34
+ # lazily via PEP 562: an eager import would pull alembic (through
35
+ # sqlpush.core.diff) into every `import sqlpush.*`, breaking the
36
+ # light-import guarantee of the annotations module (see
37
+ # tests/test_annotations.py::test_annotations_module_has_no_heavy_imports).
38
+ _LAZY_API = (
39
+ "plan",
40
+ "push",
41
+ "check",
42
+ "ensure_schema",
43
+ "aplan",
44
+ "apush",
45
+ "acheck",
46
+ "aensure_schema",
47
+ )
48
+
49
+
50
+ def __getattr__(name: str) -> Any:
51
+ if name in _LAZY_API:
52
+ from sqlpush import api
53
+
54
+ return getattr(api, name)
55
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
56
+
57
+
58
+ __all__ = [
59
+ "AppliedOperation",
60
+ "CheckResult",
61
+ "ConnectFailed",
62
+ "DestructiveBlocked",
63
+ "MetadataImportError",
64
+ "Plan",
65
+ "PlannedOperation",
66
+ "Report",
67
+ "RiskClass",
68
+ "SqlpushError",
69
+ "__version__",
70
+ "acheck",
71
+ "aensure_schema",
72
+ "aplan",
73
+ "apush",
74
+ "check",
75
+ "ensure_schema",
76
+ "plan",
77
+ "push",
78
+ ]
sqlpush/annotations.py ADDED
@@ -0,0 +1,37 @@
1
+ # src/sqlpush/annotations.py
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+
6
+ HYPERTABLE_KEY = "sqlpush_hypertable"
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class HypertableInfo:
11
+ time_column: str
12
+ chunk_time_interval: str | None = None
13
+
14
+
15
+ def hypertable(*, time_column: str, chunk_time_interval: str | None = None):
16
+ """Record hypertable intent on the model's Table (MetaData level).
17
+
18
+ Works with SQLModel, Flask-SQLAlchemy and plain declarative, anything
19
+ whose class already carries a built ``__table__`` when decorated.
20
+
21
+ The generated ``create_hypertable`` runs with
22
+ ``create_default_indexes => false``: timescale's implicit time-column
23
+ index is invisible to metadata and would drift forever. Declare it
24
+ yourself (``Index(..., "<time_column>")`` on the model) if you rely
25
+ on it for time-range scans.
26
+ """
27
+
28
+ def decorator(cls):
29
+ table = getattr(cls, "__table__", None)
30
+ if table is None:
31
+ raise TypeError(f"{cls.__name__} has no __table__; decorate a mapped class")
32
+ table.info[HYPERTABLE_KEY] = HypertableInfo(
33
+ time_column=time_column, chunk_time_interval=chunk_time_interval
34
+ )
35
+ return cls
36
+
37
+ return decorator
sqlpush/api.py ADDED
@@ -0,0 +1,172 @@
1
+ # src/sqlpush/api.py
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ from typing import NoReturn
6
+
7
+ from sqlalchemy import MetaData
8
+ from sqlalchemy.engine import Engine
9
+ from sqlalchemy.exc import (
10
+ ArgumentError,
11
+ OperationalError,
12
+ SQLAlchemyError,
13
+ )
14
+ from sqlalchemy.ext.asyncio import AsyncEngine
15
+
16
+ from sqlpush.apply.executor import apply_plan, with_advisory_lock
17
+ from sqlpush.core.diff import DiffEngine
18
+ from sqlpush.directives.timescale import hypertable_operations
19
+ from sqlpush.types import (
20
+ CheckResult,
21
+ ConnectFailed,
22
+ Plan,
23
+ Report,
24
+ SqlpushError,
25
+ )
26
+
27
+ _engine = DiffEngine()
28
+
29
+ # Connection-phase failures (server unreachable, bad DSN, auth) re-type
30
+ # as ConnectFailed; every other SQLAlchemyError leaving a DB-touching
31
+ # verb becomes a plain SqlpushError. Either way only typed errors
32
+ # escape the API.
33
+ _CONNECT_PHASE = (OperationalError, ArgumentError)
34
+
35
+
36
+ def _raise_typed(exc: SQLAlchemyError) -> NoReturn:
37
+ if isinstance(exc, _CONNECT_PHASE):
38
+ raise ConnectFailed(f"could not connect to the database: {exc}") from exc
39
+ raise SqlpushError(f"database error: {exc}") from exc
40
+
41
+
42
+ def _build_plan(metadata: MetaData, engine: Engine, schemas, exclude) -> Plan:
43
+ p = _engine.plan(metadata, engine, schemas=schemas, exclude=exclude)
44
+ return Plan(operations=p.operations + tuple(hypertable_operations(metadata, engine)))
45
+
46
+
47
+ class _PlannerWithDirectives(DiffEngine):
48
+ """DiffEngine facade whose plans carry directive ops.
49
+
50
+ ``with_advisory_lock`` re-plans via ``reverify.plan(metadata, engine,
51
+ schemas=..., exclude=...)`` once the lock is won; routing that call
52
+ through the plan builder keeps directive operations (e.g.
53
+ create_hypertable) on the DEFAULT locked push path.
54
+ """
55
+
56
+ def __init__(self, engine: DiffEngine) -> None:
57
+ self._engine = engine
58
+
59
+ def plan(self, metadata, engine, *, schemas=None, exclude=()) -> Plan:
60
+ return _build_plan(metadata, engine, schemas, exclude)
61
+
62
+
63
+ def plan(metadata, engine, *, schemas=None, exclude=()) -> Plan:
64
+ try:
65
+ return _build_plan(metadata, engine, schemas, exclude)
66
+ except SQLAlchemyError as exc:
67
+ _raise_typed(exc)
68
+
69
+
70
+ def push(
71
+ metadata,
72
+ engine,
73
+ *,
74
+ safe_only=False,
75
+ allow_destructive=False,
76
+ lock=True,
77
+ lock_timeout=5.0,
78
+ advisory_wait=30.0,
79
+ schemas=None,
80
+ exclude=(),
81
+ ) -> Report:
82
+ try:
83
+ if lock:
84
+ return with_advisory_lock(
85
+ engine,
86
+ metadata,
87
+ wait=advisory_wait,
88
+ timeout=lock_timeout,
89
+ allow_destructive=allow_destructive,
90
+ safe_only=safe_only,
91
+ reverify=_PlannerWithDirectives(_engine),
92
+ schemas=schemas,
93
+ exclude=exclude,
94
+ )
95
+ p = _build_plan(metadata, engine, schemas, exclude)
96
+ return apply_plan(
97
+ engine,
98
+ p,
99
+ allow_destructive=allow_destructive,
100
+ safe_only=safe_only,
101
+ lock_timeout=lock_timeout,
102
+ )
103
+ except SQLAlchemyError as exc:
104
+ _raise_typed(exc)
105
+
106
+
107
+ def check(metadata, engine, *, schemas=None, exclude=()) -> CheckResult:
108
+ try:
109
+ p = _build_plan(metadata, engine, schemas, exclude)
110
+ except SQLAlchemyError as exc:
111
+ _raise_typed(exc)
112
+ return CheckResult(
113
+ clean=not p.drift,
114
+ drift=p.drift,
115
+ has_destructive=p.has_destructive,
116
+ )
117
+
118
+
119
+ def _sync_engine_from(target):
120
+ if isinstance(target, AsyncEngine):
121
+ from sqlalchemy import create_engine
122
+ from sqlalchemy.pool import NullPool
123
+
124
+ dsn = target.url.render_as_string(hide_password=False)
125
+ return create_engine(dsn, poolclass=NullPool), True
126
+ if isinstance(target, str):
127
+ from sqlalchemy import create_engine
128
+ from sqlalchemy.pool import NullPool
129
+
130
+ return create_engine(target, poolclass=NullPool), True
131
+ return target, False
132
+
133
+
134
+ _ENSURE_MODES = ("push", "check")
135
+
136
+
137
+ def ensure_schema(metadata, target, mode="push", **kwargs) -> Report:
138
+ # validated BEFORE any engine work: a typo'd mode must never fall
139
+ # through to the writing branch
140
+ if mode not in _ENSURE_MODES:
141
+ raise SqlpushError(f"invalid mode {mode!r}: expected 'push' or 'check'")
142
+ try:
143
+ engine, dispose = _sync_engine_from(target)
144
+ try:
145
+ if mode == "check":
146
+ result = check(metadata, engine, **kwargs)
147
+ if result.drift:
148
+ raise SqlpushError("schema drift detected (ensure_schema mode='check')")
149
+ return Report()
150
+ return push(metadata, engine, **kwargs)
151
+ finally:
152
+ if dispose:
153
+ engine.dispose()
154
+ except SQLAlchemyError as exc:
155
+ # includes ArgumentError from a bad string DSN in _sync_engine_from
156
+ _raise_typed(exc)
157
+
158
+
159
+ async def aplan(metadata, engine, **kw):
160
+ return await asyncio.to_thread(plan, metadata, engine, **kw)
161
+
162
+
163
+ async def apush(metadata, engine, **kw):
164
+ return await asyncio.to_thread(push, metadata, engine, **kw)
165
+
166
+
167
+ async def acheck(metadata, engine, **kw):
168
+ return await asyncio.to_thread(check, metadata, engine, **kw)
169
+
170
+
171
+ async def aensure_schema(metadata, target, **kw):
172
+ return await asyncio.to_thread(ensure_schema, metadata, target, **kw)
File without changes
@@ -0,0 +1,199 @@
1
+ # src/sqlpush/apply/executor.py
2
+ from __future__ import annotations
3
+
4
+ import contextlib
5
+ import time
6
+ from typing import TYPE_CHECKING
7
+
8
+ from sqlalchemy import text
9
+ from sqlalchemy.engine import Engine
10
+
11
+ from sqlpush.types import (
12
+ AppliedOperation,
13
+ Plan,
14
+ PlannedOperation,
15
+ Report,
16
+ RiskClass,
17
+ SqlpushError,
18
+ )
19
+
20
+ if TYPE_CHECKING:
21
+ from collections.abc import Sequence
22
+
23
+ from sqlalchemy import MetaData
24
+
25
+ from sqlpush.core.diff import DiffEngine
26
+
27
+
28
+ def _is_concurrent(op: PlannedOperation) -> bool:
29
+ return "CONCURRENTLY" in op.sql.upper()
30
+
31
+
32
+ def apply_plan(
33
+ engine: Engine,
34
+ plan: Plan,
35
+ *,
36
+ allow_destructive: bool = False,
37
+ safe_only: bool = False,
38
+ lock_timeout: float = 5.0,
39
+ ) -> Report:
40
+ start = time.monotonic()
41
+
42
+ blocked = tuple(op for op in plan.operations if op.risk is RiskClass.DESTRUCTIVE)
43
+ if blocked and not allow_destructive:
44
+ # Destructive gate: nothing executes at all. When safe_only is also
45
+ # active, risky ops are still recorded as policy-skipped; destructive
46
+ # ones are already in `blocked` and are not double-listed.
47
+ skipped = (
48
+ tuple(op for op in plan.operations if op.risk is RiskClass.RISKY) if safe_only else ()
49
+ )
50
+ return Report(
51
+ applied=(),
52
+ blocked=blocked,
53
+ skipped=skipped,
54
+ partial_failure=False,
55
+ duration=time.monotonic() - start,
56
+ )
57
+
58
+ if safe_only:
59
+ runnable = [op for op in plan.operations if op.risk is RiskClass.SAFE]
60
+ skipped = tuple(op for op in plan.operations if op.risk is not RiskClass.SAFE)
61
+ else:
62
+ runnable = list(plan.operations)
63
+ skipped = ()
64
+
65
+ applied: list[AppliedOperation] = []
66
+ partial_failure = False
67
+
68
+ # --- CONCURRENTLY segment: autocommit, one op per statement ----------
69
+ concurrent = [op for op in runnable if _is_concurrent(op)]
70
+ if concurrent:
71
+ with engine.connect().execution_options(isolation_level="AUTOCOMMIT") as conn:
72
+ for op in concurrent:
73
+ try:
74
+ conn.execute(text(op.sql))
75
+ applied.append(AppliedOperation(op.type, "applied"))
76
+ except Exception: # noqa: BLE001 # concurrent ops record any DB failure and continue
77
+ applied.append(AppliedOperation(op.type, "failed"))
78
+ partial_failure = True
79
+
80
+ # --- transactional segment: atomic ----------------------------------
81
+ plain = [op for op in runnable if not _is_concurrent(op)]
82
+ if plain:
83
+ try:
84
+ with engine.begin() as conn:
85
+ # NOTE: PostgreSQL does not accept bind parameters for SET
86
+ # (utility statement), so the int is inlined; lock_timeout is
87
+ # a typed float parameter, not user input.
88
+ conn.execute(text(f"SET LOCAL lock_timeout = {int(lock_timeout * 1000)}"))
89
+ for op in plain:
90
+ conn.execute(text(op.sql))
91
+ applied.extend(AppliedOperation(op.type, "applied") for op in plain)
92
+ except Exception as exc:
93
+ msg = f"transactional segment failed, rolled back: {exc}"
94
+ if applied:
95
+ msg += f"; concurrent segment had already applied: {[a.type for a in applied]}"
96
+ raise SqlpushError(msg) from exc
97
+
98
+ return Report(
99
+ applied=tuple(applied),
100
+ blocked=(),
101
+ skipped=skipped,
102
+ partial_failure=partial_failure,
103
+ duration=time.monotonic() - start,
104
+ )
105
+
106
+
107
+ # --- advisory lock: winner/loser semantics --------------------------------
108
+ #
109
+ # v0.1 design notes:
110
+ # - The lock is session-scoped and taken/released on `conn`, while
111
+ # `apply_plan` opens its own connections. Acceptable because the
112
+ # advisory lock's job is worker coordination (one pusher per database
113
+ # at a time), not txn participation.
114
+ # - Session advisory locks survive ROLLBACK: the winner rolls back the
115
+ # txn opened by the key/probe queries right after acquiring, so the
116
+ # session never idles in transaction (an
117
+ # idle_in_transaction_session_timeout would kill the winner mid-push
118
+ # and silently release the lock).
119
+
120
+
121
+ def fnv1a_32(data: bytes) -> int:
122
+ h = 2166136261
123
+ for byte in data:
124
+ h ^= byte
125
+ h = (h * 16777619) & 0xFFFFFFFF
126
+ return h
127
+
128
+
129
+ def advisory_key(conn) -> int:
130
+ # Deterministic across DSN spellings: the key derives from the
131
+ # database's oid, not from the connection string.
132
+ oid = conn.execute(
133
+ text("SELECT oid FROM pg_database WHERE datname = current_database()")
134
+ ).scalar()
135
+ return fnv1a_32(b"sqlpush") ^ oid
136
+
137
+
138
+ def with_advisory_lock(
139
+ engine: Engine,
140
+ metadata: MetaData,
141
+ *,
142
+ wait: float = 30.0,
143
+ timeout: float = 5.0,
144
+ allow_destructive: bool = False,
145
+ safe_only: bool = False,
146
+ reverify: DiffEngine | None = None,
147
+ schemas: Sequence[str] | None = None,
148
+ exclude: Sequence[str] = (),
149
+ ) -> Report:
150
+ """Winner migrates; losers block (bounded), then re-verify.
151
+
152
+ Losers poll ``pg_try_advisory_lock`` every 0.5 s against a
153
+ ``time.monotonic()`` deadline; once the lock is acquired the winner
154
+ path re-plans (covering the case where the previous winner died
155
+ mid-push). Raises :class:`SqlpushError` if the wait budget is
156
+ exhausted.
157
+ """
158
+ if reverify is None:
159
+ raise SqlpushError(
160
+ "reverify is required: pass an object exposing a "
161
+ "DiffEngine-compatible .plan(metadata, engine, ...)"
162
+ )
163
+ if wait < 0:
164
+ raise SqlpushError(f"wait must be >= 0, got {wait}")
165
+ if timeout < 0:
166
+ raise SqlpushError(f"timeout (lock_timeout) must be >= 0, got {timeout}")
167
+ deadline = time.monotonic() + wait
168
+ with engine.connect() as conn:
169
+ key = advisory_key(conn)
170
+ locked = conn.execute(text("SELECT pg_try_advisory_lock(:k)"), {"k": key}).scalar()
171
+ while not locked and time.monotonic() < deadline:
172
+ time.sleep(0.5)
173
+ locked = conn.execute(text("SELECT pg_try_advisory_lock(:k)"), {"k": key}).scalar()
174
+ if not locked:
175
+ raise SqlpushError(f"another sqlpush worker holds the advisory lock after {wait}s")
176
+ # close the txn opened by the key/probe queries: session
177
+ # advisory locks survive ROLLBACK, and an idle-in-transaction
178
+ # winner is exposed to idle_in_transaction_session_timeout
179
+ # (killed mid-push = lock silently released)
180
+ conn.rollback()
181
+ try:
182
+ plan = reverify.plan(metadata, engine, schemas=schemas, exclude=exclude)
183
+ if not plan.drift:
184
+ return Report()
185
+ return apply_plan(
186
+ engine,
187
+ plan,
188
+ allow_destructive=allow_destructive,
189
+ safe_only=safe_only,
190
+ lock_timeout=timeout,
191
+ )
192
+ finally:
193
+ # best-effort unlock: a secondary failure here (e.g. the
194
+ # txn is already aborted) must never mask the original
195
+ # exception out of the winner path
196
+ if not conn.closed:
197
+ with contextlib.suppress(Exception):
198
+ conn.rollback()
199
+ conn.execute(text("SELECT pg_advisory_unlock(:k)"), {"k": key})
sqlpush/cli.py ADDED
@@ -0,0 +1,218 @@
1
+ # src/sqlpush/cli.py
2
+ """sqlpush: diff/push/check PostgreSQL schema drift from SQLAlchemy models.
3
+
4
+ Exit codes: diff always 0; check 0 clean / 2 drift / 3 destructive drift;
5
+ push 0 applied / 1 destructive blocked / 2 error (incl. partial failure).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import importlib
11
+ import json
12
+ import os
13
+ import sys
14
+ from typing import Annotated
15
+
16
+ import typer
17
+ from sqlalchemy import create_engine
18
+ from sqlalchemy.exc import ArgumentError
19
+ from sqlalchemy.pool import NullPool
20
+
21
+ from sqlpush import api
22
+ from sqlpush.core.render import render
23
+ from sqlpush.types import (
24
+ CheckResult,
25
+ MetadataImportError,
26
+ RiskClass,
27
+ SqlpushError,
28
+ )
29
+
30
+ app = typer.Typer(add_completion=False, help=__doc__ or "")
31
+
32
+ # Annotated + None default: keeps ruff B008/B006 quiet (no call in the
33
+ # default, no mutable default) while staying the idiomatic typer style.
34
+ SchemaOpt = Annotated[list[str] | None, typer.Option("--schema")]
35
+ ExcludeOpt = Annotated[list[str] | None, typer.Option("--exclude")]
36
+
37
+
38
+ def _load_metadata(spec: str):
39
+ module, _, attr = spec.partition(":")
40
+ try:
41
+ obj = importlib.import_module(module)
42
+ for part in attr.split("."):
43
+ obj = getattr(obj, part)
44
+ return obj
45
+ except (ImportError, AttributeError) as exc:
46
+ raise MetadataImportError(f"cannot import {spec!r}: {exc}") from exc
47
+
48
+
49
+ def _engine(dsn: str | None):
50
+ dsn = dsn or os.environ.get("DATABASE_URL")
51
+ if not dsn:
52
+ typer.secho("no --dsn and no DATABASE_URL set", fg="red", err=True)
53
+ raise typer.Exit(code=1)
54
+ try:
55
+ return create_engine(dsn, poolclass=NullPool)
56
+ except ArgumentError as exc:
57
+ # malformed DSN is a configuration error, not a verb error: exit 1
58
+ typer.secho(f"invalid DSN: {exc}", fg="red", err=True)
59
+ raise typer.Exit(code=1) from exc
60
+
61
+
62
+ def _emit_json(plan) -> None:
63
+ typer.echo(json.dumps(plan.to_json_dict(), indent=2))
64
+
65
+
66
+ def _risk_summary(p) -> None:
67
+ # --verbose adds an op-count line per PRESENT risk class (absent
68
+ # classes stay silent, mirroring render's section omission)
69
+ for cls in RiskClass:
70
+ n = sum(1 for op in p.operations if op.risk is cls)
71
+ if n:
72
+ typer.echo(f"{cls.value}: {n} operation(s)")
73
+
74
+
75
+ @app.command()
76
+ def diff(
77
+ metadata_spec: str = typer.Argument(..., help="module:metadata"),
78
+ dsn: str | None = typer.Option(None),
79
+ json_output: bool = typer.Option(False, "--json"),
80
+ verbose: bool = typer.Option(False, "--verbose"),
81
+ quiet: bool = typer.Option(False, "--quiet"),
82
+ schema: SchemaOpt = None,
83
+ exclude: ExcludeOpt = None,
84
+ ):
85
+ md = _load_metadata(metadata_spec)
86
+ engine = _engine(dsn)
87
+ try:
88
+ p = api.plan(md, engine, schemas=schema, exclude=exclude or ())
89
+ if json_output:
90
+ _emit_json(p)
91
+ raise typer.Exit(code=0)
92
+ if verbose:
93
+ _risk_summary(p)
94
+ typer.echo(render(p) or "-- schema in sync --")
95
+ finally:
96
+ engine.dispose()
97
+ raise typer.Exit(code=0)
98
+
99
+
100
+ @app.command()
101
+ def check(
102
+ metadata_spec: str = typer.Argument(...),
103
+ dsn: str | None = typer.Option(None),
104
+ json_output: bool = typer.Option(False, "--json"),
105
+ verbose: bool = typer.Option(False, "--verbose"),
106
+ quiet: bool = typer.Option(False, "--quiet"),
107
+ schema: SchemaOpt = None,
108
+ exclude: ExcludeOpt = None,
109
+ ):
110
+ md = _load_metadata(metadata_spec)
111
+ engine = _engine(dsn)
112
+ try:
113
+ # plan ONCE and derive everything from that single object: a
114
+ # second plan could observe a different DB state than the one
115
+ # the exit code was derived from (TOCTOU)
116
+ p = api.plan(md, engine, schemas=schema, exclude=exclude or ())
117
+ result = CheckResult(
118
+ clean=not p.drift,
119
+ drift=p.drift,
120
+ has_destructive=p.has_destructive,
121
+ )
122
+ if json_output:
123
+ _emit_json(p)
124
+ elif verbose:
125
+ ndest = sum(1 for op in p.operations if op.risk is RiskClass.DESTRUCTIVE)
126
+ typer.echo(f"drift: {len(p.operations)} operation(s), {ndest} destructive")
127
+ finally:
128
+ engine.dispose()
129
+ if result.clean:
130
+ raise typer.Exit(code=0)
131
+ raise typer.Exit(code=3 if result.has_destructive else 2)
132
+
133
+
134
+ @app.command()
135
+ def push(
136
+ metadata_spec: str = typer.Argument(...),
137
+ dsn: str | None = typer.Option(None),
138
+ json_output: bool = typer.Option(False, "--json"),
139
+ allow_destructive: bool = typer.Option(False, "--allow-destructive"),
140
+ safe_only: bool = typer.Option(False, "--safe-only"),
141
+ no_lock: bool = typer.Option(False, "--no-lock"),
142
+ lock_timeout: float = typer.Option(5.0, "--lock-timeout"),
143
+ advisory_wait: float = typer.Option(30.0, "--advisory-wait"),
144
+ verbose: bool = typer.Option(False, "--verbose"),
145
+ quiet: bool = typer.Option(False, "--quiet"),
146
+ schema: SchemaOpt = None,
147
+ exclude: ExcludeOpt = None,
148
+ ):
149
+ md = _load_metadata(metadata_spec)
150
+ engine = _engine(dsn)
151
+ try:
152
+ try:
153
+ report = api.push(
154
+ md,
155
+ engine,
156
+ safe_only=safe_only,
157
+ allow_destructive=allow_destructive,
158
+ lock=not no_lock,
159
+ lock_timeout=lock_timeout,
160
+ advisory_wait=advisory_wait,
161
+ schemas=schema,
162
+ exclude=exclude or (),
163
+ )
164
+ except SqlpushError as exc:
165
+ # binding exit-code table: 1 is exclusively "destructive
166
+ # blocked"; every other push error (lock timeout, connect
167
+ # failure, ...) is exit 2
168
+ typer.secho(str(exc), fg="red", err=True)
169
+ raise typer.Exit(code=2) from exc
170
+ if json_output:
171
+ typer.echo(
172
+ json.dumps(
173
+ {
174
+ "version": 1,
175
+ "applied": [a.__dict__ for a in report.applied],
176
+ "blocked": [b.__dict__ for b in report.blocked],
177
+ "skipped": [s.__dict__ for s in report.skipped],
178
+ "partial_failure": report.partial_failure,
179
+ },
180
+ indent=2,
181
+ default=str,
182
+ )
183
+ )
184
+ elif verbose:
185
+ for a in report.applied:
186
+ typer.echo(f"{a.status}: {a.type}")
187
+ if report.skipped:
188
+ typer.echo(f"skipped: {len(report.skipped)} operation(s)")
189
+ typer.echo(f"duration: {report.duration:.3f}s")
190
+ finally:
191
+ engine.dispose()
192
+ if report.blocked:
193
+ if not quiet:
194
+ typer.secho(
195
+ f"{len(report.blocked)} destructive operation(s) blocked; "
196
+ "re-run with --allow-destructive",
197
+ fg="yellow",
198
+ err=True,
199
+ )
200
+ raise typer.Exit(code=1)
201
+ if report.skipped and not json_output and not quiet:
202
+ # policy-skipped (--safe-only) is informational, never an error
203
+ typer.secho(
204
+ f"{len(report.skipped)} operation(s) skipped by --safe-only",
205
+ fg="blue",
206
+ err=True,
207
+ )
208
+ if report.partial_failure:
209
+ raise typer.Exit(code=2)
210
+ raise typer.Exit(code=0)
211
+
212
+
213
+ def main() -> None: # [project.scripts] entry point
214
+ try:
215
+ app()
216
+ except (MetadataImportError, SqlpushError) as exc:
217
+ typer.secho(str(exc), fg="red", err=True)
218
+ sys.exit(1)