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 +78 -0
- sqlpush/annotations.py +37 -0
- sqlpush/api.py +172 -0
- sqlpush/apply/__init__.py +0 -0
- sqlpush/apply/executor.py +199 -0
- sqlpush/cli.py +218 -0
- sqlpush/core/__init__.py +0 -0
- sqlpush/core/classify.py +19 -0
- sqlpush/core/diff.py +205 -0
- sqlpush/core/render.py +21 -0
- sqlpush/directives/__init__.py +0 -0
- sqlpush/directives/timescale.py +79 -0
- sqlpush/types.py +100 -0
- sqlpush-0.1.0.dist-info/METADATA +215 -0
- sqlpush-0.1.0.dist-info/RECORD +17 -0
- sqlpush-0.1.0.dist-info/WHEEL +4 -0
- sqlpush-0.1.0.dist-info/entry_points.txt +3 -0
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)
|