sqlpush 0.1.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.1.0/PKG-INFO +215 -0
- sqlpush-0.1.0/README.md +186 -0
- sqlpush-0.1.0/pyproject.toml +80 -0
- sqlpush-0.1.0/pyproject.toml.orig +78 -0
- sqlpush-0.1.0/src/sqlpush/__init__.py +78 -0
- sqlpush-0.1.0/src/sqlpush/annotations.py +37 -0
- sqlpush-0.1.0/src/sqlpush/api.py +172 -0
- sqlpush-0.1.0/src/sqlpush/apply/__init__.py +0 -0
- sqlpush-0.1.0/src/sqlpush/apply/executor.py +199 -0
- sqlpush-0.1.0/src/sqlpush/cli.py +218 -0
- sqlpush-0.1.0/src/sqlpush/core/__init__.py +0 -0
- sqlpush-0.1.0/src/sqlpush/core/classify.py +19 -0
- sqlpush-0.1.0/src/sqlpush/core/diff.py +205 -0
- sqlpush-0.1.0/src/sqlpush/core/render.py +21 -0
- sqlpush-0.1.0/src/sqlpush/directives/__init__.py +0 -0
- sqlpush-0.1.0/src/sqlpush/directives/timescale.py +79 -0
- sqlpush-0.1.0/src/sqlpush/types.py +100 -0
sqlpush-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sqlpush
|
|
3
|
+
Version: 0.1.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,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
|
+
[](https://github.com/juanmicl/sqlpush/actions/workflows/ci.yml)
|
|
33
|
+
[](https://pypi.org/project/sqlpush/)
|
|
34
|
+
[](https://pypi.org/project/sqlpush/)
|
|
35
|
+
[](LICENSE)
|
|
36
|
+
|
|
37
|
+
**Prisma `db push` for SQLAlchemy.** Apply your models (SQLAlchemy,
|
|
38
|
+
SQLModel, anything built on `MetaData`) to a live PostgreSQL / TimescaleDB
|
|
39
|
+
database directly, no migration files. sqlpush
|
|
40
|
+
diffs your models against the real schema, classifies every operation by
|
|
41
|
+
risk (safe / risky / destructive), and applies the plan atomically. Drift
|
|
42
|
+
checks exit with codes your CI can gate on.
|
|
43
|
+
|
|
44
|
+
```console
|
|
45
|
+
sqlpush diff "myapp.models:metadata" # see the SQL, ordered by risk
|
|
46
|
+
sqlpush check "myapp.models:metadata" # CI gate: exit 0/2/3
|
|
47
|
+
sqlpush push "myapp.models:metadata" # apply (destructive gated)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
If you've ever run `Base.metadata.create_all()` in production and known it
|
|
51
|
+
was wrong, then sighed at the migration-script treadmill when you reached
|
|
52
|
+
for alembic: sqlpush is for you.
|
|
53
|
+
|
|
54
|
+
## Why
|
|
55
|
+
|
|
56
|
+
Declarative models are already the source of truth. Migration files
|
|
57
|
+
re-encode what the models say, drift from them, and pile up forever.
|
|
58
|
+
sqlpush closes the loop the way Prisma's `db push` does for its schema
|
|
59
|
+
language, but for the SQLAlchemy ecosystem (SQLModel included):
|
|
60
|
+
|
|
61
|
+
- **No migration files, ever.** The diff *is* the migration: computed fresh
|
|
62
|
+
from models vs. live database on every run, via alembic's autogenerate
|
|
63
|
+
engine used as a library.
|
|
64
|
+
- **Risk-aware by default.** Every operation is classified `safe` /
|
|
65
|
+
`risky` / `destructive`. Destructive ops (drops) are **blocked until
|
|
66
|
+
`--allow-destructive`**: nothing executes at all while any is present.
|
|
67
|
+
- **Drift detection built for CI.** `check` plans once and exits `0` clean /
|
|
68
|
+
`2` drift / `3` destructive drift, scriptable without parsing output.
|
|
69
|
+
`--json` emits a stable versioned contract.
|
|
70
|
+
- **Safe under concurrency.** An advisory lock (keyed to the database, not
|
|
71
|
+
the DSN) coordinates workers: one pusher at a time, losers wait bounded
|
|
72
|
+
and re-verify, so deploy pipelines can race without corrupting anything.
|
|
73
|
+
- **Hypertables without hand-written SQL.** Decorate a model with
|
|
74
|
+
`@hypertable` and the `create_hypertable` directive is planned
|
|
75
|
+
state-aware: idempotent pushes, clean checks, no false drift.
|
|
76
|
+
|
|
77
|
+
PostgreSQL only, by design.
|
|
78
|
+
|
|
79
|
+
## Install
|
|
80
|
+
|
|
81
|
+
```console
|
|
82
|
+
pip install sqlpush
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Or from source:
|
|
86
|
+
|
|
87
|
+
```console
|
|
88
|
+
git clone https://github.com/juanmicl/sqlpush && cd sqlpush && uv sync
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## The 30-second tour
|
|
92
|
+
|
|
93
|
+
Point sqlpush at your metadata (`module:attribute`) and a database
|
|
94
|
+
(`--dsn` or `$DATABASE_URL`):
|
|
95
|
+
|
|
96
|
+
```console
|
|
97
|
+
$ export DATABASE_URL="postgresql+psycopg://user:pass@host:5432/db"
|
|
98
|
+
|
|
99
|
+
$ sqlpush diff "myapp.models:metadata"
|
|
100
|
+
-- safe
|
|
101
|
+
|
|
102
|
+
CREATE TABLE hero (
|
|
103
|
+
id SERIAL NOT NULL PRIMARY KEY,
|
|
104
|
+
name VARCHAR(50) NOT NULL
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
-- risky
|
|
108
|
+
|
|
109
|
+
CREATE INDEX ix_hero_name ON hero (name);
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Push it (the destructive gate is on by default):
|
|
113
|
+
|
|
114
|
+
```console
|
|
115
|
+
$ sqlpush push "myapp.models:metadata"
|
|
116
|
+
1 destructive operation(s) blocked; re-run with --allow-destructive
|
|
117
|
+
$ echo $?
|
|
118
|
+
1
|
|
119
|
+
|
|
120
|
+
$ sqlpush push "myapp.models:metadata" --allow-destructive
|
|
121
|
+
$ echo $?
|
|
122
|
+
0
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
In CI, check drift and fail loudly (see exit codes below). Limit scope with
|
|
126
|
+
repeated `--schema` / `--exclude` options.
|
|
127
|
+
|
|
128
|
+
## Exit codes
|
|
129
|
+
|
|
130
|
+
| verb | 0 | 1 | 2 | 3 |
|
|
131
|
+
| --- | --- | --- | --- | --- |
|
|
132
|
+
| `diff` | always | | | |
|
|
133
|
+
| `check` | clean | | drift | destructive drift |
|
|
134
|
+
| `push` | applied | destructive blocked | error (incl. partial failure) | |
|
|
135
|
+
|
|
136
|
+
`push --safe-only` runs only safe operations and skips the rest
|
|
137
|
+
informationally (exit `0`). A failed `CREATE INDEX CONCURRENTLY` marks the
|
|
138
|
+
run as partial failure (exit `2`) instead of silently half-applying.
|
|
139
|
+
|
|
140
|
+
## FastAPI / SQLModel: replace `create_all`
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
from contextlib import asynccontextmanager
|
|
144
|
+
from sqlpush import aensure_schema
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@asynccontextmanager
|
|
148
|
+
async def lifespan(app):
|
|
149
|
+
await aensure_schema(SQLModel.metadata, engine, mode="check")
|
|
150
|
+
yield
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Push in the deploy pipeline, check at startup.
|
|
154
|
+
|
|
155
|
+
## How it works
|
|
156
|
+
|
|
157
|
+
```mermaid
|
|
158
|
+
flowchart LR
|
|
159
|
+
models["SQLAlchemy MetaData"] --> diff["diff<br>alembic autogenerate, scoped"]
|
|
160
|
+
db[("live PostgreSQL")] --> diff
|
|
161
|
+
diff --> risk["risk classification<br>safe / risky / destructive"]
|
|
162
|
+
risk --> plan["plan"]
|
|
163
|
+
plan --> render["render"]
|
|
164
|
+
render --> apply["apply<br>atomic txn · CONCURRENTLY split · advisory lock"]
|
|
165
|
+
apply --> report["report"]
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
- **Diff engine** scopes reflection to your target schemas (default: the
|
|
169
|
+
session's real `search_path`) and prunes system catalogs (TimescaleDB
|
|
170
|
+
internals included) before reflection even starts.
|
|
171
|
+
- **Classifier** maps each operation to a risk class; unknown operations
|
|
172
|
+
are `risky`, never silently safe.
|
|
173
|
+
- **Executor** splits the plan: `CONCURRENTLY` statements run one-per-
|
|
174
|
+
transaction on autocommit, everything else applies in a single atomic
|
|
175
|
+
transaction with a bounded `lock_timeout`.
|
|
176
|
+
- **Typed errors**: only `SqlpushError` / `ConnectFailed` /
|
|
177
|
+
`MetadataImportError` escape the API, never raw driver exceptions.
|
|
178
|
+
|
|
179
|
+
## Comparison
|
|
180
|
+
|
|
181
|
+
An honest view of the neighborhood (stars as of 2026-08):
|
|
182
|
+
|
|
183
|
+
| | migration files | source of truth | risk gate | CI drift exit codes | TimescaleDB |
|
|
184
|
+
| --- | --- | --- | --- | --- | --- |
|
|
185
|
+
| **sqlpush** | none (the diff is the migration) | SQLAlchemy `MetaData` | classified safe/risky/destructive, destructive blocked by default | `check` 0/2/3 | `@hypertable` directives |
|
|
186
|
+
| [alembic](https://github.com/sqlalchemy/alembic) (4.4k★) | yes | migration scripts (autogenerate assists) | no | no | no |
|
|
187
|
+
| [atlas](https://github.com/ariga/atlas) (8.7k★) | optional (HCL) | HCL / SQL (ORMs via providers) | lint policies | yes | no |
|
|
188
|
+
| [prisma `db push`](https://www.prisma.io/docs/orm/reference/prisma-cli-reference) (47k★) | none | Prisma schema (Node/TS) | no | no | no |
|
|
189
|
+
| [migra](https://github.com/djrobstep/migra) (3.1k★) | diff only | SQL | n/a | partial | no (*deprecated*) |
|
|
190
|
+
|
|
191
|
+
sqlpush is narrower than atlas and younger than alembic, deliberately.
|
|
192
|
+
It is one tool for one job: keep a PostgreSQL schema in lockstep with
|
|
193
|
+
SQLAlchemy models, safely enough to run from CI.
|
|
194
|
+
|
|
195
|
+
Coming from [migra](https://github.com/djrobstep/migra) (now
|
|
196
|
+
deprecated)? There is a [migration guide](docs/migrating-from-migra.md).
|
|
197
|
+
|
|
198
|
+
## Design notes
|
|
199
|
+
|
|
200
|
+
- `import sqlpush` stays light: the public API loads lazily, so the
|
|
201
|
+
annotations module carries none of alembic/typer/psycopg.
|
|
202
|
+
- The advisory-lock key derives from the database OID: two DSN spellings
|
|
203
|
+
of the same database contend for the same lock.
|
|
204
|
+
- `--json` output is a versioned contract (`"version": 1`) meant for
|
|
205
|
+
tooling; additive changes only within a version.
|
|
206
|
+
|
|
207
|
+
## Roadmap (0.1.x)
|
|
208
|
+
|
|
209
|
+
- `CREATE INDEX CONCURRENTLY` by default for indexes on existing tables
|
|
210
|
+
- asyncpg DSN translation in `ensure_schema(AsyncEngine)`
|
|
211
|
+
- jsonschema-validated `--json` output
|
|
212
|
+
|
|
213
|
+
## License
|
|
214
|
+
|
|
215
|
+
[MIT](LICENSE) · © 2026 Juan Miguel Contreras
|
sqlpush-0.1.0/README.md
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# sqlpush
|
|
2
|
+
|
|
3
|
+
[](https://github.com/juanmicl/sqlpush/actions/workflows/ci.yml)
|
|
4
|
+
[](https://pypi.org/project/sqlpush/)
|
|
5
|
+
[](https://pypi.org/project/sqlpush/)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
|
|
8
|
+
**Prisma `db push` for SQLAlchemy.** Apply your models (SQLAlchemy,
|
|
9
|
+
SQLModel, anything built on `MetaData`) to a live PostgreSQL / TimescaleDB
|
|
10
|
+
database directly, no migration files. sqlpush
|
|
11
|
+
diffs your models against the real schema, classifies every operation by
|
|
12
|
+
risk (safe / risky / destructive), and applies the plan atomically. Drift
|
|
13
|
+
checks exit with codes your CI can gate on.
|
|
14
|
+
|
|
15
|
+
```console
|
|
16
|
+
sqlpush diff "myapp.models:metadata" # see the SQL, ordered by risk
|
|
17
|
+
sqlpush check "myapp.models:metadata" # CI gate: exit 0/2/3
|
|
18
|
+
sqlpush push "myapp.models:metadata" # apply (destructive gated)
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
If you've ever run `Base.metadata.create_all()` in production and known it
|
|
22
|
+
was wrong, then sighed at the migration-script treadmill when you reached
|
|
23
|
+
for alembic: sqlpush is for you.
|
|
24
|
+
|
|
25
|
+
## Why
|
|
26
|
+
|
|
27
|
+
Declarative models are already the source of truth. Migration files
|
|
28
|
+
re-encode what the models say, drift from them, and pile up forever.
|
|
29
|
+
sqlpush closes the loop the way Prisma's `db push` does for its schema
|
|
30
|
+
language, but for the SQLAlchemy ecosystem (SQLModel included):
|
|
31
|
+
|
|
32
|
+
- **No migration files, ever.** The diff *is* the migration: computed fresh
|
|
33
|
+
from models vs. live database on every run, via alembic's autogenerate
|
|
34
|
+
engine used as a library.
|
|
35
|
+
- **Risk-aware by default.** Every operation is classified `safe` /
|
|
36
|
+
`risky` / `destructive`. Destructive ops (drops) are **blocked until
|
|
37
|
+
`--allow-destructive`**: nothing executes at all while any is present.
|
|
38
|
+
- **Drift detection built for CI.** `check` plans once and exits `0` clean /
|
|
39
|
+
`2` drift / `3` destructive drift, scriptable without parsing output.
|
|
40
|
+
`--json` emits a stable versioned contract.
|
|
41
|
+
- **Safe under concurrency.** An advisory lock (keyed to the database, not
|
|
42
|
+
the DSN) coordinates workers: one pusher at a time, losers wait bounded
|
|
43
|
+
and re-verify, so deploy pipelines can race without corrupting anything.
|
|
44
|
+
- **Hypertables without hand-written SQL.** Decorate a model with
|
|
45
|
+
`@hypertable` and the `create_hypertable` directive is planned
|
|
46
|
+
state-aware: idempotent pushes, clean checks, no false drift.
|
|
47
|
+
|
|
48
|
+
PostgreSQL only, by design.
|
|
49
|
+
|
|
50
|
+
## Install
|
|
51
|
+
|
|
52
|
+
```console
|
|
53
|
+
pip install sqlpush
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Or from source:
|
|
57
|
+
|
|
58
|
+
```console
|
|
59
|
+
git clone https://github.com/juanmicl/sqlpush && cd sqlpush && uv sync
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## The 30-second tour
|
|
63
|
+
|
|
64
|
+
Point sqlpush at your metadata (`module:attribute`) and a database
|
|
65
|
+
(`--dsn` or `$DATABASE_URL`):
|
|
66
|
+
|
|
67
|
+
```console
|
|
68
|
+
$ export DATABASE_URL="postgresql+psycopg://user:pass@host:5432/db"
|
|
69
|
+
|
|
70
|
+
$ sqlpush diff "myapp.models:metadata"
|
|
71
|
+
-- safe
|
|
72
|
+
|
|
73
|
+
CREATE TABLE hero (
|
|
74
|
+
id SERIAL NOT NULL PRIMARY KEY,
|
|
75
|
+
name VARCHAR(50) NOT NULL
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
-- risky
|
|
79
|
+
|
|
80
|
+
CREATE INDEX ix_hero_name ON hero (name);
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Push it (the destructive gate is on by default):
|
|
84
|
+
|
|
85
|
+
```console
|
|
86
|
+
$ sqlpush push "myapp.models:metadata"
|
|
87
|
+
1 destructive operation(s) blocked; re-run with --allow-destructive
|
|
88
|
+
$ echo $?
|
|
89
|
+
1
|
|
90
|
+
|
|
91
|
+
$ sqlpush push "myapp.models:metadata" --allow-destructive
|
|
92
|
+
$ echo $?
|
|
93
|
+
0
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
In CI, check drift and fail loudly (see exit codes below). Limit scope with
|
|
97
|
+
repeated `--schema` / `--exclude` options.
|
|
98
|
+
|
|
99
|
+
## Exit codes
|
|
100
|
+
|
|
101
|
+
| verb | 0 | 1 | 2 | 3 |
|
|
102
|
+
| --- | --- | --- | --- | --- |
|
|
103
|
+
| `diff` | always | | | |
|
|
104
|
+
| `check` | clean | | drift | destructive drift |
|
|
105
|
+
| `push` | applied | destructive blocked | error (incl. partial failure) | |
|
|
106
|
+
|
|
107
|
+
`push --safe-only` runs only safe operations and skips the rest
|
|
108
|
+
informationally (exit `0`). A failed `CREATE INDEX CONCURRENTLY` marks the
|
|
109
|
+
run as partial failure (exit `2`) instead of silently half-applying.
|
|
110
|
+
|
|
111
|
+
## FastAPI / SQLModel: replace `create_all`
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
from contextlib import asynccontextmanager
|
|
115
|
+
from sqlpush import aensure_schema
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@asynccontextmanager
|
|
119
|
+
async def lifespan(app):
|
|
120
|
+
await aensure_schema(SQLModel.metadata, engine, mode="check")
|
|
121
|
+
yield
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Push in the deploy pipeline, check at startup.
|
|
125
|
+
|
|
126
|
+
## How it works
|
|
127
|
+
|
|
128
|
+
```mermaid
|
|
129
|
+
flowchart LR
|
|
130
|
+
models["SQLAlchemy MetaData"] --> diff["diff<br>alembic autogenerate, scoped"]
|
|
131
|
+
db[("live PostgreSQL")] --> diff
|
|
132
|
+
diff --> risk["risk classification<br>safe / risky / destructive"]
|
|
133
|
+
risk --> plan["plan"]
|
|
134
|
+
plan --> render["render"]
|
|
135
|
+
render --> apply["apply<br>atomic txn · CONCURRENTLY split · advisory lock"]
|
|
136
|
+
apply --> report["report"]
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
- **Diff engine** scopes reflection to your target schemas (default: the
|
|
140
|
+
session's real `search_path`) and prunes system catalogs (TimescaleDB
|
|
141
|
+
internals included) before reflection even starts.
|
|
142
|
+
- **Classifier** maps each operation to a risk class; unknown operations
|
|
143
|
+
are `risky`, never silently safe.
|
|
144
|
+
- **Executor** splits the plan: `CONCURRENTLY` statements run one-per-
|
|
145
|
+
transaction on autocommit, everything else applies in a single atomic
|
|
146
|
+
transaction with a bounded `lock_timeout`.
|
|
147
|
+
- **Typed errors**: only `SqlpushError` / `ConnectFailed` /
|
|
148
|
+
`MetadataImportError` escape the API, never raw driver exceptions.
|
|
149
|
+
|
|
150
|
+
## Comparison
|
|
151
|
+
|
|
152
|
+
An honest view of the neighborhood (stars as of 2026-08):
|
|
153
|
+
|
|
154
|
+
| | migration files | source of truth | risk gate | CI drift exit codes | TimescaleDB |
|
|
155
|
+
| --- | --- | --- | --- | --- | --- |
|
|
156
|
+
| **sqlpush** | none (the diff is the migration) | SQLAlchemy `MetaData` | classified safe/risky/destructive, destructive blocked by default | `check` 0/2/3 | `@hypertable` directives |
|
|
157
|
+
| [alembic](https://github.com/sqlalchemy/alembic) (4.4k★) | yes | migration scripts (autogenerate assists) | no | no | no |
|
|
158
|
+
| [atlas](https://github.com/ariga/atlas) (8.7k★) | optional (HCL) | HCL / SQL (ORMs via providers) | lint policies | yes | no |
|
|
159
|
+
| [prisma `db push`](https://www.prisma.io/docs/orm/reference/prisma-cli-reference) (47k★) | none | Prisma schema (Node/TS) | no | no | no |
|
|
160
|
+
| [migra](https://github.com/djrobstep/migra) (3.1k★) | diff only | SQL | n/a | partial | no (*deprecated*) |
|
|
161
|
+
|
|
162
|
+
sqlpush is narrower than atlas and younger than alembic, deliberately.
|
|
163
|
+
It is one tool for one job: keep a PostgreSQL schema in lockstep with
|
|
164
|
+
SQLAlchemy models, safely enough to run from CI.
|
|
165
|
+
|
|
166
|
+
Coming from [migra](https://github.com/djrobstep/migra) (now
|
|
167
|
+
deprecated)? There is a [migration guide](docs/migrating-from-migra.md).
|
|
168
|
+
|
|
169
|
+
## Design notes
|
|
170
|
+
|
|
171
|
+
- `import sqlpush` stays light: the public API loads lazily, so the
|
|
172
|
+
annotations module carries none of alembic/typer/psycopg.
|
|
173
|
+
- The advisory-lock key derives from the database OID: two DSN spellings
|
|
174
|
+
of the same database contend for the same lock.
|
|
175
|
+
- `--json` output is a versioned contract (`"version": 1`) meant for
|
|
176
|
+
tooling; additive changes only within a version.
|
|
177
|
+
|
|
178
|
+
## Roadmap (0.1.x)
|
|
179
|
+
|
|
180
|
+
- `CREATE INDEX CONCURRENTLY` by default for indexes on existing tables
|
|
181
|
+
- asyncpg DSN translation in `ensure_schema(AsyncEngine)`
|
|
182
|
+
- jsonschema-validated `--json` output
|
|
183
|
+
|
|
184
|
+
## License
|
|
185
|
+
|
|
186
|
+
[MIT](LICENSE) · © 2026 Juan Miguel Contreras
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "sqlpush"
|
|
3
|
+
version = "0.1.0"
|
|
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
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
keywords = [
|
|
9
|
+
"sqlalchemy",
|
|
10
|
+
"alembic",
|
|
11
|
+
"postgresql",
|
|
12
|
+
"timescaledb",
|
|
13
|
+
"prisma",
|
|
14
|
+
"schema",
|
|
15
|
+
"migrations",
|
|
16
|
+
"database",
|
|
17
|
+
"drift",
|
|
18
|
+
"cli",
|
|
19
|
+
]
|
|
20
|
+
classifiers = [
|
|
21
|
+
"Development Status :: 4 - Beta",
|
|
22
|
+
"Environment :: Console",
|
|
23
|
+
"Intended Audience :: Developers",
|
|
24
|
+
"Programming Language :: Python :: 3",
|
|
25
|
+
"Programming Language :: Python :: 3.10",
|
|
26
|
+
"Programming Language :: Python :: 3.11",
|
|
27
|
+
"Programming Language :: Python :: 3.12",
|
|
28
|
+
"Programming Language :: Python :: 3.13",
|
|
29
|
+
"Topic :: Database",
|
|
30
|
+
"Topic :: Software Development :: Quality Assurance",
|
|
31
|
+
]
|
|
32
|
+
dependencies = [
|
|
33
|
+
"alembic>=1.18,<2",
|
|
34
|
+
"psycopg[binary]>=3.2",
|
|
35
|
+
"sqlalchemy>=2.0",
|
|
36
|
+
"typer>=0.12",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
[[project.authors]]
|
|
40
|
+
name = "Juan Miguel Contreras"
|
|
41
|
+
email = "19253629+juanmicl@users.noreply.github.com"
|
|
42
|
+
|
|
43
|
+
[project.urls]
|
|
44
|
+
Homepage = "https://github.com/juanmicl/sqlpush"
|
|
45
|
+
Repository = "https://github.com/juanmicl/sqlpush"
|
|
46
|
+
Issues = "https://github.com/juanmicl/sqlpush/issues"
|
|
47
|
+
Changelog = "https://github.com/juanmicl/sqlpush/blob/main/CHANGELOG.md"
|
|
48
|
+
|
|
49
|
+
[project.scripts]
|
|
50
|
+
sqlpush = "sqlpush.cli:main"
|
|
51
|
+
|
|
52
|
+
[build-system]
|
|
53
|
+
requires = ["uv_build>=0.12.7,<0.13.0"]
|
|
54
|
+
build-backend = "uv_build"
|
|
55
|
+
|
|
56
|
+
[dependency-groups]
|
|
57
|
+
dev = [
|
|
58
|
+
"jsonschema>=4.26.0",
|
|
59
|
+
"pytest>=9.1.1",
|
|
60
|
+
"pytest-asyncio>=1.4.0",
|
|
61
|
+
"ruff>=0.16.5",
|
|
62
|
+
"ty==0.0.42",
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
[tool.ty.src]
|
|
66
|
+
include = [
|
|
67
|
+
"src",
|
|
68
|
+
"tests",
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
[tool.ty.environment]
|
|
72
|
+
python-version = "3.10"
|
|
73
|
+
|
|
74
|
+
[tool.ruff]
|
|
75
|
+
line-length = 100
|
|
76
|
+
target-version = "py310"
|
|
77
|
+
|
|
78
|
+
[tool.pytest.ini_options]
|
|
79
|
+
testpaths = ["tests"]
|
|
80
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "sqlpush"
|
|
3
|
+
version = "0.1.0"
|
|
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
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Juan Miguel Contreras", email = "19253629+juanmicl@users.noreply.github.com" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
keywords = [
|
|
12
|
+
"sqlalchemy",
|
|
13
|
+
"alembic",
|
|
14
|
+
"postgresql",
|
|
15
|
+
"timescaledb",
|
|
16
|
+
"prisma",
|
|
17
|
+
"schema",
|
|
18
|
+
"migrations",
|
|
19
|
+
"database",
|
|
20
|
+
"drift",
|
|
21
|
+
"cli",
|
|
22
|
+
]
|
|
23
|
+
classifiers = [
|
|
24
|
+
"Development Status :: 4 - Beta",
|
|
25
|
+
"Environment :: Console",
|
|
26
|
+
"Intended Audience :: Developers",
|
|
27
|
+
"Programming Language :: Python :: 3",
|
|
28
|
+
"Programming Language :: Python :: 3.10",
|
|
29
|
+
"Programming Language :: Python :: 3.11",
|
|
30
|
+
"Programming Language :: Python :: 3.12",
|
|
31
|
+
"Programming Language :: Python :: 3.13",
|
|
32
|
+
"Topic :: Database",
|
|
33
|
+
"Topic :: Software Development :: Quality Assurance",
|
|
34
|
+
]
|
|
35
|
+
dependencies = [
|
|
36
|
+
"alembic>=1.18,<2",
|
|
37
|
+
"psycopg[binary]>=3.2",
|
|
38
|
+
"sqlalchemy>=2.0",
|
|
39
|
+
"typer>=0.12",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
[project.urls]
|
|
43
|
+
Homepage = "https://github.com/juanmicl/sqlpush"
|
|
44
|
+
Repository = "https://github.com/juanmicl/sqlpush"
|
|
45
|
+
Issues = "https://github.com/juanmicl/sqlpush/issues"
|
|
46
|
+
Changelog = "https://github.com/juanmicl/sqlpush/blob/main/CHANGELOG.md"
|
|
47
|
+
|
|
48
|
+
[project.scripts]
|
|
49
|
+
sqlpush = "sqlpush.cli:main"
|
|
50
|
+
|
|
51
|
+
[build-system]
|
|
52
|
+
requires = ["uv_build>=0.12.7,<0.13.0"]
|
|
53
|
+
build-backend = "uv_build"
|
|
54
|
+
|
|
55
|
+
[dependency-groups]
|
|
56
|
+
dev = [
|
|
57
|
+
"jsonschema>=4.26.0",
|
|
58
|
+
"pytest>=9.1.1",
|
|
59
|
+
"pytest-asyncio>=1.4.0",
|
|
60
|
+
"ruff>=0.16.5",
|
|
61
|
+
"ty==0.0.42",
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
[tool.ty.src]
|
|
65
|
+
include = ["src", "tests"]
|
|
66
|
+
|
|
67
|
+
[tool.ty.environment]
|
|
68
|
+
# match requires-python floor: the checker must never accept syntax
|
|
69
|
+
# or stdlib APIs the oldest supported interpreter lacks
|
|
70
|
+
python-version = "3.10"
|
|
71
|
+
|
|
72
|
+
[tool.ruff]
|
|
73
|
+
line-length = 100
|
|
74
|
+
target-version = "py310"
|
|
75
|
+
|
|
76
|
+
[tool.pytest.ini_options]
|
|
77
|
+
testpaths = ["tests"]
|
|
78
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from typing import TYPE_CHECKING, Any
|
|
2
|
+
|
|
3
|
+
from sqlpush.types import (
|
|
4
|
+
AppliedOperation,
|
|
5
|
+
CheckResult,
|
|
6
|
+
ConnectFailed,
|
|
7
|
+
DestructiveBlocked,
|
|
8
|
+
MetadataImportError,
|
|
9
|
+
Plan,
|
|
10
|
+
PlannedOperation,
|
|
11
|
+
Report,
|
|
12
|
+
RiskClass,
|
|
13
|
+
SqlpushError,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
# Static-checker visibility for the PEP 562 lazy exports below: this
|
|
17
|
+
# block never executes at runtime, so the light-import guarantee is
|
|
18
|
+
# untouched (tests/test_annotations.py still guards it).
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from sqlpush.api import (
|
|
21
|
+
acheck,
|
|
22
|
+
aensure_schema,
|
|
23
|
+
aplan,
|
|
24
|
+
apush,
|
|
25
|
+
check,
|
|
26
|
+
ensure_schema,
|
|
27
|
+
plan,
|
|
28
|
+
push,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
__version__ = "0.1.0"
|
|
32
|
+
|
|
33
|
+
# Public API (plan/push/check/ensure_schema + async facade) is exported
|
|
34
|
+
# lazily via PEP 562: an eager import would pull alembic (through
|
|
35
|
+
# sqlpush.core.diff) into every `import sqlpush.*`, breaking the
|
|
36
|
+
# light-import guarantee of the annotations module (see
|
|
37
|
+
# tests/test_annotations.py::test_annotations_module_has_no_heavy_imports).
|
|
38
|
+
_LAZY_API = (
|
|
39
|
+
"plan",
|
|
40
|
+
"push",
|
|
41
|
+
"check",
|
|
42
|
+
"ensure_schema",
|
|
43
|
+
"aplan",
|
|
44
|
+
"apush",
|
|
45
|
+
"acheck",
|
|
46
|
+
"aensure_schema",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def __getattr__(name: str) -> Any:
|
|
51
|
+
if name in _LAZY_API:
|
|
52
|
+
from sqlpush import api
|
|
53
|
+
|
|
54
|
+
return getattr(api, name)
|
|
55
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
__all__ = [
|
|
59
|
+
"AppliedOperation",
|
|
60
|
+
"CheckResult",
|
|
61
|
+
"ConnectFailed",
|
|
62
|
+
"DestructiveBlocked",
|
|
63
|
+
"MetadataImportError",
|
|
64
|
+
"Plan",
|
|
65
|
+
"PlannedOperation",
|
|
66
|
+
"Report",
|
|
67
|
+
"RiskClass",
|
|
68
|
+
"SqlpushError",
|
|
69
|
+
"__version__",
|
|
70
|
+
"acheck",
|
|
71
|
+
"aensure_schema",
|
|
72
|
+
"aplan",
|
|
73
|
+
"apush",
|
|
74
|
+
"check",
|
|
75
|
+
"ensure_schema",
|
|
76
|
+
"plan",
|
|
77
|
+
"push",
|
|
78
|
+
]
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# src/sqlpush/annotations.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
HYPERTABLE_KEY = "sqlpush_hypertable"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class HypertableInfo:
|
|
11
|
+
time_column: str
|
|
12
|
+
chunk_time_interval: str | None = None
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def hypertable(*, time_column: str, chunk_time_interval: str | None = None):
|
|
16
|
+
"""Record hypertable intent on the model's Table (MetaData level).
|
|
17
|
+
|
|
18
|
+
Works with SQLModel, Flask-SQLAlchemy and plain declarative, anything
|
|
19
|
+
whose class already carries a built ``__table__`` when decorated.
|
|
20
|
+
|
|
21
|
+
The generated ``create_hypertable`` runs with
|
|
22
|
+
``create_default_indexes => false``: timescale's implicit time-column
|
|
23
|
+
index is invisible to metadata and would drift forever. Declare it
|
|
24
|
+
yourself (``Index(..., "<time_column>")`` on the model) if you rely
|
|
25
|
+
on it for time-range scans.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def decorator(cls):
|
|
29
|
+
table = getattr(cls, "__table__", None)
|
|
30
|
+
if table is None:
|
|
31
|
+
raise TypeError(f"{cls.__name__} has no __table__; decorate a mapped class")
|
|
32
|
+
table.info[HYPERTABLE_KEY] = HypertableInfo(
|
|
33
|
+
time_column=time_column, chunk_time_interval=chunk_time_interval
|
|
34
|
+
)
|
|
35
|
+
return cls
|
|
36
|
+
|
|
37
|
+
return decorator
|