sqlpush 0.5.1__tar.gz → 0.7.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.5.1 → sqlpush-0.7.0}/PKG-INFO +68 -5
- {sqlpush-0.5.1 → sqlpush-0.7.0}/README.md +67 -4
- {sqlpush-0.5.1 → sqlpush-0.7.0}/pyproject.toml +1 -1
- {sqlpush-0.5.1 → sqlpush-0.7.0}/pyproject.toml.orig +1 -1
- {sqlpush-0.5.1 → sqlpush-0.7.0}/src/sqlpush/cli.py +82 -26
- sqlpush-0.7.0/src/sqlpush/hook.py +147 -0
- {sqlpush-0.5.1 → sqlpush-0.7.0}/src/sqlpush/__init__.py +0 -0
- {sqlpush-0.5.1 → sqlpush-0.7.0}/src/sqlpush/annotations.py +0 -0
- {sqlpush-0.5.1 → sqlpush-0.7.0}/src/sqlpush/api.py +0 -0
- {sqlpush-0.5.1 → sqlpush-0.7.0}/src/sqlpush/apply/__init__.py +0 -0
- {sqlpush-0.5.1 → sqlpush-0.7.0}/src/sqlpush/apply/executor.py +0 -0
- {sqlpush-0.5.1 → sqlpush-0.7.0}/src/sqlpush/chain/__init__.py +0 -0
- {sqlpush-0.5.1 → sqlpush-0.7.0}/src/sqlpush/chain/format.py +0 -0
- {sqlpush-0.5.1 → sqlpush-0.7.0}/src/sqlpush/chain/migrate.py +0 -0
- {sqlpush-0.5.1 → sqlpush-0.7.0}/src/sqlpush/core/__init__.py +0 -0
- {sqlpush-0.5.1 → sqlpush-0.7.0}/src/sqlpush/core/classify.py +0 -0
- {sqlpush-0.5.1 → sqlpush-0.7.0}/src/sqlpush/core/diff.py +0 -0
- {sqlpush-0.5.1 → sqlpush-0.7.0}/src/sqlpush/core/render.py +0 -0
- {sqlpush-0.5.1 → sqlpush-0.7.0}/src/sqlpush/directives/__init__.py +0 -0
- {sqlpush-0.5.1 → sqlpush-0.7.0}/src/sqlpush/directives/timescale.py +0 -0
- {sqlpush-0.5.1 → sqlpush-0.7.0}/src/sqlpush/types.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sqlpush
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.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,sqlmodel,fastapi,alembic,postgresql,timescaledb,prisma,schema,migrations,database,drift,cli
|
|
6
6
|
Author: Juan Miguel Contreras
|
|
@@ -196,6 +196,66 @@ run under `psql`. Schema change and data backfill ship as one file.
|
|
|
196
196
|
The [chain guide](docs/the-chain.md) covers the format, the gates and
|
|
197
197
|
the workflows.
|
|
198
198
|
|
|
199
|
+
## Project hook
|
|
200
|
+
|
|
201
|
+
Drop a `sqlpush.py` where sqlpush can find it (the alembic `env.py` /
|
|
202
|
+
pytest `conftest.py` pattern) and the CLI stops needing flags, specs
|
|
203
|
+
and env vars. Two discovered locations, first match wins — or name
|
|
204
|
+
any file explicitly with `--hook`/`$SQLPUSH_HOOK`:
|
|
205
|
+
|
|
206
|
+
1. `migrations/sqlpush.py` — preferred: it lives next to the chain,
|
|
207
|
+
no repo-root clutter.
|
|
208
|
+
2. `sqlpush.py` — repo root, kept as the backwards-compat fallback.
|
|
209
|
+
|
|
210
|
+
```python
|
|
211
|
+
# migrations/sqlpush.py — the preferred location
|
|
212
|
+
def get_metadata(): # REQUIRED for diff/check/push/revision
|
|
213
|
+
from myapp.models import metadata
|
|
214
|
+
|
|
215
|
+
return metadata # -> a populated MetaData
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def get_dsn() -> str: # REQUIRED for every verb
|
|
219
|
+
from myapp.settings import DATABASE_URL
|
|
220
|
+
|
|
221
|
+
return DATABASE_URL # -> a full psycopg DSN
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
CHAIN_DIR = "migrations/chain" # OPTIONAL — default for --dir
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
With that file in place, `uv run sqlpush revision -m "change"` just
|
|
228
|
+
works: no `--dsn`, no `module:attribute`, no credentials on the
|
|
229
|
+
command line, no PYTHONPATH. Inputs resolve with a fixed precedence:
|
|
230
|
+
|
|
231
|
+
| input | explicit flag | hook | fallback |
|
|
232
|
+
| --- | --- | --- | --- |
|
|
233
|
+
| `--dsn` / `--ref-dsn` | wins | `get_dsn()` | `$DATABASE_URL`, only without a hook and never for `--ref-dsn` |
|
|
234
|
+
| `module:attribute` (diff/check/push/revision) | wins | `get_metadata()` | usage error |
|
|
235
|
+
| `--dir` (revision/migrate/stamp) | wins | `CHAIN_DIR` | `migrations/versions` |
|
|
236
|
+
| which file is the hook | `--hook PATH` | — | `$SQLPUSH_HOOK`, else first match: `migrations/sqlpush.py`, then root `sqlpush.py` |
|
|
237
|
+
|
|
238
|
+
The hook's location is the alembic `-c` equivalent: `--hook` >
|
|
239
|
+
`$SQLPUSH_HOOK` > discovery, any location you want (relative paths
|
|
240
|
+
resolve against the CWD) — and the explicit forms fail loud, with an
|
|
241
|
+
error naming that path (`custom/hook.py: file not found`)
|
|
242
|
+
instead of falling back to the discovered candidates.
|
|
243
|
+
|
|
244
|
+
A hook that is missing a member a verb needs — or whose
|
|
245
|
+
`get_dsn()`/`get_metadata()` raises — fails with a typed error naming
|
|
246
|
+
the file that actually loaded and the member
|
|
247
|
+
(`migrations/sqlpush.py: missing get_dsn()`), never a traceback.
|
|
248
|
+
Without a hook, every verb behaves exactly as before.
|
|
249
|
+
|
|
250
|
+
One deliberate detail: on discovering the hook, sqlpush **appends**
|
|
251
|
+
your CWD to `sys.path` instead of prepending it — whatever location
|
|
252
|
+
the hook loaded from, never the hook's own directory. A root
|
|
253
|
+
`sqlpush.py` would otherwise shadow the installed package the moment
|
|
254
|
+
you ran the CLI from your repo root; appending guarantees the real
|
|
255
|
+
package always wins, and the hook itself is loaded by path only (the
|
|
256
|
+
`migrations/` candidate has no shadowing concern but loads the same
|
|
257
|
+
way).
|
|
258
|
+
|
|
199
259
|
## Exit codes
|
|
200
260
|
|
|
201
261
|
| verb | 0 | 1 | 2 | 3 |
|
|
@@ -223,13 +283,16 @@ The knobs, per verb:
|
|
|
223
283
|
| verb | flags |
|
|
224
284
|
| --- | --- |
|
|
225
285
|
| `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` |
|
|
286
|
+
| `revision` | `--ref-dsn` (required without a hook) `-m/--message` `--no-concurrently` `--dir` |
|
|
227
287
|
| `migrate` | `--allow-destructive` `--advisory-wait` `--lock-timeout` `--statement-timeout` `--dir` |
|
|
228
288
|
| `stamp` | `--force` `--dir` |
|
|
229
289
|
|
|
230
|
-
Every verb
|
|
231
|
-
`revision`
|
|
232
|
-
|
|
290
|
+
Every verb takes `--hook PATH` (or `$SQLPUSH_HOOK`) to name the hook
|
|
291
|
+
file explicitly. Every verb except `revision` takes `--dsn` (or
|
|
292
|
+
`$DATABASE_URL`, or the project hook's `get_dsn()`). `revision`
|
|
293
|
+
takes `--ref-dsn` — or
|
|
294
|
+
the hook — with no env fallback: the reference DB is a different
|
|
295
|
+
database from the push target. `diff`, `check`,
|
|
233
296
|
`push` and `revision` also take repeatable `--schema` / `--exclude`.
|
|
234
297
|
Timeouts are seconds; a `lock_timeout` bounds how long a statement
|
|
235
298
|
waits on a lock before failing, `statement_timeout` bounds each
|
|
@@ -167,6 +167,66 @@ run under `psql`. Schema change and data backfill ship as one file.
|
|
|
167
167
|
The [chain guide](docs/the-chain.md) covers the format, the gates and
|
|
168
168
|
the workflows.
|
|
169
169
|
|
|
170
|
+
## Project hook
|
|
171
|
+
|
|
172
|
+
Drop a `sqlpush.py` where sqlpush can find it (the alembic `env.py` /
|
|
173
|
+
pytest `conftest.py` pattern) and the CLI stops needing flags, specs
|
|
174
|
+
and env vars. Two discovered locations, first match wins — or name
|
|
175
|
+
any file explicitly with `--hook`/`$SQLPUSH_HOOK`:
|
|
176
|
+
|
|
177
|
+
1. `migrations/sqlpush.py` — preferred: it lives next to the chain,
|
|
178
|
+
no repo-root clutter.
|
|
179
|
+
2. `sqlpush.py` — repo root, kept as the backwards-compat fallback.
|
|
180
|
+
|
|
181
|
+
```python
|
|
182
|
+
# migrations/sqlpush.py — the preferred location
|
|
183
|
+
def get_metadata(): # REQUIRED for diff/check/push/revision
|
|
184
|
+
from myapp.models import metadata
|
|
185
|
+
|
|
186
|
+
return metadata # -> a populated MetaData
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def get_dsn() -> str: # REQUIRED for every verb
|
|
190
|
+
from myapp.settings import DATABASE_URL
|
|
191
|
+
|
|
192
|
+
return DATABASE_URL # -> a full psycopg DSN
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
CHAIN_DIR = "migrations/chain" # OPTIONAL — default for --dir
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
With that file in place, `uv run sqlpush revision -m "change"` just
|
|
199
|
+
works: no `--dsn`, no `module:attribute`, no credentials on the
|
|
200
|
+
command line, no PYTHONPATH. Inputs resolve with a fixed precedence:
|
|
201
|
+
|
|
202
|
+
| input | explicit flag | hook | fallback |
|
|
203
|
+
| --- | --- | --- | --- |
|
|
204
|
+
| `--dsn` / `--ref-dsn` | wins | `get_dsn()` | `$DATABASE_URL`, only without a hook and never for `--ref-dsn` |
|
|
205
|
+
| `module:attribute` (diff/check/push/revision) | wins | `get_metadata()` | usage error |
|
|
206
|
+
| `--dir` (revision/migrate/stamp) | wins | `CHAIN_DIR` | `migrations/versions` |
|
|
207
|
+
| which file is the hook | `--hook PATH` | — | `$SQLPUSH_HOOK`, else first match: `migrations/sqlpush.py`, then root `sqlpush.py` |
|
|
208
|
+
|
|
209
|
+
The hook's location is the alembic `-c` equivalent: `--hook` >
|
|
210
|
+
`$SQLPUSH_HOOK` > discovery, any location you want (relative paths
|
|
211
|
+
resolve against the CWD) — and the explicit forms fail loud, with an
|
|
212
|
+
error naming that path (`custom/hook.py: file not found`)
|
|
213
|
+
instead of falling back to the discovered candidates.
|
|
214
|
+
|
|
215
|
+
A hook that is missing a member a verb needs — or whose
|
|
216
|
+
`get_dsn()`/`get_metadata()` raises — fails with a typed error naming
|
|
217
|
+
the file that actually loaded and the member
|
|
218
|
+
(`migrations/sqlpush.py: missing get_dsn()`), never a traceback.
|
|
219
|
+
Without a hook, every verb behaves exactly as before.
|
|
220
|
+
|
|
221
|
+
One deliberate detail: on discovering the hook, sqlpush **appends**
|
|
222
|
+
your CWD to `sys.path` instead of prepending it — whatever location
|
|
223
|
+
the hook loaded from, never the hook's own directory. A root
|
|
224
|
+
`sqlpush.py` would otherwise shadow the installed package the moment
|
|
225
|
+
you ran the CLI from your repo root; appending guarantees the real
|
|
226
|
+
package always wins, and the hook itself is loaded by path only (the
|
|
227
|
+
`migrations/` candidate has no shadowing concern but loads the same
|
|
228
|
+
way).
|
|
229
|
+
|
|
170
230
|
## Exit codes
|
|
171
231
|
|
|
172
232
|
| verb | 0 | 1 | 2 | 3 |
|
|
@@ -194,13 +254,16 @@ The knobs, per verb:
|
|
|
194
254
|
| verb | flags |
|
|
195
255
|
| --- | --- |
|
|
196
256
|
| `push` | `--allow-destructive` `--safe-only` `--no-lock` `--lock-timeout` `--advisory-wait` `--no-concurrently` `--statement-timeout` |
|
|
197
|
-
| `revision` | `--ref-dsn` (required) `-m/--message` `--no-concurrently` `--dir` |
|
|
257
|
+
| `revision` | `--ref-dsn` (required without a hook) `-m/--message` `--no-concurrently` `--dir` |
|
|
198
258
|
| `migrate` | `--allow-destructive` `--advisory-wait` `--lock-timeout` `--statement-timeout` `--dir` |
|
|
199
259
|
| `stamp` | `--force` `--dir` |
|
|
200
260
|
|
|
201
|
-
Every verb
|
|
202
|
-
`revision`
|
|
203
|
-
|
|
261
|
+
Every verb takes `--hook PATH` (or `$SQLPUSH_HOOK`) to name the hook
|
|
262
|
+
file explicitly. Every verb except `revision` takes `--dsn` (or
|
|
263
|
+
`$DATABASE_URL`, or the project hook's `get_dsn()`). `revision`
|
|
264
|
+
takes `--ref-dsn` — or
|
|
265
|
+
the hook — with no env fallback: the reference DB is a different
|
|
266
|
+
database from the push target. `diff`, `check`,
|
|
204
267
|
`push` and `revision` also take repeatable `--schema` / `--exclude`.
|
|
205
268
|
Timeouts are seconds; a `lock_timeout` bounds how long a statement
|
|
206
269
|
waits on a lock before failing, `statement_timeout` bounds each
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "sqlpush"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.7.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.
|
|
3
|
+
version = "0.7.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 = [
|
|
@@ -24,6 +24,7 @@ from sqlalchemy.pool import NullPool
|
|
|
24
24
|
|
|
25
25
|
from sqlpush import api
|
|
26
26
|
from sqlpush.core.render import render
|
|
27
|
+
from sqlpush.hook import hook_chain_dir, hook_dsn, hook_metadata, load_project_hook
|
|
27
28
|
from sqlpush.types import (
|
|
28
29
|
CheckResult,
|
|
29
30
|
MetadataImportError,
|
|
@@ -38,8 +39,14 @@ app = typer.Typer(add_completion=False, help=__doc__ or "")
|
|
|
38
39
|
SchemaOpt = Annotated[list[str] | None, typer.Option("--schema")]
|
|
39
40
|
ExcludeOpt = Annotated[list[str] | None, typer.Option("--exclude")]
|
|
40
41
|
# Path-typed options need the Annotated form: B008 (call in default) only
|
|
41
|
-
# exempts typer.Option for non-Path annotations
|
|
42
|
-
|
|
42
|
+
# exempts typer.Option for non-Path annotations. None default: lets the
|
|
43
|
+
# verb tell "user passed --dir" apart from "use hook/default" resolution.
|
|
44
|
+
DirOpt = Annotated[Path | None, typer.Option("--dir")]
|
|
45
|
+
# Hook-location override, on EVERY verb (hook loading is shared
|
|
46
|
+
# machinery like --dsn): flag > $SQLPUSH_HOOK > candidates, and an
|
|
47
|
+
# explicit path that does not exist fails loud in the loader.
|
|
48
|
+
HookOpt = Annotated[Path | None, typer.Option("--hook")]
|
|
49
|
+
DEFAULT_CHAIN_DIR = Path("migrations/versions")
|
|
43
50
|
|
|
44
51
|
|
|
45
52
|
def _load_metadata(spec: str):
|
|
@@ -53,7 +60,28 @@ def _load_metadata(spec: str):
|
|
|
53
60
|
raise MetadataImportError(f"cannot import {spec!r}: {exc}") from exc
|
|
54
61
|
|
|
55
62
|
|
|
56
|
-
def
|
|
63
|
+
def _metadata(metadata_spec: str | None, hook):
|
|
64
|
+
"""Resolve the metadata source: flag > hook.get_metadata() > usage error.
|
|
65
|
+
|
|
66
|
+
The hook path returns the OBJECT itself (no module:attribute import
|
|
67
|
+
happens); the positional path is untouched.
|
|
68
|
+
"""
|
|
69
|
+
if metadata_spec is not None:
|
|
70
|
+
return _load_metadata(metadata_spec)
|
|
71
|
+
if hook is not None:
|
|
72
|
+
return hook_metadata(hook)
|
|
73
|
+
typer.secho(
|
|
74
|
+
"metadata spec required: pass module:attribute or add get_metadata() to ./sqlpush.py",
|
|
75
|
+
fg="red",
|
|
76
|
+
err=True,
|
|
77
|
+
)
|
|
78
|
+
raise typer.Exit(code=2)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _engine(dsn: str | None, hook=None):
|
|
82
|
+
# precedence: explicit flag > hook.get_dsn() > $DATABASE_URL > error
|
|
83
|
+
if dsn is None and hook is not None:
|
|
84
|
+
dsn = hook_dsn(hook)
|
|
57
85
|
dsn = dsn or os.environ.get("DATABASE_URL")
|
|
58
86
|
if not dsn:
|
|
59
87
|
typer.secho("no --dsn and no DATABASE_URL set", fg="red", err=True)
|
|
@@ -66,6 +94,13 @@ def _engine(dsn: str | None):
|
|
|
66
94
|
raise typer.Exit(code=1) from exc
|
|
67
95
|
|
|
68
96
|
|
|
97
|
+
def _resolve_dir(out_dir: Path | None, hook) -> Path:
|
|
98
|
+
"""Resolve --dir: explicit flag > hook.CHAIN_DIR > current default."""
|
|
99
|
+
if out_dir is not None:
|
|
100
|
+
return out_dir
|
|
101
|
+
return Path(hook_chain_dir(hook, str(DEFAULT_CHAIN_DIR)))
|
|
102
|
+
|
|
103
|
+
|
|
69
104
|
def _emit_json(plan) -> None:
|
|
70
105
|
typer.echo(json.dumps(plan.to_json_dict(), indent=2))
|
|
71
106
|
|
|
@@ -81,16 +116,18 @@ def _risk_summary(p) -> None:
|
|
|
81
116
|
|
|
82
117
|
@app.command()
|
|
83
118
|
def diff(
|
|
84
|
-
metadata_spec: str = typer.Argument(
|
|
119
|
+
metadata_spec: str | None = typer.Argument(None, help="module:metadata"),
|
|
85
120
|
dsn: str | None = typer.Option(None),
|
|
86
121
|
json_output: bool = typer.Option(False, "--json"),
|
|
87
122
|
verbose: bool = typer.Option(False, "--verbose"),
|
|
88
123
|
quiet: bool = typer.Option(False, "--quiet"),
|
|
89
124
|
schema: SchemaOpt = None,
|
|
90
125
|
exclude: ExcludeOpt = None,
|
|
126
|
+
hook_path: HookOpt = None,
|
|
91
127
|
):
|
|
92
|
-
|
|
93
|
-
|
|
128
|
+
hook = load_project_hook(hook_path)
|
|
129
|
+
md = _metadata(metadata_spec, hook)
|
|
130
|
+
engine = _engine(dsn, hook)
|
|
94
131
|
try:
|
|
95
132
|
p = api.plan(md, engine, schemas=schema, exclude=exclude or ())
|
|
96
133
|
if json_output:
|
|
@@ -106,16 +143,18 @@ def diff(
|
|
|
106
143
|
|
|
107
144
|
@app.command()
|
|
108
145
|
def check(
|
|
109
|
-
metadata_spec: str = typer.Argument(
|
|
146
|
+
metadata_spec: str | None = typer.Argument(None),
|
|
110
147
|
dsn: str | None = typer.Option(None),
|
|
111
148
|
json_output: bool = typer.Option(False, "--json"),
|
|
112
149
|
verbose: bool = typer.Option(False, "--verbose"),
|
|
113
150
|
quiet: bool = typer.Option(False, "--quiet"),
|
|
114
151
|
schema: SchemaOpt = None,
|
|
115
152
|
exclude: ExcludeOpt = None,
|
|
153
|
+
hook_path: HookOpt = None,
|
|
116
154
|
):
|
|
117
|
-
|
|
118
|
-
|
|
155
|
+
hook = load_project_hook(hook_path)
|
|
156
|
+
md = _metadata(metadata_spec, hook)
|
|
157
|
+
engine = _engine(dsn, hook)
|
|
119
158
|
try:
|
|
120
159
|
# plan ONCE and derive everything from that single object: a
|
|
121
160
|
# second plan could observe a different DB state than the one
|
|
@@ -140,7 +179,7 @@ def check(
|
|
|
140
179
|
|
|
141
180
|
@app.command()
|
|
142
181
|
def push(
|
|
143
|
-
metadata_spec: str = typer.Argument(
|
|
182
|
+
metadata_spec: str | None = typer.Argument(None),
|
|
144
183
|
dsn: str | None = typer.Option(None),
|
|
145
184
|
json_output: bool = typer.Option(False, "--json"),
|
|
146
185
|
allow_destructive: bool = typer.Option(False, "--allow-destructive"),
|
|
@@ -154,9 +193,11 @@ def push(
|
|
|
154
193
|
quiet: bool = typer.Option(False, "--quiet"),
|
|
155
194
|
schema: SchemaOpt = None,
|
|
156
195
|
exclude: ExcludeOpt = None,
|
|
196
|
+
hook_path: HookOpt = None,
|
|
157
197
|
):
|
|
158
|
-
|
|
159
|
-
|
|
198
|
+
hook = load_project_hook(hook_path)
|
|
199
|
+
md = _metadata(metadata_spec, hook)
|
|
200
|
+
engine = _engine(dsn, hook)
|
|
160
201
|
try:
|
|
161
202
|
try:
|
|
162
203
|
report = api.push(
|
|
@@ -223,25 +264,36 @@ def push(
|
|
|
223
264
|
|
|
224
265
|
@app.command()
|
|
225
266
|
def revision(
|
|
226
|
-
metadata_spec: str = typer.Argument(
|
|
227
|
-
ref_dsn: str = typer.Option(
|
|
267
|
+
metadata_spec: str | None = typer.Argument(None, help="module:metadata"),
|
|
268
|
+
ref_dsn: str | None = typer.Option(None, "--ref-dsn"),
|
|
228
269
|
message: str | None = typer.Option(None, "--message", "-m"),
|
|
229
|
-
out_dir: DirOpt =
|
|
270
|
+
out_dir: DirOpt = None,
|
|
230
271
|
no_concurrently: bool = typer.Option(False, "--no-concurrently"),
|
|
231
272
|
schema: SchemaOpt = None,
|
|
232
273
|
exclude: ExcludeOpt = None,
|
|
274
|
+
hook_path: HookOpt = None,
|
|
233
275
|
):
|
|
234
276
|
"""Generate the next migration file from models vs the reference DB."""
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
#
|
|
238
|
-
#
|
|
277
|
+
hook = load_project_hook(hook_path)
|
|
278
|
+
md = _metadata(metadata_spec, hook)
|
|
279
|
+
# --ref-dsn: flag > hook.get_dsn(); NO DATABASE_URL fallback (the
|
|
280
|
+
# reference DB is a different database from the push target —
|
|
281
|
+
# conflating them silently would chain against the wrong head)
|
|
282
|
+
if ref_dsn is None and hook is not None:
|
|
283
|
+
ref_dsn = hook_dsn(hook)
|
|
284
|
+
if ref_dsn is None:
|
|
285
|
+
typer.secho(
|
|
286
|
+
"--ref-dsn is required (or add get_dsn() to ./sqlpush.py)",
|
|
287
|
+
fg="red",
|
|
288
|
+
err=True,
|
|
289
|
+
)
|
|
290
|
+
raise typer.Exit(code=2)
|
|
239
291
|
engine = _engine(ref_dsn)
|
|
240
292
|
try:
|
|
241
293
|
path = api.revision(
|
|
242
294
|
md,
|
|
243
295
|
engine,
|
|
244
|
-
out_dir=out_dir,
|
|
296
|
+
out_dir=_resolve_dir(out_dir, hook),
|
|
245
297
|
message=message,
|
|
246
298
|
concurrently=not no_concurrently,
|
|
247
299
|
schemas=schema,
|
|
@@ -260,14 +312,16 @@ def migrate(
|
|
|
260
312
|
advisory_wait: float = typer.Option(30.0, "--advisory-wait"),
|
|
261
313
|
lock_timeout: float = typer.Option(5.0, "--lock-timeout"),
|
|
262
314
|
statement_timeout: float | None = typer.Option(None, "--statement-timeout"),
|
|
263
|
-
out_dir: DirOpt =
|
|
315
|
+
out_dir: DirOpt = None,
|
|
316
|
+
hook_path: HookOpt = None,
|
|
264
317
|
):
|
|
265
318
|
"""Replay pending migration files (gates + checksum bookkeeping)."""
|
|
266
|
-
|
|
319
|
+
hook = load_project_hook(hook_path)
|
|
320
|
+
engine = _engine(dsn, hook)
|
|
267
321
|
try:
|
|
268
322
|
report = api.migrate(
|
|
269
323
|
engine,
|
|
270
|
-
chain_dir=out_dir,
|
|
324
|
+
chain_dir=_resolve_dir(out_dir, hook),
|
|
271
325
|
allow_destructive=allow_destructive,
|
|
272
326
|
advisory_wait=advisory_wait,
|
|
273
327
|
lock_timeout=lock_timeout,
|
|
@@ -290,12 +344,14 @@ def migrate(
|
|
|
290
344
|
def stamp(
|
|
291
345
|
dsn: str | None = typer.Option(None),
|
|
292
346
|
force: bool = typer.Option(False, "--force"),
|
|
293
|
-
out_dir: DirOpt =
|
|
347
|
+
out_dir: DirOpt = None,
|
|
348
|
+
hook_path: HookOpt = None,
|
|
294
349
|
):
|
|
295
350
|
"""Adopt an existing DB: register chain files without executing SQL."""
|
|
296
|
-
|
|
351
|
+
hook = load_project_hook(hook_path)
|
|
352
|
+
engine = _engine(dsn, hook)
|
|
297
353
|
try:
|
|
298
|
-
report = api.stamp(engine, chain_dir=out_dir, force=force)
|
|
354
|
+
report = api.stamp(engine, chain_dir=_resolve_dir(out_dir, hook), force=force)
|
|
299
355
|
finally:
|
|
300
356
|
engine.dispose()
|
|
301
357
|
typer.echo(
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# src/sqlpush/hook.py
|
|
2
|
+
"""Project hook: ``migrations/sqlpush.py`` first, then ``sqlpush.py``.
|
|
3
|
+
|
|
4
|
+
The alembic ``env.py`` / pytest ``conftest.py`` pattern: a hook file
|
|
5
|
+
next to where the user runs the CLI becomes the source of defaults for
|
|
6
|
+
metadata, DSN and chain dir. Explicit flags always win (flag > hook >
|
|
7
|
+
env/default); without the hook every verb behaves exactly as before.
|
|
8
|
+
|
|
9
|
+
Discovery checks ``migrations/sqlpush.py`` first (the preferred
|
|
10
|
+
location — it lives next to the chain, no root clutter) and falls
|
|
11
|
+
back to the repo-root ``sqlpush.py`` (backwards compat); first match
|
|
12
|
+
wins. An explicit override — the ``--hook`` flag or the
|
|
13
|
+
``SQLPUSH_HOOK`` env var (the alembic ``-c`` equivalent) — names any
|
|
14
|
+
file instead, and is fail-loud: a path that does not exist raises a
|
|
15
|
+
:class:`HookError` naming that path, never a silent fallback to
|
|
16
|
+
the candidates. Precedence: flag > env > candidates.
|
|
17
|
+
|
|
18
|
+
Loading is BY PATH (``spec_from_file_location``), never by module
|
|
19
|
+
name: a file named ``sqlpush.py`` in the CWD would shadow the
|
|
20
|
+
installed package if the CWD came first on ``sys.path`` — so the CWD
|
|
21
|
+
is APPENDED (never ``insert(0)``), and always the CWD, never the
|
|
22
|
+
loaded file's own directory (the append exists so the consumer's
|
|
23
|
+
package imports resolve). This guarantees the package wins for
|
|
24
|
+
normal imports while the hook itself is only ever loaded explicitly.
|
|
25
|
+
|
|
26
|
+
Only typed :class:`SqlpushError`-family errors escape this module,
|
|
27
|
+
always naming the file that actually loaded and the member involved.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import importlib.util
|
|
33
|
+
import os
|
|
34
|
+
import sys
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
from types import ModuleType
|
|
37
|
+
|
|
38
|
+
from sqlpush.types import SqlpushError
|
|
39
|
+
|
|
40
|
+
HOOK_FILENAME = "sqlpush.py"
|
|
41
|
+
HOOK_ENV_VAR = "SQLPUSH_HOOK"
|
|
42
|
+
# first match wins: migrations/ (next to the chain) preferred, the
|
|
43
|
+
# repo root kept as the backwards-compat fallback
|
|
44
|
+
CANDIDATES = (Path("migrations") / HOOK_FILENAME, Path(HOOK_FILENAME))
|
|
45
|
+
# resolved loaded path -> the spelling it was named with (candidate or
|
|
46
|
+
# override path): _hook_label uses it so override-loaded hooks error in
|
|
47
|
+
# the GIVEN spelling, matching the candidate message contract
|
|
48
|
+
_LOADED_LABELS: dict[Path, str] = {}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class HookError(SqlpushError):
|
|
52
|
+
"""The project hook is broken or incomplete — names file + member."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def load_project_hook(hook_path: Path | str | None = None) -> ModuleType | None:
|
|
56
|
+
"""Load the project hook; ``None`` when nothing resolves.
|
|
57
|
+
|
|
58
|
+
Precedence: ``hook_path`` (the ``--hook`` flag) > ``$SQLPUSH_HOOK``
|
|
59
|
+
> the first existing candidate (``migrations/sqlpush.py``, then the
|
|
60
|
+
repo root). An explicit path (flag or env) is fail-loud: when it
|
|
61
|
+
does not exist a :class:`HookError` names that path — an
|
|
62
|
+
explicit instruction must not silently fall back to discovery.
|
|
63
|
+
Relative paths resolve against the CWD. On success the CWD is
|
|
64
|
+
appended to ``sys.path`` (never inserted at the front, and never
|
|
65
|
+
the loaded file's own directory — see the module docstring). Any
|
|
66
|
+
import-time failure is re-typed as :class:`HookError` naming the
|
|
67
|
+
file that failed, in the spelling it was named with.
|
|
68
|
+
"""
|
|
69
|
+
explicit: Path | None = None
|
|
70
|
+
if hook_path is not None:
|
|
71
|
+
explicit = Path(hook_path)
|
|
72
|
+
else:
|
|
73
|
+
env_value = os.environ.get(HOOK_ENV_VAR)
|
|
74
|
+
if env_value: # empty string ≡ unset
|
|
75
|
+
explicit = Path(env_value)
|
|
76
|
+
if explicit is not None:
|
|
77
|
+
if not explicit.is_file():
|
|
78
|
+
raise HookError(f"{explicit.as_posix()}: file not found")
|
|
79
|
+
return _load_hook_file(explicit)
|
|
80
|
+
hook_file = next((p for p in CANDIDATES if p.is_file()), None)
|
|
81
|
+
if hook_file is None:
|
|
82
|
+
return None
|
|
83
|
+
return _load_hook_file(hook_file)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _load_hook_file(hook_file: Path) -> ModuleType:
|
|
87
|
+
sys.path.append(os.getcwd())
|
|
88
|
+
spec = importlib.util.spec_from_file_location("sqlpush_project_hook", hook_file)
|
|
89
|
+
if spec is None or spec.loader is None:
|
|
90
|
+
raise HookError(f"{hook_file.as_posix()}: cannot load (invalid module spec)")
|
|
91
|
+
module = importlib.util.module_from_spec(spec)
|
|
92
|
+
_LOADED_LABELS[hook_file.resolve()] = hook_file.as_posix()
|
|
93
|
+
try:
|
|
94
|
+
spec.loader.exec_module(module)
|
|
95
|
+
except Exception as exc:
|
|
96
|
+
raise HookError(f"{hook_file.as_posix()}: import raised: {exc}") from exc
|
|
97
|
+
return module
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _hook_label(hook: ModuleType) -> str:
|
|
101
|
+
"""The spelling the loaded hook file was named with (posix, portable).
|
|
102
|
+
|
|
103
|
+
Errors must name the file that actually loaded: map the loaded
|
|
104
|
+
module's ``__file__`` back to its load-time spelling (candidate or
|
|
105
|
+
override path); unknown provenance degrades to the bare filename.
|
|
106
|
+
"""
|
|
107
|
+
loaded_file = getattr(hook, "__file__", None)
|
|
108
|
+
if not loaded_file:
|
|
109
|
+
return HOOK_FILENAME
|
|
110
|
+
loaded = Path(loaded_file).resolve()
|
|
111
|
+
label = _LOADED_LABELS.get(loaded)
|
|
112
|
+
if label is not None:
|
|
113
|
+
return label
|
|
114
|
+
for candidate in CANDIDATES:
|
|
115
|
+
if (Path.cwd() / candidate).resolve() == loaded:
|
|
116
|
+
return candidate.as_posix()
|
|
117
|
+
return HOOK_FILENAME
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def hook_dsn(hook: ModuleType) -> str:
|
|
121
|
+
"""Call ``get_dsn()`` LAZILY — only when a verb actually needs it."""
|
|
122
|
+
label = _hook_label(hook)
|
|
123
|
+
getter = getattr(hook, "get_dsn", None)
|
|
124
|
+
if getter is None:
|
|
125
|
+
raise HookError(f"{label}: missing get_dsn()")
|
|
126
|
+
try:
|
|
127
|
+
return getter()
|
|
128
|
+
except Exception as exc:
|
|
129
|
+
raise HookError(f"{label}: get_dsn() raised: {exc}") from exc
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def hook_metadata(hook: ModuleType):
|
|
133
|
+
"""Call ``get_metadata()`` LAZILY — returns the populated MetaData object."""
|
|
134
|
+
label = _hook_label(hook)
|
|
135
|
+
getter = getattr(hook, "get_metadata", None)
|
|
136
|
+
if getter is None:
|
|
137
|
+
raise HookError(f"{label}: missing get_metadata()")
|
|
138
|
+
try:
|
|
139
|
+
return getter()
|
|
140
|
+
except Exception as exc:
|
|
141
|
+
raise HookError(f"{label}: get_metadata() raised: {exc}") from exc
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def hook_chain_dir(hook: ModuleType | None, default: str) -> str:
|
|
145
|
+
"""Read ``CHAIN_DIR`` as a module attribute; ``default`` when unset."""
|
|
146
|
+
value = getattr(hook, "CHAIN_DIR", None) if hook is not None else None
|
|
147
|
+
return default if value is None else value
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|