sqlpush 0.5.1__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.5.1 → sqlpush-0.6.0}/PKG-INFO +51 -5
- {sqlpush-0.5.1 → sqlpush-0.6.0}/README.md +50 -4
- {sqlpush-0.5.1 → sqlpush-0.6.0}/pyproject.toml +1 -1
- {sqlpush-0.5.1 → sqlpush-0.6.0}/pyproject.toml.orig +1 -1
- {sqlpush-0.5.1 → sqlpush-0.6.0}/src/sqlpush/cli.py +72 -26
- sqlpush-0.6.0/src/sqlpush/hook.py +84 -0
- {sqlpush-0.5.1 → sqlpush-0.6.0}/src/sqlpush/__init__.py +0 -0
- {sqlpush-0.5.1 → sqlpush-0.6.0}/src/sqlpush/annotations.py +0 -0
- {sqlpush-0.5.1 → sqlpush-0.6.0}/src/sqlpush/api.py +0 -0
- {sqlpush-0.5.1 → sqlpush-0.6.0}/src/sqlpush/apply/__init__.py +0 -0
- {sqlpush-0.5.1 → sqlpush-0.6.0}/src/sqlpush/apply/executor.py +0 -0
- {sqlpush-0.5.1 → sqlpush-0.6.0}/src/sqlpush/chain/__init__.py +0 -0
- {sqlpush-0.5.1 → sqlpush-0.6.0}/src/sqlpush/chain/format.py +0 -0
- {sqlpush-0.5.1 → sqlpush-0.6.0}/src/sqlpush/chain/migrate.py +0 -0
- {sqlpush-0.5.1 → sqlpush-0.6.0}/src/sqlpush/core/__init__.py +0 -0
- {sqlpush-0.5.1 → sqlpush-0.6.0}/src/sqlpush/core/classify.py +0 -0
- {sqlpush-0.5.1 → sqlpush-0.6.0}/src/sqlpush/core/diff.py +0 -0
- {sqlpush-0.5.1 → sqlpush-0.6.0}/src/sqlpush/core/render.py +0 -0
- {sqlpush-0.5.1 → sqlpush-0.6.0}/src/sqlpush/directives/__init__.py +0 -0
- {sqlpush-0.5.1 → sqlpush-0.6.0}/src/sqlpush/directives/timescale.py +0 -0
- {sqlpush-0.5.1 → sqlpush-0.6.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.6.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,51 @@ 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` 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
|
+
|
|
199
244
|
## Exit codes
|
|
200
245
|
|
|
201
246
|
| verb | 0 | 1 | 2 | 3 |
|
|
@@ -223,13 +268,14 @@ The knobs, per verb:
|
|
|
223
268
|
| verb | flags |
|
|
224
269
|
| --- | --- |
|
|
225
270
|
| `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` |
|
|
271
|
+
| `revision` | `--ref-dsn` (required without a hook) `-m/--message` `--no-concurrently` `--dir` |
|
|
227
272
|
| `migrate` | `--allow-destructive` `--advisory-wait` `--lock-timeout` `--statement-timeout` `--dir` |
|
|
228
273
|
| `stamp` | `--force` `--dir` |
|
|
229
274
|
|
|
230
|
-
Every verb except `revision` takes `--dsn` (or `$DATABASE_URL
|
|
231
|
-
`revision`
|
|
232
|
-
|
|
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`,
|
|
233
279
|
`push` and `revision` also take repeatable `--schema` / `--exclude`.
|
|
234
280
|
Timeouts are seconds; a `lock_timeout` bounds how long a statement
|
|
235
281
|
waits on a lock before failing, `statement_timeout` bounds each
|
|
@@ -167,6 +167,51 @@ 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` 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
|
+
|
|
170
215
|
## Exit codes
|
|
171
216
|
|
|
172
217
|
| verb | 0 | 1 | 2 | 3 |
|
|
@@ -194,13 +239,14 @@ The knobs, per verb:
|
|
|
194
239
|
| verb | flags |
|
|
195
240
|
| --- | --- |
|
|
196
241
|
| `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` |
|
|
242
|
+
| `revision` | `--ref-dsn` (required without a hook) `-m/--message` `--no-concurrently` `--dir` |
|
|
198
243
|
| `migrate` | `--allow-destructive` `--advisory-wait` `--lock-timeout` `--statement-timeout` `--dir` |
|
|
199
244
|
| `stamp` | `--force` `--dir` |
|
|
200
245
|
|
|
201
|
-
Every verb except `revision` takes `--dsn` (or `$DATABASE_URL
|
|
202
|
-
`revision`
|
|
203
|
-
|
|
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`,
|
|
204
250
|
`push` and `revision` also take repeatable `--schema` / `--exclude`.
|
|
205
251
|
Timeouts are seconds; a `lock_timeout` bounds how long a statement
|
|
206
252
|
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.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"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "sqlpush"
|
|
3
|
-
version = "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 = [
|
|
@@ -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,10 @@ 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
|
+
DEFAULT_CHAIN_DIR = Path("migrations/versions")
|
|
43
46
|
|
|
44
47
|
|
|
45
48
|
def _load_metadata(spec: str):
|
|
@@ -53,7 +56,28 @@ def _load_metadata(spec: str):
|
|
|
53
56
|
raise MetadataImportError(f"cannot import {spec!r}: {exc}") from exc
|
|
54
57
|
|
|
55
58
|
|
|
56
|
-
def
|
|
59
|
+
def _metadata(metadata_spec: str | None, hook):
|
|
60
|
+
"""Resolve the metadata source: flag > hook.get_metadata() > usage error.
|
|
61
|
+
|
|
62
|
+
The hook path returns the OBJECT itself (no module:attribute import
|
|
63
|
+
happens); the positional path is untouched.
|
|
64
|
+
"""
|
|
65
|
+
if metadata_spec is not None:
|
|
66
|
+
return _load_metadata(metadata_spec)
|
|
67
|
+
if hook is not None:
|
|
68
|
+
return hook_metadata(hook)
|
|
69
|
+
typer.secho(
|
|
70
|
+
"metadata spec required: pass module:attribute or add get_metadata() to ./sqlpush.py",
|
|
71
|
+
fg="red",
|
|
72
|
+
err=True,
|
|
73
|
+
)
|
|
74
|
+
raise typer.Exit(code=2)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _engine(dsn: str | None, hook=None):
|
|
78
|
+
# precedence: explicit flag > hook.get_dsn() > $DATABASE_URL > error
|
|
79
|
+
if dsn is None and hook is not None:
|
|
80
|
+
dsn = hook_dsn(hook)
|
|
57
81
|
dsn = dsn or os.environ.get("DATABASE_URL")
|
|
58
82
|
if not dsn:
|
|
59
83
|
typer.secho("no --dsn and no DATABASE_URL set", fg="red", err=True)
|
|
@@ -66,6 +90,13 @@ def _engine(dsn: str | None):
|
|
|
66
90
|
raise typer.Exit(code=1) from exc
|
|
67
91
|
|
|
68
92
|
|
|
93
|
+
def _resolve_dir(out_dir: Path | None, hook) -> Path:
|
|
94
|
+
"""Resolve --dir: explicit flag > hook.CHAIN_DIR > current default."""
|
|
95
|
+
if out_dir is not None:
|
|
96
|
+
return out_dir
|
|
97
|
+
return Path(hook_chain_dir(hook, str(DEFAULT_CHAIN_DIR)))
|
|
98
|
+
|
|
99
|
+
|
|
69
100
|
def _emit_json(plan) -> None:
|
|
70
101
|
typer.echo(json.dumps(plan.to_json_dict(), indent=2))
|
|
71
102
|
|
|
@@ -81,7 +112,7 @@ def _risk_summary(p) -> None:
|
|
|
81
112
|
|
|
82
113
|
@app.command()
|
|
83
114
|
def diff(
|
|
84
|
-
metadata_spec: str = typer.Argument(
|
|
115
|
+
metadata_spec: str | None = typer.Argument(None, help="module:metadata"),
|
|
85
116
|
dsn: str | None = typer.Option(None),
|
|
86
117
|
json_output: bool = typer.Option(False, "--json"),
|
|
87
118
|
verbose: bool = typer.Option(False, "--verbose"),
|
|
@@ -89,8 +120,9 @@ def diff(
|
|
|
89
120
|
schema: SchemaOpt = None,
|
|
90
121
|
exclude: ExcludeOpt = None,
|
|
91
122
|
):
|
|
92
|
-
|
|
93
|
-
|
|
123
|
+
hook = load_project_hook()
|
|
124
|
+
md = _metadata(metadata_spec, hook)
|
|
125
|
+
engine = _engine(dsn, hook)
|
|
94
126
|
try:
|
|
95
127
|
p = api.plan(md, engine, schemas=schema, exclude=exclude or ())
|
|
96
128
|
if json_output:
|
|
@@ -106,7 +138,7 @@ def diff(
|
|
|
106
138
|
|
|
107
139
|
@app.command()
|
|
108
140
|
def check(
|
|
109
|
-
metadata_spec: str = typer.Argument(
|
|
141
|
+
metadata_spec: str | None = typer.Argument(None),
|
|
110
142
|
dsn: str | None = typer.Option(None),
|
|
111
143
|
json_output: bool = typer.Option(False, "--json"),
|
|
112
144
|
verbose: bool = typer.Option(False, "--verbose"),
|
|
@@ -114,8 +146,9 @@ def check(
|
|
|
114
146
|
schema: SchemaOpt = None,
|
|
115
147
|
exclude: ExcludeOpt = None,
|
|
116
148
|
):
|
|
117
|
-
|
|
118
|
-
|
|
149
|
+
hook = load_project_hook()
|
|
150
|
+
md = _metadata(metadata_spec, hook)
|
|
151
|
+
engine = _engine(dsn, hook)
|
|
119
152
|
try:
|
|
120
153
|
# plan ONCE and derive everything from that single object: a
|
|
121
154
|
# second plan could observe a different DB state than the one
|
|
@@ -140,7 +173,7 @@ def check(
|
|
|
140
173
|
|
|
141
174
|
@app.command()
|
|
142
175
|
def push(
|
|
143
|
-
metadata_spec: str = typer.Argument(
|
|
176
|
+
metadata_spec: str | None = typer.Argument(None),
|
|
144
177
|
dsn: str | None = typer.Option(None),
|
|
145
178
|
json_output: bool = typer.Option(False, "--json"),
|
|
146
179
|
allow_destructive: bool = typer.Option(False, "--allow-destructive"),
|
|
@@ -155,8 +188,9 @@ def push(
|
|
|
155
188
|
schema: SchemaOpt = None,
|
|
156
189
|
exclude: ExcludeOpt = None,
|
|
157
190
|
):
|
|
158
|
-
|
|
159
|
-
|
|
191
|
+
hook = load_project_hook()
|
|
192
|
+
md = _metadata(metadata_spec, hook)
|
|
193
|
+
engine = _engine(dsn, hook)
|
|
160
194
|
try:
|
|
161
195
|
try:
|
|
162
196
|
report = api.push(
|
|
@@ -223,25 +257,35 @@ def push(
|
|
|
223
257
|
|
|
224
258
|
@app.command()
|
|
225
259
|
def revision(
|
|
226
|
-
metadata_spec: str = typer.Argument(
|
|
227
|
-
ref_dsn: str = typer.Option(
|
|
260
|
+
metadata_spec: str | None = typer.Argument(None, help="module:metadata"),
|
|
261
|
+
ref_dsn: str | None = typer.Option(None, "--ref-dsn"),
|
|
228
262
|
message: str | None = typer.Option(None, "--message", "-m"),
|
|
229
|
-
out_dir: DirOpt =
|
|
263
|
+
out_dir: DirOpt = None,
|
|
230
264
|
no_concurrently: bool = typer.Option(False, "--no-concurrently"),
|
|
231
265
|
schema: SchemaOpt = None,
|
|
232
266
|
exclude: ExcludeOpt = None,
|
|
233
267
|
):
|
|
234
268
|
"""Generate the next migration file from models vs the reference DB."""
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
#
|
|
238
|
-
#
|
|
269
|
+
hook = load_project_hook()
|
|
270
|
+
md = _metadata(metadata_spec, hook)
|
|
271
|
+
# --ref-dsn: flag > hook.get_dsn(); NO DATABASE_URL fallback (the
|
|
272
|
+
# reference DB is a different database from the push target —
|
|
273
|
+
# conflating them silently would chain against the wrong head)
|
|
274
|
+
if ref_dsn is None and hook is not None:
|
|
275
|
+
ref_dsn = hook_dsn(hook)
|
|
276
|
+
if ref_dsn is None:
|
|
277
|
+
typer.secho(
|
|
278
|
+
"--ref-dsn is required (or add get_dsn() to ./sqlpush.py)",
|
|
279
|
+
fg="red",
|
|
280
|
+
err=True,
|
|
281
|
+
)
|
|
282
|
+
raise typer.Exit(code=2)
|
|
239
283
|
engine = _engine(ref_dsn)
|
|
240
284
|
try:
|
|
241
285
|
path = api.revision(
|
|
242
286
|
md,
|
|
243
287
|
engine,
|
|
244
|
-
out_dir=out_dir,
|
|
288
|
+
out_dir=_resolve_dir(out_dir, hook),
|
|
245
289
|
message=message,
|
|
246
290
|
concurrently=not no_concurrently,
|
|
247
291
|
schemas=schema,
|
|
@@ -260,14 +304,15 @@ def migrate(
|
|
|
260
304
|
advisory_wait: float = typer.Option(30.0, "--advisory-wait"),
|
|
261
305
|
lock_timeout: float = typer.Option(5.0, "--lock-timeout"),
|
|
262
306
|
statement_timeout: float | None = typer.Option(None, "--statement-timeout"),
|
|
263
|
-
out_dir: DirOpt =
|
|
307
|
+
out_dir: DirOpt = None,
|
|
264
308
|
):
|
|
265
309
|
"""Replay pending migration files (gates + checksum bookkeeping)."""
|
|
266
|
-
|
|
310
|
+
hook = load_project_hook()
|
|
311
|
+
engine = _engine(dsn, hook)
|
|
267
312
|
try:
|
|
268
313
|
report = api.migrate(
|
|
269
314
|
engine,
|
|
270
|
-
chain_dir=out_dir,
|
|
315
|
+
chain_dir=_resolve_dir(out_dir, hook),
|
|
271
316
|
allow_destructive=allow_destructive,
|
|
272
317
|
advisory_wait=advisory_wait,
|
|
273
318
|
lock_timeout=lock_timeout,
|
|
@@ -290,12 +335,13 @@ def migrate(
|
|
|
290
335
|
def stamp(
|
|
291
336
|
dsn: str | None = typer.Option(None),
|
|
292
337
|
force: bool = typer.Option(False, "--force"),
|
|
293
|
-
out_dir: DirOpt =
|
|
338
|
+
out_dir: DirOpt = None,
|
|
294
339
|
):
|
|
295
340
|
"""Adopt an existing DB: register chain files without executing SQL."""
|
|
296
|
-
|
|
341
|
+
hook = load_project_hook()
|
|
342
|
+
engine = _engine(dsn, hook)
|
|
297
343
|
try:
|
|
298
|
-
report = api.stamp(engine, chain_dir=out_dir, force=force)
|
|
344
|
+
report = api.stamp(engine, chain_dir=_resolve_dir(out_dir, hook), force=force)
|
|
299
345
|
finally:
|
|
300
346
|
engine.dispose()
|
|
301
347
|
typer.echo(
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# src/sqlpush/hook.py
|
|
2
|
+
"""Project hook: ``sqlpush.py`` in the CWD.
|
|
3
|
+
|
|
4
|
+
The alembic ``env.py`` / pytest ``conftest.py`` pattern: when a
|
|
5
|
+
``sqlpush.py`` exists next to where the user runs the CLI, it becomes
|
|
6
|
+
the source of defaults for metadata, DSN and chain dir. Explicit flags
|
|
7
|
+
always win (flag > hook > env/default); without the hook every verb
|
|
8
|
+
behaves exactly as before.
|
|
9
|
+
|
|
10
|
+
Loading is BY PATH (``spec_from_file_location``), never by module
|
|
11
|
+
name: a file named ``sqlpush.py`` in the CWD would shadow the
|
|
12
|
+
installed package if the CWD came first on ``sys.path`` — so the CWD
|
|
13
|
+
is APPENDED (never ``insert(0)``), guaranteeing the package wins for
|
|
14
|
+
normal imports while the hook itself is only ever loaded explicitly.
|
|
15
|
+
|
|
16
|
+
Only typed :class:`SqlpushError`-family errors escape this module,
|
|
17
|
+
always naming the file and the member involved.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import importlib.util
|
|
23
|
+
import os
|
|
24
|
+
import sys
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from types import ModuleType
|
|
27
|
+
|
|
28
|
+
from sqlpush.types import SqlpushError
|
|
29
|
+
|
|
30
|
+
HOOK_FILENAME = "sqlpush.py"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class HookError(SqlpushError):
|
|
34
|
+
"""The project hook is broken or incomplete — names file + member."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def load_project_hook() -> ModuleType | None:
|
|
38
|
+
"""Load ``./sqlpush.py`` by path if present; ``None`` when absent.
|
|
39
|
+
|
|
40
|
+
On success the CWD is appended to ``sys.path`` (never inserted at
|
|
41
|
+
the front — see the module docstring for the shadowing rationale).
|
|
42
|
+
Any import-time failure is re-typed as :class:`HookError`.
|
|
43
|
+
"""
|
|
44
|
+
path = Path.cwd() / HOOK_FILENAME
|
|
45
|
+
if not path.is_file():
|
|
46
|
+
return None
|
|
47
|
+
sys.path.append(os.getcwd())
|
|
48
|
+
spec = importlib.util.spec_from_file_location("sqlpush_project_hook", path)
|
|
49
|
+
if spec is None or spec.loader is None:
|
|
50
|
+
raise HookError(f"{HOOK_FILENAME}: cannot load (invalid module spec)")
|
|
51
|
+
module = importlib.util.module_from_spec(spec)
|
|
52
|
+
try:
|
|
53
|
+
spec.loader.exec_module(module)
|
|
54
|
+
except Exception as exc:
|
|
55
|
+
raise HookError(f"{HOOK_FILENAME}: import raised: {exc}") from exc
|
|
56
|
+
return module
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def hook_dsn(hook: ModuleType) -> str:
|
|
60
|
+
"""Call ``get_dsn()`` LAZILY — only when a verb actually needs it."""
|
|
61
|
+
getter = getattr(hook, "get_dsn", None)
|
|
62
|
+
if getter is None:
|
|
63
|
+
raise HookError(f"{HOOK_FILENAME}: missing get_dsn()")
|
|
64
|
+
try:
|
|
65
|
+
return getter()
|
|
66
|
+
except Exception as exc:
|
|
67
|
+
raise HookError(f"{HOOK_FILENAME}: get_dsn() raised: {exc}") from exc
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def hook_metadata(hook: ModuleType):
|
|
71
|
+
"""Call ``get_metadata()`` LAZILY — returns the populated MetaData object."""
|
|
72
|
+
getter = getattr(hook, "get_metadata", None)
|
|
73
|
+
if getter is None:
|
|
74
|
+
raise HookError(f"{HOOK_FILENAME}: missing get_metadata()")
|
|
75
|
+
try:
|
|
76
|
+
return getter()
|
|
77
|
+
except Exception as exc:
|
|
78
|
+
raise HookError(f"{HOOK_FILENAME}: get_metadata() raised: {exc}") from exc
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def hook_chain_dir(hook: ModuleType | None, default: str) -> str:
|
|
82
|
+
"""Read ``CHAIN_DIR`` as a module attribute; ``default`` when unset."""
|
|
83
|
+
value = getattr(hook, "CHAIN_DIR", None) if hook is not None else None
|
|
84
|
+
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
|