Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,28 @@ the project follows [Semantic Versioning](https://semver.org/).

## [Unreleased]

### Added

- Project hook discovery now checks `migrations/sqlpush.py` FIRST
(the preferred location — it lives next to the chain, no repo-root
clutter) and falls back to the repo-root `sqlpush.py` for backwards
compat; first match wins, so the root-only setups of 0.6.0 behave
identically. Typed hook errors name the file that actually loaded
in the matched candidate's spelling (`migrations/sqlpush.py:
missing get_dsn()`), whatever the loading location. `sys.path`
still appends the CWD — never the hook's own directory — so the
consumer's package imports resolve and the root hook can never
shadow the installed package. Without a hook, no behavior change.
- Hook location override (the alembic `-c` equivalent): `--hook PATH`
on every verb, or the `SQLPUSH_HOOK` env var, loads the hook from
any location (relative paths resolve against the CWD). Precedence:
`--hook` > `SQLPUSH_HOOK` > discovery (`migrations/sqlpush.py`,
then root `sqlpush.py`). An explicit path that does not exist
fails as a typed error naming that path (`custom/hook.py:
file not found`, exit 1) — never a silent fallback to the
discovered candidates — and errors from an overridden hook name
the loaded file in the given spelling.

## [0.6.0] - 2026-09-03

### Added
Expand Down
41 changes: 29 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,12 +169,17 @@ the workflows.

## Project hook

Drop a `sqlpush.py` in your repo root (the alembic `env.py` /
pytest `conftest.py` pattern) and the CLI stops needing flags,
specs and env vars:
Drop a `sqlpush.py` where sqlpush can find it (the alembic `env.py` /
pytest `conftest.py` pattern) and the CLI stops needing flags, specs
and env vars. Two discovered locations, first match wins — or name
any file explicitly with `--hook`/`$SQLPUSH_HOOK`:

1. `migrations/sqlpush.py` — preferred: it lives next to the chain,
no repo-root clutter.
2. `sqlpush.py` — repo root, kept as the backwards-compat fallback.

```python
# sqlpush.py — in your repo root
# migrations/sqlpush.py — the preferred location
def get_metadata(): # REQUIRED for diff/check/push/revision
from myapp.models import metadata

Expand All @@ -194,23 +199,33 @@ With that file in place, `uv run sqlpush revision -m "change"` just
works: no `--dsn`, no `module:attribute`, no credentials on the
command line, no PYTHONPATH. Inputs resolve with a fixed precedence:

| input | explicit flag | `sqlpush.py` | fallback |
| input | explicit flag | hook | fallback |
| --- | --- | --- | --- |
| `--dsn` / `--ref-dsn` | wins | `get_dsn()` | `$DATABASE_URL`, only without a hook and never for `--ref-dsn` |
| `module:attribute` (diff/check/push/revision) | wins | `get_metadata()` | usage error |
| `--dir` (revision/migrate/stamp) | wins | `CHAIN_DIR` | `migrations/versions` |
| which file is the hook | `--hook PATH` | — | `$SQLPUSH_HOOK`, else first match: `migrations/sqlpush.py`, then root `sqlpush.py` |

The hook's location is the alembic `-c` equivalent: `--hook` >
`$SQLPUSH_HOOK` > discovery, any location you want (relative paths
resolve against the CWD) — and the explicit forms fail loud, with an
error naming that path (`custom/hook.py: file not found`)
instead of falling back to the discovered candidates.

A hook that is missing a member a verb needs — or whose
`get_dsn()`/`get_metadata()` raises — fails with a typed error naming
the file and the member (`sqlpush.py: missing get_dsn()`), never a
traceback. Without a `sqlpush.py`, every verb behaves exactly as
before.
the file that actually loaded and the member
(`migrations/sqlpush.py: missing get_dsn()`), never a traceback.
Without a hook, every verb behaves exactly as before.

One deliberate detail: on discovering the hook, sqlpush **appends**
your CWD to `sys.path` instead of prepending it. A file named
your CWD to `sys.path` instead of prepending it — whatever location
the hook loaded from, never the hook's own directory. A root
`sqlpush.py` would otherwise shadow the installed package the moment
you ran the CLI from your repo root; appending guarantees the real
package always wins, and the hook is loaded by path only.
package always wins, and the hook itself is loaded by path only (the
`migrations/` candidate has no shadowing concern but loads the same
way).

## Exit codes

Expand Down Expand Up @@ -243,8 +258,10 @@ The knobs, per verb:
| `migrate` | `--allow-destructive` `--advisory-wait` `--lock-timeout` `--statement-timeout` `--dir` |
| `stamp` | `--force` `--dir` |

Every verb except `revision` takes `--dsn` (or `$DATABASE_URL`, or
the project hook's `get_dsn()`). `revision` takes `--ref-dsn` — or
Every verb takes `--hook PATH` (or `$SQLPUSH_HOOK`) to name the hook
file explicitly. Every verb except `revision` takes `--dsn` (or
`$DATABASE_URL`, or the project hook's `get_dsn()`). `revision`
takes `--ref-dsn` — or
the hook — with no env fallback: the reference DB is a different
database from the push target. `diff`, `check`,
`push` and `revision` also take repeatable `--schema` / `--exclude`.
Expand Down
22 changes: 16 additions & 6 deletions src/sqlpush/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@
# exempts typer.Option for non-Path annotations. None default: lets the
# verb tell "user passed --dir" apart from "use hook/default" resolution.
DirOpt = Annotated[Path | None, typer.Option("--dir")]
# Hook-location override, on EVERY verb (hook loading is shared
# machinery like --dsn): flag > $SQLPUSH_HOOK > candidates, and an
# explicit path that does not exist fails loud in the loader.
HookOpt = Annotated[Path | None, typer.Option("--hook")]
DEFAULT_CHAIN_DIR = Path("migrations/versions")


Expand Down Expand Up @@ -119,8 +123,9 @@ def diff(
quiet: bool = typer.Option(False, "--quiet"),
schema: SchemaOpt = None,
exclude: ExcludeOpt = None,
hook_path: HookOpt = None,
):
hook = load_project_hook()
hook = load_project_hook(hook_path)
md = _metadata(metadata_spec, hook)
engine = _engine(dsn, hook)
try:
Expand All @@ -145,8 +150,9 @@ def check(
quiet: bool = typer.Option(False, "--quiet"),
schema: SchemaOpt = None,
exclude: ExcludeOpt = None,
hook_path: HookOpt = None,
):
hook = load_project_hook()
hook = load_project_hook(hook_path)
md = _metadata(metadata_spec, hook)
engine = _engine(dsn, hook)
try:
Expand Down Expand Up @@ -187,8 +193,9 @@ def push(
quiet: bool = typer.Option(False, "--quiet"),
schema: SchemaOpt = None,
exclude: ExcludeOpt = None,
hook_path: HookOpt = None,
):
hook = load_project_hook()
hook = load_project_hook(hook_path)
md = _metadata(metadata_spec, hook)
engine = _engine(dsn, hook)
try:
Expand Down Expand Up @@ -264,9 +271,10 @@ def revision(
no_concurrently: bool = typer.Option(False, "--no-concurrently"),
schema: SchemaOpt = None,
exclude: ExcludeOpt = None,
hook_path: HookOpt = None,
):
"""Generate the next migration file from models vs the reference DB."""
hook = load_project_hook()
hook = load_project_hook(hook_path)
md = _metadata(metadata_spec, hook)
# --ref-dsn: flag > hook.get_dsn(); NO DATABASE_URL fallback (the
# reference DB is a different database from the push target —
Expand Down Expand Up @@ -305,9 +313,10 @@ def migrate(
lock_timeout: float = typer.Option(5.0, "--lock-timeout"),
statement_timeout: float | None = typer.Option(None, "--statement-timeout"),
out_dir: DirOpt = None,
hook_path: HookOpt = None,
):
"""Replay pending migration files (gates + checksum bookkeeping)."""
hook = load_project_hook()
hook = load_project_hook(hook_path)
engine = _engine(dsn, hook)
try:
report = api.migrate(
Expand Down Expand Up @@ -336,9 +345,10 @@ def stamp(
dsn: str | None = typer.Option(None),
force: bool = typer.Option(False, "--force"),
out_dir: DirOpt = None,
hook_path: HookOpt = None,
):
"""Adopt an existing DB: register chain files without executing SQL."""
hook = load_project_hook()
hook = load_project_hook(hook_path)
engine = _engine(dsn, hook)
try:
report = api.stamp(engine, chain_dir=_resolve_dir(out_dir, hook), force=force)
Expand Down
109 changes: 86 additions & 23 deletions src/sqlpush/hook.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,30 @@
# src/sqlpush/hook.py
"""Project hook: ``sqlpush.py`` in the CWD.

The alembic ``env.py`` / pytest ``conftest.py`` pattern: when a
``sqlpush.py`` exists next to where the user runs the CLI, it becomes
the source of defaults for metadata, DSN and chain dir. Explicit flags
always win (flag > hook > env/default); without the hook every verb
behaves exactly as before.
"""Project hook: ``migrations/sqlpush.py`` first, then ``sqlpush.py``.

The alembic ``env.py`` / pytest ``conftest.py`` pattern: a hook file
next to where the user runs the CLI becomes the source of defaults for
metadata, DSN and chain dir. Explicit flags always win (flag > hook >
env/default); without the hook every verb behaves exactly as before.

Discovery checks ``migrations/sqlpush.py`` first (the preferred
location — it lives next to the chain, no root clutter) and falls
back to the repo-root ``sqlpush.py`` (backwards compat); first match
wins. An explicit override — the ``--hook`` flag or the
``SQLPUSH_HOOK`` env var (the alembic ``-c`` equivalent) — names any
file instead, and is fail-loud: a path that does not exist raises a
:class:`HookError` naming that path, never a silent fallback to
the candidates. Precedence: flag > env > candidates.

Loading is BY PATH (``spec_from_file_location``), never by module
name: a file named ``sqlpush.py`` in the CWD would shadow the
installed package if the CWD came first on ``sys.path`` — so the CWD
is APPENDED (never ``insert(0)``), guaranteeing the package wins for
is APPENDED (never ``insert(0)``), and always the CWD, never the
loaded file's own directory (the append exists so the consumer's
package imports resolve). This guarantees the package wins for
normal imports while the hook itself is only ever loaded explicitly.

Only typed :class:`SqlpushError`-family errors escape this module,
always naming the file and the member involved.
always naming the file that actually loaded and the member involved.
"""

from __future__ import annotations
Expand All @@ -28,54 +38,107 @@
from sqlpush.types import SqlpushError

HOOK_FILENAME = "sqlpush.py"
HOOK_ENV_VAR = "SQLPUSH_HOOK"
# first match wins: migrations/ (next to the chain) preferred, the
# repo root kept as the backwards-compat fallback
CANDIDATES = (Path("migrations") / HOOK_FILENAME, Path(HOOK_FILENAME))
# resolved loaded path -> the spelling it was named with (candidate or
# override path): _hook_label uses it so override-loaded hooks error in
# the GIVEN spelling, matching the candidate message contract
_LOADED_LABELS: dict[Path, str] = {}


class HookError(SqlpushError):
"""The project hook is broken or incomplete — names file + member."""


def load_project_hook() -> ModuleType | None:
"""Load ``./sqlpush.py`` by path if present; ``None`` when absent.
def load_project_hook(hook_path: Path | str | None = None) -> ModuleType | None:
"""Load the project hook; ``None`` when nothing resolves.

On success the CWD is appended to ``sys.path`` (never inserted at
the front — see the module docstring for the shadowing rationale).
Any import-time failure is re-typed as :class:`HookError`.
Precedence: ``hook_path`` (the ``--hook`` flag) > ``$SQLPUSH_HOOK``
> the first existing candidate (``migrations/sqlpush.py``, then the
repo root). An explicit path (flag or env) is fail-loud: when it
does not exist a :class:`HookError` names that path — an
explicit instruction must not silently fall back to discovery.
Relative paths resolve against the CWD. On success the CWD is
appended to ``sys.path`` (never inserted at the front, and never
the loaded file's own directory — see the module docstring). Any
import-time failure is re-typed as :class:`HookError` naming the
file that failed, in the spelling it was named with.
"""
path = Path.cwd() / HOOK_FILENAME
if not path.is_file():
explicit: Path | None = None
if hook_path is not None:
explicit = Path(hook_path)
else:
env_value = os.environ.get(HOOK_ENV_VAR)
if env_value: # empty string ≡ unset
explicit = Path(env_value)
if explicit is not None:
if not explicit.is_file():
raise HookError(f"{explicit.as_posix()}: file not found")
return _load_hook_file(explicit)
hook_file = next((p for p in CANDIDATES if p.is_file()), None)
if hook_file is None:
return None
return _load_hook_file(hook_file)


def _load_hook_file(hook_file: Path) -> ModuleType:
sys.path.append(os.getcwd())
spec = importlib.util.spec_from_file_location("sqlpush_project_hook", path)
spec = importlib.util.spec_from_file_location("sqlpush_project_hook", hook_file)
if spec is None or spec.loader is None:
raise HookError(f"{HOOK_FILENAME}: cannot load (invalid module spec)")
raise HookError(f"{hook_file.as_posix()}: cannot load (invalid module spec)")
module = importlib.util.module_from_spec(spec)
_LOADED_LABELS[hook_file.resolve()] = hook_file.as_posix()
try:
spec.loader.exec_module(module)
except Exception as exc:
raise HookError(f"{HOOK_FILENAME}: import raised: {exc}") from exc
raise HookError(f"{hook_file.as_posix()}: import raised: {exc}") from exc
return module


def _hook_label(hook: ModuleType) -> str:
"""The spelling the loaded hook file was named with (posix, portable).

Errors must name the file that actually loaded: map the loaded
module's ``__file__`` back to its load-time spelling (candidate or
override path); unknown provenance degrades to the bare filename.
"""
loaded_file = getattr(hook, "__file__", None)
if not loaded_file:
return HOOK_FILENAME
loaded = Path(loaded_file).resolve()
label = _LOADED_LABELS.get(loaded)
if label is not None:
return label
for candidate in CANDIDATES:
if (Path.cwd() / candidate).resolve() == loaded:
return candidate.as_posix()
return HOOK_FILENAME


def hook_dsn(hook: ModuleType) -> str:
"""Call ``get_dsn()`` LAZILY — only when a verb actually needs it."""
label = _hook_label(hook)
getter = getattr(hook, "get_dsn", None)
if getter is None:
raise HookError(f"{HOOK_FILENAME}: missing get_dsn()")
raise HookError(f"{label}: missing get_dsn()")
try:
return getter()
except Exception as exc:
raise HookError(f"{HOOK_FILENAME}: get_dsn() raised: {exc}") from exc
raise HookError(f"{label}: get_dsn() raised: {exc}") from exc


def hook_metadata(hook: ModuleType):
"""Call ``get_metadata()`` LAZILY — returns the populated MetaData object."""
label = _hook_label(hook)
getter = getattr(hook, "get_metadata", None)
if getter is None:
raise HookError(f"{HOOK_FILENAME}: missing get_metadata()")
raise HookError(f"{label}: missing get_metadata()")
try:
return getter()
except Exception as exc:
raise HookError(f"{HOOK_FILENAME}: get_metadata() raised: {exc}") from exc
raise HookError(f"{label}: get_metadata() raised: {exc}") from exc


def hook_chain_dir(hook: ModuleType | None, default: str) -> str:
Expand Down
Loading
Loading