diff --git a/CHANGELOG.md b/CHANGELOG.md index 147488d..36fa794 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 554a031..39884f1 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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`. 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 9b7adc8..3e0a205 100644 --- a/src/sqlpush/hook.py +++ b/src/sqlpush/hook.py @@ -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 @@ -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: diff --git a/tests/test_hook.py b/tests/test_hook.py index a5d058e..e569b3e 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() @@ -22,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 @@ -36,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: @@ -110,6 +122,215 @@ 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 + + +# --- 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: 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) + + 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 # the flag's, not the root candidate's + 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_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 + 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 ------------------------------------------