sqlpush 0.4.2__py3-none-any.whl → 0.5.1__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/api.py CHANGED
@@ -48,8 +48,10 @@ def _raise_typed(exc: SQLAlchemyError) -> NoReturn:
48
48
  raise SqlpushError(f"database error: {exc}") from exc
49
49
 
50
50
 
51
- def _build_plan(metadata: MetaData, engine: Engine, schemas, exclude) -> Plan:
52
- p = _engine.plan(metadata, engine, schemas=schemas, exclude=exclude)
51
+ def _build_plan(
52
+ metadata: MetaData, engine: Engine, schemas, exclude, concurrently: bool = True
53
+ ) -> Plan:
54
+ p = _engine.plan(metadata, engine, schemas=schemas, exclude=exclude, concurrently=concurrently)
53
55
  return Plan(operations=p.operations + tuple(hypertable_operations(metadata, engine)))
54
56
 
55
57
 
@@ -65,13 +67,13 @@ class _PlannerWithDirectives(DiffEngine):
65
67
  def __init__(self, engine: DiffEngine) -> None:
66
68
  self._engine = engine
67
69
 
68
- def plan(self, metadata, engine, *, schemas=None, exclude=()) -> Plan:
69
- return _build_plan(metadata, engine, schemas, exclude)
70
+ def plan(self, metadata, engine, *, schemas=None, exclude=(), concurrently=True) -> Plan:
71
+ return _build_plan(metadata, engine, schemas, exclude, concurrently)
70
72
 
71
73
 
72
- def plan(metadata, engine, *, schemas=None, exclude=()) -> Plan:
74
+ def plan(metadata, engine, *, schemas=None, exclude=(), concurrently: bool = True) -> Plan:
73
75
  try:
74
- return _build_plan(metadata, engine, schemas, exclude)
76
+ return _build_plan(metadata, engine, schemas, exclude, concurrently)
75
77
  except SQLAlchemyError as exc:
76
78
  _raise_typed(exc)
77
79
 
@@ -84,9 +86,11 @@ def push(
84
86
  allow_destructive=False,
85
87
  lock=True,
86
88
  lock_timeout=5.0,
89
+ statement_timeout=None,
87
90
  advisory_wait=30.0,
88
91
  schemas=None,
89
92
  exclude=(),
93
+ concurrently=True,
90
94
  ) -> Report:
91
95
  try:
92
96
  if lock:
@@ -100,14 +104,21 @@ def push(
100
104
  reverify=_PlannerWithDirectives(_engine),
101
105
  schemas=schemas,
102
106
  exclude=exclude,
107
+ # THREADING (pinned): the lock winner re-plans via
108
+ # reverify.plan(...) — both knobs must reach it and the
109
+ # final apply_plan, or the winner renders differently
110
+ # than requested
111
+ concurrently=concurrently,
112
+ statement_timeout=statement_timeout,
103
113
  )
104
- p = _build_plan(metadata, engine, schemas, exclude)
114
+ p = _build_plan(metadata, engine, schemas, exclude, concurrently)
105
115
  return apply_plan(
106
116
  engine,
107
117
  p,
108
118
  allow_destructive=allow_destructive,
109
119
  safe_only=safe_only,
110
120
  lock_timeout=lock_timeout,
121
+ statement_timeout=statement_timeout,
111
122
  )
112
123
  except SQLAlchemyError as exc:
113
124
  _raise_typed(exc)
@@ -126,14 +137,23 @@ def check(metadata, engine, *, schemas=None, exclude=()) -> CheckResult:
126
137
 
127
138
 
128
139
  def revision(
129
- metadata, ref_engine, *, out_dir="migrations/versions", message=None, schemas=None, exclude=()
140
+ metadata,
141
+ ref_engine,
142
+ *,
143
+ out_dir="migrations/versions",
144
+ message=None,
145
+ schemas=None,
146
+ exclude=(),
147
+ concurrently=True,
130
148
  ) -> Path:
131
149
  """Generate the next annotated-SQL migration file from models-vs-ref drift.
132
150
 
133
151
  The reference DB must sit at the chain head (caller-provided — sqlpush
134
152
  stays docker-free). Empty drift refuses loudly: no empty files.
153
+ ``concurrently`` follows :func:`plan` (default True: existing-table
154
+ indexes render CONCURRENTLY in the generated file).
135
155
  """
136
- p = plan(metadata, ref_engine, schemas=schemas, exclude=exclude)
156
+ p = plan(metadata, ref_engine, schemas=schemas, exclude=exclude, concurrently=concurrently)
137
157
  if not p.operations:
138
158
  raise SqlpushError("no drift between models and reference DB — nothing to revise")
139
159
  risk = max((op.risk for op in p.operations), key=lambda r: RISK_RANK[r])
@@ -175,6 +195,7 @@ def migrate(
175
195
  allow_destructive=False,
176
196
  advisory_wait=30.0,
177
197
  lock_timeout=5.0,
198
+ statement_timeout=None,
178
199
  ) -> MigrateReport:
179
200
  """Replay annotated-SQL chain files with gates + same-txn bookkeeping.
180
201
 
@@ -182,7 +203,10 @@ def migrate(
182
203
  via ``_sync_engine_from``; engines created here are disposed).
183
204
  ``advisory_wait`` bounds the advisory-lock wait and ``lock_timeout``
184
205
  bounds each per-file transaction's lock wait (seconds; 0 = fail
185
- immediately — same contract as ``push``). See
206
+ immediately — same contract as ``push``). ``statement_timeout``
207
+ (seconds, ``None`` = set nothing) bounds each statement's runtime —
208
+ ``SET LOCAL`` in per-file transactions, session-level on the
209
+ CONCURRENTLY autocommit connection. See
186
210
  ``chain.migrate.run_migrate`` for the execution contract.
187
211
  """
188
212
  engine, dispose = _sync_engine_from(target)
@@ -193,6 +217,7 @@ def migrate(
193
217
  allow_destructive=allow_destructive,
194
218
  advisory_wait=advisory_wait,
195
219
  lock_timeout=lock_timeout,
220
+ statement_timeout=statement_timeout,
196
221
  )
197
222
  except SQLAlchemyError as exc:
198
223
  # MigrationFileError/SqlpushError (typed) pass through untouched
sqlpush/apply/executor.py CHANGED
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
 
4
4
  import contextlib
5
5
  import time
6
+ from collections.abc import Iterator
6
7
  from typing import TYPE_CHECKING
7
8
 
8
9
  from sqlalchemy import text
@@ -26,7 +27,37 @@ if TYPE_CHECKING:
26
27
 
27
28
 
28
29
  def _is_concurrent(op: PlannedOperation) -> bool:
29
- return "CONCURRENTLY" in op.sql.upper()
30
+ # Union (A5): the `concurrent` flag is authoritative for generated
31
+ # plans (set by the diff's injection); the SQL substring keeps
32
+ # hand-built Plans splitting to the autocommit segment.
33
+ return op.concurrent or "CONCURRENTLY" in op.sql.upper()
34
+
35
+
36
+ @contextlib.contextmanager
37
+ def _session_gucs(conn, **gucs: str | int) -> Iterator[None]:
38
+ """Session-level ``SET``/``RESET`` with pooled-connection hygiene.
39
+
40
+ The autocommit (CONCURRENTLY) segment cannot use ``SET LOCAL`` —
41
+ there is no surrounding transaction — so its GUCs are session-level,
42
+ and a session ``SET`` survives segment end (0.4.2 lesson). RESET runs
43
+ per GUC in ``finally``; a reset failure is suppressed (it must never
44
+ mask the segment's own outcome) and the connection is invalidated so
45
+ the pool discards it instead of handing the next borrower a session
46
+ still carrying the shrunken budget.
47
+ """
48
+ try:
49
+ for name, value in gucs.items():
50
+ # names come from sqlpush code, values are ints inlined in
51
+ # the same style as the txn segment's SET LOCAL (PostgreSQL
52
+ # does not accept bind parameters for SET)
53
+ conn.execute(text(f"SET {name} = '{value}'"))
54
+ yield
55
+ finally:
56
+ for name in gucs:
57
+ try:
58
+ conn.execute(text(f"RESET {name}"))
59
+ except Exception: # noqa: BLE001 # never mask the segment's outcome
60
+ conn.invalidate()
30
61
 
31
62
 
32
63
  def apply_plan(
@@ -36,8 +67,11 @@ def apply_plan(
36
67
  allow_destructive: bool = False,
37
68
  safe_only: bool = False,
38
69
  lock_timeout: float = 5.0,
70
+ statement_timeout: float | None = None,
39
71
  ) -> Report:
40
72
  start = time.monotonic()
73
+ if statement_timeout is not None and statement_timeout < 0:
74
+ raise SqlpushError(f"statement_timeout must be >= 0, got {statement_timeout}")
41
75
 
42
76
  blocked = tuple(op for op in plan.operations if op.risk is RiskClass.DESTRUCTIVE)
43
77
  if blocked and not allow_destructive:
@@ -69,13 +103,21 @@ def apply_plan(
69
103
  concurrent = [op for op in runnable if _is_concurrent(op)]
70
104
  if concurrent:
71
105
  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
106
+ # A7: the concurrent segment gets the SAME lock budget as the
107
+ # txn segment (session-level: autocommit has no surrounding
108
+ # transaction for SET LOCAL), plus the optional statement
109
+ # budget; both RESET before the connection returns to the pool
110
+ session_gucs: dict[str, int] = {"lock_timeout": int(lock_timeout * 1000)}
111
+ if statement_timeout is not None:
112
+ session_gucs["statement_timeout"] = int(statement_timeout * 1000)
113
+ with _session_gucs(conn, **session_gucs):
114
+ for op in concurrent:
115
+ try:
116
+ conn.execute(text(op.sql))
117
+ applied.append(AppliedOperation(op.type, "applied"))
118
+ except Exception: # noqa: BLE001 # concurrent ops record any DB failure and continue
119
+ applied.append(AppliedOperation(op.type, "failed"))
120
+ partial_failure = True
79
121
 
80
122
  # --- transactional segment: atomic ----------------------------------
81
123
  plain = [op for op in runnable if not _is_concurrent(op)]
@@ -83,9 +125,13 @@ def apply_plan(
83
125
  try:
84
126
  with engine.begin() as conn:
85
127
  # 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.
128
+ # (utility statement), so the int is inlined; both timeouts
129
+ # are typed float parameters, not user input.
88
130
  conn.execute(text(f"SET LOCAL lock_timeout = {int(lock_timeout * 1000)}"))
131
+ if statement_timeout is not None:
132
+ conn.execute(
133
+ text(f"SET LOCAL statement_timeout = {int(statement_timeout * 1000)}")
134
+ )
89
135
  for op in plain:
90
136
  conn.execute(text(op.sql))
91
137
  applied.extend(AppliedOperation(op.type, "applied") for op in plain)
@@ -146,6 +192,8 @@ def with_advisory_lock(
146
192
  reverify: DiffEngine | None = None,
147
193
  schemas: Sequence[str] | None = None,
148
194
  exclude: Sequence[str] = (),
195
+ concurrently: bool = True,
196
+ statement_timeout: float | None = None,
149
197
  ) -> Report:
150
198
  """Winner migrates; losers block (bounded), then re-verify.
151
199
 
@@ -153,7 +201,9 @@ def with_advisory_lock(
153
201
  ``time.monotonic()`` deadline; once the lock is acquired the winner
154
202
  path re-plans (covering the case where the previous winner died
155
203
  mid-push). Raises :class:`SqlpushError` if the wait budget is
156
- exhausted.
204
+ exhausted. ``concurrently`` and ``statement_timeout`` thread into
205
+ BOTH the winner's re-plan and its apply — the re-plan must render
206
+ exactly what the caller asked for.
157
207
  """
158
208
  if reverify is None:
159
209
  raise SqlpushError(
@@ -179,7 +229,13 @@ def with_advisory_lock(
179
229
  # (killed mid-push = lock silently released)
180
230
  conn.rollback()
181
231
  try:
182
- plan = reverify.plan(metadata, engine, schemas=schemas, exclude=exclude)
232
+ plan = reverify.plan(
233
+ metadata,
234
+ engine,
235
+ schemas=schemas,
236
+ exclude=exclude,
237
+ concurrently=concurrently,
238
+ )
183
239
  if not plan.drift:
184
240
  return Report()
185
241
  return apply_plan(
@@ -188,6 +244,7 @@ def with_advisory_lock(
188
244
  allow_destructive=allow_destructive,
189
245
  safe_only=safe_only,
190
246
  lock_timeout=timeout,
247
+ statement_timeout=statement_timeout,
191
248
  )
192
249
  finally:
193
250
  # best-effort unlock: a secondary failure here (e.g. the
sqlpush/chain/migrate.py CHANGED
@@ -1,13 +1,26 @@
1
1
  """migrate/stamp: chain replay + bootstrap with gates and bookkeeping.
2
2
 
3
- migrate execution contract (spec 2026-09-02 §5): each file's WHOLE TEXT is
4
- replayed in ONE ``exec_driver_sql()`` call inside a per-file transaction —
5
- the parsed ``ops`` are for gating/display only, NEVER reconstructed for
6
- execution (psycopg3 accepts multi-statement strings; nothing is tokenized,
7
- so dollar-quoted bodies are safe). The checksum row is inserted INSIDE the
8
- same transaction as the file's SQL — bookkeeping in a separate txn would
9
- let a crash between apply and registry re-apply the file forever
10
- (crash-loop over existing objects).
3
+ migrate execution contract (spec 2026-09-02 §5, 0.5.0 hybrid): a file
4
+ whose text contains no CONCURRENTLY replays its WHOLE TEXT in ONE
5
+ ``exec_driver_sql()`` call inside a per-file transaction — nothing is
6
+ tokenized, so dollar-quoted bodies (and their internal ``--`` lines)
7
+ are safe; that is the 0.4.2 fast path, byte-identical. A file that
8
+ DOES contain CONCURRENTLY replays per-op on the op-label delimiters
9
+ (``mf.ops``): the plain segment runs FIRST in one transaction (chain
10
+ files can create→index within one file — a table made by a plain op
11
+ must exist before a concurrent index on it; push's concurrent-first
12
+ order does NOT apply here), then concurrent ops run per-op on a
13
+ dedicated autocommit connection, and only after all of them succeed
14
+ is the versions row written. Known cost (chain spec §7): hand-edits
15
+ bypass the label mechanism — a label-less body containing
16
+ CONCURRENTLY routes the whole body to the autocommit lane (executed
17
+ statement-by-statement: the server runs a multi-statement string as
18
+ one implicit transaction, which CONCURRENTLY refuses), and lines
19
+ starting ``--`` are stripped from per-op parsing (they exist inside
20
+ dollar-quoted bodies at the author's risk). Concurrent-free files
21
+ are immune: they take the raw fast path. The crash window (plain
22
+ committed, concurrent applied, no row yet) re-runs loud on existing
23
+ objects — the documented chain-side cost of per-op replay.
11
24
 
12
25
  Fail-loud ordering: any blocked file (parse error, checksum mismatch,
13
26
  destructive gate, SQL failure) stops the chain — nothing later runs (R4).
@@ -19,6 +32,7 @@ holder — diagnose the holder via pg_locks / pg_stat_activity.
19
32
  from __future__ import annotations
20
33
 
21
34
  import contextlib
35
+ import re
22
36
  import time
23
37
  from collections.abc import Iterator
24
38
  from pathlib import Path
@@ -26,7 +40,7 @@ from pathlib import Path
26
40
  from sqlalchemy import text
27
41
  from sqlalchemy.engine import Connection, Engine
28
42
 
29
- from sqlpush.apply.executor import advisory_key
43
+ from sqlpush.apply.executor import _session_gucs, advisory_key
30
44
  from sqlpush.chain.format import MigrationFileError, checksum, parse_migration_file
31
45
  from sqlpush.types import MigrateReport, RiskClass, SqlpushError
32
46
 
@@ -46,6 +60,101 @@ def _chain_files(chain_dir: str | Path) -> list[Path]:
46
60
  return sorted(chain_path.glob("*.sql"))
47
61
 
48
62
 
63
+ def _set_local_gucs(
64
+ conn: Connection, *, lock_timeout: float, statement_timeout: float | None
65
+ ) -> None:
66
+ # txn-scoped: SET LOCAL dies with the surrounding transaction (style:
67
+ # push's transactional segment, executor.py). NOTE: PostgreSQL does
68
+ # not accept bind parameters for SET (utility statement), so ints
69
+ # are inlined; both timeouts are typed float parameters, not user
70
+ # input. A chain file blocked behind another transaction's lock
71
+ # fails fast instead of queuing.
72
+ conn.execute(text(f"SET LOCAL lock_timeout = {int(lock_timeout * 1000)}"))
73
+ if statement_timeout is not None:
74
+ conn.execute(text(f"SET LOCAL statement_timeout = {int(statement_timeout * 1000)}"))
75
+
76
+
77
+ def _record_version(conn: Connection, name: str, sha: str) -> None:
78
+ conn.execute(
79
+ text("INSERT INTO public.sqlpush_versions (name, sha256) VALUES (:n, :s)"),
80
+ {"n": name, "s": sha},
81
+ )
82
+
83
+
84
+ _DOLLAR_TAG_RE = re.compile(r"\$[A-Za-z_][A-Za-z0-9_]*\$|\$\$")
85
+
86
+
87
+ def _split_statements(sql: str) -> list[str]:
88
+ """Split a concurrent-lane op into single statements.
89
+
90
+ Physical constraint (live-verified): the server executes a
91
+ multi-statement simple-protocol string inside ONE implicit
92
+ transaction block, even on an autocommit connection — so
93
+ ``CREATE INDEX CONCURRENTLY`` cannot ride a multi-statement
94
+ ``exec_driver_sql`` call; the autocommit lane needs one statement
95
+ per execute. Split on top-level ``;`` only, treating single-quoted
96
+ strings (``''`` doubling), double-quoted identifiers, dollar-quoted
97
+ bodies (``$tag$...$tag$``) and ``--`` / ``/* */`` comments as
98
+ opaque (psql-equivalent boundaries). Generated ops are single
99
+ statements — the splitter is a no-op for them; only hand-edited
100
+ label-less bodies exercise it (chain spec §7: at the author's
101
+ risk). The plain segment and the fast path NEVER split.
102
+ """
103
+ statements: list[str] = []
104
+ buf: list[str] = []
105
+ i, n = 0, len(sql)
106
+ while i < n:
107
+ ch = sql[i]
108
+ nxt = sql[i + 1] if i + 1 < n else ""
109
+ if ch == "-" and nxt == "-": # line comment
110
+ j = sql.find("\n", i)
111
+ j = n if j == -1 else j + 1
112
+ buf.append(sql[i:j])
113
+ i = j
114
+ elif ch == "/" and nxt == "*": # block comment
115
+ j = sql.find("*/", i + 2)
116
+ j = n if j == -1 else j + 2
117
+ buf.append(sql[i:j])
118
+ i = j
119
+ elif ch == "'": # string literal, '' doubling
120
+ j = i + 1
121
+ while j < n:
122
+ if sql[j] == "'" and not (j + 1 < n and sql[j + 1] == "'"):
123
+ break
124
+ j += 2 if sql[j] == "'" else 1
125
+ j = min(j + 1, n)
126
+ buf.append(sql[i:j])
127
+ i = j
128
+ elif ch == '"': # quoted identifier
129
+ j = sql.find('"', i + 1)
130
+ j = n if j == -1 else j + 1
131
+ buf.append(sql[i:j])
132
+ i = j
133
+ elif ch == "$":
134
+ m = _DOLLAR_TAG_RE.match(sql, i)
135
+ if m: # dollar-quoted body: opaque through the closing tag
136
+ end = sql.find(m.group(0), m.end())
137
+ j = n if end == -1 else end + len(m.group(0))
138
+ buf.append(sql[i:j])
139
+ i = j
140
+ else:
141
+ buf.append(ch)
142
+ i += 1
143
+ elif ch == ";":
144
+ stmt = "".join(buf).strip()
145
+ if stmt:
146
+ statements.append(stmt)
147
+ buf = []
148
+ i += 1
149
+ else:
150
+ buf.append(ch)
151
+ i += 1
152
+ tail = "".join(buf).strip()
153
+ if tail:
154
+ statements.append(tail)
155
+ return statements
156
+
157
+
49
158
  @contextlib.contextmanager
50
159
  def _chain_session(engine: Engine, *, advisory_wait: float = 30.0) -> Iterator[Connection]:
51
160
  """Session-scoped advisory lock + versions table, shared by every verb.
@@ -97,12 +206,15 @@ def run_migrate(
97
206
  allow_destructive: bool,
98
207
  advisory_wait: float = 30.0,
99
208
  lock_timeout: float = 5.0,
209
+ statement_timeout: float | None = None,
100
210
  ) -> MigrateReport:
101
211
  if lock_timeout < 0:
102
212
  # same contract as push (executor.with_advisory_lock): budgets
103
213
  # are typed floats, never user input, and a negative one must
104
214
  # fail before any file or connection work
105
215
  raise SqlpushError(f"lock_timeout must be >= 0, got {lock_timeout}")
216
+ if statement_timeout is not None and statement_timeout < 0:
217
+ raise SqlpushError(f"statement_timeout must be >= 0, got {statement_timeout}")
106
218
  applied: list[str] = []
107
219
  skipped: list[str] = []
108
220
  blocked: list[str] = []
@@ -115,49 +227,115 @@ def run_migrate(
115
227
  for row in conn.execute(text("SELECT name, sha256 FROM public.sqlpush_versions"))
116
228
  }
117
229
  conn.commit()
118
- for f in chain:
119
- raw = f.read_text()
120
- try:
121
- mf = parse_migration_file(raw, name=f.name)
122
- except MigrationFileError as exc:
123
- blocked.append(f.name)
124
- notes.append(f"{f.name}: {exc}")
125
- break # orden estricto: nada posterior corre
126
- if f.name in recorded:
127
- if recorded[f.name] != checksum(raw):
230
+ # B11: ONE lazily-created autocommit connection for the whole
231
+ # walk, GUC-pinned for its entire lifetime. ExitStack ordering:
232
+ # _session_gucs' finally (per-GUC RESET, suppress + invalidate
233
+ # on reset failure) unwinds BEFORE the connection close (LIFO).
234
+ concurrent_stack = contextlib.ExitStack()
235
+ concurrent_conn: Connection | None = None
236
+ try:
237
+ for f in chain:
238
+ raw = f.read_text()
239
+ try:
240
+ mf = parse_migration_file(raw, name=f.name)
241
+ except MigrationFileError as exc:
242
+ blocked.append(f.name)
243
+ notes.append(f"{f.name}: {exc}")
244
+ break # orden estricto: nada posterior corre
245
+ if f.name in recorded:
246
+ if recorded[f.name] != checksum(raw):
247
+ blocked.append(f.name)
248
+ notes.append(f"{f.name}: checksum mismatch (edited after apply?)")
249
+ break
250
+ skipped.append(f.name)
251
+ continue
252
+ if mf.risk is RiskClass.DESTRUCTIVE and not allow_destructive:
128
253
  blocked.append(f.name)
129
- notes.append(f"{f.name}: checksum mismatch (edited after apply?)")
254
+ notes.append(f"{f.name}: DESTRUCTIVE requires --allow-destructive")
130
255
  break
131
- skipped.append(f.name)
132
- continue
133
- if mf.risk is RiskClass.DESTRUCTIVE and not allow_destructive:
134
- blocked.append(f.name)
135
- notes.append(f"{f.name}: DESTRUCTIVE requires --allow-destructive")
136
- break
137
- try:
138
- with conn.begin():
139
- # txn-scoped: SET LOCAL dies with the per-file txn
140
- # (style: push's transactional segment, executor.py).
141
- # NOTE: PostgreSQL does not accept bind parameters for
142
- # SET (utility statement), so the int is inlined;
143
- # lock_timeout is a typed float parameter, not user
144
- # input. A chain file blocked behind another
145
- # transaction's lock fails fast instead of queuing.
146
- conn.execute(text(f"SET LOCAL lock_timeout = {int(lock_timeout * 1000)}"))
147
- # whole-file replay: exec_driver_sql bypasses text()'s
148
- # bind-param parsing entirely — ":casts" and ":=" in
149
- # hand-edited SQL must reach the server verbatim
150
- conn.exec_driver_sql(raw)
151
- conn.execute(
152
- text("INSERT INTO public.sqlpush_versions (name, sha256) VALUES (:n, :s)"),
153
- {"n": f.name, "s": checksum(raw)},
154
- )
155
- applied.append(f.name)
156
- except Exception as exc: # noqa: BLE001 — report, no mask
157
- blocked.append(f.name)
158
- notes.append(f"{f.name}: {exc}")
159
- partial = True
160
- break
256
+ if "CONCURRENTLY" not in raw.upper():
257
+ # fast path — byte-identical to 0.4.2: whole text,
258
+ # one txn, one exec_driver_sql call (":casts" and
259
+ # ":=" reach the server verbatim; dollar-quoted
260
+ # bodies and their internal `--` lines are safe —
261
+ # the parser's line-stripping quirks never execute
262
+ # on this path). Checksum row rides the SAME txn.
263
+ try:
264
+ with conn.begin():
265
+ _set_local_gucs(
266
+ conn,
267
+ lock_timeout=lock_timeout,
268
+ statement_timeout=statement_timeout,
269
+ )
270
+ conn.exec_driver_sql(raw)
271
+ _record_version(conn, f.name, checksum(raw))
272
+ applied.append(f.name)
273
+ except Exception as exc: # noqa: BLE001 — report, no mask
274
+ blocked.append(f.name)
275
+ notes.append(f"{f.name}: {exc}")
276
+ partial = True
277
+ break
278
+ continue
279
+ # mixed path — per-op replay on the op-label delimiters.
280
+ # PLAIN SEGMENT FIRST (create→index dependencies inside
281
+ # one file); concurrent ops afterwards, per-op on the
282
+ # dedicated autocommit connection; versions row LAST.
283
+ plain = [sql for _, sql in mf.ops if "CONCURRENTLY" not in sql.upper()]
284
+ conc = [sql for _, sql in mf.ops if "CONCURRENTLY" in sql.upper()]
285
+ plain_committed = False
286
+ try:
287
+ if plain:
288
+ with conn.begin():
289
+ _set_local_gucs(
290
+ conn,
291
+ lock_timeout=lock_timeout,
292
+ statement_timeout=statement_timeout,
293
+ )
294
+ for sql in plain:
295
+ conn.exec_driver_sql(sql)
296
+ plain_committed = True
297
+ for sql in conc:
298
+ if concurrent_conn is None:
299
+ concurrent_conn = engine.connect().execution_options(
300
+ isolation_level="AUTOCOMMIT"
301
+ )
302
+ concurrent_stack.callback(concurrent_conn.close)
303
+ session_gucs: dict[str, int] = {
304
+ "lock_timeout": int(lock_timeout * 1000)
305
+ }
306
+ if statement_timeout is not None:
307
+ session_gucs["statement_timeout"] = int(statement_timeout * 1000)
308
+ concurrent_stack.enter_context(
309
+ _session_gucs(concurrent_conn, **session_gucs)
310
+ )
311
+ # statement-per-execute: a multi-statement string
312
+ # is one implicit server txn (CONCURRENTLY refuses
313
+ # it) — generated ops are single statements, so
314
+ # this split only fires for hand-edited bodies
315
+ for stmt in _split_statements(sql):
316
+ concurrent_conn.exec_driver_sql(stmt)
317
+ # never record before the concurrent ops succeed: a
318
+ # row for a file whose concurrent op then failed would
319
+ # be silent divergence. The crash window this leaves
320
+ # (plain committed + concurrent applied + no row)
321
+ # re-runs loud on existing objects — documented cost.
322
+ with conn.begin():
323
+ _record_version(conn, f.name, checksum(raw))
324
+ applied.append(f.name)
325
+ except Exception as exc: # noqa: BLE001 — report, no mask
326
+ blocked.append(f.name)
327
+ notes.append(f"{f.name}: {exc}")
328
+ if plain_committed:
329
+ # honest report: the plain segment of THIS file is
330
+ # already committed (same class as push's partial
331
+ # failure) and no versions row was recorded
332
+ notes.append(
333
+ f"{f.name}: plain segment already committed; no versions row recorded"
334
+ )
335
+ partial = True
336
+ break
337
+ finally:
338
+ concurrent_stack.close()
161
339
  return MigrateReport(
162
340
  applied=tuple(applied),
163
341
  skipped=tuple(skipped),
sqlpush/cli.py CHANGED
@@ -148,6 +148,8 @@ def push(
148
148
  no_lock: bool = typer.Option(False, "--no-lock"),
149
149
  lock_timeout: float = typer.Option(5.0, "--lock-timeout"),
150
150
  advisory_wait: float = typer.Option(30.0, "--advisory-wait"),
151
+ no_concurrently: bool = typer.Option(False, "--no-concurrently"),
152
+ statement_timeout: float | None = typer.Option(None, "--statement-timeout"),
151
153
  verbose: bool = typer.Option(False, "--verbose"),
152
154
  quiet: bool = typer.Option(False, "--quiet"),
153
155
  schema: SchemaOpt = None,
@@ -165,6 +167,8 @@ def push(
165
167
  lock=not no_lock,
166
168
  lock_timeout=lock_timeout,
167
169
  advisory_wait=advisory_wait,
170
+ concurrently=not no_concurrently,
171
+ statement_timeout=statement_timeout,
168
172
  schemas=schema,
169
173
  exclude=exclude or (),
170
174
  )
@@ -223,6 +227,7 @@ def revision(
223
227
  ref_dsn: str = typer.Option(..., "--ref-dsn"),
224
228
  message: str | None = typer.Option(None, "--message", "-m"),
225
229
  out_dir: DirOpt = Path("migrations/versions"),
230
+ no_concurrently: bool = typer.Option(False, "--no-concurrently"),
226
231
  schema: SchemaOpt = None,
227
232
  exclude: ExcludeOpt = None,
228
233
  ):
@@ -238,6 +243,7 @@ def revision(
238
243
  engine,
239
244
  out_dir=out_dir,
240
245
  message=message,
246
+ concurrently=not no_concurrently,
241
247
  schemas=schema,
242
248
  exclude=exclude or (),
243
249
  )
@@ -253,6 +259,7 @@ def migrate(
253
259
  allow_destructive: bool = typer.Option(False, "--allow-destructive"),
254
260
  advisory_wait: float = typer.Option(30.0, "--advisory-wait"),
255
261
  lock_timeout: float = typer.Option(5.0, "--lock-timeout"),
262
+ statement_timeout: float | None = typer.Option(None, "--statement-timeout"),
256
263
  out_dir: DirOpt = Path("migrations/versions"),
257
264
  ):
258
265
  """Replay pending migration files (gates + checksum bookkeeping)."""
@@ -264,6 +271,7 @@ def migrate(
264
271
  allow_destructive=allow_destructive,
265
272
  advisory_wait=advisory_wait,
266
273
  lock_timeout=lock_timeout,
274
+ statement_timeout=statement_timeout,
267
275
  )
268
276
  finally:
269
277
  engine.dispose()
sqlpush/core/classify.py CHANGED
@@ -8,13 +8,18 @@ DESTRUCTIVE = frozenset({"drop_column", "drop_table", "drop_index", "drop_constr
8
8
 
9
9
 
10
10
  def classify(op_type: str) -> RiskClass:
11
- """add_index renders standalone: on alembic 1.19.1 even plain
12
- declared indexes of NEW tables arrive standalone
13
- (CreateTableOp.from_table captures columns+constraints, not
14
- indexes; only instrumentation-embedded ones ride inside the
15
- add_table render, and the diff dedups those away). What survives
16
- runs CREATE INDEX alone — a SHARE lock that blocks writes, hence
17
- risky."""
11
+ """add_index stays RISKY in BOTH renderings. The plain form takes a
12
+ SHARE lock that blocks writes for the whole build. CONCURRENTLY
13
+ (0.5 default for existing-table indexes) trades that write-block
14
+ for a different risk profile: the build is concurrent and
15
+ non-transactional (no rollback — a failure leaves no index behind
16
+ to retry with, but an aborted build can leave an INVALID index),
17
+ so the op is still flagged, never silently safe. Standalone
18
+ rendering note: on alembic 1.19.1 even plain declared indexes of
19
+ NEW tables arrive standalone (CreateTableOp.from_table captures
20
+ columns+constraints, not indexes; only instrumentation-embedded
21
+ ones ride inside the add_table render, and the diff dedups those
22
+ away)."""
18
23
  if op_type in DESTRUCTIVE:
19
24
  return RiskClass.DESTRUCTIVE
20
25
  if op_type in SAFE:
sqlpush/core/diff.py CHANGED
@@ -2,8 +2,10 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import dataclasses
5
6
  import fnmatch
6
7
  import io
8
+ import re
7
9
  from collections.abc import Sequence
8
10
 
9
11
  from alembic.autogenerate import produce_migrations
@@ -464,6 +466,43 @@ def _render_op_sql(op, engine: Engine) -> str:
464
466
  return buf.getvalue().strip().rstrip(";")
465
467
 
466
468
 
469
+ # A1: match the CREATE [UNIQUE] INDEX head of an op's (single-statement)
470
+ # SQL. Anchored with no MULTILINE and sub'd with count=1: only the op's
471
+ # own leading statement is touched. The lookahead makes it idempotent —
472
+ # a second pass over an already-injected render finds CONCURRENTLY right
473
+ # after the consumed whitespace and declines. IF NOT EXISTS lands after
474
+ # the inserted keyword, which is the correct PostgreSQL order.
475
+ _CONCURRENTLY_RE = re.compile(
476
+ r"^(CREATE\s+(?:UNIQUE\s+)?INDEX)\s+(?!CONCURRENTLY\s)", re.IGNORECASE
477
+ )
478
+
479
+
480
+ def _render_concurrent(ops: list[PlannedOperation]) -> list[PlannedOperation]:
481
+ """Render standalone ``add_index`` ops on EXISTING tables CONCURRENTLY.
482
+
483
+ Plain ``CREATE INDEX`` takes a SHARE lock that blocks all writes on
484
+ the table for the whole build; ``CREATE INDEX CONCURRENTLY`` is the
485
+ production-safe form for tables that already carry traffic. Indexes
486
+ on tables CREATED in the same plan stay plain: a brand-new table has
487
+ no concurrent writers to protect, and CONCURRENTLY cannot run inside
488
+ the plan's transactional create. The new-table guard keys on bare
489
+ table names (``op.table``), which is conservative-correct: a false
490
+ "new" (cross-schema bare-name collision) yields a plain
491
+ ``CREATE INDEX`` — always executable — while the dangerous direction
492
+ (injecting on a new table) cannot happen.
493
+ """
494
+ new_tables = {op.table for op in ops if op.type == "add_table"}
495
+ out: list[PlannedOperation] = []
496
+ for op in ops:
497
+ if op.type == "add_index" and op.table not in new_tables:
498
+ sql = _CONCURRENTLY_RE.sub(r"\1 CONCURRENTLY ", op.sql, count=1)
499
+ if sql != op.sql:
500
+ out.append(dataclasses.replace(op, sql=sql, concurrent=True))
501
+ continue
502
+ out.append(op)
503
+ return out
504
+
505
+
467
506
  class DiffEngine:
468
507
  def plan(
469
508
  self,
@@ -472,6 +511,7 @@ class DiffEngine:
472
511
  *,
473
512
  schemas: Sequence[str] | None = None,
474
513
  exclude: Sequence[str] = (),
514
+ concurrently: bool = True,
475
515
  ) -> Plan:
476
516
  exclude = tuple(exclude)
477
517
  # Typed `str | None` by SQLAlchemy; a dialect without a default
@@ -516,6 +556,15 @@ class DiffEngine:
516
556
  ops.extend(self._translate(op, engine, exclude))
517
557
  ops = _dedup_embedded_indexes(ops)
518
558
  ops = _dedup_enum_types(ops)
559
+ # ORDERING INVARIANT: CONCURRENTLY injection must run AFTER both
560
+ # dedups, as the final pipeline stage. _dedup_embedded_indexes
561
+ # matches standalone renders against the add_table render by
562
+ # byte containment — injecting first would rewrite only the
563
+ # standalone copy, break the byte-identity, and let the pair
564
+ # execute twice. By construction the dedup never sees a
565
+ # CONCURRENTLY render.
566
+ if concurrently:
567
+ ops = _render_concurrent(ops)
519
568
  return Plan(operations=tuple(ops))
520
569
 
521
570
  def _translate(self, op, engine: Engine, exclude: tuple[str, ...]) -> list[PlannedOperation]:
sqlpush/types.py CHANGED
@@ -19,6 +19,11 @@ class PlannedOperation:
19
19
  sql: str
20
20
  table: str | None = None
21
21
  column: str | None = None
22
+ # True when the op's SQL was rendered with CONCURRENTLY (the
23
+ # executor's authoritative routing signal for generated plans;
24
+ # hand-built plans keep the SQL-substring fallback). Defaults False
25
+ # so every pre-existing construction site is unchanged.
26
+ concurrent: bool = False
22
27
 
23
28
 
24
29
  @dataclass(frozen=True)
@@ -44,6 +49,7 @@ class Plan:
44
49
  "table": op.table,
45
50
  "column": op.column,
46
51
  "sql": op.sql,
52
+ "concurrent": op.concurrent,
47
53
  }
48
54
  for op in self.operations
49
55
  ],
@@ -0,0 +1,299 @@
1
+ Metadata-Version: 2.4
2
+ Name: sqlpush
3
+ Version: 0.5.1
4
+ Summary: Schema lifecycle tool for production PostgreSQL/TimescaleDB on SQLAlchemy 2.0 (SQLModel included): diff, push and check schema drift straight from your models
5
+ Keywords: sqlalchemy,sqlmodel,fastapi,alembic,postgresql,timescaledb,prisma,schema,migrations,database,drift,cli
6
+ Author: Juan Miguel Contreras
7
+ Author-email: Juan Miguel Contreras <19253629+juanmicl@users.noreply.github.com>
8
+ License-Expression: MIT
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Database
18
+ Classifier: Topic :: Software Development :: Quality Assurance
19
+ Requires-Dist: alembic>=1.18,<2
20
+ Requires-Dist: psycopg[binary]>=3.2
21
+ Requires-Dist: sqlalchemy>=2.0
22
+ Requires-Dist: typer>=0.12
23
+ Requires-Python: >=3.10
24
+ Project-URL: Homepage, https://github.com/juanmicl/sqlpush
25
+ Project-URL: Repository, https://github.com/juanmicl/sqlpush
26
+ Project-URL: Issues, https://github.com/juanmicl/sqlpush/issues
27
+ Project-URL: Changelog, https://github.com/juanmicl/sqlpush/blob/main/CHANGELOG.md
28
+ Description-Content-Type: text/markdown
29
+
30
+ # sqlpush
31
+
32
+ [![PyPI](https://img.shields.io/pypi/v/sqlpush?style=for-the-badge)](https://pypi.org/project/sqlpush/)
33
+ [![Python](https://img.shields.io/pypi/pyversions/sqlpush?style=for-the-badge)](https://pypi.org/project/sqlpush/)
34
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge)](LICENSE)
35
+
36
+ **Prisma `db push` for SQLAlchemy.** Your models are the migration.
37
+ sqlpush diffs them (SQLAlchemy, SQLModel, anything built on `MetaData`)
38
+ against the live PostgreSQL / TimescaleDB database, classifies every
39
+ operation by risk (safe / risky / destructive), and applies the plan
40
+ atomically. No migration files to write, no `upgrade` step to forget.
41
+ Drift checks exit with codes your CI can gate on.
42
+
43
+ ```console
44
+ sqlpush diff "myapp.models:metadata" # see the SQL, ordered by risk
45
+ sqlpush check "myapp.models:metadata" # CI gate: exit 0/2/3
46
+ sqlpush push "myapp.models:metadata" # apply (destructive gated)
47
+ ```
48
+
49
+ If you've run `Base.metadata.create_all()` in production and known it
50
+ was wrong, sqlpush is for you.
51
+
52
+ ## Install
53
+
54
+ ```console
55
+ pip install sqlpush
56
+ ```
57
+
58
+ Or from source:
59
+
60
+ ```console
61
+ git clone https://github.com/juanmicl/sqlpush && cd sqlpush && uv sync
62
+ ```
63
+
64
+ Python 3.10 or newer. PostgreSQL only.
65
+
66
+ ## Why
67
+
68
+ Declarative models are already the source of truth. Migration files
69
+ re-encode what the models say, drift from them, and pile up forever.
70
+ sqlpush closes the loop the way Prisma's `db push` does for its schema
71
+ language, but for the SQLAlchemy ecosystem (SQLModel included):
72
+
73
+ - **No migration files required.** The diff *is* the migration: computed fresh
74
+ from models vs. live database on every run, via alembic's autogenerate
75
+ engine used as a library. Files exist as a second workflow when you want
76
+ them (see below).
77
+ - **Risk-aware by default.** Every operation is classified `safe` /
78
+ `risky` / `destructive`. Destructive ops (drops) are **blocked until
79
+ `--allow-destructive`**: nothing executes at all while any is present.
80
+ - **Drift detection built for CI.** `check` plans once and reports
81
+ through its exit code, no output parsing; `--json` emits a stable
82
+ versioned contract.
83
+ - **Safe under concurrency.** An advisory lock coordinates workers: one
84
+ pusher at a time, losers wait bounded
85
+ and re-verify, so deploy pipelines can race without corrupting anything.
86
+ - **Hypertables without hand-written SQL.** Decorate a model with
87
+ `@hypertable` and the `create_hypertable` directive is planned
88
+ state-aware: idempotent pushes, clean checks, no false drift.
89
+
90
+ If you know alembic: sqlpush is its autogenerate engine, productized
91
+ into apply and check verbs, with no revision scripts to maintain.
92
+
93
+ PostgreSQL only, by design.
94
+
95
+ ## The 30-second tour
96
+
97
+ Point sqlpush at your metadata (`module:attribute`) and a database
98
+ (`--dsn` or `$DATABASE_URL`):
99
+
100
+ ```console
101
+ $ export DATABASE_URL="postgresql+psycopg://user:pass@host:5432/db"
102
+
103
+ $ sqlpush diff "myapp.models:metadata"
104
+ -- safe
105
+ CREATE TABLE hero (
106
+ id SERIAL NOT NULL,
107
+ name VARCHAR(50) NOT NULL,
108
+ PRIMARY KEY (id)
109
+ );
110
+
111
+ -- risky
112
+ CREATE INDEX ix_hero_name ON hero (name);
113
+ ```
114
+
115
+ Push it (the destructive gate is on by default):
116
+
117
+ ```console
118
+ $ sqlpush push "myapp.models:metadata"
119
+ 1 destructive operation(s) blocked; re-run with --allow-destructive
120
+ $ echo $?
121
+ 1
122
+
123
+ $ sqlpush push "myapp.models:metadata" --allow-destructive
124
+ $ echo $?
125
+ 0
126
+ ```
127
+
128
+ In CI, check drift and fail loudly (see exit codes below). Limit scope with
129
+ repeated `--schema` / `--exclude` options.
130
+
131
+ ## FastAPI: retire `create_all()`
132
+
133
+ Most FastAPI + SQLModel apps ship the lifespan the tutorials teach:
134
+
135
+ ```python
136
+ @asynccontextmanager
137
+ async def lifespan(app):
138
+ async with engine.begin() as conn:
139
+ await conn.run_sync(SQLModel.metadata.create_all)
140
+ yield
141
+ ```
142
+
143
+ `create_all` creates tables that are missing. That is all it ever
144
+ does. Add a column to a model and the database never hears about it;
145
+ an index on an existing table, a type change, a drop: nothing.
146
+ Production drifts from the models in silence, so every real change
147
+ still rides the alembic treadmill: autogenerate, review, upgrade, and
148
+ two histories to keep in agreement forever.
149
+
150
+ The sqlpush lifespan is one line:
151
+
152
+ ```python
153
+ from contextlib import asynccontextmanager
154
+ from sqlpush import aensure_schema
155
+
156
+
157
+ @asynccontextmanager
158
+ async def lifespan(app):
159
+ await aensure_schema(SQLModel.metadata, engine, mode="check")
160
+ yield
161
+ ```
162
+
163
+ `mode="check"` verifies the models against the database at startup and
164
+ raises when they disagree: the app refuses to boot against a schema it
165
+ does not match, which beats failing on the first query at 3am. The
166
+ schema change itself comes from wherever you put it: `sqlpush push` in
167
+ the deploy pipeline (destructive ops gated), or
168
+ `aensure_schema(..., mode="push")` when you want the API to apply it.
169
+
170
+ asyncpg URLs work too: a DSN or `AsyncEngine` spelling
171
+ `postgresql+asyncpg` is translated to the psycopg driver automatically,
172
+ and asyncpg is never required in the sqlpush process.
173
+
174
+ Push in the deploy pipeline, check at boot.
175
+
176
+ ## An inherited database
177
+
178
+ The first `check` against a database with history often reports drift:
179
+ hand-built indexes, audit tables, a column someone added by hand. If
180
+ any of the drift looks destructive, `check` exits `3` and `push`
181
+ blocks. That is the tool refusing to silently drop your legacy
182
+ objects. Two escape hatches: `--exclude` accepts objects you choose to
183
+ keep (fnmatch patterns, repeatable), and `--allow-destructive` accepts
184
+ the drops when you really do want them.
185
+
186
+ ## When you want files: the chain
187
+
188
+ Most changes never need a file. When one does, sqlpush has a second
189
+ workflow built on the same diff engine: the chain. `revision` writes
190
+ the next numbered SQL file from your models against a reference DB,
191
+ `migrate` replays pending files with gates and checksums, and `stamp`
192
+ adopts an existing database without executing anything.
193
+
194
+ The files are plain SQL you can review, edit before first apply, and
195
+ run under `psql`. Schema change and data backfill ship as one file.
196
+ The [chain guide](docs/the-chain.md) covers the format, the gates and
197
+ the workflows.
198
+
199
+ ## Exit codes
200
+
201
+ | verb | 0 | 1 | 2 | 3 |
202
+ | --- | --- | --- | --- | --- |
203
+ | `diff` | always | | | |
204
+ | `check` | clean | | drift | destructive drift |
205
+ | `push` | applied | destructive blocked | error (incl. partial failure) | |
206
+ | `revision` | file written | error (empty drift refuses) | | |
207
+ | `migrate` | clean | blocked or partial failure | | |
208
+ | `stamp` | registered | blocked or refused | | |
209
+
210
+ Failures print a typed error on stderr, never a traceback.
211
+
212
+ `push --safe-only` runs only safe operations and skips the rest
213
+ informationally (exit `0`). Indexes on existing tables build
214
+ `CONCURRENTLY` by default (opt out with `--no-concurrently`); a failed
215
+ `CREATE INDEX CONCURRENTLY` marks the run as partial failure (exit `2`)
216
+ instead of silently half-applying, and leaves an INVALID index: drop it
217
+ (`DROP INDEX CONCURRENTLY`) and re-push. `stamp` refuses a file whose
218
+ checksum no longer matches the registry; `--force` accepts the new
219
+ content.
220
+
221
+ The knobs, per verb:
222
+
223
+ | verb | flags |
224
+ | --- | --- |
225
+ | `push` | `--allow-destructive` `--safe-only` `--no-lock` `--lock-timeout` `--advisory-wait` `--no-concurrently` `--statement-timeout` |
226
+ | `revision` | `--ref-dsn` (required) `-m/--message` `--no-concurrently` `--dir` |
227
+ | `migrate` | `--allow-destructive` `--advisory-wait` `--lock-timeout` `--statement-timeout` `--dir` |
228
+ | `stamp` | `--force` `--dir` |
229
+
230
+ Every verb except `revision` takes `--dsn` (or `$DATABASE_URL`).
231
+ `revision` requires `--ref-dsn`, with no env fallback: the reference
232
+ DB is a different database from the push target. `diff`, `check`,
233
+ `push` and `revision` also take repeatable `--schema` / `--exclude`.
234
+ Timeouts are seconds; a `lock_timeout` bounds how long a statement
235
+ waits on a lock before failing, `statement_timeout` bounds each
236
+ statement's runtime, and an exhausted `advisory-wait` raises instead
237
+ of hanging on a stuck lock holder.
238
+
239
+ ## How it works
240
+
241
+ ```mermaid
242
+ flowchart LR
243
+ models["SQLAlchemy MetaData"] --> diff["diff<br>alembic autogenerate, scoped"]
244
+ db[("live PostgreSQL")] --> diff
245
+ diff --> risk["risk classification<br>safe / risky / destructive"]
246
+ risk --> plan["plan"]
247
+ plan --> render["render"]
248
+ render --> apply["apply<br>atomic txn · CONCURRENTLY split · advisory lock"]
249
+ apply --> report["report"]
250
+ ```
251
+
252
+ - **Diff engine** scopes reflection to your target schemas and prunes
253
+ system catalogs (TimescaleDB internals included) before reflection
254
+ even starts.
255
+ - **Classifier** maps each operation to a risk class; unknown operations
256
+ are `risky`, never silently safe.
257
+ - **Executor** splits the plan: concurrent index builds run one per
258
+ transaction on autocommit, everything else applies in a single atomic
259
+ transaction with a bounded `lock_timeout`.
260
+ - **Typed errors**: only `SqlpushError` / `ConnectFailed` /
261
+ `MetadataImportError` escape the API, never raw driver exceptions.
262
+
263
+ Scoping: `--schema` restricts the diff to named schemas (default: the
264
+ session's real `search_path`). Extension-owned schemas never enter
265
+ scope automatically, and schemas you pass explicitly are never
266
+ filtered. The chain's registry table (`public.sqlpush_versions`)
267
+ always lives in `public` and is pruned from every diff, so `check`
268
+ after `migrate` is clean. `alembic_version` gets the same treatment.
269
+
270
+ ## Comparison
271
+
272
+ An honest view of the neighborhood (stars as of 2026-08):
273
+
274
+ | | migration files | source of truth | risk gate | CI drift exit codes | TimescaleDB |
275
+ | --- | --- | --- | --- | --- | --- |
276
+ | **sqlpush** | optional: push needs none; the chain has reviewable, checksummed files | SQLAlchemy `MetaData` | classified safe/risky/destructive, destructive blocked by default | `check` 0/2/3 | `@hypertable` directives |
277
+ | [alembic](https://github.com/sqlalchemy/alembic) (4.4k★) | yes | migration scripts (autogenerate assists) | no | no | no |
278
+ | [atlas](https://github.com/ariga/atlas) (8.7k★) | optional (HCL) | HCL / SQL (ORMs via providers) | lint policies | yes | no |
279
+ | [prisma `db push`](https://www.prisma.io/docs/orm/reference/prisma-cli-reference) (47k★) | none | Prisma schema (Node/TS) | no | no | no |
280
+ | [migra](https://github.com/djrobstep/migra) (3.1k★) | diff only | SQL | n/a | partial | no (*deprecated*) |
281
+
282
+ sqlpush is narrower than atlas and younger than alembic, deliberately.
283
+ It is one tool for one job: keep a PostgreSQL schema in lockstep with
284
+ SQLAlchemy models, safely enough to run from CI.
285
+
286
+ Guides: [the chain](docs/the-chain.md) (file format, gates, backfills),
287
+ [migrating from alembic](docs/migrating-from-alembic.md), and
288
+ [migrating from migra](docs/migrating-from-migra.md) (deprecated).
289
+ Changes land in the [CHANGELOG](CHANGELOG.md).
290
+
291
+ ## Design notes
292
+
293
+ - `import sqlpush` stays light: the public API loads lazily, so the
294
+ annotations module carries none of alembic/typer/psycopg.
295
+ - The advisory-lock key derives from the database OID: two DSN spellings
296
+ of the same database contend for the same lock.
297
+ - `--json` output is a versioned contract (`"version": 1`) meant for
298
+ tooling; additive changes only within a version (operations now carry
299
+ a `concurrent` boolean).
@@ -0,0 +1,20 @@
1
+ sqlpush/__init__.py,sha256=Px0jJFHbwwM1-UHtDwD3xQlRifEMW4StSXQs5DD9z6w,1697
2
+ sqlpush/annotations.py,sha256=xe5EoYPvraLJy-WvMkFK_zOIKquB3-VsrAzy6HE_hiA,4652
3
+ sqlpush/api.py,sha256=JTIDn-O50j9ytVTMRXJVu6eZcGbCX_v-03zqJ23laig,11235
4
+ sqlpush/apply/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ sqlpush/apply/executor.py,sha256=qHvfxB1sWQAQ0oMPDGuEr-HRaJwOYoVSB_s-7x-CZTc,10087
6
+ sqlpush/chain/__init__.py,sha256=AJ96P3I63mX6-uMuK0paCpKtaRexufnCqjUuElbyAsM,293
7
+ sqlpush/chain/format.py,sha256=uj0iMQfFWa9HpKhrokfJu_5B_FesiD4qCZxAGDJ3bfQ,4938
8
+ sqlpush/chain/migrate.py,sha256=P2grgrg8dkLJCBhL6CzNwa4xG8-VuFLqgN7XuvjFhwQ,18575
9
+ sqlpush/cli.py,sha256=7bjfh_YcFs4yIJtTS44YWr2YWQwGNuccHirkdorJXm4,11099
10
+ sqlpush/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ sqlpush/core/classify.py,sha256=xjBFIPAV8lxMsZZRiOQ1Yxlpgz4r-ZpCtnNXA4KAw0o,1290
12
+ sqlpush/core/diff.py,sha256=OkH0fLl9NLOyWFnefknvBK8SWeN6bnJz4kQJfK3-wFA,26552
13
+ sqlpush/core/render.py,sha256=9SNEyHOGmMxKUfIjvo3x0MNVQ6N2o2fl3noWT1EWO3M,609
14
+ sqlpush/directives/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
+ sqlpush/directives/timescale.py,sha256=W_Cd_stdhKZlzlMum7Bdzq99VfXOulYxq0B-sukz-xE,2645
16
+ sqlpush/types.py,sha256=hXetdVqMzYcBQi0M_x3qmCIOa3QzhuwxYNJkZRWz4qM,3280
17
+ sqlpush-0.5.1.dist-info/WHEEL,sha256=y6e-a5KI2W-qDAJKfh9Xr81bin8NgUdfWgz3VSXXpe4,80
18
+ sqlpush-0.5.1.dist-info/entry_points.txt,sha256=VYha3PJSvklRgpcDAZYJdE66EXM79MSKQf0z8igyAOI,46
19
+ sqlpush-0.5.1.dist-info/METADATA,sha256=ZxtbwPr8QWKh40NMrRpn87B_uGoMUleqYHYC-UzIZUM,12518
20
+ sqlpush-0.5.1.dist-info/RECORD,,
@@ -1,214 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: sqlpush
3
- Version: 0.4.2
4
- Summary: Schema lifecycle tool for production PostgreSQL/TimescaleDB on SQLAlchemy 2.0 (SQLModel included): diff, push and check schema drift straight from your models
5
- Keywords: sqlalchemy,alembic,postgresql,timescaledb,prisma,schema,migrations,database,drift,cli
6
- Author: Juan Miguel Contreras
7
- Author-email: Juan Miguel Contreras <19253629+juanmicl@users.noreply.github.com>
8
- License-Expression: MIT
9
- Classifier: Development Status :: 4 - Beta
10
- Classifier: Environment :: Console
11
- Classifier: Intended Audience :: Developers
12
- Classifier: Programming Language :: Python :: 3
13
- Classifier: Programming Language :: Python :: 3.10
14
- Classifier: Programming Language :: Python :: 3.11
15
- Classifier: Programming Language :: Python :: 3.12
16
- Classifier: Programming Language :: Python :: 3.13
17
- Classifier: Topic :: Database
18
- Classifier: Topic :: Software Development :: Quality Assurance
19
- Requires-Dist: alembic>=1.18,<2
20
- Requires-Dist: psycopg[binary]>=3.2
21
- Requires-Dist: sqlalchemy>=2.0
22
- Requires-Dist: typer>=0.12
23
- Requires-Python: >=3.10
24
- Project-URL: Homepage, https://github.com/juanmicl/sqlpush
25
- Project-URL: Repository, https://github.com/juanmicl/sqlpush
26
- Project-URL: Issues, https://github.com/juanmicl/sqlpush/issues
27
- Project-URL: Changelog, https://github.com/juanmicl/sqlpush/blob/main/CHANGELOG.md
28
- Description-Content-Type: text/markdown
29
-
30
- # sqlpush
31
-
32
- [![PyPI](https://img.shields.io/pypi/v/sqlpush?style=for-the-badge)](https://pypi.org/project/sqlpush/)
33
- [![Python](https://img.shields.io/pypi/pyversions/sqlpush?style=for-the-badge)](https://pypi.org/project/sqlpush/)
34
- [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge)](LICENSE)
35
-
36
- **Prisma `db push` for SQLAlchemy.** Apply your models (SQLAlchemy,
37
- SQLModel, anything built on `MetaData`) to a live PostgreSQL / TimescaleDB
38
- database directly, no migration files. sqlpush
39
- diffs your models against the real schema, classifies every operation by
40
- risk (safe / risky / destructive), and applies the plan atomically. Drift
41
- checks exit with codes your CI can gate on.
42
-
43
- ```console
44
- sqlpush diff "myapp.models:metadata" # see the SQL, ordered by risk
45
- sqlpush check "myapp.models:metadata" # CI gate: exit 0/2/3
46
- sqlpush push "myapp.models:metadata" # apply (destructive gated)
47
- ```
48
-
49
- If you've ever run `Base.metadata.create_all()` in production and known it
50
- was wrong, then sighed at the migration-script treadmill when you reached
51
- for alembic: sqlpush is for you.
52
-
53
- ## Why
54
-
55
- Declarative models are already the source of truth. Migration files
56
- re-encode what the models say, drift from them, and pile up forever.
57
- sqlpush closes the loop the way Prisma's `db push` does for its schema
58
- language, but for the SQLAlchemy ecosystem (SQLModel included):
59
-
60
- - **No migration files, ever.** The diff *is* the migration: computed fresh
61
- from models vs. live database on every run, via alembic's autogenerate
62
- engine used as a library.
63
- - **Risk-aware by default.** Every operation is classified `safe` /
64
- `risky` / `destructive`. Destructive ops (drops) are **blocked until
65
- `--allow-destructive`**: nothing executes at all while any is present.
66
- - **Drift detection built for CI.** `check` plans once and exits `0` clean /
67
- `2` drift / `3` destructive drift, scriptable without parsing output.
68
- `--json` emits a stable versioned contract.
69
- - **Safe under concurrency.** An advisory lock (keyed to the database, not
70
- the DSN) coordinates workers: one pusher at a time, losers wait bounded
71
- and re-verify, so deploy pipelines can race without corrupting anything.
72
- - **Hypertables without hand-written SQL.** Decorate a model with
73
- `@hypertable` and the `create_hypertable` directive is planned
74
- state-aware: idempotent pushes, clean checks, no false drift.
75
-
76
- PostgreSQL only, by design.
77
-
78
- ## Install
79
-
80
- ```console
81
- pip install sqlpush
82
- ```
83
-
84
- Or from source:
85
-
86
- ```console
87
- git clone https://github.com/juanmicl/sqlpush && cd sqlpush && uv sync
88
- ```
89
-
90
- ## The 30-second tour
91
-
92
- Point sqlpush at your metadata (`module:attribute`) and a database
93
- (`--dsn` or `$DATABASE_URL`):
94
-
95
- ```console
96
- $ export DATABASE_URL="postgresql+psycopg://user:pass@host:5432/db"
97
-
98
- $ sqlpush diff "myapp.models:metadata"
99
- -- safe
100
-
101
- CREATE TABLE hero (
102
- id SERIAL NOT NULL PRIMARY KEY,
103
- name VARCHAR(50) NOT NULL
104
- );
105
-
106
- -- risky
107
-
108
- CREATE INDEX ix_hero_name ON hero (name);
109
- ```
110
-
111
- Push it (the destructive gate is on by default):
112
-
113
- ```console
114
- $ sqlpush push "myapp.models:metadata"
115
- 1 destructive operation(s) blocked; re-run with --allow-destructive
116
- $ echo $?
117
- 1
118
-
119
- $ sqlpush push "myapp.models:metadata" --allow-destructive
120
- $ echo $?
121
- 0
122
- ```
123
-
124
- In CI, check drift and fail loudly (see exit codes below). Limit scope with
125
- repeated `--schema` / `--exclude` options.
126
-
127
- ## Exit codes
128
-
129
- | verb | 0 | 1 | 2 | 3 |
130
- | --- | --- | --- | --- | --- |
131
- | `diff` | always | | | |
132
- | `check` | clean | | drift | destructive drift |
133
- | `push` | applied | destructive blocked | error (incl. partial failure) | |
134
-
135
- `push --safe-only` runs only safe operations and skips the rest
136
- informationally (exit `0`). A failed `CREATE INDEX CONCURRENTLY` marks the
137
- run as partial failure (exit `2`) instead of silently half-applying.
138
-
139
- ## FastAPI / SQLModel: replace `create_all`
140
-
141
- ```python
142
- from contextlib import asynccontextmanager
143
- from sqlpush import aensure_schema
144
-
145
-
146
- @asynccontextmanager
147
- async def lifespan(app):
148
- await aensure_schema(SQLModel.metadata, engine, mode="check")
149
- yield
150
- ```
151
-
152
- Push in the deploy pipeline, check at startup.
153
-
154
- ## How it works
155
-
156
- ```mermaid
157
- flowchart LR
158
- models["SQLAlchemy MetaData"] --> diff["diff<br>alembic autogenerate, scoped"]
159
- db[("live PostgreSQL")] --> diff
160
- diff --> risk["risk classification<br>safe / risky / destructive"]
161
- risk --> plan["plan"]
162
- plan --> render["render"]
163
- render --> apply["apply<br>atomic txn · CONCURRENTLY split · advisory lock"]
164
- apply --> report["report"]
165
- ```
166
-
167
- - **Diff engine** scopes reflection to your target schemas (default: the
168
- session's real `search_path`) and prunes system catalogs (TimescaleDB
169
- internals included) before reflection even starts.
170
- - **Classifier** maps each operation to a risk class; unknown operations
171
- are `risky`, never silently safe.
172
- - **Executor** splits the plan: `CONCURRENTLY` statements run one-per-
173
- transaction on autocommit, everything else applies in a single atomic
174
- transaction with a bounded `lock_timeout`.
175
- - **Typed errors**: only `SqlpushError` / `ConnectFailed` /
176
- `MetadataImportError` escape the API, never raw driver exceptions.
177
-
178
- ## Comparison
179
-
180
- An honest view of the neighborhood (stars as of 2026-08):
181
-
182
- | | migration files | source of truth | risk gate | CI drift exit codes | TimescaleDB |
183
- | --- | --- | --- | --- | --- | --- |
184
- | **sqlpush** | none (the diff is the migration) | SQLAlchemy `MetaData` | classified safe/risky/destructive, destructive blocked by default | `check` 0/2/3 | `@hypertable` directives |
185
- | [alembic](https://github.com/sqlalchemy/alembic) (4.4k★) | yes | migration scripts (autogenerate assists) | no | no | no |
186
- | [atlas](https://github.com/ariga/atlas) (8.7k★) | optional (HCL) | HCL / SQL (ORMs via providers) | lint policies | yes | no |
187
- | [prisma `db push`](https://www.prisma.io/docs/orm/reference/prisma-cli-reference) (47k★) | none | Prisma schema (Node/TS) | no | no | no |
188
- | [migra](https://github.com/djrobstep/migra) (3.1k★) | diff only | SQL | n/a | partial | no (*deprecated*) |
189
-
190
- sqlpush is narrower than atlas and younger than alembic, deliberately.
191
- It is one tool for one job: keep a PostgreSQL schema in lockstep with
192
- SQLAlchemy models, safely enough to run from CI.
193
-
194
- Coming from [migra](https://github.com/djrobstep/migra) (now
195
- deprecated)? There is a [migration guide](docs/migrating-from-migra.md).
196
-
197
- ## Design notes
198
-
199
- - `import sqlpush` stays light: the public API loads lazily, so the
200
- annotations module carries none of alembic/typer/psycopg.
201
- - The advisory-lock key derives from the database OID: two DSN spellings
202
- of the same database contend for the same lock.
203
- - `--json` output is a versioned contract (`"version": 1`) meant for
204
- tooling; additive changes only within a version.
205
-
206
- ## Roadmap (0.1.x)
207
-
208
- - `CREATE INDEX CONCURRENTLY` by default for indexes on existing tables
209
- - asyncpg DSN translation in `ensure_schema(AsyncEngine)`
210
- - jsonschema-validated `--json` output
211
-
212
- ## License
213
-
214
- [MIT](LICENSE) · © 2026 Juan Miguel Contreras
@@ -1,20 +0,0 @@
1
- sqlpush/__init__.py,sha256=Px0jJFHbwwM1-UHtDwD3xQlRifEMW4StSXQs5DD9z6w,1697
2
- sqlpush/annotations.py,sha256=xe5EoYPvraLJy-WvMkFK_zOIKquB3-VsrAzy6HE_hiA,4652
3
- sqlpush/api.py,sha256=7fHsy1AHd2ffca-wtQDowfpt70P7c6L0_s720k8gRcU,10166
4
- sqlpush/apply/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
- sqlpush/apply/executor.py,sha256=-HgmscUO6M_xQ5HPBQhJkUo6XwEPPqBig7_G9aWQJAU,7242
6
- sqlpush/chain/__init__.py,sha256=AJ96P3I63mX6-uMuK0paCpKtaRexufnCqjUuElbyAsM,293
7
- sqlpush/chain/format.py,sha256=uj0iMQfFWa9HpKhrokfJu_5B_FesiD4qCZxAGDJ3bfQ,4938
8
- sqlpush/chain/migrate.py,sha256=P7I07Zv3xRmRYgFTTLAqkrUdbjsGOGNQZG2j-eP5Hgk,10055
9
- sqlpush/cli.py,sha256=7TBKMxmvkpwSH6gyqs2fDQ5Tyec1yMOLGJCUJb-2Af0,10599
10
- sqlpush/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
- sqlpush/core/classify.py,sha256=-HNPQ1Kfnr0MMYLoP26_VC14JjcQHravrbT7lPGqlIU,927
12
- sqlpush/core/diff.py,sha256=q0f7NtiKQ8N33UbI40ttxgiNbsAsBpqa0XJ15oTLSBY,24147
13
- sqlpush/core/render.py,sha256=9SNEyHOGmMxKUfIjvo3x0MNVQ6N2o2fl3noWT1EWO3M,609
14
- sqlpush/directives/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
- sqlpush/directives/timescale.py,sha256=W_Cd_stdhKZlzlMum7Bdzq99VfXOulYxq0B-sukz-xE,2645
16
- sqlpush/types.py,sha256=IMA1HRLIumat7tpt8Yscv-9g2U5U_--xxnfcf_pfkyo,2938
17
- sqlpush-0.4.2.dist-info/WHEEL,sha256=y6e-a5KI2W-qDAJKfh9Xr81bin8NgUdfWgz3VSXXpe4,80
18
- sqlpush-0.4.2.dist-info/entry_points.txt,sha256=VYha3PJSvklRgpcDAZYJdE66EXM79MSKQf0z8igyAOI,46
19
- sqlpush-0.4.2.dist-info/METADATA,sha256=KwaHMTxd1Upr_Mf2GbulnbnY3MY58NfBLNBacSNQjWE,8373
20
- sqlpush-0.4.2.dist-info/RECORD,,