sqlpush 0.6.0__tar.gz → 0.7.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.6.0
3
+ Version: 0.7.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
@@ -198,12 +198,17 @@ the workflows.
198
198
 
199
199
  ## Project hook
200
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:
201
+ Drop a `sqlpush.py` where sqlpush can find it (the alembic `env.py` /
202
+ pytest `conftest.py` pattern) and the CLI stops needing flags, specs
203
+ and env vars. Two discovered locations, first match wins — or name
204
+ any file explicitly with `--hook`/`$SQLPUSH_HOOK`:
205
+
206
+ 1. `migrations/sqlpush.py` — preferred: it lives next to the chain,
207
+ no repo-root clutter.
208
+ 2. `sqlpush.py` — repo root, kept as the backwards-compat fallback.
204
209
 
205
210
  ```python
206
- # sqlpush.py — in your repo root
211
+ # migrations/sqlpush.py — the preferred location
207
212
  def get_metadata(): # REQUIRED for diff/check/push/revision
208
213
  from myapp.models import metadata
209
214
 
@@ -223,23 +228,33 @@ With that file in place, `uv run sqlpush revision -m "change"` just
223
228
  works: no `--dsn`, no `module:attribute`, no credentials on the
224
229
  command line, no PYTHONPATH. Inputs resolve with a fixed precedence:
225
230
 
226
- | input | explicit flag | `sqlpush.py` | fallback |
231
+ | input | explicit flag | hook | fallback |
227
232
  | --- | --- | --- | --- |
228
233
  | `--dsn` / `--ref-dsn` | wins | `get_dsn()` | `$DATABASE_URL`, only without a hook and never for `--ref-dsn` |
229
234
  | `module:attribute` (diff/check/push/revision) | wins | `get_metadata()` | usage error |
230
235
  | `--dir` (revision/migrate/stamp) | wins | `CHAIN_DIR` | `migrations/versions` |
236
+ | which file is the hook | `--hook PATH` | — | `$SQLPUSH_HOOK`, else first match: `migrations/sqlpush.py`, then root `sqlpush.py` |
237
+
238
+ The hook's location is the alembic `-c` equivalent: `--hook` >
239
+ `$SQLPUSH_HOOK` > discovery, any location you want (relative paths
240
+ resolve against the CWD) — and the explicit forms fail loud, with an
241
+ error naming that path (`custom/hook.py: file not found`)
242
+ instead of falling back to the discovered candidates.
231
243
 
232
244
  A hook that is missing a member a verb needs — or whose
233
245
  `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.
246
+ the file that actually loaded and the member
247
+ (`migrations/sqlpush.py: missing get_dsn()`), never a traceback.
248
+ Without a hook, every verb behaves exactly as before.
237
249
 
238
250
  One deliberate detail: on discovering the hook, sqlpush **appends**
239
- your CWD to `sys.path` instead of prepending it. A file named
251
+ your CWD to `sys.path` instead of prepending it — whatever location
252
+ the hook loaded from, never the hook's own directory. A root
240
253
  `sqlpush.py` would otherwise shadow the installed package the moment
241
254
  you ran the CLI from your repo root; appending guarantees the real
242
- package always wins, and the hook is loaded by path only.
255
+ package always wins, and the hook itself is loaded by path only (the
256
+ `migrations/` candidate has no shadowing concern but loads the same
257
+ way).
243
258
 
244
259
  ## Exit codes
245
260
 
@@ -272,8 +287,10 @@ The knobs, per verb:
272
287
  | `migrate` | `--allow-destructive` `--advisory-wait` `--lock-timeout` `--statement-timeout` `--dir` |
273
288
  | `stamp` | `--force` `--dir` |
274
289
 
275
- Every verb except `revision` takes `--dsn` (or `$DATABASE_URL`, or
276
- the project hook's `get_dsn()`). `revision` takes `--ref-dsn` — or
290
+ Every verb takes `--hook PATH` (or `$SQLPUSH_HOOK`) to name the hook
291
+ file explicitly. Every verb except `revision` takes `--dsn` (or
292
+ `$DATABASE_URL`, or the project hook's `get_dsn()`). `revision`
293
+ takes `--ref-dsn` — or
277
294
  the hook — with no env fallback: the reference DB is a different
278
295
  database from the push target. `diff`, `check`,
279
296
  `push` and `revision` also take repeatable `--schema` / `--exclude`.
@@ -169,12 +169,17 @@ the workflows.
169
169
 
170
170
  ## Project hook
171
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:
172
+ Drop a `sqlpush.py` where sqlpush can find it (the alembic `env.py` /
173
+ pytest `conftest.py` pattern) and the CLI stops needing flags, specs
174
+ and env vars. Two discovered locations, first match wins — or name
175
+ any file explicitly with `--hook`/`$SQLPUSH_HOOK`:
176
+
177
+ 1. `migrations/sqlpush.py` — preferred: it lives next to the chain,
178
+ no repo-root clutter.
179
+ 2. `sqlpush.py` — repo root, kept as the backwards-compat fallback.
175
180
 
176
181
  ```python
177
- # sqlpush.py — in your repo root
182
+ # migrations/sqlpush.py — the preferred location
178
183
  def get_metadata(): # REQUIRED for diff/check/push/revision
179
184
  from myapp.models import metadata
180
185
 
@@ -194,23 +199,33 @@ With that file in place, `uv run sqlpush revision -m "change"` just
194
199
  works: no `--dsn`, no `module:attribute`, no credentials on the
195
200
  command line, no PYTHONPATH. Inputs resolve with a fixed precedence:
196
201
 
197
- | input | explicit flag | `sqlpush.py` | fallback |
202
+ | input | explicit flag | hook | fallback |
198
203
  | --- | --- | --- | --- |
199
204
  | `--dsn` / `--ref-dsn` | wins | `get_dsn()` | `$DATABASE_URL`, only without a hook and never for `--ref-dsn` |
200
205
  | `module:attribute` (diff/check/push/revision) | wins | `get_metadata()` | usage error |
201
206
  | `--dir` (revision/migrate/stamp) | wins | `CHAIN_DIR` | `migrations/versions` |
207
+ | which file is the hook | `--hook PATH` | — | `$SQLPUSH_HOOK`, else first match: `migrations/sqlpush.py`, then root `sqlpush.py` |
208
+
209
+ The hook's location is the alembic `-c` equivalent: `--hook` >
210
+ `$SQLPUSH_HOOK` > discovery, any location you want (relative paths
211
+ resolve against the CWD) — and the explicit forms fail loud, with an
212
+ error naming that path (`custom/hook.py: file not found`)
213
+ instead of falling back to the discovered candidates.
202
214
 
203
215
  A hook that is missing a member a verb needs — or whose
204
216
  `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.
217
+ the file that actually loaded and the member
218
+ (`migrations/sqlpush.py: missing get_dsn()`), never a traceback.
219
+ Without a hook, every verb behaves exactly as before.
208
220
 
209
221
  One deliberate detail: on discovering the hook, sqlpush **appends**
210
- your CWD to `sys.path` instead of prepending it. A file named
222
+ your CWD to `sys.path` instead of prepending it — whatever location
223
+ the hook loaded from, never the hook's own directory. A root
211
224
  `sqlpush.py` would otherwise shadow the installed package the moment
212
225
  you ran the CLI from your repo root; appending guarantees the real
213
- package always wins, and the hook is loaded by path only.
226
+ package always wins, and the hook itself is loaded by path only (the
227
+ `migrations/` candidate has no shadowing concern but loads the same
228
+ way).
214
229
 
215
230
  ## Exit codes
216
231
 
@@ -243,8 +258,10 @@ The knobs, per verb:
243
258
  | `migrate` | `--allow-destructive` `--advisory-wait` `--lock-timeout` `--statement-timeout` `--dir` |
244
259
  | `stamp` | `--force` `--dir` |
245
260
 
246
- Every verb except `revision` takes `--dsn` (or `$DATABASE_URL`, or
247
- the project hook's `get_dsn()`). `revision` takes `--ref-dsn` — or
261
+ Every verb takes `--hook PATH` (or `$SQLPUSH_HOOK`) to name the hook
262
+ file explicitly. Every verb except `revision` takes `--dsn` (or
263
+ `$DATABASE_URL`, or the project hook's `get_dsn()`). `revision`
264
+ takes `--ref-dsn` — or
248
265
  the hook — with no env fallback: the reference DB is a different
249
266
  database from the push target. `diff`, `check`,
250
267
  `push` and `revision` also take repeatable `--schema` / `--exclude`.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sqlpush"
3
- version = "0.6.0"
3
+ version = "0.7.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.6.0"
3
+ version = "0.7.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 = [
@@ -42,6 +42,10 @@ ExcludeOpt = Annotated[list[str] | None, typer.Option("--exclude")]
42
42
  # exempts typer.Option for non-Path annotations. None default: lets the
43
43
  # verb tell "user passed --dir" apart from "use hook/default" resolution.
44
44
  DirOpt = Annotated[Path | None, typer.Option("--dir")]
45
+ # Hook-location override, on EVERY verb (hook loading is shared
46
+ # machinery like --dsn): flag > $SQLPUSH_HOOK > candidates, and an
47
+ # explicit path that does not exist fails loud in the loader.
48
+ HookOpt = Annotated[Path | None, typer.Option("--hook")]
45
49
  DEFAULT_CHAIN_DIR = Path("migrations/versions")
46
50
 
47
51
 
@@ -119,8 +123,9 @@ def diff(
119
123
  quiet: bool = typer.Option(False, "--quiet"),
120
124
  schema: SchemaOpt = None,
121
125
  exclude: ExcludeOpt = None,
126
+ hook_path: HookOpt = None,
122
127
  ):
123
- hook = load_project_hook()
128
+ hook = load_project_hook(hook_path)
124
129
  md = _metadata(metadata_spec, hook)
125
130
  engine = _engine(dsn, hook)
126
131
  try:
@@ -145,8 +150,9 @@ def check(
145
150
  quiet: bool = typer.Option(False, "--quiet"),
146
151
  schema: SchemaOpt = None,
147
152
  exclude: ExcludeOpt = None,
153
+ hook_path: HookOpt = None,
148
154
  ):
149
- hook = load_project_hook()
155
+ hook = load_project_hook(hook_path)
150
156
  md = _metadata(metadata_spec, hook)
151
157
  engine = _engine(dsn, hook)
152
158
  try:
@@ -187,8 +193,9 @@ def push(
187
193
  quiet: bool = typer.Option(False, "--quiet"),
188
194
  schema: SchemaOpt = None,
189
195
  exclude: ExcludeOpt = None,
196
+ hook_path: HookOpt = None,
190
197
  ):
191
- hook = load_project_hook()
198
+ hook = load_project_hook(hook_path)
192
199
  md = _metadata(metadata_spec, hook)
193
200
  engine = _engine(dsn, hook)
194
201
  try:
@@ -264,9 +271,10 @@ def revision(
264
271
  no_concurrently: bool = typer.Option(False, "--no-concurrently"),
265
272
  schema: SchemaOpt = None,
266
273
  exclude: ExcludeOpt = None,
274
+ hook_path: HookOpt = None,
267
275
  ):
268
276
  """Generate the next migration file from models vs the reference DB."""
269
- hook = load_project_hook()
277
+ hook = load_project_hook(hook_path)
270
278
  md = _metadata(metadata_spec, hook)
271
279
  # --ref-dsn: flag > hook.get_dsn(); NO DATABASE_URL fallback (the
272
280
  # reference DB is a different database from the push target —
@@ -305,9 +313,10 @@ def migrate(
305
313
  lock_timeout: float = typer.Option(5.0, "--lock-timeout"),
306
314
  statement_timeout: float | None = typer.Option(None, "--statement-timeout"),
307
315
  out_dir: DirOpt = None,
316
+ hook_path: HookOpt = None,
308
317
  ):
309
318
  """Replay pending migration files (gates + checksum bookkeeping)."""
310
- hook = load_project_hook()
319
+ hook = load_project_hook(hook_path)
311
320
  engine = _engine(dsn, hook)
312
321
  try:
313
322
  report = api.migrate(
@@ -336,9 +345,10 @@ def stamp(
336
345
  dsn: str | None = typer.Option(None),
337
346
  force: bool = typer.Option(False, "--force"),
338
347
  out_dir: DirOpt = None,
348
+ hook_path: HookOpt = None,
339
349
  ):
340
350
  """Adopt an existing DB: register chain files without executing SQL."""
341
- hook = load_project_hook()
351
+ hook = load_project_hook(hook_path)
342
352
  engine = _engine(dsn, hook)
343
353
  try:
344
354
  report = api.stamp(engine, chain_dir=_resolve_dir(out_dir, hook), force=force)
@@ -0,0 +1,147 @@
1
+ # src/sqlpush/hook.py
2
+ """Project hook: ``migrations/sqlpush.py`` first, then ``sqlpush.py``.
3
+
4
+ The alembic ``env.py`` / pytest ``conftest.py`` pattern: a hook file
5
+ next to where the user runs the CLI becomes the source of defaults for
6
+ metadata, DSN and chain dir. Explicit flags always win (flag > hook >
7
+ env/default); without the hook every verb behaves exactly as before.
8
+
9
+ Discovery checks ``migrations/sqlpush.py`` first (the preferred
10
+ location — it lives next to the chain, no root clutter) and falls
11
+ back to the repo-root ``sqlpush.py`` (backwards compat); first match
12
+ wins. An explicit override — the ``--hook`` flag or the
13
+ ``SQLPUSH_HOOK`` env var (the alembic ``-c`` equivalent) — names any
14
+ file instead, and is fail-loud: a path that does not exist raises a
15
+ :class:`HookError` naming that path, never a silent fallback to
16
+ the candidates. Precedence: flag > env > candidates.
17
+
18
+ Loading is BY PATH (``spec_from_file_location``), never by module
19
+ name: a file named ``sqlpush.py`` in the CWD would shadow the
20
+ installed package if the CWD came first on ``sys.path`` — so the CWD
21
+ is APPENDED (never ``insert(0)``), and always the CWD, never the
22
+ loaded file's own directory (the append exists so the consumer's
23
+ package imports resolve). This guarantees the package wins for
24
+ normal imports while the hook itself is only ever loaded explicitly.
25
+
26
+ Only typed :class:`SqlpushError`-family errors escape this module,
27
+ always naming the file that actually loaded and the member involved.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import importlib.util
33
+ import os
34
+ import sys
35
+ from pathlib import Path
36
+ from types import ModuleType
37
+
38
+ from sqlpush.types import SqlpushError
39
+
40
+ HOOK_FILENAME = "sqlpush.py"
41
+ HOOK_ENV_VAR = "SQLPUSH_HOOK"
42
+ # first match wins: migrations/ (next to the chain) preferred, the
43
+ # repo root kept as the backwards-compat fallback
44
+ CANDIDATES = (Path("migrations") / HOOK_FILENAME, Path(HOOK_FILENAME))
45
+ # resolved loaded path -> the spelling it was named with (candidate or
46
+ # override path): _hook_label uses it so override-loaded hooks error in
47
+ # the GIVEN spelling, matching the candidate message contract
48
+ _LOADED_LABELS: dict[Path, str] = {}
49
+
50
+
51
+ class HookError(SqlpushError):
52
+ """The project hook is broken or incomplete — names file + member."""
53
+
54
+
55
+ def load_project_hook(hook_path: Path | str | None = None) -> ModuleType | None:
56
+ """Load the project hook; ``None`` when nothing resolves.
57
+
58
+ Precedence: ``hook_path`` (the ``--hook`` flag) > ``$SQLPUSH_HOOK``
59
+ > the first existing candidate (``migrations/sqlpush.py``, then the
60
+ repo root). An explicit path (flag or env) is fail-loud: when it
61
+ does not exist a :class:`HookError` names that path — an
62
+ explicit instruction must not silently fall back to discovery.
63
+ Relative paths resolve against the CWD. On success the CWD is
64
+ appended to ``sys.path`` (never inserted at the front, and never
65
+ the loaded file's own directory — see the module docstring). Any
66
+ import-time failure is re-typed as :class:`HookError` naming the
67
+ file that failed, in the spelling it was named with.
68
+ """
69
+ explicit: Path | None = None
70
+ if hook_path is not None:
71
+ explicit = Path(hook_path)
72
+ else:
73
+ env_value = os.environ.get(HOOK_ENV_VAR)
74
+ if env_value: # empty string ≡ unset
75
+ explicit = Path(env_value)
76
+ if explicit is not None:
77
+ if not explicit.is_file():
78
+ raise HookError(f"{explicit.as_posix()}: file not found")
79
+ return _load_hook_file(explicit)
80
+ hook_file = next((p for p in CANDIDATES if p.is_file()), None)
81
+ if hook_file is None:
82
+ return None
83
+ return _load_hook_file(hook_file)
84
+
85
+
86
+ def _load_hook_file(hook_file: Path) -> ModuleType:
87
+ sys.path.append(os.getcwd())
88
+ spec = importlib.util.spec_from_file_location("sqlpush_project_hook", hook_file)
89
+ if spec is None or spec.loader is None:
90
+ raise HookError(f"{hook_file.as_posix()}: cannot load (invalid module spec)")
91
+ module = importlib.util.module_from_spec(spec)
92
+ _LOADED_LABELS[hook_file.resolve()] = hook_file.as_posix()
93
+ try:
94
+ spec.loader.exec_module(module)
95
+ except Exception as exc:
96
+ raise HookError(f"{hook_file.as_posix()}: import raised: {exc}") from exc
97
+ return module
98
+
99
+
100
+ def _hook_label(hook: ModuleType) -> str:
101
+ """The spelling the loaded hook file was named with (posix, portable).
102
+
103
+ Errors must name the file that actually loaded: map the loaded
104
+ module's ``__file__`` back to its load-time spelling (candidate or
105
+ override path); unknown provenance degrades to the bare filename.
106
+ """
107
+ loaded_file = getattr(hook, "__file__", None)
108
+ if not loaded_file:
109
+ return HOOK_FILENAME
110
+ loaded = Path(loaded_file).resolve()
111
+ label = _LOADED_LABELS.get(loaded)
112
+ if label is not None:
113
+ return label
114
+ for candidate in CANDIDATES:
115
+ if (Path.cwd() / candidate).resolve() == loaded:
116
+ return candidate.as_posix()
117
+ return HOOK_FILENAME
118
+
119
+
120
+ def hook_dsn(hook: ModuleType) -> str:
121
+ """Call ``get_dsn()`` LAZILY — only when a verb actually needs it."""
122
+ label = _hook_label(hook)
123
+ getter = getattr(hook, "get_dsn", None)
124
+ if getter is None:
125
+ raise HookError(f"{label}: missing get_dsn()")
126
+ try:
127
+ return getter()
128
+ except Exception as exc:
129
+ raise HookError(f"{label}: get_dsn() raised: {exc}") from exc
130
+
131
+
132
+ def hook_metadata(hook: ModuleType):
133
+ """Call ``get_metadata()`` LAZILY — returns the populated MetaData object."""
134
+ label = _hook_label(hook)
135
+ getter = getattr(hook, "get_metadata", None)
136
+ if getter is None:
137
+ raise HookError(f"{label}: missing get_metadata()")
138
+ try:
139
+ return getter()
140
+ except Exception as exc:
141
+ raise HookError(f"{label}: get_metadata() raised: {exc}") from exc
142
+
143
+
144
+ def hook_chain_dir(hook: ModuleType | None, default: str) -> str:
145
+ """Read ``CHAIN_DIR`` as a module attribute; ``default`` when unset."""
146
+ value = getattr(hook, "CHAIN_DIR", None) if hook is not None else None
147
+ return default if value is None else value
@@ -1,84 +0,0 @@
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