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.
- {tablassert-8.2.1 → tablassert-9.1.0}/PKG-INFO +8 -2
- {tablassert-8.2.1 → tablassert-9.1.0}/README.md +5 -1
- {tablassert-8.2.1 → tablassert-9.1.0}/pyproject.toml +5 -1
- {tablassert-8.2.1 → tablassert-9.1.0}/src/tablassert/_lazy.py +13 -1
- {tablassert-8.2.1 → tablassert-9.1.0}/src/tablassert/agent.py +34 -28
- {tablassert-8.2.1 → tablassert-9.1.0}/src/tablassert/cli.py +325 -14
- {tablassert-8.2.1 → tablassert-9.1.0}/src/tablassert/errors.py +69 -2
- tablassert-9.1.0/src/tablassert/extras.py +159 -0
- {tablassert-8.2.1 → tablassert-9.1.0}/src/tablassert/lib.py +63 -4
- {tablassert-8.2.1 → tablassert-9.1.0}/src/tablassert/models.py +28 -0
- {tablassert-8.2.1 → tablassert-9.1.0}/src/tablassert/qc.py +10 -1
- {tablassert-8.2.1 → tablassert-9.1.0}/LICENSE +0 -0
- {tablassert-8.2.1 → tablassert-9.1.0}/rust/Cargo.lock +0 -0
- {tablassert-8.2.1 → tablassert-9.1.0}/rust/Cargo.toml +0 -0
- {tablassert-8.2.1 → tablassert-9.1.0}/rust/examples/count_tables.rs +0 -0
- {tablassert-8.2.1 → tablassert-9.1.0}/rust/src/fullmap.rs +0 -0
- {tablassert-8.2.1 → tablassert-9.1.0}/rust/src/json.rs +0 -0
- {tablassert-8.2.1 → tablassert-9.1.0}/rust/src/lib.rs +0 -0
- {tablassert-8.2.1 → tablassert-9.1.0}/rust/src/ndjson.rs +0 -0
- {tablassert-8.2.1 → tablassert-9.1.0}/rust/src/uuid.rs +0 -0
- {tablassert-8.2.1 → tablassert-9.1.0}/rust/tests/build_golden.rs +0 -0
- {tablassert-8.2.1 → tablassert-9.1.0}/src/tablassert/__init__.py +0 -0
- {tablassert-8.2.1 → tablassert-9.1.0}/src/tablassert/biolink.py +0 -0
- {tablassert-8.2.1 → tablassert-9.1.0}/src/tablassert/coerce.py +0 -0
- {tablassert-8.2.1 → tablassert-9.1.0}/src/tablassert/enums.py +0 -0
- {tablassert-8.2.1 → tablassert-9.1.0}/src/tablassert/fullmap.py +0 -0
- {tablassert-8.2.1 → tablassert-9.1.0}/src/tablassert/graph_registry.py +0 -0
- {tablassert-8.2.1 → tablassert-9.1.0}/src/tablassert/ingests.py +0 -0
- {tablassert-8.2.1 → tablassert-9.1.0}/src/tablassert/log.py +0 -0
- {tablassert-8.2.1 → tablassert-9.1.0}/src/tablassert/nlp.py +0 -0
- {tablassert-8.2.1 → tablassert-9.1.0}/src/tablassert/progress.py +0 -0
- {tablassert-8.2.1 → tablassert-9.1.0}/src/tablassert/rig.py +0 -0
- {tablassert-8.2.1 → tablassert-9.1.0}/src/tablassert/rs.pyi +0 -0
- {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:
|
|
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`).
|
|
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`).
|
|
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 = "
|
|
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
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
459
|
-
introspectable wherever it is readable. Raises a clear ``ValueError`` naming the
|
|
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
|
|
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: #
|
|
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"
|
|
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
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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: #
|
|
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"
|
|
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
|
|
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
|
|
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.
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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 ``
|
|
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
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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
|
-
[
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|