Skip to content

feat(cli): project hook sqlpush.py — dsn/metadata/chain-dir defaults with flag > hook > env precedence - #8

Merged
juanmicl merged 5 commits into
mainfrom
feat/project-hook
Sep 3, 2026
Merged

feat(cli): project hook sqlpush.py — dsn/metadata/chain-dir defaults with flag > hook > env precedence#8
juanmicl merged 5 commits into
mainfrom
feat/project-hook

Conversation

@juanmicl

@juanmicl juanmicl commented Sep 3, 2026

Copy link
Copy Markdown
Owner

Summary

The 0.6.0 payload: the project hook, sqlpush.py in the CWD (the env.py/conftest.py pattern) delegates metadata, DSN and chain-dir to the consumer's own code. Result: uv run sqlpush revision -m "change" with no --dsn, no module:attribute, no credentials on the command line, no PYTHONPATH.

The hook contract (user-side code)

# sqlpush.py — in the consumer's repo root
def get_metadata():          # required for diff/check/push/revision
    ...
def get_dsn() -> str:        # required for every verb (reads your settings/.env)
    ...
CHAIN_DIR = "migrations/chain"   # optional — default for --dir

Behavior

  • Discovery by path only (importlib.util.spec_from_file_location, never by module name); on discovery the CWD is appended to sys.path (never prepended) so a file named sqlpush.py can never shadow the installed package. Pinned by a subprocess test that asserts import sqlpush resolves to the package both from cache and after a fresh re-import.
  • Fixed precedence per input: explicit flag > hook > $DATABASE_URL > default. --ref-dsn keeps its no-env-fallback isolation (pinned); the hook's get_dsn() beats $DATABASE_URL; without a hook every verb behaves exactly as before (pinned).
  • Lazy: get_dsn()/get_metadata() are called only at the moment a verb needs the value, a hook whose get_metadata raises works fine for migrate (pinned).
  • Typed errors (HookError(SqlpushError) family): messages name the file and the member (sqlpush.py: missing get_dsn(), get_dsn() raised: <original> with __cause__ preserved); exit 1 via the existing handler, never a traceback.
  • Backwards-compatible: the module:attribute positional becomes optional; explicit positionals route through the unchanged loader path.

Docs

README gains a Project hook section (complete example, precedence table with the fail-loud fallback note, typed-error contract, shadowing note). CHANGELOG [Unreleased] Added entry (CLI surface).

Verification

  • 181 passed, 1 xfailed (baseline 166 + 15: 13 feature tests, 2 review-nit pins, 1 in-place strengthening) against the live dev DB; DB-free subset skips cleanly
  • ruff check, ruff format --check, ty check all clean
  • TDD throughout: 12 of 13 feature tests observed red pre-implementation (the 13th is the backwards-compat pin, green by design); the revision env-isolation pin is mutation-checked (removing the ordering guard in cli.py fails it)
  • Review chain: verdict approve for 0.6.0 (shadowing invariant audited and confirmed sound; all 4 nits folded in as 458d7c8 + 41b3ded)

Release plan

Feature release: on merge, cut v0.6.0. Post-publish, the consumer-side verification lands (an sqlpush.py in the atlas repo exercising the one-command workflow).

Any verb run with a sqlpush.py in the CWD loads it by path
(spec_from_file_location, never by module name) and uses it as the
source of defaults: get_dsn() for --dsn/--ref-dsn, get_metadata()
for the module:attribute positional (now optional on
diff/check/push/revision — the object flows straight into the api
calls, no import happens), CHAIN_DIR for --dir. Precedence:
explicit flag > hook > $DATABASE_URL/current default. Missing or
raising members fail as typed HookError (SqlpushError family)
naming the file and member — exit 1 via main(), no traceback. The
CWD is APPENDED to sys.path on discovery, never inserted at the
front: a file named sqlpush.py must not shadow the installed
package (pinned by a subprocess test replicating the console-script
sys.path shape). Without a hook, behavior is identical to 0.5.1
(pinned). hook.py is pure stdlib + SqlpushError.
Complete sqlpush.py example (get_metadata/get_dsn/CHAIN_DIR), the
precedence table (flag > hook > env/default, including which inputs
each verb resolves), the typed-error contract, and the sys.path
shadowing note (append, never prepend). The knobs table now marks
--ref-dsn as required only without a hook.
ruff 0.16 formats Python fences inside Markdown; the Project hook
example needed two-space comment spacing and blank lines between
top-level defs.
…hadowing

Three review nits as test pins:

- revision env-isolation: $DATABASE_URL set + no hook + no --ref-dsn
  must exit 2 with the remedy, and create_engine must never see the
  env DSN — guards the cli.py ordering (ref_dsn checked None before
  _engine) against a silent refactor. Mutation-checked: removing the
  guard so _engine falls back to the env var fails the pin.

- shadowing: the subprocess script now drops sys.modules['sqlpush']
  and re-imports — the fresh PATH-ORDER resolution (CWD appended
  last) must still find the installed package, proving the original
  assert was not a sys.modules cache short-circuit.

- laziness: migrate with a hook whose get_metadata RAISES (get_dsn
  fine, empty chain dir) is a clean idle run — get_metadata() is
  never called for verbs that do not need it.
@juanmicl
juanmicl merged commit dc59041 into main Sep 3, 2026
7 checks passed
@juanmicl
juanmicl deleted the feat/project-hook branch September 3, 2026 12:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant