sqlpush 0.4.1__tar.gz → 0.5.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlpush
3
- Version: 0.4.1
3
+ Version: 0.5.0
4
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
5
  Keywords: sqlalchemy,alembic,postgresql,timescaledb,prisma,schema,migrations,database,drift,cli
6
6
  Author: Juan Miguel Contreras
@@ -133,8 +133,11 @@ repeated `--schema` / `--exclude` options.
133
133
  | `push` | applied | destructive blocked | error (incl. partial failure) | |
134
134
 
135
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.
136
+ informationally (exit `0`). Indexes on existing tables build
137
+ `CONCURRENTLY` by default (opt out with `--no-concurrently`); a failed
138
+ `CREATE INDEX CONCURRENTLY` marks the run as partial failure (exit `2`)
139
+ instead of silently half-applying, and leaves an INVALID index — drop it
140
+ (`DROP INDEX CONCURRENTLY`) and re-push.
138
141
 
139
142
  ## FastAPI / SQLModel: replace `create_all`
140
143
 
@@ -169,9 +172,11 @@ flowchart LR
169
172
  internals included) before reflection even starts.
170
173
  - **Classifier** maps each operation to a risk class; unknown operations
171
174
  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
+ - **Executor** splits the plan: existing-table indexes render
176
+ `CREATE INDEX CONCURRENTLY` and run one-per-transaction on autocommit
177
+ (`--no-concurrently` opts out; indexes on tables the same plan creates
178
+ stay in the atomic transaction), everything else applies in a single
179
+ atomic transaction with a bounded `lock_timeout`.
175
180
  - **Typed errors**: only `SqlpushError` / `ConnectFailed` /
176
181
  `MetadataImportError` escape the API, never raw driver exceptions.
177
182
 
@@ -201,12 +206,11 @@ deprecated)? There is a [migration guide](docs/migrating-from-migra.md).
201
206
  - The advisory-lock key derives from the database OID: two DSN spellings
202
207
  of the same database contend for the same lock.
203
208
  - `--json` output is a versioned contract (`"version": 1`) meant for
204
- tooling; additive changes only within a version.
209
+ tooling; additive changes only within a version (operations now carry
210
+ a `concurrent` boolean).
205
211
 
206
212
  ## Roadmap (0.1.x)
207
213
 
208
- - `CREATE INDEX CONCURRENTLY` by default for indexes on existing tables
209
- - asyncpg DSN translation in `ensure_schema(AsyncEngine)`
210
214
  - jsonschema-validated `--json` output
211
215
 
212
216
  ## License
@@ -104,8 +104,11 @@ repeated `--schema` / `--exclude` options.
104
104
  | `push` | applied | destructive blocked | error (incl. partial failure) | |
105
105
 
106
106
  `push --safe-only` runs only safe operations and skips the rest
107
- informationally (exit `0`). A failed `CREATE INDEX CONCURRENTLY` marks the
108
- run as partial failure (exit `2`) instead of silently half-applying.
107
+ informationally (exit `0`). Indexes on existing tables build
108
+ `CONCURRENTLY` by default (opt out with `--no-concurrently`); a failed
109
+ `CREATE INDEX CONCURRENTLY` marks the run as partial failure (exit `2`)
110
+ instead of silently half-applying, and leaves an INVALID index — drop it
111
+ (`DROP INDEX CONCURRENTLY`) and re-push.
109
112
 
110
113
  ## FastAPI / SQLModel: replace `create_all`
111
114
 
@@ -140,9 +143,11 @@ flowchart LR
140
143
  internals included) before reflection even starts.
141
144
  - **Classifier** maps each operation to a risk class; unknown operations
142
145
  are `risky`, never silently safe.
143
- - **Executor** splits the plan: `CONCURRENTLY` statements run one-per-
144
- transaction on autocommit, everything else applies in a single atomic
145
- transaction with a bounded `lock_timeout`.
146
+ - **Executor** splits the plan: existing-table indexes render
147
+ `CREATE INDEX CONCURRENTLY` and run one-per-transaction on autocommit
148
+ (`--no-concurrently` opts out; indexes on tables the same plan creates
149
+ stay in the atomic transaction), everything else applies in a single
150
+ atomic transaction with a bounded `lock_timeout`.
146
151
  - **Typed errors**: only `SqlpushError` / `ConnectFailed` /
147
152
  `MetadataImportError` escape the API, never raw driver exceptions.
148
153
 
@@ -172,12 +177,11 @@ deprecated)? There is a [migration guide](docs/migrating-from-migra.md).
172
177
  - The advisory-lock key derives from the database OID: two DSN spellings
173
178
  of the same database contend for the same lock.
174
179
  - `--json` output is a versioned contract (`"version": 1`) meant for
175
- tooling; additive changes only within a version.
180
+ tooling; additive changes only within a version (operations now carry
181
+ a `concurrent` boolean).
176
182
 
177
183
  ## Roadmap (0.1.x)
178
184
 
179
- - `CREATE INDEX CONCURRENTLY` by default for indexes on existing tables
180
- - asyncpg DSN translation in `ensure_schema(AsyncEngine)`
181
185
  - jsonschema-validated `--json` output
182
186
 
183
187
  ## License
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sqlpush"
3
- version = "0.4.1"
3
+ version = "0.5.0"
4
4
  description = "Schema lifecycle tool for production PostgreSQL/TimescaleDB on SQLAlchemy 2.0 (SQLModel included): diff, push and check schema drift straight from your models"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sqlpush"
3
- version = "0.4.1"
3
+ version = "0.5.0"
4
4
  description = "Schema lifecycle tool for production PostgreSQL/TimescaleDB on SQLAlchemy 2.0 (SQLModel included): diff, push and check schema drift straight from your models"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -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,20 +137,34 @@ 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])
140
160
  ops = [(f"[{op.risk.name}] {op.type} {op.table or '?'}", op.sql) for op in p.operations]
141
161
  out = Path(out_dir)
142
- out.mkdir(parents=True, exist_ok=True)
162
+ try:
163
+ out.mkdir(parents=True, exist_ok=True)
164
+ except OSError as exc:
165
+ # same typing as the write below: only SqlpushError subclasses
166
+ # escape the API surface (e.g. out_dir under a file)
167
+ raise SqlpushError(f"cannot create migrations directory {out}: {exc}") from exc
143
168
  rev_id = next_revision_id(out)
144
169
  slug = re.sub(r"[^a-z0-9_]+", "_", (message or "migration").lower())[:40]
145
170
  path = out / f"{rev_id}_{slug}.sql"
@@ -163,16 +188,37 @@ def revision(
163
188
  return path
164
189
 
165
190
 
166
- def migrate(target, *, chain_dir="migrations/versions", allow_destructive=False) -> MigrateReport:
191
+ def migrate(
192
+ target,
193
+ *,
194
+ chain_dir="migrations/versions",
195
+ allow_destructive=False,
196
+ advisory_wait=30.0,
197
+ lock_timeout=5.0,
198
+ statement_timeout=None,
199
+ ) -> MigrateReport:
167
200
  """Replay annotated-SQL chain files with gates + same-txn bookkeeping.
168
201
 
169
202
  ``target`` is a DSN string, sync ``Engine`` or ``AsyncEngine`` (resolved
170
- via ``_sync_engine_from``; engines created here are disposed). See
203
+ via ``_sync_engine_from``; engines created here are disposed).
204
+ ``advisory_wait`` bounds the advisory-lock wait and ``lock_timeout``
205
+ bounds each per-file transaction's lock wait (seconds; 0 = fail
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
171
210
  ``chain.migrate.run_migrate`` for the execution contract.
172
211
  """
173
212
  engine, dispose = _sync_engine_from(target)
174
213
  try:
175
- return run_migrate(engine, chain_dir=chain_dir, allow_destructive=allow_destructive)
214
+ return run_migrate(
215
+ engine,
216
+ chain_dir=chain_dir,
217
+ allow_destructive=allow_destructive,
218
+ advisory_wait=advisory_wait,
219
+ lock_timeout=lock_timeout,
220
+ statement_timeout=statement_timeout,
221
+ )
176
222
  except SQLAlchemyError as exc:
177
223
  # MigrationFileError/SqlpushError (typed) pass through untouched
178
224
  _raise_typed(exc)
@@ -181,16 +227,19 @@ def migrate(target, *, chain_dir="migrations/versions", allow_destructive=False)
181
227
  engine.dispose()
182
228
 
183
229
 
184
- def stamp(target, *, chain_dir="migrations/versions") -> MigrateReport:
230
+ def stamp(target, *, chain_dir="migrations/versions", force: bool = False) -> MigrateReport:
185
231
  """Bootstrap: register chain files as applied WITHOUT executing SQL.
186
232
 
187
- For adopting a DB whose schema already reflects the chain. ``target``
188
- resolution and error typing match :func:`migrate`. See
189
- ``chain.migrate.run_stamp`` for the report convention.
233
+ For adopting a DB whose schema already reflects the chain. A file
234
+ already registered with a different checksum (edited after apply)
235
+ is refused unless ``force`` is set — edit detection survives
236
+ re-stamping. ``target`` resolution and error typing match
237
+ :func:`migrate`. See ``chain.migrate.run_stamp`` for the report
238
+ convention.
190
239
  """
191
240
  engine, dispose = _sync_engine_from(target)
192
241
  try:
193
- return run_stamp(engine, chain_dir=chain_dir)
242
+ return run_stamp(engine, chain_dir=chain_dir, force=force)
194
243
  except SQLAlchemyError as exc:
195
244
  # MigrationFileError/SqlpushError (typed) pass through untouched
196
245
  _raise_typed(exc)
@@ -199,18 +248,35 @@ def stamp(target, *, chain_dir="migrations/versions") -> MigrateReport:
199
248
  engine.dispose()
200
249
 
201
250
 
251
+ def _translate_asyncpg(url):
252
+ """Map a ``postgresql+asyncpg`` URL onto the sync psycopg driver.
253
+
254
+ An asyncpg URL cannot back a SYNC engine (the dialect is
255
+ async-only), but psycopg is a runtime dependency — the translated
256
+ engine actually connects. asyncpg itself is never required in this
257
+ process. Everything but the driver (host/db/credentials/query
258
+ options) is preserved via the URL API, no string surgery.
259
+ """
260
+ if url.drivername == "postgresql+asyncpg":
261
+ return url.set(drivername="postgresql+psycopg")
262
+ return url
263
+
264
+
202
265
  def _sync_engine_from(target):
203
266
  if isinstance(target, AsyncEngine):
204
267
  from sqlalchemy import create_engine
205
268
  from sqlalchemy.pool import NullPool
206
269
 
207
- dsn = target.url.render_as_string(hide_password=False)
208
- return create_engine(dsn, poolclass=NullPool), True
270
+ dsn = _translate_asyncpg(target.url)
271
+ return create_engine(dsn.render_as_string(hide_password=False), poolclass=NullPool), True
209
272
  if isinstance(target, str):
210
273
  from sqlalchemy import create_engine
274
+ from sqlalchemy.engine import make_url
211
275
  from sqlalchemy.pool import NullPool
212
276
 
213
- return create_engine(target, poolclass=NullPool), True
277
+ # make_url raises the same ArgumentError a bad DSN would raise
278
+ # inside create_engine — error typing downstream is unchanged
279
+ return create_engine(_translate_asyncpg(make_url(target)), poolclass=NullPool), True
214
280
  return target, False
215
281
 
216
282
 
@@ -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