backdraft 0.2.0__py3-none-any.whl

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.
Files changed (50) hide show
  1. backdraft/__init__.py +13 -0
  2. backdraft/bind/__init__.py +37 -0
  3. backdraft/bind/binder.py +653 -0
  4. backdraft/bind/cli.py +133 -0
  5. backdraft/bind/evidence.py +232 -0
  6. backdraft/bind/verify/__init__.py +30 -0
  7. backdraft/bind/verify/base.py +70 -0
  8. backdraft/bind/verify/entail.py +156 -0
  9. backdraft/bind/verify/overlap.py +114 -0
  10. backdraft/bind/verify/recompute.py +45 -0
  11. backdraft/bind/verify/value_trace.py +352 -0
  12. backdraft/cli.py +341 -0
  13. backdraft/cli_context.py +152 -0
  14. backdraft/credentials.py +106 -0
  15. backdraft/extract/__init__.py +37 -0
  16. backdraft/extract/base.py +184 -0
  17. backdraft/extract/pdf_text.py +124 -0
  18. backdraft/extract/text.py +44 -0
  19. backdraft/extract/vlm.py +175 -0
  20. backdraft/extract/vlm_settings.py +197 -0
  21. backdraft/extract/xlsx.py +244 -0
  22. backdraft/gate/__init__.py +39 -0
  23. backdraft/gate/cli.py +131 -0
  24. backdraft/gate/reader.py +592 -0
  25. backdraft/gate/searcher.py +123 -0
  26. backdraft/kernel/__init__.py +17 -0
  27. backdraft/kernel/artifact.py +234 -0
  28. backdraft/kernel/chunking.py +162 -0
  29. backdraft/kernel/claims.py +142 -0
  30. backdraft/kernel/errors.py +35 -0
  31. backdraft/kernel/hashing.py +89 -0
  32. backdraft/kernel/model.py +336 -0
  33. backdraft/kernel/tokens.py +291 -0
  34. backdraft/registry/__init__.py +39 -0
  35. backdraft/registry/ledger.py +82 -0
  36. backdraft/registry/schema.sql +90 -0
  37. backdraft/registry/store.py +915 -0
  38. backdraft/render/__init__.py +31 -0
  39. backdraft/render/_text.py +69 -0
  40. backdraft/render/cli.py +107 -0
  41. backdraft/render/footnotes.py +172 -0
  42. backdraft/render/html.py +1211 -0
  43. backdraft/render/markdown.py +313 -0
  44. backdraft/render/placement.py +105 -0
  45. backdraft/render/sidecar.py +164 -0
  46. backdraft-0.2.0.dist-info/METADATA +253 -0
  47. backdraft-0.2.0.dist-info/RECORD +50 -0
  48. backdraft-0.2.0.dist-info/WHEEL +4 -0
  49. backdraft-0.2.0.dist-info/entry_points.txt +2 -0
  50. backdraft-0.2.0.dist-info/licenses/LICENSE +21 -0
backdraft/cli.py ADDED
@@ -0,0 +1,341 @@
1
+ """The CLI — the system's first substrate (SPEC.md § Addendum B).
2
+
3
+ This module owns the typer app, the `init` / `ingest` / `ls` / `export`
4
+ commands, and the mounts. The context every command shares — registry discovery,
5
+ session resolution, the exit codes and the error guard — lives in
6
+ `cli_context.py`, which the sub-apps import directly; the names are re-exported
7
+ here because `cli.find_root`, `cli.open_registry` and `cli.resolve_session` are
8
+ what the spec and the tests call the CLI's surface.
9
+
10
+ The gate, bind and render workstreams each ship an `app = typer.Typer()` that is
11
+ mounted here; each mount is guarded, so a partial checkout still runs the
12
+ commands it does have.
13
+
14
+ Exit codes: 0 clean, 1 usage or environment error, 2 `bind` completing with
15
+ non-resolved citations (so a hook can gate on it).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ from pathlib import Path
22
+ from typing import Annotated, Iterable
23
+
24
+ import typer
25
+
26
+ from .cli_context import (
27
+ DEFAULT_SESSION,
28
+ EXIT_UNRESOLVED,
29
+ EXIT_USAGE,
30
+ HOME_ENV,
31
+ SESSION_ENV,
32
+ UsageError,
33
+ find_root,
34
+ guard,
35
+ open_registry,
36
+ opened_registry,
37
+ resolve_session,
38
+ )
39
+ from .extract import vlm_ready
40
+ from .registry import DIRECTORY, Registry
41
+
42
+ __all__ = [
43
+ "app",
44
+ "main",
45
+ # re-exported from cli_context: this module is the CLI's front door
46
+ "DEFAULT_SESSION",
47
+ "EXIT_UNRESOLVED",
48
+ "EXIT_USAGE",
49
+ "HOME_ENV",
50
+ "SESSION_ENV",
51
+ "find_root",
52
+ "open_registry",
53
+ "resolve_session",
54
+ ]
55
+
56
+ app = typer.Typer(
57
+ name="backdraft",
58
+ help="Drop-in provenance for factual claims.",
59
+ no_args_is_help=True,
60
+ add_completion=False,
61
+ )
62
+
63
+
64
+ _ENV_TEMPLATE = """\
65
+ # Backdraft reads credentials only from here, BACKDRAFT_* variables, or --config.
66
+ # Ambient provider keys (OPENAI_API_KEY, ...) are never read.
67
+ BACKDRAFT_VLM_API_KEY=
68
+ BACKDRAFT_ENTAIL_API_KEY=
69
+ """
70
+
71
+ # ---- commands ---------------------------------------------------------------
72
+
73
+
74
+ @app.command()
75
+ def init(
76
+ directory: Annotated[
77
+ Path | None,
78
+ typer.Argument(help="Project root to initialize. Defaults to the current directory."),
79
+ ] = None,
80
+ ) -> None:
81
+ """Create `.backdraft/` here and print the registry's status."""
82
+ root = (directory or Path.cwd()).resolve()
83
+ with guard():
84
+ registry = Registry.open(root)
85
+ try:
86
+ documents = registry.documents()
87
+ finally:
88
+ registry.close()
89
+ env_path = root / DIRECTORY / "env"
90
+ if not env_path.exists():
91
+ env_path.write_text(_ENV_TEMPLATE, encoding="utf-8")
92
+ typer.echo(f"registry: {root / DIRECTORY}")
93
+ typer.echo(f"documents: {len(documents)}")
94
+ typer.echo(f"settings: {env_path} (credentials go here, deliberately)")
95
+ typer.echo(
96
+ "next: ingest sources, then have an agent write against them — "
97
+ "the backdraft skill (skills/backdraft) is the writing contract."
98
+ )
99
+
100
+
101
+ @app.command()
102
+ def ingest(
103
+ files: Annotated[list[Path], typer.Argument(help="Files to ingest.")],
104
+ extractor: Annotated[str, typer.Option("--extractor", help="Extractor name, or `auto`.")] = (
105
+ "auto"
106
+ ),
107
+ slug: Annotated[
108
+ str | None, typer.Option("--slug", help="Slug for a new document. One file only.")
109
+ ] = None,
110
+ config: Annotated[
111
+ list[str] | None,
112
+ typer.Option("--config", help="Extractor config as `key=value`. Repeatable."),
113
+ ] = None,
114
+ ) -> None:
115
+ """Snapshot files into the registry, minting their anchors."""
116
+ nudge_vlm = False
117
+ with guard():
118
+ if slug is not None and len(files) > 1:
119
+ raise UsageError("--slug names one document; pass one file")
120
+ settings = _parse_config(config or [])
121
+ with opened_registry() as registry:
122
+ for path in files:
123
+ document = registry.ingest(
124
+ path, extractor=extractor, slug=slug, config=settings
125
+ )
126
+ pages = registry.pages(document.slug)
127
+ typer.echo(
128
+ f"{document.slug} {document.filename} "
129
+ f"{document.media_type} {len(pages)} pages"
130
+ )
131
+ nudge_vlm = nudge_vlm or (
132
+ extractor == "auto"
133
+ and document.media_type == "pdf"
134
+ and not vlm_ready(settings)
135
+ )
136
+ if nudge_vlm:
137
+ # One line, once per invocation: `auto` fell back to the text layer,
138
+ # and the note names the condition that failed.
139
+ typer.echo(f"note: extracted with pdf-text (the embedded text layer). {_vlm_gap()}")
140
+
141
+
142
+ @app.command("snapshot-pages")
143
+ def snapshot_pages(
144
+ slug: Annotated[str, typer.Argument(help="An ingested PDF's slug.")],
145
+ file: Annotated[
146
+ Path | None,
147
+ typer.Option("--file", help="The source PDF, when it moved since ingest."),
148
+ ] = None,
149
+ dpi: Annotated[int, typer.Option("--dpi", help="Render resolution.")] = 200,
150
+ ) -> None:
151
+ """Backfill page snapshots for an already-ingested PDF. Local, no model calls.
152
+
153
+ Ingesting through the VLM extractor stores each page's image automatically;
154
+ this command adds them to registries created before that, or to text-layer
155
+ ingests, so `bind` can embed the cited pages into the artifact. Requires the
156
+ `[vlm]` extra for PDF rendering.
157
+ """
158
+ with guard():
159
+ try:
160
+ import io
161
+
162
+ from PIL import Image # noqa: F401 (pdf2image needs it anyway)
163
+ from pdf2image import convert_from_path
164
+ except ImportError as error:
165
+ raise UsageError(
166
+ "snapshot-pages needs the [vlm] extra for PDF rendering: "
167
+ f"pip install 'backdraft[vlm]' ({error})"
168
+ ) from error
169
+ from .extract.vlm_settings import MAX_IMAGE_HEIGHT, SNAPSHOT_QUALITY
170
+
171
+ with opened_registry() as registry:
172
+ document = registry.document(slug)
173
+ if document is None:
174
+ raise UsageError(f"no document with slug {slug!r}")
175
+ if document.media_type != "pdf":
176
+ raise UsageError(f"{slug} is {document.media_type}, not a PDF")
177
+ source = file or Path(document.path)
178
+ if not source.is_file():
179
+ raise UsageError(
180
+ f"source file not found at {source}; pass --file to point at it"
181
+ )
182
+ extraction_id = registry.current_extraction_id(slug)
183
+ if extraction_id is None:
184
+ raise UsageError(f"{slug} has no current extraction")
185
+ pages = registry.pages(slug)
186
+ for page in pages:
187
+ images = convert_from_path(
188
+ str(source), dpi=dpi, fmt="png",
189
+ first_page=page.number, last_page=page.number,
190
+ )
191
+ image = images[0]
192
+ if image.height > MAX_IMAGE_HEIGHT:
193
+ scale = MAX_IMAGE_HEIGHT / image.height
194
+ image = image.resize(
195
+ (max(1, round(image.width * scale)), MAX_IMAGE_HEIGHT)
196
+ )
197
+ buffer = io.BytesIO()
198
+ image.save(buffer, format="WEBP", quality=SNAPSHOT_QUALITY)
199
+ registry.save_page_image(
200
+ extraction_id, page.number,
201
+ data=buffer.getvalue(), format="webp",
202
+ width=image.width, height=image.height,
203
+ )
204
+ typer.echo(f"{slug} p{page.number} {image.width}x{image.height}")
205
+ typer.echo(f"stored {len(pages)} page snapshot(s)")
206
+
207
+
208
+ @app.command()
209
+ def clean(
210
+ directory: Annotated[
211
+ Path | None,
212
+ typer.Argument(help="Directory to tidy. Defaults to the current directory."),
213
+ ] = None,
214
+ ) -> None:
215
+ """Tidy a working directory: move stray records into `.backdraft/records/`
216
+ and delete leftover `.bound.md` projections.
217
+
218
+ Everything removed or moved is regenerable with `backdraft bind`; artifacts
219
+ (`.backdraft.html`) and authored documents are never touched.
220
+ """
221
+ from .kernel.artifact import BOUND_SUFFIX, SIDECAR_SUFFIX, record_path
222
+
223
+ with guard():
224
+ base = (directory or Path.cwd()).resolve()
225
+ root = find_root(base)
226
+ touched = 0
227
+ for stray in sorted(base.glob(f"*{BOUND_SUFFIX}")):
228
+ stray.unlink()
229
+ typer.echo(f"removed {stray.name}")
230
+ touched += 1
231
+ for stray in sorted(base.glob(f"*{SIDECAR_SUFFIX}")):
232
+ doc_stem = stray.name.removesuffix(SIDECAR_SUFFIX)
233
+ target = record_path(root, base / f"{doc_stem}.md")
234
+ target.parent.mkdir(parents=True, exist_ok=True)
235
+ stray.replace(target)
236
+ typer.echo(f"moved {stray.name} -> {target.relative_to(root)}")
237
+ touched += 1
238
+ if not touched:
239
+ typer.echo("nothing to tidy")
240
+
241
+
242
+ @app.command("ls")
243
+ def list_documents() -> None:
244
+ """List the ingested documents: slug, filename, media type, page count."""
245
+ with opened_registry() as registry:
246
+ documents = registry.documents()
247
+ if not documents:
248
+ typer.echo("no documents ingested")
249
+ return
250
+ for document in documents:
251
+ pages = registry.pages(document.slug)
252
+ typer.echo(
253
+ f"{document.slug}\t{document.filename}\t{document.media_type}\t"
254
+ f"{len(pages)} pages"
255
+ )
256
+
257
+
258
+ @app.command()
259
+ def export(
260
+ out: Annotated[
261
+ Path | None, typer.Option("--out", "-o", help="Write here instead of stdout.")
262
+ ] = None,
263
+ ) -> None:
264
+ """Export the whole registry as JSON, every generation included."""
265
+ with opened_registry() as registry:
266
+ payload = json.dumps(registry.export_json(), indent=2, ensure_ascii=False)
267
+ if out is None:
268
+ typer.echo(payload)
269
+ else:
270
+ out.write_text(payload + "\n", encoding="utf-8")
271
+ typer.echo(f"wrote {out}")
272
+
273
+
274
+ def _vlm_gap() -> str:
275
+ """Which condition keeps `auto` off the vision model — key, extra, or both."""
276
+ from .credentials import setting
277
+ from .extract.base import ExtractionError, get
278
+
279
+ has_key = bool(setting("BACKDRAFT_VLM_API_KEY"))
280
+ try:
281
+ get("vlm")
282
+ has_extra = True
283
+ except ExtractionError:
284
+ has_extra = False
285
+ if not has_key and not has_extra:
286
+ return (
287
+ "Glossy or scanned PDFs extract better through a vision model: "
288
+ "install backdraft[vlm] and set BACKDRAFT_VLM_API_KEY in .backdraft/env."
289
+ )
290
+ if not has_key:
291
+ return (
292
+ "The [vlm] extra is installed but BACKDRAFT_VLM_API_KEY is not set — "
293
+ "add it to .backdraft/env to use the vision model."
294
+ )
295
+ return (
296
+ "BACKDRAFT_VLM_API_KEY is set but the [vlm] extra is not installed — "
297
+ "install backdraft[vlm] to use the vision model."
298
+ )
299
+
300
+
301
+ def _parse_config(pairs: Iterable[str]) -> dict:
302
+ """`key=value` strings into a config dict. Values stay strings."""
303
+ settings: dict[str, str] = {}
304
+ for pair in pairs:
305
+ key, separator, value = pair.partition("=")
306
+ if not separator or not key:
307
+ raise UsageError(f"--config expects key=value, got {pair!r}")
308
+ settings[key] = value
309
+ return settings
310
+
311
+
312
+ # ---- sub-app mounts ---------------------------------------------------------
313
+
314
+
315
+ def _mount(module: str) -> bool:
316
+ """Merge a workstream's sub-app into this one. False if it isn't built yet.
317
+
318
+ The spec's commands are flat — `backdraft read`, not `backdraft gate read` —
319
+ so a sub-app's commands are adopted rather than nested. A missing module is a
320
+ partial checkout, not an error.
321
+ """
322
+ try:
323
+ sub = __import__(module, fromlist=["app"]).app
324
+ except ImportError:
325
+ return False
326
+ app.registered_commands.extend(sub.registered_commands)
327
+ app.registered_groups.extend(sub.registered_groups)
328
+ return True
329
+
330
+
331
+ for _module in ("backdraft.gate.cli", "backdraft.bind.cli", "backdraft.render.cli"):
332
+ _mount(_module)
333
+
334
+
335
+ def main() -> None:
336
+ """Console-script entry point."""
337
+ app()
338
+
339
+
340
+ if __name__ == "__main__": # pragma: no cover
341
+ main()
@@ -0,0 +1,152 @@
1
+ """What every command needs before it can do anything: a registry and a session.
2
+
3
+ Discovery, session resolution, the exit codes and the one error guard live here
4
+ rather than in `cli.py` so that the sub-apps can import them the ordinary way.
5
+ The top level imports `gate.cli`, `bind.cli` and `render.cli` in order to mount
6
+ them (SPEC Addendum B), so anything they read off `cli.py` has to be fetched
7
+ lazily inside each command — which is how three copies of the same lookup grew.
8
+ This module breaks the cycle instead: it imports typer, the kernel and the
9
+ registry, and **never a sub-app**, so it is importable from anywhere.
10
+
11
+ `cli.py` re-exports these names, because `cli.find_root` / `cli.open_registry` /
12
+ `cli.resolve_session` are what the spec, the tests and the docs already call the
13
+ CLI's own surface. This module is the definition; `cli.py` is the front door.
14
+
15
+ Exit codes (SPEC § CLI): 0 clean, 1 usage or environment error, 2 bind completed
16
+ with non-resolved citations.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import os
22
+ from contextlib import contextmanager
23
+ from pathlib import Path
24
+ from typing import TYPE_CHECKING, NoReturn
25
+
26
+ import typer
27
+
28
+ from .kernel.errors import BackdraftError
29
+ from .registry import DIRECTORY, Registry
30
+
31
+ if TYPE_CHECKING:
32
+ from collections.abc import Iterator
33
+
34
+ __all__ = [
35
+ "DEFAULT_SESSION",
36
+ "EXIT_USAGE",
37
+ "EXIT_UNRESOLVED",
38
+ "HOME_ENV",
39
+ "SESSION_ENV",
40
+ "UsageError",
41
+ "fail",
42
+ "find_root",
43
+ "guard",
44
+ "open_registry",
45
+ "opened_registry",
46
+ "resolve_session",
47
+ ]
48
+
49
+ HOME_ENV = "BACKDRAFT_HOME"
50
+ SESSION_ENV = "BACKDRAFT_SESSION"
51
+
52
+ DEFAULT_SESSION = "default"
53
+ """The auto-created session. Stable across invocations, so reads accumulate."""
54
+
55
+ EXIT_USAGE = 1
56
+ """Usage or environment error."""
57
+
58
+ EXIT_UNRESOLVED = 2
59
+ """`bind` completed and something did not resolve. Owned by `bind`; hooks gate on it."""
60
+
61
+
62
+ class UsageError(BackdraftError):
63
+ """The command cannot run as asked: no registry, no such file, bad flag.
64
+
65
+ A domain error like any other, so it travels out of a helper and is turned
66
+ into a message and an exit code in exactly one place (`guard`).
67
+ """
68
+
69
+
70
+ # ---- discovery --------------------------------------------------------------
71
+
72
+
73
+ def find_root(start: Path | None = None) -> Path | None:
74
+ """The nearest directory containing `.backdraft/`, walking up from `start`.
75
+
76
+ `BACKDRAFT_HOME` overrides the walk. It may name either the project root or
77
+ the `.backdraft` directory itself — NOTE: the spec does not say which, and
78
+ guessing wrong is a confusing failure, so both are accepted.
79
+ """
80
+ override = os.environ.get(HOME_ENV)
81
+ if override:
82
+ home = Path(override).expanduser()
83
+ return home.parent if home.name == DIRECTORY else home
84
+ current = (start or Path.cwd()).resolve()
85
+ for candidate in (current, *current.parents):
86
+ if (candidate / DIRECTORY).is_dir():
87
+ return candidate
88
+ return None
89
+
90
+
91
+ def open_registry(start: Path | None = None) -> Registry:
92
+ """Open the discovered registry, or raise `UsageError` for `guard` to report."""
93
+ root = find_root(start)
94
+ if root is None:
95
+ raise UsageError(
96
+ f"no {DIRECTORY}/ found in this directory or any parent; run `backdraft init`"
97
+ )
98
+ return Registry.open(root)
99
+
100
+
101
+ def resolve_session(session: str | None = None, registry: Registry | None = None) -> str:
102
+ """The session this invocation belongs to.
103
+
104
+ Precedence: `--session` flag, then `BACKDRAFT_SESSION`, then the default
105
+ session. Pure name resolution — pass `registry` to also create the session
106
+ row. The gate's reader/searcher ensure the session themselves, so their CLI
107
+ passes no registry. Shared with the gate and bind sub-apps so every command
108
+ agrees on which ledger it is writing to.
109
+ """
110
+ chosen = session or os.environ.get(SESSION_ENV) or DEFAULT_SESSION
111
+ if registry is not None:
112
+ registry.ensure_session(chosen)
113
+ return chosen
114
+
115
+
116
+ # ---- the error path ---------------------------------------------------------
117
+
118
+
119
+ def fail(message: str, code: int = EXIT_USAGE) -> NoReturn:
120
+ """One line on stderr, then exit. The only place a command prints an error."""
121
+ typer.echo(f"backdraft: {message}", err=True)
122
+ raise typer.Exit(code)
123
+
124
+
125
+ @contextmanager
126
+ def guard(code: int = EXIT_USAGE) -> Iterator[None]:
127
+ """Turn any `BackdraftError` raised inside into a message and an exit code.
128
+
129
+ Libraries raise; the CLI maps — once, here. Every subclass is covered, so a
130
+ `GateError`, an `ExtractionError`, a `RegistryError` and a `UsageError` all
131
+ reach the user as one line rather than a traceback, and a command that wants
132
+ a different exit code passes one instead of writing its own handler.
133
+ """
134
+ try:
135
+ yield
136
+ except BackdraftError as error:
137
+ fail(str(error), code)
138
+
139
+
140
+ @contextmanager
141
+ def opened_registry(start: Path | None = None) -> Iterator[Registry]:
142
+ """The discovered registry, guarded and always closed.
143
+
144
+ The shape almost every command wants: discovery failures and domain errors
145
+ raised in the body both become exit 1, and the connection closes either way.
146
+ """
147
+ with guard():
148
+ registry = open_registry(start)
149
+ try:
150
+ yield registry
151
+ finally:
152
+ registry.close()
@@ -0,0 +1,106 @@
1
+ """Backdraft-scoped settings: the only way a credential reaches this tool.
2
+
3
+ The rule, decided after a real incident: **backdraft never reads a credential it
4
+ did not explicitly ask you to write.** Ambient provider variables — an
5
+ `OPENAI_API_KEY` exported for some other tool, an `ANTHROPIC_API_KEY` in a
6
+ dotfile — are never consulted. Presence of a generic key in the environment is
7
+ not consent to spend it or to send documents to its provider.
8
+
9
+ A setting is looked up in three places, most explicit first:
10
+
11
+ 1. the `--config key=value` dict a command was invoked with;
12
+ 2. a `BACKDRAFT_<NAME>` environment variable — still env, but exporting a
13
+ backdraft-prefixed variable is unambiguous intent *for this tool*;
14
+ 3. a `.backdraft/env` file — plain `KEY=VALUE` lines, written deliberately,
15
+ living inside the state directory that is already gitignored.
16
+
17
+ Names in use: `BACKDRAFT_VLM_API_KEY`, `BACKDRAFT_VLM_MODEL`,
18
+ `BACKDRAFT_VLM_BASE_URL`, `BACKDRAFT_ENTAIL_API_KEY`, `BACKDRAFT_ENTAIL_MODEL`.
19
+ The same names are used verbatim in the env file.
20
+
21
+ Stdlib only, no imports from the package: this module sits beside the kernel so
22
+ that `extract` and `bind` can both use it without a sideways import.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import os
28
+ from pathlib import Path
29
+
30
+ __all__ = ["setting", "env_file", "ENV_FILE_NAME"]
31
+
32
+ ENV_FILE_NAME = "env"
33
+ _STATE_DIR = ".backdraft"
34
+ _PREFIX = "BACKDRAFT_"
35
+
36
+
37
+ def setting(
38
+ name: str,
39
+ config: dict | None = None,
40
+ *,
41
+ config_key: str | None = None,
42
+ start: Path | None = None,
43
+ ) -> str | None:
44
+ """The value of the backdraft-scoped setting `name`, or None.
45
+
46
+ `name` is the full variable name (`BACKDRAFT_VLM_API_KEY`). `config_key`
47
+ names the `--config` dict key that overrides it (`api_key`); omitted means
48
+ the setting has no per-invocation form. Precedence: config → environment →
49
+ `.backdraft/env` discovered from `start` (default: cwd, honoring
50
+ `BACKDRAFT_HOME`).
51
+ """
52
+ if not name.startswith(_PREFIX): # pragma: no cover - caller bug, fail loud
53
+ raise ValueError(f"not a backdraft-scoped name: {name!r}")
54
+ if config_key is not None and config and config.get(config_key):
55
+ return str(config[config_key])
56
+ if value := os.environ.get(name):
57
+ return value
58
+ return _read_env_file(start).get(name)
59
+
60
+
61
+ def env_file(start: Path | None = None) -> Path | None:
62
+ """The `.backdraft/env` file that governs `start` (default cwd), if any.
63
+
64
+ `BACKDRAFT_HOME` overrides discovery, accepting either the project root or
65
+ the `.backdraft` directory itself — the same rule the CLI uses for the
66
+ registry.
67
+ """
68
+ override = os.environ.get("BACKDRAFT_HOME")
69
+ if override:
70
+ home = Path(override).expanduser()
71
+ root = home.parent if home.name == _STATE_DIR else home
72
+ candidate = root / _STATE_DIR / ENV_FILE_NAME
73
+ return candidate if candidate.is_file() else None
74
+ current = (start or Path.cwd()).resolve()
75
+ for parent in (current, *current.parents):
76
+ candidate = parent / _STATE_DIR / ENV_FILE_NAME
77
+ if candidate.is_file():
78
+ return candidate
79
+ return None
80
+
81
+
82
+ def _read_env_file(start: Path | None) -> dict[str, str]:
83
+ """Parse the governing env file: `KEY=VALUE` lines, `#` comments, optional
84
+ single or double quotes around the value. Unreadable or absent → empty."""
85
+ path = env_file(start)
86
+ if path is None:
87
+ return {}
88
+ try:
89
+ text = path.read_text(encoding="utf-8")
90
+ except OSError:
91
+ return {}
92
+ values: dict[str, str] = {}
93
+ for line in text.splitlines():
94
+ line = line.strip()
95
+ if not line or line.startswith("#"):
96
+ continue
97
+ key, separator, value = line.partition("=")
98
+ if not separator:
99
+ continue
100
+ key = key.strip()
101
+ value = value.strip()
102
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
103
+ value = value[1:-1]
104
+ if key.startswith(_PREFIX):
105
+ values[key] = value
106
+ return values
@@ -0,0 +1,37 @@
1
+ """Extractors: files in, ordered pages out.
2
+
3
+ The protocol and the registry live in `base`; the built-ins (`text`, `pdf-text`,
4
+ `xlsx`) and the `[vlm]` extra are imported on demand by `base.get`, so importing
5
+ this package costs nothing but the protocol.
6
+
7
+ Export style (shared by every package above the kernel): **names**, never
8
+ submodules, and only what a consumer outside the package imports.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from .base import (
14
+ AUTO_ORDER,
15
+ EXTRACTORS,
16
+ ExtractedPage,
17
+ ExtractionError,
18
+ Extractor,
19
+ get,
20
+ names,
21
+ register,
22
+ select,
23
+ vlm_ready,
24
+ )
25
+
26
+ __all__ = [
27
+ "AUTO_ORDER",
28
+ "EXTRACTORS",
29
+ "ExtractedPage",
30
+ "Extractor",
31
+ "ExtractionError",
32
+ "get",
33
+ "names",
34
+ "register",
35
+ "select",
36
+ "vlm_ready",
37
+ ]