tablassert 8.2.1__tar.gz → 9.1.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.
Files changed (34) hide show
  1. {tablassert-8.2.1 → tablassert-9.1.0}/PKG-INFO +8 -2
  2. {tablassert-8.2.1 → tablassert-9.1.0}/README.md +5 -1
  3. {tablassert-8.2.1 → tablassert-9.1.0}/pyproject.toml +5 -1
  4. {tablassert-8.2.1 → tablassert-9.1.0}/src/tablassert/_lazy.py +13 -1
  5. {tablassert-8.2.1 → tablassert-9.1.0}/src/tablassert/agent.py +34 -28
  6. {tablassert-8.2.1 → tablassert-9.1.0}/src/tablassert/cli.py +325 -14
  7. {tablassert-8.2.1 → tablassert-9.1.0}/src/tablassert/errors.py +69 -2
  8. tablassert-9.1.0/src/tablassert/extras.py +159 -0
  9. {tablassert-8.2.1 → tablassert-9.1.0}/src/tablassert/lib.py +63 -4
  10. {tablassert-8.2.1 → tablassert-9.1.0}/src/tablassert/models.py +28 -0
  11. {tablassert-8.2.1 → tablassert-9.1.0}/src/tablassert/qc.py +10 -1
  12. {tablassert-8.2.1 → tablassert-9.1.0}/LICENSE +0 -0
  13. {tablassert-8.2.1 → tablassert-9.1.0}/rust/Cargo.lock +0 -0
  14. {tablassert-8.2.1 → tablassert-9.1.0}/rust/Cargo.toml +0 -0
  15. {tablassert-8.2.1 → tablassert-9.1.0}/rust/examples/count_tables.rs +0 -0
  16. {tablassert-8.2.1 → tablassert-9.1.0}/rust/src/fullmap.rs +0 -0
  17. {tablassert-8.2.1 → tablassert-9.1.0}/rust/src/json.rs +0 -0
  18. {tablassert-8.2.1 → tablassert-9.1.0}/rust/src/lib.rs +0 -0
  19. {tablassert-8.2.1 → tablassert-9.1.0}/rust/src/ndjson.rs +0 -0
  20. {tablassert-8.2.1 → tablassert-9.1.0}/rust/src/uuid.rs +0 -0
  21. {tablassert-8.2.1 → tablassert-9.1.0}/rust/tests/build_golden.rs +0 -0
  22. {tablassert-8.2.1 → tablassert-9.1.0}/src/tablassert/__init__.py +0 -0
  23. {tablassert-8.2.1 → tablassert-9.1.0}/src/tablassert/biolink.py +0 -0
  24. {tablassert-8.2.1 → tablassert-9.1.0}/src/tablassert/coerce.py +0 -0
  25. {tablassert-8.2.1 → tablassert-9.1.0}/src/tablassert/enums.py +0 -0
  26. {tablassert-8.2.1 → tablassert-9.1.0}/src/tablassert/fullmap.py +0 -0
  27. {tablassert-8.2.1 → tablassert-9.1.0}/src/tablassert/graph_registry.py +0 -0
  28. {tablassert-8.2.1 → tablassert-9.1.0}/src/tablassert/ingests.py +0 -0
  29. {tablassert-8.2.1 → tablassert-9.1.0}/src/tablassert/log.py +0 -0
  30. {tablassert-8.2.1 → tablassert-9.1.0}/src/tablassert/nlp.py +0 -0
  31. {tablassert-8.2.1 → tablassert-9.1.0}/src/tablassert/progress.py +0 -0
  32. {tablassert-8.2.1 → tablassert-9.1.0}/src/tablassert/rig.py +0 -0
  33. {tablassert-8.2.1 → tablassert-9.1.0}/src/tablassert/rs.pyi +0 -0
  34. {tablassert-8.2.1 → tablassert-9.1.0}/src/tablassert/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tablassert
3
- Version: 8.2.1
3
+ Version: 9.1.0
4
4
  Classifier: License :: OSI Approved :: Apache Software License
5
5
  Classifier: Development Status :: 5 - Production/Stable
6
6
  Classifier: Intended Audience :: Science/Research
@@ -32,11 +32,13 @@ Requires-Dist: fastexcel>=0.20.2
32
32
  Requires-Dist: smolagents>=1.26.0 ; extra == 'agent'
33
33
  Requires-Dist: litellm>=1.93.0 ; extra == 'agent'
34
34
  Requires-Dist: pdfminer-six>=20221105 ; extra == 'agent'
35
+ Requires-Dist: aria2==0.0.1b0 ; extra == 'aria2'
35
36
  Requires-Dist: dspy>=3.2.1 ; extra == 'optimize'
36
37
  Requires-Dist: scikit-learn>=1.8.0 ; extra == 'qc'
37
38
  Requires-Dist: sentence-transformers>=5.3.0 ; extra == 'qc'
38
39
  Requires-Dist: polars[rtcompat]>=1.40.1 ; extra == 'rt'
39
40
  Provides-Extra: agent
41
+ Provides-Extra: aria2
40
42
  Provides-Extra: optimize
41
43
  Provides-Extra: qc
42
44
  Provides-Extra: rt
@@ -152,11 +154,15 @@ CSV/TSV/Excel sources; optional extras add runtime and pipeline capabilities:
152
154
  | Extra | Adds | Install |
153
155
  | ----- | ---- | ------- |
154
156
  | `rt` | CPU-compatible Polars runtime | `pip install "tablassert[rt]"` |
157
+ | `aria2` | bundled aria2c downloader for `build-fullmap --aria2c` (Linux/Windows wheels only) | `pip install "tablassert[aria2]"` |
155
158
  | `qc` | three-stage QC audit (exact → fuzzy → BioBERT embeddings) | `pip install "tablassert[qc]"` |
156
159
  | `agent` | autonomous agent (smolagents, litellm, PDF context) | `pip install "tablassert[agent]"` |
157
160
  | `optimize` | GEPA prompt optimization for `agent --optimize` (dspy) | `pip install "tablassert[optimize]"` |
158
161
 
159
- QC is opt-in at build time (`build-kg --qc`). See the
162
+ QC is opt-in at build time (`build-kg --qc`). Reaching a feature whose extra is not installed never
163
+ produces a bare `ModuleNotFoundError`: the failure names the missing package and the exact install
164
+ command, and for `build-kg --qc` and `tablassert agent` it arrives before the run starts rather than
165
+ partway through. See the
160
166
  [Installation guide](https://skyeav.github.io/Tablassert/installation/) for the full matrix and the
161
167
  [CLI Reference](https://skyeav.github.io/Tablassert/cli/) for every flag.
162
168
 
@@ -99,11 +99,15 @@ CSV/TSV/Excel sources; optional extras add runtime and pipeline capabilities:
99
99
  | Extra | Adds | Install |
100
100
  | ----- | ---- | ------- |
101
101
  | `rt` | CPU-compatible Polars runtime | `pip install "tablassert[rt]"` |
102
+ | `aria2` | bundled aria2c downloader for `build-fullmap --aria2c` (Linux/Windows wheels only) | `pip install "tablassert[aria2]"` |
102
103
  | `qc` | three-stage QC audit (exact → fuzzy → BioBERT embeddings) | `pip install "tablassert[qc]"` |
103
104
  | `agent` | autonomous agent (smolagents, litellm, PDF context) | `pip install "tablassert[agent]"` |
104
105
  | `optimize` | GEPA prompt optimization for `agent --optimize` (dspy) | `pip install "tablassert[optimize]"` |
105
106
 
106
- QC is opt-in at build time (`build-kg --qc`). See the
107
+ QC is opt-in at build time (`build-kg --qc`). Reaching a feature whose extra is not installed never
108
+ produces a bare `ModuleNotFoundError`: the failure names the missing package and the exact install
109
+ command, and for `build-kg --qc` and `tablassert agent` it arrives before the run starts rather than
110
+ partway through. See the
107
111
  [Installation guide](https://skyeav.github.io/Tablassert/installation/) for the full matrix and the
108
112
  [CLI Reference](https://skyeav.github.io/Tablassert/cli/) for every flag.
109
113
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tablassert"
3
- version = "8.2.1"
3
+ version = "9.1.0"
4
4
  description = "Extract knowledge assertions from tabular data into NCATS Translator-compliant KGX NDJSON — declaratively, with entity resolution and quality control built in."
5
5
  authors = [
6
6
  { name = "Skye Lane Goetz", email = "sgoetz@isbscience.org" }
@@ -78,6 +78,10 @@ tablassert = "tablassert.cli:APP"
78
78
  rt = [
79
79
  "polars[rtcompat]>=1.40.1",
80
80
  ]
81
+ # PyPI distribution "aria2" exposes import module "aria2c" with a bundled aria2c binary.
82
+ aria2 = [
83
+ "aria2==0.0.1b0",
84
+ ]
81
85
  qc = [
82
86
  "scikit-learn>=1.8.0",
83
87
  "sentence-transformers>=5.3.0",
@@ -34,7 +34,19 @@ class LazyModule:
34
34
  def _load(self) -> Any:
35
35
  module = self._module
36
36
  if module is None:
37
- module = import_module(self._name)
37
+ try:
38
+ module = import_module(self._name)
39
+ except ImportError as exc:
40
+ # Deferred: keeps this module import-light (everything imports it) and the
41
+ # registry lookup off the success path. A proxied optional dependency that
42
+ # is absent must name its extra here -- this is the LAST point that knows
43
+ # the module name, and the caller only sees "No module named 'x'".
44
+ from tablassert.extras import actionable_import_error
45
+
46
+ actionable = actionable_import_error(self._name)
47
+ if actionable is None:
48
+ raise
49
+ raise actionable from exc
38
50
  self._module = module
39
51
  return module
40
52
 
@@ -4,11 +4,11 @@ This module hosts a smolagents ``CodeAgent`` pipeline that autonomously builds
4
4
  and audits KGX knowledge graphs from PubMed Central articles. It is part of the
5
5
  OPTIONAL ``[agent]`` extra, so ``smolagents`` is imported LAZILY (via
6
6
  :class:`tablassert._lazy.LazyModule`) and the base package never requires it at
7
- import time. Install the extra with ``pip install tablassert[agent]``.
7
+ import time. Install the extra with ``pip install "tablassert[agent]"``.
8
8
 
9
9
  ``dspy`` powers ONLY the GEPA prompt-optimization path (``agent --optimize``)
10
10
  and lives in its own OPTIONAL ``[optimize]`` extra
11
- (``pip install tablassert[optimize]``); it is likewise lazy-imported and never
11
+ (``pip install "tablassert[optimize]"``); it is likewise lazy-imported and never
12
12
  required by ordinary agent runs.
13
13
  """
14
14
 
@@ -26,7 +26,6 @@ import xml.etree.ElementTree as ET
26
26
  from collections import Counter
27
27
  from collections.abc import Callable, Sequence
28
28
  from dataclasses import asdict, dataclass, field
29
- from importlib import import_module
30
29
  from pathlib import Path
31
30
  from typing import TYPE_CHECKING, Any, ClassVar, Literal, cast
32
31
  from urllib.request import Request, urlopen
@@ -38,6 +37,7 @@ from tablassert._lazy import LazyModule
38
37
  from tablassert.biolink import ENUM_RANGED_QUALIFIERS, Categories
39
38
  from tablassert.enums import EncodingMethods
40
39
  from tablassert.errors import GraphValidationError, QcRuntimeMissingError, SectionValidationError, TablassertValidationError
40
+ from tablassert.extras import install_command, require_module
41
41
  from tablassert.fullmap import distinct, fullmap_db_path, is_lock_contention, lookup_rows
42
42
  from tablassert.graph_registry import REGISTERED_STATUSES, register_build
43
43
  from tablassert.lib import Tcode
@@ -55,21 +55,23 @@ else:
55
55
  pl = LazyModule("polars")
56
56
  smolagents = LazyModule("smolagents")
57
57
 
58
- AGENT_EXTRA: str = "pip install tablassert[agent]"
59
- OPTIMIZE_EXTRA: str = "pip install tablassert[optimize]"
60
-
61
- # Package -> install hint for the extra that actually ships it (default: [agent]).
62
- _EXTRA_HINT: dict[str, str] = {"dspy": OPTIMIZE_EXTRA}
58
+ # Published install hints, derived from the extras registry so they cannot drift from
59
+ # either pyproject.toml or the messages users actually see.
60
+ AGENT_EXTRA: str = install_command("agent")
61
+ OPTIMIZE_EXTRA: str = install_command("optimize")
63
62
 
64
63
  logger = cat("AGENT")
65
64
 
66
65
 
67
66
  def _require(name: str) -> None:
68
- """Import an optional dependency or raise a loud, actionable ImportError."""
69
- try:
70
- import_module(name)
71
- except ImportError as exc:
72
- raise ImportError(f"tablassert agent features require the '{name}' package. Install with {_EXTRA_HINT.get(name, AGENT_EXTRA)}.") from exc
67
+ """Import an optional dependency or raise a loud, actionable ImportError.
68
+
69
+ Thin wrapper over :func:`tablassert.extras.require_module`, which owns the
70
+ package -> extra mapping (``dspy`` belongs to ``[optimize]``, everything else the
71
+ agent lazy-imports to ``[agent]``) so the hints cannot drift from
72
+ ``pyproject.toml``.
73
+ """
74
+ require_module(name, required_by="tablassert agent features")
73
75
 
74
76
 
75
77
  def is_lazy() -> bool:
@@ -455,43 +457,44 @@ DATA_GUARDRAIL: str = (
455
457
  def excel_sheet_names(path: Path) -> list[str]:
456
458
  """Return the worksheet names of an Excel workbook (calamine preferred, openpyxl fallback).
457
459
 
458
- Uses the SAME optional engines :func:`_read_excel` reads with (imported lazily), so a workbook is
459
- introspectable wherever it is readable. Raises a clear ``ValueError`` naming the install path when
460
- neither engine can open the workbook (a corrupt file or a missing engine).
460
+ Uses the SAME engines :func:`_read_excel` reads with (imported lazily), so a workbook is
461
+ introspectable wherever it is readable. Raises a clear ``ValueError`` naming the fix when
462
+ neither engine can open the workbook (a corrupt file, or a base install missing its engine).
461
463
  """
462
464
  try:
463
- import fastexcel # lazy optional engine (calamine), same as _read_excel
465
+ import fastexcel # lazy import of the core calamine engine, same as _read_excel
464
466
 
465
467
  return [str(name) for name in fastexcel.read_excel(path).sheet_names]
466
- except Exception as calamine_err: # missing fastexcel OR a genuinely unreadable workbook
468
+ except Exception as calamine_err: # unreadable workbook OR (rarely) a broken fastexcel install
467
469
  try:
468
470
  import openpyxl # lazy pure-Python fallback engine, same as _read_excel
469
471
 
470
472
  return [str(name) for name in openpyxl.load_workbook(path, read_only=True).sheetnames]
471
473
  except Exception:
472
474
  raise ValueError(
473
- f"Listing Excel sheets requires an excel engine (calamine/openpyxl); install tablassert[agent] or tablassert[rt]. ({calamine_err})"
475
+ f"Could not list Excel sheets with either engine. calamine (fastexcel) is a core dependency, so this is usually an unreadable "
476
+ f"workbook; `pip install openpyxl` adds the pure-Python fallback engine. ({calamine_err})"
474
477
  ) from calamine_err
475
478
 
476
479
 
477
480
  def _read_excel(path: Path, sheet: str | None = None) -> pl.DataFrame:
478
481
  """Read an Excel worksheet, preferring ``calamine`` and falling back to ``openpyxl``.
479
482
 
480
- WHY two engines: the fast ``calamine`` engine needs the optional ``fastexcel``
481
- package, which the base install lacks; ``openpyxl`` is a pure-Python fallback
482
- that is commonly present. ``sheet`` selects a worksheet BY NAME (``None`` reads
483
- the first/active sheet, matching polars' default). If neither engine can load the
484
- file (both missing, or the workbook is corrupt), raise a clear ``ValueError``
485
- naming the install path instead of leaking a raw engine error to the caller.
483
+ WHY two engines: the fast ``calamine`` engine (``fastexcel``) is a core dependency and
484
+ handles almost every workbook; ``openpyxl`` is a pure-Python fallback that reads some
485
+ files calamine rejects. ``sheet`` selects a worksheet BY NAME (``None`` reads the
486
+ first/active sheet, matching polars' default). If neither engine can load the file,
487
+ raise a clear ``ValueError`` naming the fix instead of leaking a raw engine error.
486
488
  """
487
489
  try:
488
490
  return pl.read_excel(path, engine="calamine", sheet_name=sheet)
489
- except Exception as calamine_err: # missing fastexcel OR a genuinely unreadable workbook
491
+ except Exception as calamine_err: # unreadable workbook OR (rarely) a broken fastexcel install
490
492
  try:
491
493
  return pl.read_excel(path, engine="openpyxl", sheet_name=sheet)
492
494
  except Exception:
493
495
  raise ValueError(
494
- f"Reading Excel requires an excel engine (calamine/openpyxl); install tablassert[agent] or tablassert[rt]. ({calamine_err})"
496
+ f"Could not read Excel with either engine. calamine (fastexcel) is a core dependency, so this is usually an unreadable "
497
+ f"workbook; `pip install openpyxl` adds the pure-Python fallback engine. ({calamine_err})"
495
498
  ) from calamine_err
496
499
 
497
500
 
@@ -572,7 +575,7 @@ def _extract_pdf_text(path: Path) -> str:
572
575
  try:
573
576
  from pdfminer.high_level import extract_text # pyright: ignore[reportMissingImports] # lazy optional dep ([agent] extra)
574
577
  except ImportError as exc:
575
- raise ValueError(f"Reading PDF main text requires pdfminer.six; install tablassert[agent]. ({exc})") from exc
578
+ raise ValueError(f"Reading PDF main text requires pdfminer.six. {install_command('agent')} ({exc})") from exc
576
579
  return str(extract_text(str(path)))
577
580
 
578
581
 
@@ -2042,6 +2045,9 @@ def build_model(model_id: str | None, api_base: str | None, api_key: str | None,
2042
2045
 
2043
2046
  _require("smolagents")
2044
2047
  if backend == "litellm":
2048
+ # smolagents imports litellm lazily inside LiteLLMModel; require it here so the
2049
+ # failure names the [agent] extra instead of surfacing smolagents' own message.
2050
+ _require("litellm")
2045
2051
  from smolagents import LiteLLMModel # local import keeps module import lazy # pyright: ignore[reportMissingImports]
2046
2052
 
2047
2053
  return LiteLLMModel(model_id=rid, api_base=rbase, api_key=rkey)
@@ -1,11 +1,15 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import hashlib
3
4
  import re
4
5
  import shutil
5
6
  import subprocess
6
7
  import sys
8
+ import tarfile
9
+ import tempfile
7
10
  import time
8
11
  from collections.abc import Callable
12
+ from importlib import import_module
9
13
  from importlib.metadata import version as get_version
10
14
  from itertools import chain
11
15
  from multiprocessing import Pool
@@ -16,6 +20,7 @@ from urllib.request import Request, urlopen
16
20
 
17
21
  import cyclopts
18
22
 
23
+ from tablassert import extras
19
24
  from tablassert._lazy import LazyModule
20
25
  from tablassert.errors import BabelDownloadError, GraphValidationError, SectionValidationError
21
26
  from tablassert.log import cat
@@ -422,14 +427,42 @@ def download_babel_file(filename: str, url: str, destination: Path, retries: int
422
427
  raise BabelDownloadError(url, retries, last_error or RuntimeError("no attempts made")) from last_error
423
428
 
424
429
 
430
+ def _resolve_aria2_binary() -> str:
431
+ """Return the bundled aria2c binary path from the optional ``[aria2]`` extra.
432
+
433
+ Raises:
434
+ ImportError: If the optional ``[aria2]`` extra is not installed.
435
+ """
436
+ aria2c = import_module("aria2c")
437
+ try:
438
+ binary = aria2c.ARIA2C
439
+ except AttributeError as e:
440
+ raise ImportError("aria2c.ARIA2C was not found; install the [aria2] extra") from e
441
+ return str(binary)
442
+
443
+
444
+ def aria2_unavailable_detail() -> str:
445
+ """Explain how to get ``aria2c``, accounting for the platform.
446
+
447
+ macOS gets DIFFERENT advice on purpose: the ``aria2`` distribution publishes no macOS
448
+ wheels, so pointing a mac user at the extra sends them to a dead end. There the fix is
449
+ to drop the flag, and the default Python downloader takes over.
450
+ """
451
+ if sys.platform == "darwin":
452
+ return "the [aria2] extra ships no macOS wheels; drop --aria2c to use the default Python downloader"
453
+ return f"install the [aria2] extra: {extras.install_command('aria2')}"
454
+
455
+
425
456
  def download_babel_file_aria2c(filename: str, url: str, destination: Path, retries: int = 5) -> Path:
426
- """Download one BABEL file with the optional external ``aria2c`` executable.
457
+ """Download one BABEL file with the bundled aria2c binary from ``[aria2]``.
427
458
 
428
459
  The helper mirrors ``download_babel_file``'s final-file cache contract but
429
- delegates resume/retry behavior to aria2. Incomplete aria2 downloads leave a
430
- ``<filename>.aria2`` control file next to the target; when that control file
431
- exists we do NOT treat the target as a cache hit, and failures never remove
432
- either file so a later run can continue.
460
+ delegates resume/retry behavior to aria2. The ``aria2`` PyPI package is an
461
+ optional extra that bundles the aria2c binary and exposes it as
462
+ ``aria2c.ARIA2C``, so no system ``aria2c`` executable is required. Incomplete
463
+ aria2 downloads leave a ``<filename>.aria2`` control file next to the target;
464
+ when that control file exists we do NOT treat the target as a cache hit, and
465
+ failures never remove either file so a later run can continue.
433
466
 
434
467
  Args:
435
468
  filename: Output basename under ``destination``.
@@ -441,8 +474,8 @@ def download_babel_file_aria2c(filename: str, url: str, destination: Path, retri
441
474
  Path to the downloaded file.
442
475
 
443
476
  Raises:
444
- BabelDownloadError: If ``aria2c`` is missing, fails, or does not leave a
445
- complete final file.
477
+ BabelDownloadError: If the ``[aria2]`` extra is missing, aria2c fails,
478
+ or aria2c does not leave a complete final file.
446
479
  """
447
480
  destination.mkdir(parents=True, exist_ok=True)
448
481
  final_path: Path = destination / filename
@@ -454,10 +487,11 @@ def download_babel_file_aria2c(filename: str, url: str, destination: Path, retri
454
487
  error = ValueError("aria2c retries must be a positive integer")
455
488
  raise BabelDownloadError(url, retries, error) from error
456
489
 
457
- binary: str | None = shutil.which("aria2c")
458
- if binary is None:
459
- error = FileNotFoundError("aria2c executable not found; install aria2 or omit --aria2c")
460
- raise BabelDownloadError(url, 0, error) from error
490
+ try:
491
+ binary: str = _resolve_aria2_binary()
492
+ except ImportError as e:
493
+ error = FileNotFoundError(aria2_unavailable_detail())
494
+ raise BabelDownloadError(url, 0, error) from e
461
495
 
462
496
  command: list[str] = [
463
497
  binary,
@@ -562,7 +596,13 @@ def build_kg(
562
596
 
563
597
  The positional config is a Graph YAML that orchestrates one or more table
564
598
  configs into a single knowledge-graph build.
599
+
600
+ ``--qc`` requires the ``[qc]`` extra (``pip install "tablassert[qc]"``); it is
601
+ checked before the build starts, because the audit stage runs LAST and a missing
602
+ extra would otherwise surface only after entity resolution has finished.
565
603
  """
604
+ if qc:
605
+ extras.require("qc", required_by="--qc")
566
606
  run(6, build_pipeline, graph_configuration_file, release=release, qc=qc, log=log, head=head)
567
607
 
568
608
 
@@ -659,7 +699,7 @@ def agent(
659
699
  Model config comes from ``--model-id``/``--api-base``/``--api-key`` OR the ``TABLASSERT_AGENT_MODEL_ID``
660
700
  / ``TABLASSERT_AGENT_API_BASE`` / ``TABLASSERT_AGENT_API_KEY`` environment variables (explicit flags win).
661
701
  Secrets are NEVER hardcoded or defaulted: a missing value fails loud (exit 2) BEFORE any model is built.
662
- Requires the ``[agent]`` extra (``pip install tablassert[agent]``).
702
+ Requires the ``[agent]`` extra (``pip install "tablassert[agent]"``).
663
703
 
664
704
  Args:
665
705
  pmc_ids: One or more PMC article ids (positional).
@@ -730,6 +770,14 @@ def agent(
730
770
  print("tablassert agent: --gepa-threads must be a positive integer.", file=sys.stderr)
731
771
  raise SystemExit(2)
732
772
 
773
+ # Preflight the extras once the flags are known to be valid and BEFORE any model is
774
+ # built or any article fetched. smolagents is otherwise only required per-article
775
+ # (inside build_agent) and dspy only once GEPA starts, so an absent extra would
776
+ # surface after real work. --optimize needs BOTH, and reports whichever is missing.
777
+ extras.require("agent", required_by="tablassert agent")
778
+ if optimize:
779
+ extras.require("optimize", required_by="tablassert agent --optimize")
780
+
733
781
  def build_model_factory() -> object:
734
782
  return agent_mod.build_model(resolved_id, resolved_base, resolved_key, backend=backend)
735
783
 
@@ -886,6 +934,233 @@ def rebuild_agent_graph(
886
934
  print(f"tablassert rebuild-agent-graph: wrote {graph_path} with {count} table config(s).")
887
935
 
888
936
 
937
+ class PrebuiltFullmapUnavailable(Exception):
938
+ """A prebuilt fullmap could not be fetched or extracted.
939
+
940
+ Raised by :func:`fetch_prebuilt_fullmap` whenever the prebuilt is absent for this
941
+ version, the download/extract fails, or the checksum mismatches. The
942
+ ``build-fullmap`` command catches it to fall back to a from-scratch BABEL build, so
943
+ it is control flow, not a user-facing error (it never reaches the docs-coded
944
+ ``TablassertError`` surface). Carries a short reason so the fallback warning is actionable.
945
+ """
946
+
947
+
948
+ def _prebuilt_fullmap_urls(babel_version: str) -> tuple[str, str]:
949
+ """Resolve the prebuilt archive + checksum URLs for THIS Tablassert version.
950
+
951
+ RENCI publishes a prebuilt ``fullmap.tar.zst`` (and a ``sha256sum.txt``) under
952
+ ``{BABEL_BASE}/{babel_version}/fullmap/{tablassert_version}/``, where the version
953
+ directory is the INSTALLED Tablassert package version (e.g. ``9.1.0``) — resolved from
954
+ installed-package metadata, never hardcoded, so a new release looks itself up.
955
+
956
+ Args:
957
+ babel_version: BABEL snapshot label (the ``--version`` value), e.g. ``2026jul22``.
958
+
959
+ Returns:
960
+ ``(archive_url, checksum_url)`` for ``fullmap.tar.zst`` and ``sha256sum.txt``.
961
+ """
962
+ base: str = f"{BABEL_BASE}/{babel_version}/fullmap/{get_version('tablassert')}"
963
+ return f"{base}/fullmap.tar.zst", f"{base}/sha256sum.txt"
964
+
965
+
966
+ def _fetch_prebuilt_sha256(url: str) -> str | None:
967
+ """Fetch ``sha256sum.txt`` and return the hex digest listed for ``fullmap.tar.zst``.
968
+
969
+ Best-effort: a missing or malformed checksum file returns ``None`` so the caller
970
+ proceeds without verification (with a warning) instead of blocking a download.
971
+
972
+ Args:
973
+ url: URL of the ``sha256sum.txt`` file.
974
+
975
+ Returns:
976
+ The 64-char lowercase hex sha256, or ``None`` if it could not be fetched/parsed.
977
+ """
978
+ try:
979
+ request: Request = Request(url, headers={"User-Agent": "tablassert"})
980
+ with urlopen(request, timeout=60) as response:
981
+ body: str = response.read().decode("utf-8", errors="replace")
982
+ except (HTTPError, OSError, URLError):
983
+ return None
984
+ for line in body.splitlines():
985
+ parts: list[str] = line.split()
986
+ # sha256sum format: "<hex> <filename>" (two spaces, optional leading "*").
987
+ # sha256sum format: "<hex> <filename>"; binary mode prefixes the filename with "*".
988
+ if len(parts) >= 2 and Path(parts[1].removeprefix("*")).name == "fullmap.tar.zst":
989
+ digest: str = parts[0].lower()
990
+ if len(digest) == 64 and all(c in "0123456789abcdef" for c in digest):
991
+ return digest
992
+ return None
993
+
994
+
995
+ def _stream_tar(tar: tarfile.TarFile, dest: Path, on_phase: Callable[[str], None]) -> None:
996
+ """Extract every member of a streaming tarfile into ``dest`` (data-filtered on 3.12+).
997
+
998
+ Streaming mode (``r|``) only allows extracting each member as it is read (no random
999
+ access), which is exactly the loop here. PEP 706 (Python 3.12) added tar-extraction
1000
+ filters; ``filter="data"`` strips absolute paths, traversals, and unsafe links. On
1001
+ 3.11 the kwarg is absent and is omitted — the archive is RENCI-published, but the
1002
+ filter is cheap defense-in-depth when available.
1003
+
1004
+ Args:
1005
+ tar: An open streaming-mode tarfile.
1006
+ dest: Directory members are written into.
1007
+ on_phase: Progress callback fired with the active step label.
1008
+ """
1009
+ use_data_filter: bool = sys.version_info >= (3, 12)
1010
+ for member in tar:
1011
+ on_phase(f"extracting {member.name}")
1012
+ # Explicit branch so pyright sees the "data" literal (PEP 706, Python 3.12+).
1013
+ if use_data_filter:
1014
+ tar.extract(member, dest, filter="data")
1015
+ else:
1016
+ tar.extract(member, dest)
1017
+
1018
+
1019
+ def _extract_zst_tar(archive: Path, dest: Path, on_phase: Callable[[str], None]) -> None:
1020
+ """Stream-extract a ``.tar.zst`` archive into ``dest`` without materializing the tar on disk.
1021
+
1022
+ Prefers Python 3.14+ native tarfile zstd support; on older runtimes (where ``r|zst``
1023
+ raises ``CompressionError``) it streams the archive through the installed ``zstd``
1024
+ binary into tarfile. Streaming keeps peak disk near the redb files' own size even
1025
+ though the uncompressed tar is tens of GB.
1026
+
1027
+ Args:
1028
+ archive: Path to the downloaded ``fullmap.tar.zst``.
1029
+ dest: Directory members are written into (created if missing).
1030
+ on_phase: Progress callback fired with the active step label.
1031
+
1032
+ Raises:
1033
+ PrebuiltFullmapUnavailable: If the archive cannot be decompressed/extracted
1034
+ (native zstd unavailable AND no ``zstd`` binary, a read error, or a zstd failure).
1035
+ """
1036
+ dest.mkdir(parents=True, exist_ok=True)
1037
+ on_phase("opening archive")
1038
+ # Native zstd landed in tarfile for 3.14; older interpreters reject the ``zst`` mode
1039
+ # with CompressionError, which is caught to fall through to the zstd binary.
1040
+ try:
1041
+ with tarfile.open(archive, "r|zst") as tar:
1042
+ _stream_tar(tar, dest, on_phase)
1043
+ return
1044
+ except tarfile.CompressionError:
1045
+ pass
1046
+ except (OSError, tarfile.TarError) as exc:
1047
+ raise PrebuiltFullmapUnavailable(f"failed to read prebuilt archive: {exc}") from exc
1048
+
1049
+ on_phase("streaming via zstd")
1050
+ binary: str | None = shutil.which("zstd")
1051
+ if binary is None:
1052
+ raise PrebuiltFullmapUnavailable("no native zstd support and the `zstd` executable was not found")
1053
+ try:
1054
+ proc: subprocess.Popen[bytes] = subprocess.Popen([binary, "-d", "-c", "-T0", str(archive)], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
1055
+ except OSError as exc:
1056
+ raise PrebuiltFullmapUnavailable(f"could not start zstd: {exc}") from exc
1057
+ assert proc.stdout is not None
1058
+ try:
1059
+ with tarfile.open(fileobj=proc.stdout, mode="r|") as tar:
1060
+ _stream_tar(tar, dest, on_phase)
1061
+ except (OSError, tarfile.TarError) as exc:
1062
+ raise PrebuiltFullmapUnavailable(f"failed to extract prebuilt archive: {exc}") from exc
1063
+ finally:
1064
+ if proc.stdout is not None:
1065
+ proc.stdout.close()
1066
+ proc.wait()
1067
+ if proc.returncode not in (0, None):
1068
+ stderr: bytes = proc.stderr.read() if proc.stderr else b""
1069
+ detail: str = stderr.decode("utf-8", "replace").strip()[-500:]
1070
+ raise PrebuiltFullmapUnavailable(f"zstd exited with status {proc.returncode}: {detail}")
1071
+
1072
+
1073
+ def fetch_prebuilt_fullmap(output: Path, progress: PipelineProgress, version: str = BABEL_VERSION, aria2c: bool = False) -> None:
1074
+ """Download and extract a prebuilt fullmap database from RENCI (instead of building).
1075
+
1076
+ Two stages: download ``fullmap.tar.zst`` for THIS Tablassert version (cached +
1077
+ resumable, optionally via ``aria2c``) beside ``output``, then stream-extract it so the
1078
+ primary redb and its shards land beside ``output`` named after its stem. The checksum
1079
+ published alongside the archive is verified when present.
1080
+
1081
+ Args:
1082
+ output: Target primary redb path; the archive is downloaded + extracted beside it.
1083
+ progress: Pipeline progress reporter.
1084
+ version: BABEL snapshot label selecting the RENCI release directory (NOT the
1085
+ Tablassert package version, which the URL derives from installed-package metadata).
1086
+ aria2c: Use the optional aria2c executable for the archive download when true.
1087
+
1088
+ Raises:
1089
+ PrebuiltFullmapUnavailable: If the prebuilt is absent for this version, the download
1090
+ or extraction fails, or the checksum mismatches. ``build-fullmap`` catches this
1091
+ and falls back to a from-scratch BABEL build.
1092
+ """
1093
+ release: str = get_version("tablassert")
1094
+ archive_url, checksum_url = _prebuilt_fullmap_urls(version)
1095
+ download_dir: Path = output.parent
1096
+ archive: Path = download_dir / "fullmap.tar.zst"
1097
+
1098
+ # Stage 1/2: download the prebuilt archive (cached + resumable, like a BABEL file).
1099
+ progress.stage("Downloading Prebuilt Fullmap")
1100
+ start, advance, sub_step = progress.section_loop(1, "Download")
1101
+ start(f"fullmap.tar.zst v{release}")
1102
+
1103
+ def report_progress(downloaded: int, total: int) -> None:
1104
+ sub_step(_download_detail(downloaded, total))
1105
+
1106
+ try:
1107
+ if aria2c:
1108
+ sub_step("aria2c downloading")
1109
+ download_babel_file_aria2c("fullmap.tar.zst", archive_url, download_dir)
1110
+ else:
1111
+ sub_step("downloading")
1112
+ download_babel_file("fullmap.tar.zst", archive_url, download_dir, on_progress=report_progress)
1113
+ except BabelDownloadError as exc:
1114
+ raise PrebuiltFullmapUnavailable(f"prebuilt archive download failed: {exc}") from exc
1115
+ advance()
1116
+
1117
+ # Best-effort checksum: RENCI publishes sha256sum.txt; verify when present, warn otherwise.
1118
+ expected: str | None = _fetch_prebuilt_sha256(checksum_url)
1119
+ if expected is None:
1120
+ download_logger.warning("No sha256sum.txt at {url}; skipping integrity check", url=checksum_url)
1121
+ else:
1122
+ sub_step("verifying checksum")
1123
+ hasher = hashlib.sha256()
1124
+ with archive.open("rb") as handle:
1125
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
1126
+ hasher.update(chunk)
1127
+ actual: str = hasher.hexdigest()
1128
+ if actual != expected:
1129
+ archive.unlink(missing_ok=True)
1130
+ raise PrebuiltFullmapUnavailable(f"checksum mismatch for {archive.name}: expected {expected}, got {actual}")
1131
+
1132
+ # Stage 2/2: stream-extract into a temp dir ON THE SAME FILESYSTEM as the output (so
1133
+ # the renames are atomic), then move the primary + shards beside ``output`` named
1134
+ # after its stem. A custom --output stem is honored, not assumed to be fullmap.redb.
1135
+ progress.stage("Extracting Fullmap")
1136
+ start, advance, sub_step = progress.section_loop(1, "Extract")
1137
+ start("fullmap.tar.zst")
1138
+ sub_step("extracting")
1139
+ download_dir.mkdir(parents=True, exist_ok=True)
1140
+ shard_re: re.Pattern[str] = re.compile(r"\.s(\d+)\.redb$")
1141
+ with tempfile.TemporaryDirectory(dir=download_dir) as tmp_name:
1142
+ tmp_dir: Path = Path(tmp_name)
1143
+ _extract_zst_tar(archive, tmp_dir, on_phase=sub_step)
1144
+ primary_src: Path | None = None
1145
+ shards: dict[int, Path] = {}
1146
+ for candidate in tmp_dir.rglob("*.redb"):
1147
+ match: re.Match[str] | None = shard_re.search(candidate.name)
1148
+ if match:
1149
+ shards[int(match.group(1))] = candidate
1150
+ elif primary_src is None or candidate.name == "fullmap.redb":
1151
+ # Prefer a primary literally named fullmap.redb when several non-shard redb files appear.
1152
+ primary_src = candidate
1153
+ if primary_src is None:
1154
+ raise PrebuiltFullmapUnavailable("prebuilt archive contained no primary .redb file")
1155
+ primary_src.replace(output)
1156
+ for shard_index, shard_src in sorted(shards.items()):
1157
+ shard_src.replace(output.parent / f"{output.stem}.s{shard_index}.redb")
1158
+
1159
+ # The extracted redb files are the cache; drop the multi-GB archive to free the space.
1160
+ archive.unlink(missing_ok=True)
1161
+ download_logger.info("Installed prebuilt fullmap v{release} -> {output}", release=release, output=output)
1162
+
1163
+
889
1164
  def build_fullmap_pipeline(
890
1165
  output: Path,
891
1166
  progress: PipelineProgress,
@@ -905,7 +1180,7 @@ def build_fullmap_pipeline(
905
1180
  cache: Directory for downloaded BABEL files.
906
1181
  version: BABEL version label.
907
1182
  threads: Optional thread count forwarded to Rust.
908
- aria2c: Use the optional aria2c executable for downloads when true.
1183
+ aria2c: Use the bundled aria2c binary from the optional ``[aria2]`` extra for downloads when true.
909
1184
  """
910
1185
  from tablassert import rs
911
1186
 
@@ -967,6 +1242,42 @@ def build_fullmap(
967
1242
  version: Annotated[str, cyclopts.Parameter(name=["--version", "-v"])] = BABEL_VERSION,
968
1243
  threads: Annotated[int | None, cyclopts.Parameter(name=["--threads", "-t"])] = None,
969
1244
  aria2c: Annotated[bool, cyclopts.Parameter(name=["--aria2c", "-a"], negative="")] = False,
1245
+ force: Annotated[bool, cyclopts.Parameter(name=["--force", "-f"], negative="")] = False,
970
1246
  ) -> None:
971
- """Build an embedded fullmap redb database from hardcoded BABEL outputs."""
1247
+ """Build an embedded fullmap redb database, or download a prebuilt one from RENCI.
1248
+
1249
+ By default, first try to download a prebuilt ``fullmap.tar.zst`` published for THIS
1250
+ Tablassert version under ``{BABEL_BASE}/{version}/fullmap/<tablassert-version>/`` and
1251
+ extract it — far faster than building from BABEL. If no prebuilt exists for this
1252
+ version (or the download/extract fails), fall back to a from-scratch build.
1253
+ ``--force`` / ``-f`` skips the prebuilt attempt and always builds from BABEL outputs.
1254
+
1255
+ ``--aria2c`` requires the ``[aria2]`` extra, checked before the first download rather
1256
+ than on it, so an unusable flag costs nothing.
1257
+
1258
+ Args:
1259
+ output: Path to write the redb file (prebuilt extraction or build output).
1260
+ cache: Directory for downloaded BABEL files when building from scratch.
1261
+ version: BABEL snapshot date to fetch (a RENCI stamp, NOT Tablassert's version).
1262
+ threads: Worker threads for a from-scratch build (auto when unset).
1263
+ aria2c: Use the bundled aria2c binary from the ``[aria2]`` extra for downloads
1264
+ (prebuilt or BABEL).
1265
+ force: Skip the prebuilt download and always rebuild from BABEL outputs.
1266
+ """
1267
+ # A complete primary redb already on disk means the DB is in place: reuse it. Only
1268
+ # --force rebuilds once a DB exists, so it is the explicit "fresh build" knob.
1269
+ if not force and output.is_file() and output.stat().st_size > 0:
1270
+ print(f"tablassert build-fullmap: fullmap already present at {output}; skipping (use --force to rebuild).", file=sys.stderr)
1271
+ return
1272
+ # Checked here rather than earlier: the reuse path above downloads nothing, so a
1273
+ # missing [aria2] extra is irrelevant to it and must not fail a no-op command.
1274
+ if aria2c and not extras.is_installed("aria2"):
1275
+ print(f"tablassert build-fullmap: --aria2c is unavailable — {aria2_unavailable_detail()}", file=sys.stderr)
1276
+ raise SystemExit(2)
1277
+ if not force:
1278
+ try:
1279
+ run(2, fetch_prebuilt_fullmap, output, version=version, aria2c=aria2c)
1280
+ return
1281
+ except PrebuiltFullmapUnavailable as exc:
1282
+ logger.warning("Prebuilt fullmap unavailable ({reason}); building from BABEL outputs.", reason=exc)
972
1283
  run(3, build_fullmap_pipeline, output, cache=cache, version=version, threads=threads, aria2c=aria2c)
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from collections.abc import Sequence
3
4
  from pathlib import Path
4
5
  from typing import Literal
5
6
 
@@ -7,6 +8,7 @@ DOCS_URL: str = "https://tablassert.readthedocs.io/errors/"
7
8
 
8
9
  TablassertErrorCodes = Literal[
9
10
  "qc-runtime-missing",
11
+ "missing-extra",
10
12
  "graph-validation-failed",
11
13
  "section-validation-failed",
12
14
  "babel-download-failed",
@@ -26,6 +28,8 @@ TablassertErrorCodes = Literal[
26
28
  "encoding-list-requires-list",
27
29
  "encoding-list-incompatible-ops",
28
30
  "encoding-list-annotation-only",
31
+ "annotation-split-by-requires-column",
32
+ "annotation-split-by-empty",
29
33
  "qualifier-auto-derived",
30
34
  "qualifier-bad-value",
31
35
  "qualifier-unsatisfiable",
@@ -72,9 +76,72 @@ class BiolinkRelocationWarning(UserWarning):
72
76
  """
73
77
 
74
78
 
79
+ def format_missing_extra(extra: str, problem: str) -> str:
80
+ """Append the install instructions for ``extra`` to a one-sentence ``problem``.
81
+
82
+ Every missing-extra failure — whichever module noticed it — ends in the same
83
+ two commands, so a user never has to guess the package name behind an extra or
84
+ which installer their CLI came from.
85
+
86
+ Args:
87
+ extra: Extra name as it appears in ``pyproject.toml`` (``qc``, ``agent``, ...).
88
+ problem: One complete sentence stating what is missing and what needed it.
89
+
90
+ Returns:
91
+ The full message body (the docs URL is appended separately by :class:`_Coded`).
92
+ """
93
+ return f'{problem} Install the [{extra}] extra: pip install "tablassert[{extra}]" (uv: uv tool install "tablassert[{extra}]")'
94
+
95
+
96
+ def describe_missing(missing: Sequence[str], required_by: str) -> str:
97
+ """Phrase the ``problem`` sentence naming the absent distributions and the feature that wanted them.
98
+
99
+ Args:
100
+ missing: DISTRIBUTION names (what a user types into pip: ``scikit-learn``,
101
+ not the ``sklearn`` import name).
102
+ required_by: The feature that needs them, phrased to follow "required by".
103
+
104
+ Returns:
105
+ A single sentence, or a generic one when ``missing`` is empty.
106
+ """
107
+ if not missing:
108
+ return f"{required_by} requires optional dependencies that are not installed."
109
+ plural: str = "y" if len(missing) == 1 else "ies"
110
+ names: str = ", ".join(repr(name) for name in missing)
111
+ return f"Missing optional dependenc{plural} {names} — required by {required_by}."
112
+
113
+
114
+ class MissingExtraError(_Coded, ImportError):
115
+ """An optional extra is not installed, reported with the exact install command.
116
+
117
+ Notes:
118
+ Inherits ``ImportError`` rather than :class:`TablassertError` (a ``RuntimeError``)
119
+ so the existing ``except ImportError`` guards wrapping lazy optional imports keep
120
+ catching it, and so a missing package still reads as an import failure to callers
121
+ that never heard of Tablassert's error hierarchy.
122
+ """
123
+
124
+ def __init__(self, extra: str, problem: str, *, missing: Sequence[str] = ()) -> None:
125
+ message: str = format_missing_extra(extra, problem)
126
+ super().__init__(message)
127
+ self.message = message
128
+ self.code = "missing-extra"
129
+ self.extra = extra
130
+ self.missing: tuple[str, ...] = tuple(missing)
131
+
132
+
75
133
  class QcRuntimeMissingError(TablassertError):
76
- def __init__(self) -> None:
77
- super().__init__("QC requires optional runtime dependencies. Install tablassert[qc].", code="qc-runtime-missing")
134
+ """The ``[qc]`` extra is absent on a QC code path.
135
+
136
+ Notes:
137
+ Keeps its own ``qc-runtime-missing`` code and ``TablassertError`` base for
138
+ back-compat (``build_and_audit`` catches it by name, and the code is documented),
139
+ but shares :class:`MissingExtraError`'s message so both read identically.
140
+ """
141
+
142
+ def __init__(self, missing: Sequence[str] = ()) -> None:
143
+ super().__init__(format_missing_extra("qc", describe_missing(missing, "the QC audit")), code="qc-runtime-missing")
144
+ self.missing: tuple[str, ...] = tuple(missing)
78
145
 
79
146
 
80
147
  class GraphValidationError(TablassertError):
@@ -0,0 +1,159 @@
1
+ """Optional-extra registry: which extra ships which package, and how to install it.
2
+
3
+ Tablassert's base install builds knowledge graphs from CSV/TSV sources. QC, the
4
+ autonomous agent, and GEPA prompt optimization are OPTIONAL extras, so a user can
5
+ reach a code path whose dependencies were never installed. When that happens the
6
+ failure must name the extra and the exact install command — never a bare
7
+ ``ModuleNotFoundError: No module named 'sklearn'`` raised hours into a build.
8
+
9
+ This module is the single source of truth for that mapping. Two entry points:
10
+
11
+ - :func:`require` is a PREFLIGHT check. It probes with ``importlib.util.find_spec``
12
+ (which does not execute the module, so it costs nothing) and is called at the CLI
13
+ boundary, before any expensive work starts.
14
+ - :func:`require_module` is the LATE check for a lazy import that has already been
15
+ reached, used by :mod:`tablassert.agent` and :class:`tablassert._lazy.LazyModule`.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from importlib import import_module
21
+ from importlib.util import find_spec
22
+ from typing import Final
23
+
24
+ from tablassert.errors import MissingExtraError, QcRuntimeMissingError, describe_missing
25
+
26
+ # Extra -> {import name: distribution name}. Both are needed: find_spec probes the import
27
+ # name, while the message must show the DISTRIBUTION name, which differs often enough
28
+ # (sklearn/scikit-learn, pdfminer/pdfminer.six) that printing the module would misdirect.
29
+ #
30
+ # ``rt`` is absent BY DESIGN. It installs polars[rtcompat], which imports as plain
31
+ # ``polars``, so no find_spec probe can tell it apart from the stock wheel; its hint is
32
+ # emitted from the polars import-failure path instead (see :func:`actionable_import_error`).
33
+ EXTRA_PACKAGES: Final[dict[str, dict[str, str]]] = {
34
+ "aria2": {"aria2c": "aria2"},
35
+ "qc": {"sklearn": "scikit-learn", "sentence_transformers": "sentence-transformers"},
36
+ "agent": {"smolagents": "smolagents", "litellm": "litellm", "pdfminer": "pdfminer.six"},
37
+ "optimize": {"dspy": "dspy"},
38
+ }
39
+
40
+ # Import name -> owning extra, derived so the two can never drift apart.
41
+ EXTRA_FOR_MODULE: Final[dict[str, str]] = {module: extra for extra, packages in EXTRA_PACKAGES.items() for module in packages}
42
+
43
+ # What each extra unlocks, phrased to follow "required by ...".
44
+ FEATURES: Final[dict[str, str]] = {
45
+ "rt": "the runtime-compatible polars build",
46
+ "aria2": "the bundled aria2c downloader (build-fullmap --aria2c)",
47
+ "qc": "the QC audit",
48
+ "agent": "the tablassert agent",
49
+ "optimize": "GEPA prompt optimization (tablassert agent --optimize)",
50
+ }
51
+
52
+ # An unregistered module reaching :func:`require_module` came from the agent's dynamic
53
+ # lazy-import path (the only place a module name is not known statically).
54
+ _FALLBACK_EXTRA: Final[str] = "agent"
55
+
56
+
57
+ def install_command(extra: str) -> str:
58
+ """Return the pip command that installs ``extra``, quoted.
59
+
60
+ The quotes are load-bearing, not cosmetic: ``pip install tablassert[agent]`` is a glob
61
+ pattern in zsh and fails with ``no matches found`` before pip is ever reached.
62
+ """
63
+ return f'pip install "tablassert[{extra}]"'
64
+
65
+
66
+ def missing(extra: str) -> tuple[str, ...]:
67
+ """Return the distribution names in ``extra`` that are not importable.
68
+
69
+ Probes with ``find_spec``, so a present dependency is NOT imported and an absent
70
+ one costs nothing: safe to call on every invocation of a command.
71
+
72
+ Args:
73
+ extra: A detectable extra (``qc``, ``agent`` or ``optimize``).
74
+
75
+ Returns:
76
+ Distribution names that could not be found, in declaration order; empty when
77
+ the extra is fully installed.
78
+
79
+ Raises:
80
+ KeyError: If ``extra`` is not detectable (notably ``rt``; see the module docstring).
81
+ """
82
+ return tuple(dist for module, dist in EXTRA_PACKAGES[extra].items() if find_spec(module) is None)
83
+
84
+
85
+ def is_installed(extra: str) -> bool:
86
+ """Return whether every package in ``extra`` is importable."""
87
+ return not missing(extra)
88
+
89
+
90
+ def require(extra: str, *, required_by: str | None = None) -> None:
91
+ """Fail loudly unless ``extra`` is fully installed.
92
+
93
+ Preflight guard: call it at the point the user's intent is known (a ``--qc`` flag, the
94
+ ``agent`` command) rather than where the import happens, so the error arrives before
95
+ the work instead of after it.
96
+
97
+ Args:
98
+ extra: A detectable extra (``qc``, ``agent`` or ``optimize``).
99
+ required_by: Feature name for the message, phrased to follow "required by".
100
+ Defaults to the extra's entry in :data:`FEATURES`.
101
+
102
+ Raises:
103
+ QcRuntimeMissingError: If ``extra`` is ``qc`` and any of its packages is absent
104
+ (kept distinct so the documented ``qc-runtime-missing`` code still fires).
105
+ MissingExtraError: If any other extra is incomplete.
106
+ """
107
+ absent: tuple[str, ...] = missing(extra)
108
+ if not absent:
109
+ return
110
+ if extra == "qc":
111
+ raise QcRuntimeMissingError(absent)
112
+ raise MissingExtraError(extra, describe_missing(absent, required_by or FEATURES[extra]), missing=absent)
113
+
114
+
115
+ def require_module(module: str, *, required_by: str | None = None) -> None:
116
+ """Import one optional module or raise an error naming the extra that ships it.
117
+
118
+ Unlike :func:`require` this actually imports, so a package that is present but broken
119
+ still fails here (with its own error) rather than being reported as missing.
120
+
121
+ Args:
122
+ module: Import name (``smolagents``, ``dspy``, ...).
123
+ required_by: Feature name for the message; defaults to the owning extra's entry
124
+ in :data:`FEATURES`.
125
+
126
+ Raises:
127
+ MissingExtraError: If ``module`` cannot be imported.
128
+ """
129
+ extra: str = EXTRA_FOR_MODULE.get(module, _FALLBACK_EXTRA)
130
+ try:
131
+ import_module(module)
132
+ except ImportError as exc:
133
+ dist: str = EXTRA_PACKAGES.get(extra, {}).get(module, module)
134
+ raise MissingExtraError(extra, describe_missing((dist,), required_by or FEATURES[extra]), missing=(dist,)) from exc
135
+
136
+
137
+ def actionable_import_error(module: str) -> MissingExtraError | None:
138
+ """Return the extra-aware error to raise for a failed ``import module``, or ``None``.
139
+
140
+ Used by :class:`tablassert._lazy.LazyModule`, which imports optional and core modules
141
+ alike and only knows the module name. ``None`` means "nothing useful to add" — the
142
+ caller re-raises the original ``ImportError`` untouched.
143
+
144
+ ``polars`` is special-cased: it is a CORE dependency, so a failure to import it is
145
+ almost never absence but an incompatible wheel — the exact problem the ``[rt]`` extra
146
+ (``polars[rtcompat]``) exists to solve.
147
+ """
148
+ if module == "polars":
149
+ return MissingExtraError(
150
+ "rt",
151
+ "polars failed to import — if this machine's CPU lacks the instructions the default polars wheel needs, "
152
+ "the runtime-compatible build is the fix.",
153
+ missing=("polars",),
154
+ )
155
+ extra: str | None = EXTRA_FOR_MODULE.get(module)
156
+ if extra is None:
157
+ return None
158
+ dist: str = EXTRA_PACKAGES[extra][module]
159
+ return MissingExtraError(extra, describe_missing((dist,), FEATURES[extra]), missing=(dist,))
@@ -603,6 +603,54 @@ def format_numeric(lf: pl.LazyFrame) -> pl.LazyFrame:
603
603
  return df.lazy()
604
604
 
605
605
 
606
+ def split_expr(col: str, delimiter: str) -> pl.Expr:
607
+ """Build the shared "split a delimited cell into items" expression.
608
+
609
+ The single splitting primitive behind both delimiter-driven ops: ``explode_by``
610
+ fans the items out into rows (node encodings), ``split_by`` keeps them as a real
611
+ JSON array on the row (annotations). Only what happens to the items afterwards
612
+ differs, so the parsing rules stay defined in exactly one place.
613
+
614
+ Items are trimmed and blanks are dropped -- a trailing or doubled separator
615
+ (``"a;b;"``, ``"a;;b"``) is a delimited-text artifact, not a value. A null cell
616
+ stays null rather than becoming a one-element list of null.
617
+
618
+ Args:
619
+ col: Column whose string values should be split.
620
+ delimiter: Separator to split on.
621
+
622
+ Returns:
623
+ Expression yielding a ``list[str]`` column (null preserved).
624
+ """
625
+ text: pl.Expr = pl.col(col).cast(pl.String)
626
+ items: pl.Expr = text.str.split(delimiter).list.eval(pl.element().str.strip_chars()).list.drop_nulls()
627
+ return pl.when(text.is_null()).then(None).otherwise(items.list.eval(pl.element().filter(pl.element() != "")))
628
+
629
+
630
+ def split_list(lf: pl.LazyFrame, col: str, delimiter: str) -> pl.LazyFrame:
631
+ """Split a delimited cell into a real JSON array, in place.
632
+
633
+ A column encoding is scalar by construction, so a multivalued Biolink slot such as
634
+ ``has_evidence`` fed from an aggregated cell would otherwise be emitted as a single
635
+ joined string -- and ``mask_illegal_edge_fields`` wraps that scalar into a
636
+ one-element list, so the value survives Biolink validation while consumers iterate a
637
+ single ``"a|b|c"`` blob instead of three ids.
638
+
639
+ Same split as ``explode``, minus the fan-out: this is the per-row counterpart of
640
+ ``method: list`` (the literal form covers a fixed array known at config time, this
641
+ covers an array that differs on every row).
642
+
643
+ Args:
644
+ lf: Source LazyFrame.
645
+ col: Annotation column to split.
646
+ delimiter: Separator to split on.
647
+
648
+ Returns:
649
+ LazyFrame with ``col`` converted to a ``list[str]`` column, blanks dropped.
650
+ """
651
+ return lf.with_columns(split_expr(col, delimiter).alias(col))
652
+
653
+
606
654
  def prefix(lf: pl.LazyFrame, col: str, prefix: str) -> pl.LazyFrame:
607
655
  expr: pl.Expr = pl.lit(prefix) + pl.col(col).cast(pl.String)
608
656
  return lf.with_columns(expr.alias(col))
@@ -634,10 +682,14 @@ def explode(lf: pl.LazyFrame, col: str, delimiter: str) -> pl.LazyFrame:
634
682
  Returns:
635
683
  LazyFrame with one row per item (the split column becomes a list
636
684
  before the explode).
685
+
686
+ Notes:
687
+ Shares ``split_expr`` with ``split_list`` (the ``split_by`` annotation op), so
688
+ both read a delimited cell the same way: items trimmed, blanks dropped. A
689
+ trailing/doubled separator therefore no longer fans out rows carrying ``""``,
690
+ which only ever failed entity resolution and dropped the edge downstream.
637
691
  """
638
- expr: pl.Expr = pl.col(col).cast(pl.String).str.split(delimiter)
639
- lf = lf.with_columns(expr.alias(col))
640
- return lf.explode(col)
692
+ return lf.with_columns(split_expr(col, delimiter).alias(col)).explode(col)
641
693
 
642
694
 
643
695
  def idx(lf: pl.LazyFrame, col: str = "extracted_from_row_number") -> pl.LazyFrame:
@@ -943,7 +995,13 @@ class Tcode(Section):
943
995
  else None,
944
996
  # --head preview: randomly sample min(HEAD_ROWS, height) rows before any encoding/resolve.
945
997
  (head, (HEAD_ROWS,)) if self.head else None,
946
- [op for x in self.annotations for op in self.encoding(x, x.annotation.lower())] if self.annotations else None,
998
+ [
999
+ op
1000
+ for x in self.annotations
1001
+ for op in [*self.encoding(x, x.annotation.lower()), *([(split_list, (x.annotation.lower(), x.split_by))] if x.split_by else [])]
1002
+ ]
1003
+ if self.annotations
1004
+ else None,
947
1005
  (coerce_pvalue_columns, ()),
948
1006
  (coerce_study_size_columns, ()),
949
1007
  (coerce_effect_size_columns, ()),
@@ -1071,6 +1129,7 @@ PHASE_OF: dict[Callable, str] = {
1071
1129
  retrieval_sources: "provenance",
1072
1130
  inline_supporting_study: "provenance",
1073
1131
  prune_to_class: "finalize",
1132
+ split_list: "encode",
1074
1133
  sig: "significance",
1075
1134
  drop_not_significant: "significance",
1076
1135
  trim: "finalize",
@@ -495,12 +495,40 @@ class Provenance(TablaBase):
495
495
 
496
496
  class Annotation(Encoding):
497
497
  annotation: str = Field(..., description="Output column name that receives this encoded annotation.", examples=["p_value", "cohort"])
498
+ split_by: str | None = Field(
499
+ None, description="Separator splitting each cell of a `method: column` annotation into a real JSON array.", examples=["|", ";"]
500
+ )
498
501
 
499
502
  @field_validator("annotation", mode="after")
500
503
  @classmethod
501
504
  def clean_annotation(cls, annotation: str) -> str:
502
505
  return annotation.lower().strip()
503
506
 
507
+ @model_validator(mode="after")
508
+ def split_by_requires_a_column(self) -> Self:
509
+ """Enforce that ``split_by`` carries a real separator and a ``method: column`` encoding.
510
+
511
+ ``split_by`` is the per-row counterpart of ``method: list``: it turns each cell's
512
+ own delimited text into a real JSON array, which is the one multivalued shape a
513
+ literal cannot express (a list ``encoding`` is fixed at config time, so it emits
514
+ the same array on every row). A ``value``/``list`` encoding therefore declares its
515
+ members directly rather than round-tripping them through a separator.
516
+ """
517
+ if self.split_by is None:
518
+ return self
519
+ if self.method != EncodingMethods.COLUMN:
520
+ raise TablassertValidationError(
521
+ "`split_by` splits a column's per-row text and requires `method: column`; "
522
+ "declare a literal multivalued annotation with `method: list` instead.",
523
+ code="annotation-split-by-requires-column",
524
+ )
525
+ if not self.split_by:
526
+ # An empty separator splits into individual characters -- exactly the
527
+ # character-walking failure the JSON array exists to prevent.
528
+ raise TablassertValidationError("`split_by` must be a non-empty separator.", code="annotation-split-by-empty")
529
+
530
+ return self
531
+
504
532
  @model_validator(mode="after")
505
533
  def warn_when_the_slot_cannot_reach_the_edge(self) -> Self:
506
534
  # Deliberately a WARNING, not an error like the Qualifier guards above: the value is never
@@ -14,6 +14,7 @@ else:
14
14
  sentence_transformers = LazyModule("sentence_transformers")
15
15
  pl = LazyModule("polars")
16
16
 
17
+ from tablassert import extras
17
18
  from tablassert.errors import QcRuntimeMissingError
18
19
  from tablassert.log import cat
19
20
  from tablassert.utils import BASE
@@ -62,7 +63,7 @@ def get_biobert() -> object:
62
63
  MODEL.mkdir(parents=True, exist_ok=True)
63
64
  model.save(MODEL) # pyright: ignore
64
65
  except ImportError as exc:
65
- raise QcRuntimeMissingError() from exc
66
+ raise QcRuntimeMissingError(extras.missing("qc")) from exc
66
67
  return model
67
68
 
68
69
 
@@ -106,10 +107,18 @@ def fullmap_audit(
106
107
  Returns:
107
108
  LazyFrame containing only rows whose ``col`` value passed QC.
108
109
 
110
+ Raises:
111
+ QcRuntimeMissingError: If the ``[qc]`` extra is not installed.
112
+
109
113
  Notes:
110
114
  Collection point: pending pairs are handled eagerly because each stage
111
115
  needs the full set of survivors to batch-similarity-score them.
112
116
  """
117
+ # The whole extra is checked up front, not just the package the next line needs:
118
+ # scikit-learn is imported here but sentence-transformers only in Stage 3, so a
119
+ # half-installed extra would otherwise fail after the audit had already run.
120
+ extras.require("qc", required_by="the QC audit")
121
+
113
122
  # Stage 0: deletes suspected errors.
114
123
  from rapidfuzz import fuzz
115
124
  from rapidfuzz.process import cpdist
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes