From 41ad2aa03512cb9b4373d3802803f983d20f7a2e Mon Sep 17 00:00:00 2001 From: Juan Miguel Contreras <19253629+juanmicl@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:51:38 +0200 Subject: [PATCH 1/5] feat(hook): prefer migrations/sqlpush.py, root fallback (file-named errors) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discovery now checks migrations/sqlpush.py first (preferred: lives next to the chain, no root clutter) then the repo-root sqlpush.py (backwards compat) — first match wins; root-only setups behave exactly as in 0.6.0 (the existing root-location tests now double as the fallback pin, unchanged). Two correctness requirements beyond the owner's snippet: - typed errors name the file that ACTUALLY loaded, in the candidate spelling (load-time errors use the matched candidate directly; accessors map the loaded module's __file__ back to its candidate — posix-formatted so messages and tests read migrations/sqlpush.py). - sys.path still appends the CWD regardless of which candidate loaded, never the hook's own directory (migrations/ on sys.path would be wrong for consumer package imports) — pinned by test. Filed under Added rather than Changed: the only behavior change vs 0.6.0 is the exotic both-files-present case (migrations/ wins); root hook users see nothing move. --- CHANGELOG.md | 13 +++++++ src/sqlpush/hook.py | 78 ++++++++++++++++++++++++++++------------- tests/test_hook.py | 84 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 152 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 147488d..7670cab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,19 @@ 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. + ## [0.6.0] - 2026-09-03 ### Added diff --git a/src/sqlpush/hook.py b/src/sqlpush/hook.py index 9b7adc8..8930003 100644 --- a/src/sqlpush/hook.py +++ b/src/sqlpush/hook.py @@ -1,20 +1,27 @@ # src/sqlpush/hook.py -"""Project hook: ``sqlpush.py`` in the CWD. +"""Project hook: ``migrations/sqlpush.py`` first, then ``sqlpush.py``. -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. +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. 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 -normal imports while the hook itself is only ever loaded explicitly. +is APPENDED (never ``insert(0)``), and always the CWD, never the +loaded candidate'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 @@ -28,6 +35,9 @@ from sqlpush.types import SqlpushError HOOK_FILENAME = "sqlpush.py" +# 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)) class HookError(SqlpushError): @@ -35,47 +45,69 @@ class HookError(SqlpushError): def load_project_hook() -> ModuleType | None: - """Load ``./sqlpush.py`` by path if present; ``None`` when absent. - - 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`. + """Load the first existing hook candidate by path; ``None`` when absent. + + ``migrations/sqlpush.py`` is checked before the repo-root + ``sqlpush.py`` — first match wins. On success the CWD is appended + to ``sys.path`` (never inserted at the front, and never the + candidate's own directory — see the module docstring). Any + import-time failure is re-typed as :class:`HookError` naming the + candidate that failed, in the candidate spelling. """ - path = Path.cwd() / HOOK_FILENAME - if not path.is_file(): + hook_file = next((p for p in CANDIDATES if p.is_file()), None) + if hook_file is None: return None 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) 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 CANDIDATES spelling of the loaded hook file (posix, portable). + + Errors must name the file that actually loaded, so map the loaded + module's ``__file__`` back to its candidate spelling; 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() + 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: diff --git a/tests/test_hook.py b/tests/test_hook.py index a5d058e..8796461 100644 --- a/tests/test_hook.py +++ b/tests/test_hook.py @@ -13,6 +13,7 @@ import sqlpush.cli from sqlpush.cli import app +from sqlpush.hook import load_project_hook from sqlpush.types import MigrateReport, Plan, SqlpushError runner = CliRunner() @@ -110,6 +111,89 @@ def test_hook_revision_ref_dsn_and_chain_dir(tmp_path, monkeypatch): assert seen["out_dir"] == Path("migrations/chain") +# --- two candidate locations: migrations/ first, root fallback --------------- +# (the existing root-location tests below/above now double as the +# backwards-compat fallback pin — they are deliberately NOT rewritten) + + +def test_hook_discovered_from_migrations_dir(tmp_path, monkeypatch): + # preferred location: migrations/sqlpush.py (lives next to the + # chain); root sqlpush.py absent — full verb resolution from there + monkeypatch.chdir(tmp_path) + mig = tmp_path / "migrations" + mig.mkdir() + (mig / "sqlpush.py").write_text(FULL_HOOK) + seen: dict = {} + _capture_engine(monkeypatch, seen) + + def fake_plan(md, engine, **kw): + seen["md"] = md + return Plan() # clean → exit 0 + + monkeypatch.setattr(sqlpush.cli.api, "plan", fake_plan) + monkeypatch.delenv("DATABASE_URL", raising=False) + r = runner.invoke(app, ["check"]) + assert r.exit_code == 0, r.output + assert seen["dsn"] == HOOK_DSN + assert "hook_marker_tbl" in seen["md"].tables + + +def test_migrations_candidate_wins_over_root(tmp_path, monkeypatch): + # BOTH candidates present → first match wins: migrations/. The two + # hooks are distinguishable by DSN so precedence is pinned exactly. + monkeypatch.chdir(tmp_path) + mig = tmp_path / "migrations" + mig.mkdir() + root_hook_dsn = "postgresql+psycopg://u:p@localhost:1/roothook" + (mig / "sqlpush.py").write_text(f"def get_dsn():\n return {HOOK_DSN!r}\n") + (tmp_path / "sqlpush.py").write_text(f"def get_dsn():\n return {root_hook_dsn!r}\n") + seen: dict = {} + _capture_engine(monkeypatch, seen) + monkeypatch.setattr(sqlpush.cli.api, "migrate", lambda *a, **k: MigrateReport()) + r = runner.invoke(app, ["migrate"]) + assert r.exit_code == 0, r.output + assert seen["dsn"] == HOOK_DSN # the migrations/ one, not the root's + + +def test_error_messages_name_the_loaded_file(tmp_path, monkeypatch): + # correctness req 1: typed errors must name the file that ACTUALLY + # loaded, in the candidate spelling (tests assert it exactly) + monkeypatch.chdir(tmp_path) + mig = tmp_path / "migrations" + mig.mkdir() + (mig / "sqlpush.py").write_text("CHAIN_DIR = 'x'\n") # no get_dsn + r = runner.invoke(app, ["migrate"]) + assert r.exit_code == 1 + assert isinstance(r.exception, SqlpushError) + assert str(r.exception).startswith("migrations/sqlpush.py: missing get_dsn()") + + +def test_raised_error_names_the_loaded_file(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + mig = tmp_path / "migrations" + mig.mkdir() + (mig / "sqlpush.py").write_text("def get_dsn():\n raise RuntimeError('boom-mig')\n") + r = runner.invoke(app, ["migrate"]) + assert r.exit_code == 1 + assert isinstance(r.exception, SqlpushError) + assert str(r.exception).startswith("migrations/sqlpush.py: get_dsn() raised:") + assert "boom-mig" in str(r.exception) + + +def test_sys_path_appends_cwd_not_hook_dir(tmp_path, monkeypatch): + # correctness req 2: whatever candidate loaded, the sys.path append + # is the CWD (so the consumer's package imports resolve) — never + # the hook's own directory (migrations/ on sys.path would be wrong) + monkeypatch.chdir(tmp_path) + mig = tmp_path / "migrations" + mig.mkdir() + (mig / "sqlpush.py").write_text("def get_dsn():\n return 'postgresql://x'\n") + hook = load_project_hook() + assert hook is not None + assert sys.path[-1] == str(tmp_path) # cwd appended LAST + assert str(mig) not in sys.path # never the hook's own directory + + # --- precedence: flag > hook > env ------------------------------------------ From 62dc7dc1a0a24f2642c77c298bfe3a0463107a7a Mon Sep 17 00:00:00 2001 From: Juan Miguel Contreras <19253629+juanmicl@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:52:41 +0200 Subject: [PATCH 2/5] docs(readme): hook locations and precedence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Project hook section now states the two candidate locations and the first-match order (migrations/sqlpush.py preferred — next to the chain; repo-root sqlpush.py as the backwards-compat fallback), moves the example header to the preferred location, and updates the error contract sentence (the message names the file that actually loaded) and the shadowing note (append-the-CWD applies whatever location loaded; the migrations candidate has no shadowing concern but the same load-by-path mechanics). --- README.md | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 554a031..9e6ba9b 100644 --- a/README.md +++ b/README.md @@ -169,12 +169,16 @@ 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 candidate locations, first match wins: + +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 @@ -194,7 +198,7 @@ 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 | @@ -202,15 +206,18 @@ command line, no PYTHONPATH. Inputs resolve with a fixed precedence: 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 From 544b2fc433cc99c0bcfe1ef8d052b41204bbfcc6 Mon Sep 17 00:00:00 2001 From: Juan Miguel Contreras <19253629+juanmicl@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:59:13 +0200 Subject: [PATCH 3/5] feat(cli): --hook / SQLPUSH_HOOK override for the hook location MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Explicit override on top of candidate discovery (the alembic -c equivalent): --hook PATH on every verb, or $SQLPUSH_HOOK, loads the hook from any location. Precedence: flag > env > candidates; an explicit path that does not exist fails loud as a typed HookError naming that exact path — never a silent fallback. Errors from an overridden hook name the loaded file in the given spelling (load-time label registry), and sys.path still appends the CWD, never the loaded file's directory. --- CHANGELOG.md | 9 ++++ src/sqlpush/cli.py | 22 ++++++--- src/sqlpush/hook.py | 63 +++++++++++++++++------ tests/test_hook.py | 118 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 190 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7670cab..c4967bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,15 @@ the project follows [Semantic Versioning](https://semver.org/). 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 exact 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 diff --git a/src/sqlpush/cli.py b/src/sqlpush/cli.py index f26e1c9..1c83bbf 100644 --- a/src/sqlpush/cli.py +++ b/src/sqlpush/cli.py @@ -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") @@ -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: @@ -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: @@ -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: @@ -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 — @@ -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( @@ -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) diff --git a/src/sqlpush/hook.py b/src/sqlpush/hook.py index 8930003..1d30aea 100644 --- a/src/sqlpush/hook.py +++ b/src/sqlpush/hook.py @@ -9,16 +9,19 @@ 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. +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 exact 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)``), and always the CWD, never the -loaded candidate'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. +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 that actually loaded and the member involved. @@ -35,33 +38,58 @@ 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 the first existing hook candidate by path; ``None`` when absent. +def load_project_hook(hook_path: Path | str | None = None) -> ModuleType | None: + """Load the project hook; ``None`` when nothing resolves. - ``migrations/sqlpush.py`` is checked before the repo-root - ``sqlpush.py`` — first match wins. On success the CWD is appended - to ``sys.path`` (never inserted at the front, and never the - candidate's own directory — see the module docstring). Any + 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 exact 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 - candidate that failed, in the candidate spelling. + file that failed, in the spelling it was named with. """ + 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", hook_file) if spec is None or spec.loader is None: 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: @@ -70,16 +98,19 @@ def load_project_hook() -> ModuleType | None: def _hook_label(hook: ModuleType) -> str: - """The CANDIDATES spelling of the loaded hook file (posix, portable). + """The spelling the loaded hook file was named with (posix, portable). - Errors must name the file that actually loaded, so map the loaded - module's ``__file__`` back to its candidate spelling; unknown - provenance degrades to the bare filename. + 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() diff --git a/tests/test_hook.py b/tests/test_hook.py index 8796461..0594827 100644 --- a/tests/test_hook.py +++ b/tests/test_hook.py @@ -23,6 +23,9 @@ HOOK_DSN = "postgresql+psycopg://u:p@localhost:1/hookunit" ENV_DSN = "postgresql+psycopg://u:p@localhost:1/envunit" FLAG_DSN = "postgresql+psycopg://u:p@localhost:1/flagunit" +CUSTOM_HOOK_DSN = "postgresql+psycopg://u:p@localhost:1/customhook" +ENV_HOOK_DSN = "postgresql+psycopg://u:p@localhost:1/envhook" +FLAG_HOOK_DSN = "postgresql+psycopg://u:p@localhost:1/flaghook" # a parseable-but-never-connected DSN: create_engine is faked in the # DB-free tests; only the string value matters @@ -37,6 +40,14 @@ " return md\n" "CHAIN_DIR = 'migrations/chain'\n" ) +# the same full hook, distinguishable by DSN (for the override tests) +CUSTOM_HOOK = FULL_HOOK.replace(HOOK_DSN, CUSTOM_HOOK_DSN) + + +@pytest.fixture(autouse=True) +def _no_ambient_hook_env(monkeypatch): + # ambient SQLPUSH_HOOK must never leak into discovery/override tests + monkeypatch.delenv("SQLPUSH_HOOK", raising=False) class _FakeEngine: @@ -194,6 +205,113 @@ def test_sys_path_appends_cwd_not_hook_dir(tmp_path, monkeypatch): assert str(mig) not in sys.path # never the hook's own directory +# --- explicit override: --hook flag / SQLPUSH_HOOK ---------------------------- +# adjudicated defaults+override design (the alembic -c equivalent): +# --hook > SQLPUSH_HOOK > candidates; explicit paths FAIL LOUD — an +# instruction that does not resolve is an error, never a fallback. + + +def test_hook_flag_loads_custom_path(tmp_path, monkeypatch): + # --hook names any location: no candidates present, the flag alone + # loads the file and full verb resolution works from it (relative + # path resolves against the CWD) + monkeypatch.chdir(tmp_path) + custom = tmp_path / "custom" + custom.mkdir() + (custom / "hook.py").write_text(CUSTOM_HOOK) + seen: dict = {} + _capture_engine(monkeypatch, seen) + + def fake_plan(md, engine, **kw): + seen["md"] = md + return Plan() # clean → exit 0 + + monkeypatch.setattr(sqlpush.cli.api, "plan", fake_plan) + monkeypatch.delenv("DATABASE_URL", raising=False) + r = runner.invoke(app, ["check", "--hook", "custom/hook.py"]) + assert r.exit_code == 0, r.output + assert seen["dsn"] == CUSTOM_HOOK_DSN + assert "hook_marker_tbl" in seen["md"].tables + + +def test_hook_flag_beats_env_var(tmp_path, monkeypatch): + # both override channels set, distinguishable hooks → flag's wins + monkeypatch.chdir(tmp_path) + (tmp_path / "envhook.py").write_text(f"def get_dsn():\n return {ENV_HOOK_DSN!r}\n") + (tmp_path / "flaghook.py").write_text(f"def get_dsn():\n return {FLAG_HOOK_DSN!r}\n") + monkeypatch.setenv("SQLPUSH_HOOK", str(tmp_path / "envhook.py")) # absolute + seen: dict = {} + _capture_engine(monkeypatch, seen) + monkeypatch.setattr(sqlpush.cli.api, "migrate", lambda *a, **k: MigrateReport()) + r = runner.invoke(app, ["migrate", "--hook", "flaghook.py"]) # relative to CWD + assert r.exit_code == 0, r.output + assert seen["dsn"] == FLAG_HOOK_DSN + + +def test_hook_env_beats_candidates(tmp_path, monkeypatch): + # env names a hook while the root candidate ALSO exists → env's wins + monkeypatch.chdir(tmp_path) + (tmp_path / "sqlpush.py").write_text(FULL_HOOK) + (tmp_path / "envhook.py").write_text(f"def get_dsn():\n return {ENV_HOOK_DSN!r}\n") + monkeypatch.setenv("SQLPUSH_HOOK", "envhook.py") # relative, CWD + seen: dict = {} + _capture_engine(monkeypatch, seen) + monkeypatch.setattr(sqlpush.cli.api, "migrate", lambda *a, **k: MigrateReport()) + r = runner.invoke(app, ["migrate"]) + assert r.exit_code == 0, r.output + assert seen["dsn"] == ENV_HOOK_DSN + + +def test_hook_flag_missing_file_fails_loud(tmp_path, monkeypatch): + # explicit = fail-loud: even with a candidate that WOULD load, the + # unresolvable path errors — never a silent fallback + monkeypatch.chdir(tmp_path) + (tmp_path / "sqlpush.py").write_text(FULL_HOOK) # fallback temptation + seen: dict = {} + _capture_engine(monkeypatch, seen) + monkeypatch.setattr(sqlpush.cli.api, "migrate", lambda *a, **k: MigrateReport()) + r = runner.invoke(app, ["migrate", "--hook", "missing.py"]) + assert r.exit_code == 1 + assert isinstance(r.exception, SqlpushError) + assert str(r.exception).startswith("missing.py: file not found") + assert "dsn" not in seen # nothing loaded, nothing resolved + + +def test_hook_env_missing_file_fails_loud(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("SQLPUSH_HOOK", "missing.py") + r = runner.invoke(app, ["migrate"]) + assert r.exit_code == 1 + assert isinstance(r.exception, SqlpushError) + assert str(r.exception).startswith("missing.py: file not found") + + +def test_custom_path_hook_error_names_loaded_file(tmp_path, monkeypatch): + # errors from an overridden hook name the loaded file in the GIVEN + # spelling (same message contract as the candidates) + monkeypatch.chdir(tmp_path) + custom = tmp_path / "custom" + custom.mkdir() + (custom / "hook.py").write_text("CHAIN_DIR = 'x'\n") # no get_dsn + r = runner.invoke(app, ["migrate", "--hook", "custom/hook.py"]) + assert r.exit_code == 1 + assert isinstance(r.exception, SqlpushError) + assert str(r.exception).startswith("custom/hook.py: missing get_dsn()") + + +def test_override_sys_path_appends_cwd_not_hook_dir(tmp_path, monkeypatch): + # same pin as discovery, via the override channel: the sys.path + # append is the CWD, never the loaded hook's own directory + monkeypatch.chdir(tmp_path) + custom = tmp_path / "custom" + custom.mkdir() + (custom / "hook.py").write_text("def get_dsn():\n return 'postgresql://x'\n") + hook = load_project_hook("custom/hook.py") + assert hook is not None + assert sys.path[-1] == str(tmp_path) # cwd appended LAST + assert str(custom) not in sys.path # never the hook's own directory + + # --- precedence: flag > hook > env ------------------------------------------ From a65006e8eb4cee463c1d34636cc4e7e211980514 Mon Sep 17 00:00:00 2001 From: Juan Miguel Contreras <19253629+juanmicl@users.noreply.github.com> Date: Thu, 3 Sep 2026 15:00:56 +0200 Subject: [PATCH 4/5] docs(readme): hook location override row --- README.md | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 9e6ba9b..374b909 100644 --- a/README.md +++ b/README.md @@ -171,7 +171,8 @@ the workflows. 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 candidate locations, first match wins: +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. @@ -203,6 +204,13 @@ command line, no PYTHONPATH. Inputs resolve with a fixed precedence: | `--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 exact 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 @@ -250,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`. From 8ec2814508a491a7b3c985b073a8b68a87e1a950 Mon Sep 17 00:00:00 2001 From: Juan Miguel Contreras <19253629+juanmicl@users.noreply.github.com> Date: Thu, 3 Sep 2026 15:13:08 +0200 Subject: [PATCH 5/5] test(hook): empty-env and flag-beats-candidates pins; soften path wording --- CHANGELOG.md | 2 +- README.md | 2 +- src/sqlpush/hook.py | 4 ++-- tests/test_hook.py | 27 +++++++++++++++++++++++---- 4 files changed, 27 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c4967bf..36fa794 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,7 +23,7 @@ the project follows [Semantic Versioning](https://semver.org/). 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 exact path (`custom/hook.py: + 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. diff --git a/README.md b/README.md index 374b909..39884f1 100644 --- a/README.md +++ b/README.md @@ -209,7 +209,7 @@ command line, no PYTHONPATH. Inputs resolve with a fixed precedence: 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 exact path (`custom/hook.py: file not found`) +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 diff --git a/src/sqlpush/hook.py b/src/sqlpush/hook.py index 1d30aea..3e0a205 100644 --- a/src/sqlpush/hook.py +++ b/src/sqlpush/hook.py @@ -12,7 +12,7 @@ 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 exact path, never a silent fallback to +: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 @@ -58,7 +58,7 @@ def load_project_hook(hook_path: Path | str | None = None) -> ModuleType | None: 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 exact path — an + 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 diff --git a/tests/test_hook.py b/tests/test_hook.py index 0594827..e569b3e 100644 --- a/tests/test_hook.py +++ b/tests/test_hook.py @@ -212,13 +212,16 @@ def test_sys_path_appends_cwd_not_hook_dir(tmp_path, monkeypatch): def test_hook_flag_loads_custom_path(tmp_path, monkeypatch): - # --hook names any location: no candidates present, the flag alone - # loads the file and full verb resolution works from it (relative - # path resolves against the CWD) + # --hook names any location: the flag alone loads the file and full + # verb resolution works from it (relative path resolves against the + # CWD). The root candidate is ALSO present, carrying a different + # DSN — so this pins flag > candidates directly, not just by + # transitivity through the env-var test. monkeypatch.chdir(tmp_path) custom = tmp_path / "custom" custom.mkdir() (custom / "hook.py").write_text(CUSTOM_HOOK) + (tmp_path / "sqlpush.py").write_text(FULL_HOOK) # candidate temptation seen: dict = {} _capture_engine(monkeypatch, seen) @@ -230,7 +233,7 @@ def fake_plan(md, engine, **kw): monkeypatch.delenv("DATABASE_URL", raising=False) r = runner.invoke(app, ["check", "--hook", "custom/hook.py"]) assert r.exit_code == 0, r.output - assert seen["dsn"] == CUSTOM_HOOK_DSN + assert seen["dsn"] == CUSTOM_HOOK_DSN # the flag's, not the root candidate's assert "hook_marker_tbl" in seen["md"].tables @@ -262,6 +265,22 @@ def test_hook_env_beats_candidates(tmp_path, monkeypatch): assert seen["dsn"] == ENV_HOOK_DSN +def test_hook_env_empty_string_means_unset(tmp_path, monkeypatch): + # real-CI case (systems export empty vars): SQLPUSH_HOOK="" must + # behave as unset — discovery proceeds and the root candidate + # loads, pinned via its distinguishable DSN (an empty Path would + # otherwise fail loud as ".: file not found") + monkeypatch.chdir(tmp_path) + (tmp_path / "sqlpush.py").write_text(FULL_HOOK) + monkeypatch.setenv("SQLPUSH_HOOK", "") + seen: dict = {} + _capture_engine(monkeypatch, seen) + monkeypatch.setattr(sqlpush.cli.api, "migrate", lambda *a, **k: MigrateReport()) + r = runner.invoke(app, ["migrate"]) + assert r.exit_code == 0, r.output + assert seen["dsn"] == HOOK_DSN + + def test_hook_flag_missing_file_fails_loud(tmp_path, monkeypatch): # explicit = fail-loud: even with a candidate that WOULD load, the # unresolvable path errors — never a silent fallback