statcatlib 0.8.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.
@@ -0,0 +1,546 @@
1
+ """statcatlib command-line interface.
2
+
3
+ Top-level entry point installed as the ``statcatlib`` console script. Today's
4
+ single command is `build_index`, which rebuilds a bundle's
5
+ ``search/`` LanceDB index. The CLI is intentionally thin — all real work
6
+ lives in `statcatlib.Catalog.build_index`.
7
+
8
+ Requires the ``[search]`` extra (typer + sentence-transformers + lancedb).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ import os
15
+ import sys
16
+ import time
17
+ import warnings
18
+ from pathlib import Path
19
+
20
+ import typer
21
+ from rich.console import Console
22
+ from rich.progress import (
23
+ BarColumn,
24
+ MofNCompleteColumn,
25
+ Progress,
26
+ SpinnerColumn,
27
+ TaskID,
28
+ TaskProgressColumn,
29
+ TextColumn,
30
+ TimeElapsedColumn,
31
+ TimeRemainingColumn,
32
+ )
33
+
34
+ import statcatlib as sc
35
+ from statcatlib.store.store import ASYMMETRIC_MODEL_PREFIXES
36
+
37
+ __all__ = ["app", "main"]
38
+
39
+
40
+ _DEFAULT_MODEL = "intfloat/multilingual-e5-base"
41
+
42
+ _ACCEPTED_CODELIST_MODES = frozenset({"fts_only"})
43
+
44
+
45
+ def _parse_codelist_policy(raw: str) -> sc.CodelistIndexPolicy:
46
+ """Parse a ``URN[,URN...]=mode`` string into a CodelistIndexPolicy."""
47
+ urns_part, sep, mode = raw.rpartition("=")
48
+ if not sep or not mode.strip() or not urns_part.strip():
49
+ msg = f"Expected 'URN[,URN...]=mode', got {raw!r}"
50
+ raise typer.BadParameter(msg)
51
+ mode = mode.strip()
52
+ urns = tuple(u.strip() for u in urns_part.split(",") if u.strip())
53
+ if not urns:
54
+ msg = f"No URNs in codelist policy {raw!r}"
55
+ raise typer.BadParameter(msg)
56
+ if mode not in _ACCEPTED_CODELIST_MODES:
57
+ accepted = ", ".join(sorted(_ACCEPTED_CODELIST_MODES))
58
+ msg = f"Unknown codelist policy mode {mode!r}; accepted: {accepted}"
59
+ raise typer.BadParameter(msg)
60
+ return sc.CodelistIndexPolicy(urns=urns, mode=mode)
61
+
62
+
63
+ def _parse_reference_metadata_blend(raw: str) -> sc.ReferenceMetadataBlend:
64
+ """Parse an ``ATTRIBUTE_PATH[:max_chars]`` string into a blend.
65
+
66
+ ``max_chars`` is optional and defaults to ``ReferenceMetadataBlend``'s
67
+ own default. Examples: ``"FOOTNOTE_TEXT"`` or ``"FOOTNOTE_TEXT:240"``.
68
+ """
69
+ path, sep, max_chars_part = raw.partition(":")
70
+ path = path.strip()
71
+ if not path:
72
+ msg = f"Expected 'ATTRIBUTE_PATH[:max_chars]', got {raw!r}"
73
+ raise typer.BadParameter(msg)
74
+ if not sep:
75
+ return sc.ReferenceMetadataBlend(attribute_path=path)
76
+ try:
77
+ max_chars = int(max_chars_part.strip())
78
+ except ValueError:
79
+ msg = f"max_chars must be an integer in reference-metadata blend {raw!r}"
80
+ raise typer.BadParameter(msg) from None
81
+ if max_chars <= 0:
82
+ msg = f"max_chars must be positive in reference-metadata blend {raw!r}"
83
+ raise typer.BadParameter(msg)
84
+ return sc.ReferenceMetadataBlend(attribute_path=path, max_chars=max_chars)
85
+
86
+
87
+ app = typer.Typer(
88
+ name="statcat",
89
+ help="Operational tools for statcatlib Catalog bundles.",
90
+ no_args_is_help=True,
91
+ add_completion=False,
92
+ )
93
+ bundle_app = typer.Typer(
94
+ name="bundle",
95
+ help="Manage statcatlib bundles (inspect and rebind data paths, emit ATTACH commands).",
96
+ no_args_is_help=True,
97
+ add_completion=False,
98
+ )
99
+ app.add_typer(bundle_app, name="bundle")
100
+
101
+ from statcatlib.store.store import EmbeddingModel
102
+
103
+ console = Console()
104
+ log = logging.getLogger("statcatlib.cli")
105
+
106
+
107
+ def _require_bundle(bundle: Path) -> Path:
108
+ """Validate that ``bundle`` looks like a statcatlib Catalog bundle."""
109
+ if not (bundle / "meta.duckdb").is_file():
110
+ console.print(
111
+ f"[red]Not a statcatlib Catalog bundle:[/] {bundle}\n"
112
+ f"Expected to find [bold]meta.duckdb[/] at the top level."
113
+ )
114
+ raise typer.Exit(code=1)
115
+ if not (bundle / "lake.ducklake").is_file():
116
+ console.print(
117
+ f"[red]Not a statcatlib Catalog bundle:[/] {bundle}\n"
118
+ f"Expected to find [bold]lake.ducklake[/] at the top level."
119
+ )
120
+ raise typer.Exit(code=1)
121
+ return bundle
122
+
123
+
124
+ @bundle_app.command("rebind")
125
+ def bundle_rebind(
126
+ bundle: Path = typer.Argument(
127
+ ...,
128
+ help="Path to a statcatlib Catalog bundle (the folder containing meta.duckdb).",
129
+ exists=True,
130
+ file_okay=False,
131
+ dir_okay=True,
132
+ ),
133
+ to: str | None = typer.Option(
134
+ None,
135
+ "--to",
136
+ help=(
137
+ "Where the observation parquet lives. A local directory or a remote URI "
138
+ "(abfss://, s3://, gs://). Defaults to <bundle>/parquet/."
139
+ ),
140
+ ),
141
+ scratch_dir: Path | None = typer.Option(
142
+ None,
143
+ "--scratch-dir",
144
+ help=(
145
+ "Fallback directory when the lake file is on a read-only mount. "
146
+ "The lake file is byte-copied there and rewritten in place."
147
+ ),
148
+ ),
149
+ force: bool = typer.Option(
150
+ False,
151
+ "--force",
152
+ help="Permit rewriting a remote data_path to a local one, discarding the only record of where the data lives.",
153
+ ),
154
+ ) -> None:
155
+ """Point a bundle at where its observation parquet actually lives.
156
+
157
+ A deliberate, once-per-deploy act — the init-container step for a
158
+ bundle synced from object storage, whose stamp nobody set at publish
159
+ time. Opening a bundle does not do this; ``statcat bundle explain``
160
+ shows what an open would decide without changing anything.
161
+
162
+ Idempotent and read-first: a bundle already pointing at the target
163
+ completes with no writes, no lock contention, and exits 0.
164
+ """
165
+ from statcatlib.bundle import BundleDataPathError, normalize_lake_data_path # noqa: PLC0415
166
+
167
+ bundle = _require_bundle(bundle.resolve())
168
+ target = to if to is not None else f"{bundle / 'parquet'}/"
169
+
170
+ try:
171
+ result = normalize_lake_data_path(
172
+ bundle / "lake.ducklake",
173
+ target,
174
+ scratch_dir=scratch_dir,
175
+ force=force,
176
+ )
177
+ except BundleDataPathError as exc:
178
+ console.print(f"[red]refused[/] — {exc}")
179
+ raise typer.Exit(code=1) from exc
180
+
181
+ if result == bundle / "lake.ducklake":
182
+ console.print(f"[green]ok[/] — data_path = [bold]{target}[/]")
183
+ else:
184
+ console.print(f"[yellow]copied[/] read-only lake to [bold]{result}[/] with data_path = [bold]{target}[/]")
185
+
186
+
187
+ @bundle_app.command("explain")
188
+ def bundle_explain(
189
+ bundle: Path = typer.Argument(
190
+ ...,
191
+ help="Path to a statcatlib Catalog bundle (the folder containing meta.duckdb).",
192
+ exists=True,
193
+ file_okay=False,
194
+ dir_okay=True,
195
+ ),
196
+ ) -> None:
197
+ """Report where a bundle says its data is, and what an open would do.
198
+
199
+ Read-only — takes no write lock and changes nothing. Run it in an
200
+ init container before serving: a stale data_path does not fail at
201
+ ATTACH, it fails on the first query that reads observations, so this
202
+ is what turns a latent runtime failure into a startup one.
203
+
204
+ Exits 1 when the layout is ambiguous, so it composes with ``&&``.
205
+ """
206
+ from statcatlib.bundle import inspect_bundle # noqa: PLC0415
207
+
208
+ bundle = _require_bundle(bundle.resolve())
209
+ info = inspect_bundle(bundle)
210
+
211
+ console.print(f"bundle [bold]{info.bundle}[/]")
212
+ console.print(f"data_path [bold]{info.stamp}[/]")
213
+ console.print(f"remote {info.is_remote}")
214
+ console.print(f"resolves {'not checked (remote)' if info.stamp_resolves is None else info.stamp_resolves}")
215
+ console.print(f"parquet/ {info.colocated_exists}")
216
+ console.print(f"data files {info.data_files}")
217
+ attach = "honour the embedded data_path" if info.target is None else info.target
218
+ console.print(f"would open {attach}")
219
+ console.print(f"would write {info.will_write}")
220
+
221
+ ambiguous = info.target is None and info.will_write is False and info.verdict.startswith("ambiguous")
222
+ colour = "red" if ambiguous else "green"
223
+ console.print(f"[{colour}]{info.verdict}[/]")
224
+ if ambiguous:
225
+ raise typer.Exit(code=1)
226
+
227
+
228
+ @bundle_app.command("attach-cmd")
229
+ def bundle_attach_cmd(
230
+ bundle: Path = typer.Argument(
231
+ ...,
232
+ help="Path to a statcatlib Catalog bundle (the folder containing meta.duckdb).",
233
+ exists=True,
234
+ file_okay=False,
235
+ dir_okay=True,
236
+ ),
237
+ data_path: str | None = typer.Option(
238
+ None,
239
+ "--data-path",
240
+ help="Override DATA_PATH in the printed ATTACH (defaults to <bundle>/parquet/).",
241
+ ),
242
+ read_only: bool = typer.Option(
243
+ False,
244
+ "--read-only",
245
+ help="Include READ_ONLY in the ATTACH options.",
246
+ ),
247
+ ) -> None:
248
+ """Print the ``ATTACH`` statement for ad-hoc ``duckdb`` CLI use."""
249
+ bundle = _require_bundle(bundle.resolve())
250
+ target = data_path if data_path is not None else f"{bundle / 'parquet'}/"
251
+ options = f"DATA_PATH '{target}'"
252
+ if read_only:
253
+ options += ", READ_ONLY"
254
+ # Plain stdout — rich.Console wraps lines, but this is meant to be
255
+ # piped into the duckdb CLI or copy-pasted verbatim.
256
+ typer.echo(f"ATTACH 'ducklake:{bundle / 'lake.ducklake'}' AS lake ({options});")
257
+
258
+
259
+ @app.callback()
260
+ def _main() -> None:
261
+ """Force multi-command mode.
262
+
263
+ Without an explicit callback, Typer treats a single-command app as
264
+ that command and the command name becomes implicit — so
265
+ ``statcat build-index <bundle>`` would parse ``build-index`` as the
266
+ BUNDLE argument. This empty callback keeps the subcommand seam in
267
+ place even while ``build-index`` is the only command.
268
+ """
269
+
270
+
271
+ def _resolve_prefixes(
272
+ model: str,
273
+ document_prefix: str | None,
274
+ query_prefix: str | None,
275
+ ) -> tuple[str, str]:
276
+ """Decide which document/query prefixes to use.
277
+
278
+ Explicit CLI overrides win. When omitted, look up the model in
279
+ `ASYMMETRIC_MODEL_PREFIXES`; an unrecognized model gets empty prefixes
280
+ (correct for symmetric encoders, harmless for ones that don't care).
281
+
282
+ The table used to live here, which left the Python API — the interface
283
+ #81 was reported against — with no such default at all.
284
+ """
285
+ default_doc, default_q = ASYMMETRIC_MODEL_PREFIXES.get(model, ("", ""))
286
+ doc = default_doc if document_prefix is None else document_prefix
287
+ qry = default_q if query_prefix is None else query_prefix
288
+ return doc, qry
289
+
290
+
291
+ def _hush_encoder_logs() -> None:
292
+ """Silence the noisy encoder/runtime libraries before they emit anything."""
293
+ for noisy in ("httpx", "huggingface_hub", "transformers", "sentence_transformers"):
294
+ logging.getLogger(noisy).setLevel(logging.CRITICAL)
295
+ warnings.filterwarnings("ignore", message=".*unauthenticated.*")
296
+ try:
297
+ import transformers # noqa: PLC0415
298
+
299
+ transformers.logging.set_verbosity_error()
300
+ transformers.logging.disable_progress_bar()
301
+ except ImportError:
302
+ return
303
+
304
+
305
+ def _load_encoder(model_name: str, device: str | None) -> EmbeddingModel:
306
+ """Load a SentenceTransformer encoder, preferring offline files.
307
+
308
+ Returns `EmbeddingModel` — the Protocol naming the one method statcatlib
309
+ calls — rather than the concrete class. The import stays inside the
310
+ function so the search extra is only required when indexing.
311
+
312
+ Tries ``local_files_only=True`` first so cached models avoid hitting
313
+ the network on every invocation; falls back to a normal load (which
314
+ will download if needed). ``device`` may be ``None`` / ``"auto"`` to
315
+ let SentenceTransformers pick.
316
+ """
317
+ try:
318
+ from sentence_transformers import SentenceTransformer # noqa: PLC0415
319
+ except ImportError as exc:
320
+ console.print(
321
+ f"[red]Missing dependency:[/] {exc}.\n"
322
+ f"Install the search extra: [bold]uv pip install 'statcatlib[search]'[/]"
323
+ )
324
+ raise typer.Exit(code=1) from exc
325
+
326
+ resolved_device = None if device in (None, "auto") else device
327
+
328
+ try:
329
+ model = SentenceTransformer(model_name, device=resolved_device, local_files_only=True)
330
+ except OSError:
331
+ # Cache miss — fall back to a network-allowed load.
332
+ model = SentenceTransformer(model_name, device=resolved_device)
333
+
334
+ # No isinstance narrowing needed: `SentenceTransformer` satisfies
335
+ # `EmbeddingModel` structurally, because the Protocol asks only for a
336
+ # positional `encode` returning `object`. An earlier draft promised
337
+ # `-> Iterable[float]` there and had to narrow here, which basedpyright
338
+ # correctly called unsafe — a runtime_checkable Protocol tests method
339
+ # presence, never signatures, so that check would have passed on anything
340
+ # with an `encode` attribute.
341
+ return model
342
+
343
+
344
+ @app.command("build-index")
345
+ def build_index(
346
+ bundle: Path = typer.Argument(
347
+ ...,
348
+ help="Path to a statcatlib Catalog bundle (the folder containing meta.duckdb).",
349
+ exists=True,
350
+ file_okay=False,
351
+ dir_okay=True,
352
+ ),
353
+ model: str = typer.Option(
354
+ _DEFAULT_MODEL,
355
+ "--model",
356
+ help="SentenceTransformers / Hugging Face model id.",
357
+ ),
358
+ document_prefix: str | None = typer.Option(
359
+ None,
360
+ "--document-prefix",
361
+ help="Prefix to prepend to documents at index time. Auto-derived for known model families.",
362
+ ),
363
+ query_prefix: str | None = typer.Option(
364
+ None,
365
+ "--query-prefix",
366
+ help="Prefix to prepend to queries at search time. Auto-derived for known model families.",
367
+ ),
368
+ batch_size: int = typer.Option(
369
+ 256,
370
+ "--batch-size",
371
+ help="Encoder batch size. Sets STATCAT_EMBED_BATCH_SIZE for this run.",
372
+ min=1,
373
+ ),
374
+ device: str = typer.Option(
375
+ "auto",
376
+ "--device",
377
+ help="Encoder device: auto | cpu | cuda | mps.",
378
+ ),
379
+ include_codes: bool = typer.Option(
380
+ True,
381
+ "--include-codes/--no-include-codes",
382
+ help="Index SDMX code rows for BM25 search.",
383
+ ),
384
+ embed_codes: bool = typer.Option(
385
+ False,
386
+ "--embed-codes/--no-embed-codes",
387
+ help="Also compute vector embeddings for code rows (slow; hours on large catalogs).",
388
+ ),
389
+ max_codes_per_codelist: int = typer.Option(
390
+ 500,
391
+ "--max-codes-per-codelist",
392
+ help="Skip codelists with more entries than this to avoid index bloat.",
393
+ min=1,
394
+ ),
395
+ codelist_policy: list[str] = typer.Option(
396
+ [],
397
+ "--codelist-policy",
398
+ help=(
399
+ "Per-codelist override 'URN[,URN...]=mode' (repeatable). "
400
+ "Bypasses --max-codes-per-codelist for the listed URNs. "
401
+ "Accepted modes: fts_only."
402
+ ),
403
+ ),
404
+ reference_metadata_blend: list[str] = typer.Option(
405
+ [],
406
+ "--reference-metadata-blend",
407
+ help=(
408
+ "Blend an SDMX MetadataSet attribute into each code's search "
409
+ "text, 'ATTRIBUTE_PATH[:max_chars]' (repeatable). "
410
+ "E.g. 'FOOTNOTE_TEXT:240'. Requires the bundle to carry "
411
+ "reference-metadata tables, else build fails fast."
412
+ ),
413
+ ),
414
+ include_reference_metadata: bool = typer.Option(
415
+ False,
416
+ "--include-reference-metadata/--no-include-reference-metadata",
417
+ help=(
418
+ "Index SDMX reference-metadata sections (reference guides, "
419
+ "methodology reports) as their own searchable 'reference_metadata' "
420
+ "rows, chunked one unit per section. Complements "
421
+ "--reference-metadata-blend: the blend boosts a code's recall, "
422
+ "this makes the document itself retrievable."
423
+ ),
424
+ ),
425
+ embed_reference_metadata: bool = typer.Option(
426
+ True,
427
+ "--embed-reference-metadata/--no-embed-reference-metadata",
428
+ help="Compute vector embeddings for reference-metadata rows (default on).",
429
+ ),
430
+ reference_metadataflow: list[str] = typer.Option(
431
+ [],
432
+ "--reference-metadataflow",
433
+ help=(
434
+ "Restrict reference-metadata indexing to sets whose structure is "
435
+ "one of these MSD/Metadataflow URNs (repeatable). Empty = all sets."
436
+ ),
437
+ ),
438
+ ) -> None:
439
+ """Build or rebuild the LanceDB vector index for a Catalog bundle.
440
+
441
+ The bundle's ``search/embeddings.json`` sidecar is updated with the
442
+ chosen model, prefixes, embedding dimensionality, and row counts so
443
+ that a query-side client can call
444
+ `statcatlib.Catalog.check_index_compatible` to validate its encoder.
445
+ """
446
+ logging.basicConfig(
447
+ level=logging.INFO,
448
+ format="%(asctime)s %(levelname)-8s %(message)s",
449
+ datefmt="%H:%M:%S",
450
+ )
451
+
452
+ if not (bundle / "meta.duckdb").is_file():
453
+ console.print(
454
+ f"[red]Not a statcatlib Catalog bundle:[/] {bundle}\n"
455
+ f"Expected to find [bold]meta.duckdb[/] at the top level."
456
+ )
457
+ raise typer.Exit(code=1)
458
+
459
+ if embed_codes and not include_codes:
460
+ console.print("[yellow]--embed-codes implies --include-codes; enabling.[/]")
461
+ include_codes = True
462
+
463
+ policies = [_parse_codelist_policy(s) for s in codelist_policy]
464
+ blends = [_parse_reference_metadata_blend(s) for s in reference_metadata_blend]
465
+
466
+ os.environ["STATCAT_EMBED_BATCH_SIZE"] = str(batch_size)
467
+ _hush_encoder_logs()
468
+
469
+ doc_pref, qry_pref = _resolve_prefixes(model, document_prefix, query_prefix)
470
+
471
+ console.print(
472
+ f"[dim]bundle:[/] {bundle}\n"
473
+ f"[dim]model:[/] {model} ([dim]device={device}, batch_size={batch_size}[/])\n"
474
+ f"[dim]prefixes:[/] document={doc_pref!r} query={qry_pref!r}"
475
+ )
476
+ if policies:
477
+ for p in policies:
478
+ console.print(f"[dim]codelist-policy:[/] {','.join(p.urns)} = {p.mode}")
479
+ if blends:
480
+ for b in blends:
481
+ console.print(f"[dim]reference-metadata-blend:[/] {b.attribute_path} (max_chars={b.max_chars})")
482
+ if include_reference_metadata:
483
+ scope = ", ".join(reference_metadataflow) if reference_metadataflow else "all sets"
484
+ console.print(f"[dim]reference-metadata index:[/] on (embed={embed_reference_metadata}, scope={scope})")
485
+
486
+ with console.status(f"[dim]Loading encoder {model!r}…[/]"):
487
+ encoder = _load_encoder(model, device)
488
+
489
+ with console.status("[dim]Opening catalog…[/]"):
490
+ store = sc.Catalog(str(bundle)).open()
491
+
492
+ progress = Progress(
493
+ SpinnerColumn(),
494
+ TextColumn("[bold cyan]Embedding[/]"),
495
+ BarColumn(bar_width=40),
496
+ MofNCompleteColumn(),
497
+ TaskProgressColumn(),
498
+ TimeElapsedColumn(),
499
+ TimeRemainingColumn(),
500
+ console=console,
501
+ )
502
+
503
+ task_id: TaskID | None = None
504
+
505
+ def _on_progress(current: int, total: int) -> None:
506
+ nonlocal task_id
507
+ if task_id is None:
508
+ task_id = progress.add_task("embed", total=total)
509
+ else:
510
+ progress.update(task_id, total=total)
511
+ progress.update(task_id, completed=current)
512
+
513
+ start = time.monotonic()
514
+ with progress:
515
+ n = store.build_index(
516
+ embedding_model=encoder,
517
+ progress=_on_progress,
518
+ include_codes=include_codes,
519
+ embed_codes=embed_codes,
520
+ max_codes_per_codelist=max_codes_per_codelist,
521
+ codelist_policies=policies,
522
+ reference_metadata_blends=blends,
523
+ include_reference_metadata=include_reference_metadata,
524
+ embed_reference_metadata=embed_reference_metadata,
525
+ reference_metadataflow_urns=reference_metadataflow or None,
526
+ document_prefix=doc_pref,
527
+ query_prefix=qry_pref,
528
+ model_name=model,
529
+ )
530
+
531
+ elapsed = time.monotonic() - start
532
+ meta = store.embedding_metadata
533
+ dim = meta.embedding_dim if meta is not None else None
534
+ dim_str = f"dim={dim}" if dim is not None else "dim=?"
535
+ console.print(
536
+ f"[bold green]Done[/] — [green]{n:,}[/] datasets indexed ([{dim_str}], {elapsed:.1f}s) → {bundle / 'search'}"
537
+ )
538
+
539
+
540
+ def main() -> None:
541
+ """Console-script entry point for ``statcatlib``."""
542
+ app()
543
+
544
+
545
+ if __name__ == "__main__":
546
+ sys.exit(app() or 0)