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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlpush
3
- Version: 0.5.1
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 except `revision` takes `--dsn` (or `$DATABASE_URL`).
231
- `revision` requires `--ref-dsn`, with no env fallback: the reference
232
- DB is a different database from the push target. `diff`, `check`,
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 except `revision` takes `--dsn` (or `$DATABASE_URL`).
202
- `revision` requires `--ref-dsn`, with no env fallback: the reference
203
- DB is a different database from the push target. `diff`, `check`,
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.5.1"
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.5.1"
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
- DirOpt = Annotated[Path, typer.Option("--dir")]
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 _engine(dsn: str | None):
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(..., help="module:metadata"),
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
- md = _load_metadata(metadata_spec)
93
- engine = _engine(dsn)
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
- md = _load_metadata(metadata_spec)
118
- engine = _engine(dsn)
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
- md = _load_metadata(metadata_spec)
159
- engine = _engine(dsn)
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(..., help="module:metadata"),
227
- ref_dsn: str = typer.Option(..., "--ref-dsn"),
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 = Path("migrations/versions"),
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
- md = _load_metadata(metadata_spec)
236
- # required --ref-dsn (no DATABASE_URL fallback): the reference DB is a
237
- # different database from the push target — conflating them silently
238
- # would chain against the wrong head
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 = Path("migrations/versions"),
315
+ out_dir: DirOpt = None,
316
+ hook_path: HookOpt = None,
264
317
  ):
265
318
  """Replay pending migration files (gates + checksum bookkeeping)."""
266
- engine = _engine(dsn)
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 = Path("migrations/versions"),
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
- engine = _engine(dsn)
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