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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlpush
3
- Version: 0.5.1
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` requires `--ref-dsn`, with no env fallback: the reference
232
- DB is a different database from the push target. `diff`, `check`,
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` requires `--ref-dsn`, with no env fallback: the reference
203
- DB is a different database from the push target. `diff`, `check`,
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.5.1"
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.5.1"
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
- 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
+ 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 _engine(dsn: str | None):
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(..., help="module:metadata"),
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
- md = _load_metadata(metadata_spec)
93
- engine = _engine(dsn)
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
- md = _load_metadata(metadata_spec)
118
- engine = _engine(dsn)
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
- md = _load_metadata(metadata_spec)
159
- engine = _engine(dsn)
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(..., help="module:metadata"),
227
- ref_dsn: str = typer.Option(..., "--ref-dsn"),
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 = Path("migrations/versions"),
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
- 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
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 = Path("migrations/versions"),
307
+ out_dir: DirOpt = None,
264
308
  ):
265
309
  """Replay pending migration files (gates + checksum bookkeeping)."""
266
- engine = _engine(dsn)
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 = Path("migrations/versions"),
338
+ out_dir: DirOpt = None,
294
339
  ):
295
340
  """Adopt an existing DB: register chain files without executing SQL."""
296
- engine = _engine(dsn)
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