sqlpush 0.6.0__py3-none-any.whl → 0.7.0__py3-none-any.whl

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/cli.py CHANGED
@@ -42,6 +42,10 @@ ExcludeOpt = Annotated[list[str] | None, typer.Option("--exclude")]
42
42
  # exempts typer.Option for non-Path annotations. None default: lets the
43
43
  # verb tell "user passed --dir" apart from "use hook/default" resolution.
44
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")]
45
49
  DEFAULT_CHAIN_DIR = Path("migrations/versions")
46
50
 
47
51
 
@@ -119,8 +123,9 @@ def diff(
119
123
  quiet: bool = typer.Option(False, "--quiet"),
120
124
  schema: SchemaOpt = None,
121
125
  exclude: ExcludeOpt = None,
126
+ hook_path: HookOpt = None,
122
127
  ):
123
- hook = load_project_hook()
128
+ hook = load_project_hook(hook_path)
124
129
  md = _metadata(metadata_spec, hook)
125
130
  engine = _engine(dsn, hook)
126
131
  try:
@@ -145,8 +150,9 @@ def check(
145
150
  quiet: bool = typer.Option(False, "--quiet"),
146
151
  schema: SchemaOpt = None,
147
152
  exclude: ExcludeOpt = None,
153
+ hook_path: HookOpt = None,
148
154
  ):
149
- hook = load_project_hook()
155
+ hook = load_project_hook(hook_path)
150
156
  md = _metadata(metadata_spec, hook)
151
157
  engine = _engine(dsn, hook)
152
158
  try:
@@ -187,8 +193,9 @@ def push(
187
193
  quiet: bool = typer.Option(False, "--quiet"),
188
194
  schema: SchemaOpt = None,
189
195
  exclude: ExcludeOpt = None,
196
+ hook_path: HookOpt = None,
190
197
  ):
191
- hook = load_project_hook()
198
+ hook = load_project_hook(hook_path)
192
199
  md = _metadata(metadata_spec, hook)
193
200
  engine = _engine(dsn, hook)
194
201
  try:
@@ -264,9 +271,10 @@ def revision(
264
271
  no_concurrently: bool = typer.Option(False, "--no-concurrently"),
265
272
  schema: SchemaOpt = None,
266
273
  exclude: ExcludeOpt = None,
274
+ hook_path: HookOpt = None,
267
275
  ):
268
276
  """Generate the next migration file from models vs the reference DB."""
269
- hook = load_project_hook()
277
+ hook = load_project_hook(hook_path)
270
278
  md = _metadata(metadata_spec, hook)
271
279
  # --ref-dsn: flag > hook.get_dsn(); NO DATABASE_URL fallback (the
272
280
  # reference DB is a different database from the push target —
@@ -305,9 +313,10 @@ def migrate(
305
313
  lock_timeout: float = typer.Option(5.0, "--lock-timeout"),
306
314
  statement_timeout: float | None = typer.Option(None, "--statement-timeout"),
307
315
  out_dir: DirOpt = None,
316
+ hook_path: HookOpt = None,
308
317
  ):
309
318
  """Replay pending migration files (gates + checksum bookkeeping)."""
310
- hook = load_project_hook()
319
+ hook = load_project_hook(hook_path)
311
320
  engine = _engine(dsn, hook)
312
321
  try:
313
322
  report = api.migrate(
@@ -336,9 +345,10 @@ def stamp(
336
345
  dsn: str | None = typer.Option(None),
337
346
  force: bool = typer.Option(False, "--force"),
338
347
  out_dir: DirOpt = None,
348
+ hook_path: HookOpt = None,
339
349
  ):
340
350
  """Adopt an existing DB: register chain files without executing SQL."""
341
- hook = load_project_hook()
351
+ hook = load_project_hook(hook_path)
342
352
  engine = _engine(dsn, hook)
343
353
  try:
344
354
  report = api.stamp(engine, chain_dir=_resolve_dir(out_dir, hook), force=force)
sqlpush/hook.py CHANGED
@@ -1,20 +1,30 @@
1
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.
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.
9
17
 
10
18
  Loading is BY PATH (``spec_from_file_location``), never by module
11
19
  name: a file named ``sqlpush.py`` in the CWD would shadow the
12
20
  installed package if the CWD came first on ``sys.path`` — so the CWD
13
- is APPENDED (never ``insert(0)``), guaranteeing the package wins for
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
14
24
  normal imports while the hook itself is only ever loaded explicitly.
15
25
 
16
26
  Only typed :class:`SqlpushError`-family errors escape this module,
17
- always naming the file and the member involved.
27
+ always naming the file that actually loaded and the member involved.
18
28
  """
19
29
 
20
30
  from __future__ import annotations
@@ -28,54 +38,107 @@ from types import ModuleType
28
38
  from sqlpush.types import SqlpushError
29
39
 
30
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] = {}
31
49
 
32
50
 
33
51
  class HookError(SqlpushError):
34
52
  """The project hook is broken or incomplete — names file + member."""
35
53
 
36
54
 
37
- def load_project_hook() -> ModuleType | None:
38
- """Load ``./sqlpush.py`` by path if present; ``None`` when absent.
55
+ def load_project_hook(hook_path: Path | str | None = None) -> ModuleType | None:
56
+ """Load the project hook; ``None`` when nothing resolves.
39
57
 
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`.
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.
43
68
  """
44
- path = Path.cwd() / HOOK_FILENAME
45
- if not path.is_file():
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:
46
82
  return None
83
+ return _load_hook_file(hook_file)
84
+
85
+
86
+ def _load_hook_file(hook_file: Path) -> ModuleType:
47
87
  sys.path.append(os.getcwd())
48
- spec = importlib.util.spec_from_file_location("sqlpush_project_hook", path)
88
+ spec = importlib.util.spec_from_file_location("sqlpush_project_hook", hook_file)
49
89
  if spec is None or spec.loader is None:
50
- raise HookError(f"{HOOK_FILENAME}: cannot load (invalid module spec)")
90
+ raise HookError(f"{hook_file.as_posix()}: cannot load (invalid module spec)")
51
91
  module = importlib.util.module_from_spec(spec)
92
+ _LOADED_LABELS[hook_file.resolve()] = hook_file.as_posix()
52
93
  try:
53
94
  spec.loader.exec_module(module)
54
95
  except Exception as exc:
55
- raise HookError(f"{HOOK_FILENAME}: import raised: {exc}") from exc
96
+ raise HookError(f"{hook_file.as_posix()}: import raised: {exc}") from exc
56
97
  return module
57
98
 
58
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
+
59
120
  def hook_dsn(hook: ModuleType) -> str:
60
121
  """Call ``get_dsn()`` LAZILY — only when a verb actually needs it."""
122
+ label = _hook_label(hook)
61
123
  getter = getattr(hook, "get_dsn", None)
62
124
  if getter is None:
63
- raise HookError(f"{HOOK_FILENAME}: missing get_dsn()")
125
+ raise HookError(f"{label}: missing get_dsn()")
64
126
  try:
65
127
  return getter()
66
128
  except Exception as exc:
67
- raise HookError(f"{HOOK_FILENAME}: get_dsn() raised: {exc}") from exc
129
+ raise HookError(f"{label}: get_dsn() raised: {exc}") from exc
68
130
 
69
131
 
70
132
  def hook_metadata(hook: ModuleType):
71
133
  """Call ``get_metadata()`` LAZILY — returns the populated MetaData object."""
134
+ label = _hook_label(hook)
72
135
  getter = getattr(hook, "get_metadata", None)
73
136
  if getter is None:
74
- raise HookError(f"{HOOK_FILENAME}: missing get_metadata()")
137
+ raise HookError(f"{label}: missing get_metadata()")
75
138
  try:
76
139
  return getter()
77
140
  except Exception as exc:
78
- raise HookError(f"{HOOK_FILENAME}: get_metadata() raised: {exc}") from exc
141
+ raise HookError(f"{label}: get_metadata() raised: {exc}") from exc
79
142
 
80
143
 
81
144
  def hook_chain_dir(hook: ModuleType | None, default: str) -> str:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlpush
3
- Version: 0.6.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
@@ -198,12 +198,17 @@ the workflows.
198
198
 
199
199
  ## Project hook
200
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:
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.
204
209
 
205
210
  ```python
206
- # sqlpush.py — in your repo root
211
+ # migrations/sqlpush.py — the preferred location
207
212
  def get_metadata(): # REQUIRED for diff/check/push/revision
208
213
  from myapp.models import metadata
209
214
 
@@ -223,23 +228,33 @@ With that file in place, `uv run sqlpush revision -m "change"` just
223
228
  works: no `--dsn`, no `module:attribute`, no credentials on the
224
229
  command line, no PYTHONPATH. Inputs resolve with a fixed precedence:
225
230
 
226
- | input | explicit flag | `sqlpush.py` | fallback |
231
+ | input | explicit flag | hook | fallback |
227
232
  | --- | --- | --- | --- |
228
233
  | `--dsn` / `--ref-dsn` | wins | `get_dsn()` | `$DATABASE_URL`, only without a hook and never for `--ref-dsn` |
229
234
  | `module:attribute` (diff/check/push/revision) | wins | `get_metadata()` | usage error |
230
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.
231
243
 
232
244
  A hook that is missing a member a verb needs — or whose
233
245
  `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.
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.
237
249
 
238
250
  One deliberate detail: on discovering the hook, sqlpush **appends**
239
- your CWD to `sys.path` instead of prepending it. A file named
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
240
253
  `sqlpush.py` would otherwise shadow the installed package the moment
241
254
  you ran the CLI from your repo root; appending guarantees the real
242
- package always wins, and the hook is loaded by path only.
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).
243
258
 
244
259
  ## Exit codes
245
260
 
@@ -272,8 +287,10 @@ The knobs, per verb:
272
287
  | `migrate` | `--allow-destructive` `--advisory-wait` `--lock-timeout` `--statement-timeout` `--dir` |
273
288
  | `stamp` | `--force` `--dir` |
274
289
 
275
- Every verb except `revision` takes `--dsn` (or `$DATABASE_URL`, or
276
- the project hook's `get_dsn()`). `revision` takes `--ref-dsn` — or
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
277
294
  the hook — with no env fallback: the reference DB is a different
278
295
  database from the push target. `diff`, `check`,
279
296
  `push` and `revision` also take repeatable `--schema` / `--exclude`.
@@ -6,16 +6,16 @@ sqlpush/apply/executor.py,sha256=qHvfxB1sWQAQ0oMPDGuEr-HRaJwOYoVSB_s-7x-CZTc,100
6
6
  sqlpush/chain/__init__.py,sha256=AJ96P3I63mX6-uMuK0paCpKtaRexufnCqjUuElbyAsM,293
7
7
  sqlpush/chain/format.py,sha256=uj0iMQfFWa9HpKhrokfJu_5B_FesiD4qCZxAGDJ3bfQ,4938
8
8
  sqlpush/chain/migrate.py,sha256=P2grgrg8dkLJCBhL6CzNwa4xG8-VuFLqgN7XuvjFhwQ,18575
9
- sqlpush/cli.py,sha256=A49c6HxDv6-8I9cX3jT5ujqmBqD-keUPpcOSPExw48o,12860
9
+ sqlpush/cli.py,sha256=sF0F0xNTUzdXRPLB09EDHgNJUqHkTMgCbv_vSvSHgFk,13350
10
10
  sqlpush/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
11
  sqlpush/core/classify.py,sha256=xjBFIPAV8lxMsZZRiOQ1Yxlpgz4r-ZpCtnNXA4KAw0o,1290
12
12
  sqlpush/core/diff.py,sha256=OkH0fLl9NLOyWFnefknvBK8SWeN6bnJz4kQJfK3-wFA,26552
13
13
  sqlpush/core/render.py,sha256=9SNEyHOGmMxKUfIjvo3x0MNVQ6N2o2fl3noWT1EWO3M,609
14
14
  sqlpush/directives/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
15
  sqlpush/directives/timescale.py,sha256=W_Cd_stdhKZlzlMum7Bdzq99VfXOulYxq0B-sukz-xE,2645
16
- sqlpush/hook.py,sha256=RRGm0-ODh4YqsG6s-uvaCBIyJ6HvDUcBgHD-OxlQcQQ,3076
16
+ sqlpush/hook.py,sha256=t2qOLt4m8JjTDEIO7eJCusvdhLx3u8ztvtO8705yb5Q,6068
17
17
  sqlpush/types.py,sha256=hXetdVqMzYcBQi0M_x3qmCIOa3QzhuwxYNJkZRWz4qM,3280
18
- sqlpush-0.6.0.dist-info/WHEEL,sha256=y6e-a5KI2W-qDAJKfh9Xr81bin8NgUdfWgz3VSXXpe4,80
19
- sqlpush-0.6.0.dist-info/entry_points.txt,sha256=VYha3PJSvklRgpcDAZYJdE66EXM79MSKQf0z8igyAOI,46
20
- sqlpush-0.6.0.dist-info/METADATA,sha256=bO1edF0e_VA7G_Ya6sXboPndA4cYrY-5JpAr3MD9VyA,14317
21
- sqlpush-0.6.0.dist-info/RECORD,,
18
+ sqlpush-0.7.0.dist-info/WHEEL,sha256=y6e-a5KI2W-qDAJKfh9Xr81bin8NgUdfWgz3VSXXpe4,80
19
+ sqlpush-0.7.0.dist-info/entry_points.txt,sha256=VYha3PJSvklRgpcDAZYJdE66EXM79MSKQf0z8igyAOI,46
20
+ sqlpush-0.7.0.dist-info/METADATA,sha256=v7OfjIX8LydcDbKIuF8T4Fb4hnzRLuYBhz7s_MSIMUE,15317
21
+ sqlpush-0.7.0.dist-info/RECORD,,