sqlpush 0.5.0__tar.gz → 0.6.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.
sqlpush-0.6.0/PKG-INFO ADDED
@@ -0,0 +1,345 @@
1
+ Metadata-Version: 2.4
2
+ Name: sqlpush
3
+ Version: 0.6.0
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
+ ## Project hook
200
+
201
+ Drop a `sqlpush.py` in your repo root (the alembic `env.py` /
202
+ pytest `conftest.py` pattern) and the CLI stops needing flags,
203
+ specs and env vars:
204
+
205
+ ```python
206
+ # sqlpush.py — in your repo root
207
+ def get_metadata(): # REQUIRED for diff/check/push/revision
208
+ from myapp.models import metadata
209
+
210
+ return metadata # -> a populated MetaData
211
+
212
+
213
+ def get_dsn() -> str: # REQUIRED for every verb
214
+ from myapp.settings import DATABASE_URL
215
+
216
+ return DATABASE_URL # -> a full psycopg DSN
217
+
218
+
219
+ CHAIN_DIR = "migrations/chain" # OPTIONAL — default for --dir
220
+ ```
221
+
222
+ With that file in place, `uv run sqlpush revision -m "change"` just
223
+ works: no `--dsn`, no `module:attribute`, no credentials on the
224
+ command line, no PYTHONPATH. Inputs resolve with a fixed precedence:
225
+
226
+ | input | explicit flag | `sqlpush.py` | fallback |
227
+ | --- | --- | --- | --- |
228
+ | `--dsn` / `--ref-dsn` | wins | `get_dsn()` | `$DATABASE_URL`, only without a hook and never for `--ref-dsn` |
229
+ | `module:attribute` (diff/check/push/revision) | wins | `get_metadata()` | usage error |
230
+ | `--dir` (revision/migrate/stamp) | wins | `CHAIN_DIR` | `migrations/versions` |
231
+
232
+ A hook that is missing a member a verb needs — or whose
233
+ `get_dsn()`/`get_metadata()` raises — fails with a typed error naming
234
+ the file and the member (`sqlpush.py: missing get_dsn()`), never a
235
+ traceback. Without a `sqlpush.py`, every verb behaves exactly as
236
+ before.
237
+
238
+ One deliberate detail: on discovering the hook, sqlpush **appends**
239
+ your CWD to `sys.path` instead of prepending it. A file named
240
+ `sqlpush.py` would otherwise shadow the installed package the moment
241
+ you ran the CLI from your repo root; appending guarantees the real
242
+ package always wins, and the hook is loaded by path only.
243
+
244
+ ## Exit codes
245
+
246
+ | verb | 0 | 1 | 2 | 3 |
247
+ | --- | --- | --- | --- | --- |
248
+ | `diff` | always | | | |
249
+ | `check` | clean | | drift | destructive drift |
250
+ | `push` | applied | destructive blocked | error (incl. partial failure) | |
251
+ | `revision` | file written | error (empty drift refuses) | | |
252
+ | `migrate` | clean | blocked or partial failure | | |
253
+ | `stamp` | registered | blocked or refused | | |
254
+
255
+ Failures print a typed error on stderr, never a traceback.
256
+
257
+ `push --safe-only` runs only safe operations and skips the rest
258
+ informationally (exit `0`). Indexes on existing tables build
259
+ `CONCURRENTLY` by default (opt out with `--no-concurrently`); a failed
260
+ `CREATE INDEX CONCURRENTLY` marks the run as partial failure (exit `2`)
261
+ instead of silently half-applying, and leaves an INVALID index: drop it
262
+ (`DROP INDEX CONCURRENTLY`) and re-push. `stamp` refuses a file whose
263
+ checksum no longer matches the registry; `--force` accepts the new
264
+ content.
265
+
266
+ The knobs, per verb:
267
+
268
+ | verb | flags |
269
+ | --- | --- |
270
+ | `push` | `--allow-destructive` `--safe-only` `--no-lock` `--lock-timeout` `--advisory-wait` `--no-concurrently` `--statement-timeout` |
271
+ | `revision` | `--ref-dsn` (required without a hook) `-m/--message` `--no-concurrently` `--dir` |
272
+ | `migrate` | `--allow-destructive` `--advisory-wait` `--lock-timeout` `--statement-timeout` `--dir` |
273
+ | `stamp` | `--force` `--dir` |
274
+
275
+ Every verb except `revision` takes `--dsn` (or `$DATABASE_URL`, or
276
+ the project hook's `get_dsn()`). `revision` takes `--ref-dsn` — or
277
+ the hook — with no env fallback: the reference DB is a different
278
+ database from the push target. `diff`, `check`,
279
+ `push` and `revision` also take repeatable `--schema` / `--exclude`.
280
+ Timeouts are seconds; a `lock_timeout` bounds how long a statement
281
+ waits on a lock before failing, `statement_timeout` bounds each
282
+ statement's runtime, and an exhausted `advisory-wait` raises instead
283
+ of hanging on a stuck lock holder.
284
+
285
+ ## How it works
286
+
287
+ ```mermaid
288
+ flowchart LR
289
+ models["SQLAlchemy MetaData"] --> diff["diff<br>alembic autogenerate, scoped"]
290
+ db[("live PostgreSQL")] --> diff
291
+ diff --> risk["risk classification<br>safe / risky / destructive"]
292
+ risk --> plan["plan"]
293
+ plan --> render["render"]
294
+ render --> apply["apply<br>atomic txn · CONCURRENTLY split · advisory lock"]
295
+ apply --> report["report"]
296
+ ```
297
+
298
+ - **Diff engine** scopes reflection to your target schemas and prunes
299
+ system catalogs (TimescaleDB internals included) before reflection
300
+ even starts.
301
+ - **Classifier** maps each operation to a risk class; unknown operations
302
+ are `risky`, never silently safe.
303
+ - **Executor** splits the plan: concurrent index builds run one per
304
+ transaction on autocommit, everything else applies in a single atomic
305
+ transaction with a bounded `lock_timeout`.
306
+ - **Typed errors**: only `SqlpushError` / `ConnectFailed` /
307
+ `MetadataImportError` escape the API, never raw driver exceptions.
308
+
309
+ Scoping: `--schema` restricts the diff to named schemas (default: the
310
+ session's real `search_path`). Extension-owned schemas never enter
311
+ scope automatically, and schemas you pass explicitly are never
312
+ filtered. The chain's registry table (`public.sqlpush_versions`)
313
+ always lives in `public` and is pruned from every diff, so `check`
314
+ after `migrate` is clean. `alembic_version` gets the same treatment.
315
+
316
+ ## Comparison
317
+
318
+ An honest view of the neighborhood (stars as of 2026-08):
319
+
320
+ | | migration files | source of truth | risk gate | CI drift exit codes | TimescaleDB |
321
+ | --- | --- | --- | --- | --- | --- |
322
+ | **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 |
323
+ | [alembic](https://github.com/sqlalchemy/alembic) (4.4k★) | yes | migration scripts (autogenerate assists) | no | no | no |
324
+ | [atlas](https://github.com/ariga/atlas) (8.7k★) | optional (HCL) | HCL / SQL (ORMs via providers) | lint policies | yes | no |
325
+ | [prisma `db push`](https://www.prisma.io/docs/orm/reference/prisma-cli-reference) (47k★) | none | Prisma schema (Node/TS) | no | no | no |
326
+ | [migra](https://github.com/djrobstep/migra) (3.1k★) | diff only | SQL | n/a | partial | no (*deprecated*) |
327
+
328
+ sqlpush is narrower than atlas and younger than alembic, deliberately.
329
+ It is one tool for one job: keep a PostgreSQL schema in lockstep with
330
+ SQLAlchemy models, safely enough to run from CI.
331
+
332
+ Guides: [the chain](docs/the-chain.md) (file format, gates, backfills),
333
+ [migrating from alembic](docs/migrating-from-alembic.md), and
334
+ [migrating from migra](docs/migrating-from-migra.md) (deprecated).
335
+ Changes land in the [CHANGELOG](CHANGELOG.md).
336
+
337
+ ## Design notes
338
+
339
+ - `import sqlpush` stays light: the public API loads lazily, so the
340
+ annotations module carries none of alembic/typer/psycopg.
341
+ - The advisory-lock key derives from the database OID: two DSN spellings
342
+ of the same database contend for the same lock.
343
+ - `--json` output is a versioned contract (`"version": 1`) meant for
344
+ tooling; additive changes only within a version (operations now carry
345
+ a `concurrent` boolean).
@@ -0,0 +1,316 @@
1
+ # sqlpush
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/sqlpush?style=for-the-badge)](https://pypi.org/project/sqlpush/)
4
+ [![Python](https://img.shields.io/pypi/pyversions/sqlpush?style=for-the-badge)](https://pypi.org/project/sqlpush/)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge)](LICENSE)
6
+
7
+ **Prisma `db push` for SQLAlchemy.** Your models are the migration.
8
+ sqlpush diffs them (SQLAlchemy, SQLModel, anything built on `MetaData`)
9
+ against the live PostgreSQL / TimescaleDB database, classifies every
10
+ operation by risk (safe / risky / destructive), and applies the plan
11
+ atomically. No migration files to write, no `upgrade` step to forget.
12
+ Drift checks exit with codes your CI can gate on.
13
+
14
+ ```console
15
+ sqlpush diff "myapp.models:metadata" # see the SQL, ordered by risk
16
+ sqlpush check "myapp.models:metadata" # CI gate: exit 0/2/3
17
+ sqlpush push "myapp.models:metadata" # apply (destructive gated)
18
+ ```
19
+
20
+ If you've run `Base.metadata.create_all()` in production and known it
21
+ was wrong, sqlpush is for you.
22
+
23
+ ## Install
24
+
25
+ ```console
26
+ pip install sqlpush
27
+ ```
28
+
29
+ Or from source:
30
+
31
+ ```console
32
+ git clone https://github.com/juanmicl/sqlpush && cd sqlpush && uv sync
33
+ ```
34
+
35
+ Python 3.10 or newer. PostgreSQL only.
36
+
37
+ ## Why
38
+
39
+ Declarative models are already the source of truth. Migration files
40
+ re-encode what the models say, drift from them, and pile up forever.
41
+ sqlpush closes the loop the way Prisma's `db push` does for its schema
42
+ language, but for the SQLAlchemy ecosystem (SQLModel included):
43
+
44
+ - **No migration files required.** The diff *is* the migration: computed fresh
45
+ from models vs. live database on every run, via alembic's autogenerate
46
+ engine used as a library. Files exist as a second workflow when you want
47
+ them (see below).
48
+ - **Risk-aware by default.** Every operation is classified `safe` /
49
+ `risky` / `destructive`. Destructive ops (drops) are **blocked until
50
+ `--allow-destructive`**: nothing executes at all while any is present.
51
+ - **Drift detection built for CI.** `check` plans once and reports
52
+ through its exit code, no output parsing; `--json` emits a stable
53
+ versioned contract.
54
+ - **Safe under concurrency.** An advisory lock coordinates workers: one
55
+ pusher at a time, losers wait bounded
56
+ and re-verify, so deploy pipelines can race without corrupting anything.
57
+ - **Hypertables without hand-written SQL.** Decorate a model with
58
+ `@hypertable` and the `create_hypertable` directive is planned
59
+ state-aware: idempotent pushes, clean checks, no false drift.
60
+
61
+ If you know alembic: sqlpush is its autogenerate engine, productized
62
+ into apply and check verbs, with no revision scripts to maintain.
63
+
64
+ PostgreSQL only, by design.
65
+
66
+ ## The 30-second tour
67
+
68
+ Point sqlpush at your metadata (`module:attribute`) and a database
69
+ (`--dsn` or `$DATABASE_URL`):
70
+
71
+ ```console
72
+ $ export DATABASE_URL="postgresql+psycopg://user:pass@host:5432/db"
73
+
74
+ $ sqlpush diff "myapp.models:metadata"
75
+ -- safe
76
+ CREATE TABLE hero (
77
+ id SERIAL NOT NULL,
78
+ name VARCHAR(50) NOT NULL,
79
+ PRIMARY KEY (id)
80
+ );
81
+
82
+ -- risky
83
+ CREATE INDEX ix_hero_name ON hero (name);
84
+ ```
85
+
86
+ Push it (the destructive gate is on by default):
87
+
88
+ ```console
89
+ $ sqlpush push "myapp.models:metadata"
90
+ 1 destructive operation(s) blocked; re-run with --allow-destructive
91
+ $ echo $?
92
+ 1
93
+
94
+ $ sqlpush push "myapp.models:metadata" --allow-destructive
95
+ $ echo $?
96
+ 0
97
+ ```
98
+
99
+ In CI, check drift and fail loudly (see exit codes below). Limit scope with
100
+ repeated `--schema` / `--exclude` options.
101
+
102
+ ## FastAPI: retire `create_all()`
103
+
104
+ Most FastAPI + SQLModel apps ship the lifespan the tutorials teach:
105
+
106
+ ```python
107
+ @asynccontextmanager
108
+ async def lifespan(app):
109
+ async with engine.begin() as conn:
110
+ await conn.run_sync(SQLModel.metadata.create_all)
111
+ yield
112
+ ```
113
+
114
+ `create_all` creates tables that are missing. That is all it ever
115
+ does. Add a column to a model and the database never hears about it;
116
+ an index on an existing table, a type change, a drop: nothing.
117
+ Production drifts from the models in silence, so every real change
118
+ still rides the alembic treadmill: autogenerate, review, upgrade, and
119
+ two histories to keep in agreement forever.
120
+
121
+ The sqlpush lifespan is one line:
122
+
123
+ ```python
124
+ from contextlib import asynccontextmanager
125
+ from sqlpush import aensure_schema
126
+
127
+
128
+ @asynccontextmanager
129
+ async def lifespan(app):
130
+ await aensure_schema(SQLModel.metadata, engine, mode="check")
131
+ yield
132
+ ```
133
+
134
+ `mode="check"` verifies the models against the database at startup and
135
+ raises when they disagree: the app refuses to boot against a schema it
136
+ does not match, which beats failing on the first query at 3am. The
137
+ schema change itself comes from wherever you put it: `sqlpush push` in
138
+ the deploy pipeline (destructive ops gated), or
139
+ `aensure_schema(..., mode="push")` when you want the API to apply it.
140
+
141
+ asyncpg URLs work too: a DSN or `AsyncEngine` spelling
142
+ `postgresql+asyncpg` is translated to the psycopg driver automatically,
143
+ and asyncpg is never required in the sqlpush process.
144
+
145
+ Push in the deploy pipeline, check at boot.
146
+
147
+ ## An inherited database
148
+
149
+ The first `check` against a database with history often reports drift:
150
+ hand-built indexes, audit tables, a column someone added by hand. If
151
+ any of the drift looks destructive, `check` exits `3` and `push`
152
+ blocks. That is the tool refusing to silently drop your legacy
153
+ objects. Two escape hatches: `--exclude` accepts objects you choose to
154
+ keep (fnmatch patterns, repeatable), and `--allow-destructive` accepts
155
+ the drops when you really do want them.
156
+
157
+ ## When you want files: the chain
158
+
159
+ Most changes never need a file. When one does, sqlpush has a second
160
+ workflow built on the same diff engine: the chain. `revision` writes
161
+ the next numbered SQL file from your models against a reference DB,
162
+ `migrate` replays pending files with gates and checksums, and `stamp`
163
+ adopts an existing database without executing anything.
164
+
165
+ The files are plain SQL you can review, edit before first apply, and
166
+ run under `psql`. Schema change and data backfill ship as one file.
167
+ The [chain guide](docs/the-chain.md) covers the format, the gates and
168
+ the workflows.
169
+
170
+ ## Project hook
171
+
172
+ Drop a `sqlpush.py` in your repo root (the alembic `env.py` /
173
+ pytest `conftest.py` pattern) and the CLI stops needing flags,
174
+ specs and env vars:
175
+
176
+ ```python
177
+ # sqlpush.py — in your repo root
178
+ def get_metadata(): # REQUIRED for diff/check/push/revision
179
+ from myapp.models import metadata
180
+
181
+ return metadata # -> a populated MetaData
182
+
183
+
184
+ def get_dsn() -> str: # REQUIRED for every verb
185
+ from myapp.settings import DATABASE_URL
186
+
187
+ return DATABASE_URL # -> a full psycopg DSN
188
+
189
+
190
+ CHAIN_DIR = "migrations/chain" # OPTIONAL — default for --dir
191
+ ```
192
+
193
+ With that file in place, `uv run sqlpush revision -m "change"` just
194
+ works: no `--dsn`, no `module:attribute`, no credentials on the
195
+ command line, no PYTHONPATH. Inputs resolve with a fixed precedence:
196
+
197
+ | input | explicit flag | `sqlpush.py` | fallback |
198
+ | --- | --- | --- | --- |
199
+ | `--dsn` / `--ref-dsn` | wins | `get_dsn()` | `$DATABASE_URL`, only without a hook and never for `--ref-dsn` |
200
+ | `module:attribute` (diff/check/push/revision) | wins | `get_metadata()` | usage error |
201
+ | `--dir` (revision/migrate/stamp) | wins | `CHAIN_DIR` | `migrations/versions` |
202
+
203
+ A hook that is missing a member a verb needs — or whose
204
+ `get_dsn()`/`get_metadata()` raises — fails with a typed error naming
205
+ the file and the member (`sqlpush.py: missing get_dsn()`), never a
206
+ traceback. Without a `sqlpush.py`, every verb behaves exactly as
207
+ before.
208
+
209
+ One deliberate detail: on discovering the hook, sqlpush **appends**
210
+ your CWD to `sys.path` instead of prepending it. A file named
211
+ `sqlpush.py` would otherwise shadow the installed package the moment
212
+ you ran the CLI from your repo root; appending guarantees the real
213
+ package always wins, and the hook is loaded by path only.
214
+
215
+ ## Exit codes
216
+
217
+ | verb | 0 | 1 | 2 | 3 |
218
+ | --- | --- | --- | --- | --- |
219
+ | `diff` | always | | | |
220
+ | `check` | clean | | drift | destructive drift |
221
+ | `push` | applied | destructive blocked | error (incl. partial failure) | |
222
+ | `revision` | file written | error (empty drift refuses) | | |
223
+ | `migrate` | clean | blocked or partial failure | | |
224
+ | `stamp` | registered | blocked or refused | | |
225
+
226
+ Failures print a typed error on stderr, never a traceback.
227
+
228
+ `push --safe-only` runs only safe operations and skips the rest
229
+ informationally (exit `0`). Indexes on existing tables build
230
+ `CONCURRENTLY` by default (opt out with `--no-concurrently`); a failed
231
+ `CREATE INDEX CONCURRENTLY` marks the run as partial failure (exit `2`)
232
+ instead of silently half-applying, and leaves an INVALID index: drop it
233
+ (`DROP INDEX CONCURRENTLY`) and re-push. `stamp` refuses a file whose
234
+ checksum no longer matches the registry; `--force` accepts the new
235
+ content.
236
+
237
+ The knobs, per verb:
238
+
239
+ | verb | flags |
240
+ | --- | --- |
241
+ | `push` | `--allow-destructive` `--safe-only` `--no-lock` `--lock-timeout` `--advisory-wait` `--no-concurrently` `--statement-timeout` |
242
+ | `revision` | `--ref-dsn` (required without a hook) `-m/--message` `--no-concurrently` `--dir` |
243
+ | `migrate` | `--allow-destructive` `--advisory-wait` `--lock-timeout` `--statement-timeout` `--dir` |
244
+ | `stamp` | `--force` `--dir` |
245
+
246
+ Every verb except `revision` takes `--dsn` (or `$DATABASE_URL`, or
247
+ the project hook's `get_dsn()`). `revision` takes `--ref-dsn` — or
248
+ the hook — with no env fallback: the reference DB is a different
249
+ database from the push target. `diff`, `check`,
250
+ `push` and `revision` also take repeatable `--schema` / `--exclude`.
251
+ Timeouts are seconds; a `lock_timeout` bounds how long a statement
252
+ waits on a lock before failing, `statement_timeout` bounds each
253
+ statement's runtime, and an exhausted `advisory-wait` raises instead
254
+ of hanging on a stuck lock holder.
255
+
256
+ ## How it works
257
+
258
+ ```mermaid
259
+ flowchart LR
260
+ models["SQLAlchemy MetaData"] --> diff["diff<br>alembic autogenerate, scoped"]
261
+ db[("live PostgreSQL")] --> diff
262
+ diff --> risk["risk classification<br>safe / risky / destructive"]
263
+ risk --> plan["plan"]
264
+ plan --> render["render"]
265
+ render --> apply["apply<br>atomic txn · CONCURRENTLY split · advisory lock"]
266
+ apply --> report["report"]
267
+ ```
268
+
269
+ - **Diff engine** scopes reflection to your target schemas and prunes
270
+ system catalogs (TimescaleDB internals included) before reflection
271
+ even starts.
272
+ - **Classifier** maps each operation to a risk class; unknown operations
273
+ are `risky`, never silently safe.
274
+ - **Executor** splits the plan: concurrent index builds run one per
275
+ transaction on autocommit, everything else applies in a single atomic
276
+ transaction with a bounded `lock_timeout`.
277
+ - **Typed errors**: only `SqlpushError` / `ConnectFailed` /
278
+ `MetadataImportError` escape the API, never raw driver exceptions.
279
+
280
+ Scoping: `--schema` restricts the diff to named schemas (default: the
281
+ session's real `search_path`). Extension-owned schemas never enter
282
+ scope automatically, and schemas you pass explicitly are never
283
+ filtered. The chain's registry table (`public.sqlpush_versions`)
284
+ always lives in `public` and is pruned from every diff, so `check`
285
+ after `migrate` is clean. `alembic_version` gets the same treatment.
286
+
287
+ ## Comparison
288
+
289
+ An honest view of the neighborhood (stars as of 2026-08):
290
+
291
+ | | migration files | source of truth | risk gate | CI drift exit codes | TimescaleDB |
292
+ | --- | --- | --- | --- | --- | --- |
293
+ | **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 |
294
+ | [alembic](https://github.com/sqlalchemy/alembic) (4.4k★) | yes | migration scripts (autogenerate assists) | no | no | no |
295
+ | [atlas](https://github.com/ariga/atlas) (8.7k★) | optional (HCL) | HCL / SQL (ORMs via providers) | lint policies | yes | no |
296
+ | [prisma `db push`](https://www.prisma.io/docs/orm/reference/prisma-cli-reference) (47k★) | none | Prisma schema (Node/TS) | no | no | no |
297
+ | [migra](https://github.com/djrobstep/migra) (3.1k★) | diff only | SQL | n/a | partial | no (*deprecated*) |
298
+
299
+ sqlpush is narrower than atlas and younger than alembic, deliberately.
300
+ It is one tool for one job: keep a PostgreSQL schema in lockstep with
301
+ SQLAlchemy models, safely enough to run from CI.
302
+
303
+ Guides: [the chain](docs/the-chain.md) (file format, gates, backfills),
304
+ [migrating from alembic](docs/migrating-from-alembic.md), and
305
+ [migrating from migra](docs/migrating-from-migra.md) (deprecated).
306
+ Changes land in the [CHANGELOG](CHANGELOG.md).
307
+
308
+ ## Design notes
309
+
310
+ - `import sqlpush` stays light: the public API loads lazily, so the
311
+ annotations module carries none of alembic/typer/psycopg.
312
+ - The advisory-lock key derives from the database OID: two DSN spellings
313
+ of the same database contend for the same lock.
314
+ - `--json` output is a versioned contract (`"version": 1`) meant for
315
+ tooling; additive changes only within a version (operations now carry
316
+ a `concurrent` boolean).
@@ -1,12 +1,14 @@
1
1
  [project]
2
2
  name = "sqlpush"
3
- version = "0.5.0"
3
+ version = "0.6.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"
7
7
  license = "MIT"
8
8
  keywords = [
9
9
  "sqlalchemy",
10
+ "sqlmodel",
11
+ "fastapi",
10
12
  "alembic",
11
13
  "postgresql",
12
14
  "timescaledb",
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sqlpush"
3
- version = "0.5.0"
3
+ version = "0.6.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 = [
@@ -10,6 +10,8 @@ requires-python = ">=3.10"
10
10
  license = "MIT"
11
11
  keywords = [
12
12
  "sqlalchemy",
13
+ "sqlmodel",
14
+ "fastapi",
13
15
  "alembic",
14
16
  "postgresql",
15
17
  "timescaledb",