seqforge 2026.7.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- seqforge/__init__.py +16 -0
- seqforge/cli/__init__.py +38 -0
- seqforge/cli/__main__.py +8 -0
- seqforge/cli/_common.py +105 -0
- seqforge/cli/compose.py +119 -0
- seqforge/cli/eval.py +103 -0
- seqforge/cli/harvest.py +417 -0
- seqforge/cli/hook.py +247 -0
- seqforge/cli/io.py +502 -0
- seqforge/cli/kb.py +348 -0
- seqforge/cli/manifest.py +536 -0
- seqforge/cli/probe.py +43 -0
- seqforge/cli/processing.py +192 -0
- seqforge/cli/project.py +52 -0
- seqforge/cli/resolve.py +55 -0
- seqforge/cli/root.py +66 -0
- seqforge/cli/run.py +463 -0
- seqforge/cli/schema.py +41 -0
- seqforge/compose/__init__.py +28 -0
- seqforge/compose/core.py +515 -0
- seqforge/compose/gates.py +113 -0
- seqforge/compose/params.py +447 -0
- seqforge/e2e.py +1926 -0
- seqforge/evals/__init__.py +78 -0
- seqforge/evals/case.py +382 -0
- seqforge/evals/grade.py +300 -0
- seqforge/evals/run.py +420 -0
- seqforge/harvest/__init__.py +121 -0
- seqforge/harvest/extract.py +319 -0
- seqforge/harvest/fields.py +212 -0
- seqforge/harvest/normalize.py +537 -0
- seqforge/harvest/prep.py +41 -0
- seqforge/harvest/providers.py +321 -0
- seqforge/harvest/verify.py +251 -0
- seqforge/hooks/__init__.py +33 -0
- seqforge/hooks/guards.py +214 -0
- seqforge/io/__init__.py +61 -0
- seqforge/io/archive.py +450 -0
- seqforge/io/attributes.py +190 -0
- seqforge/io/biosample/attributes.json +6341 -0
- seqforge/io/efo/labels.json +55 -0
- seqforge/io/efo.py +138 -0
- seqforge/io/onlist.py +661 -0
- seqforge/io/onlists/3M-february-2018.codes.gz +0 -0
- seqforge/io/onlists/737K-arc-v1.codes.gz +0 -0
- seqforge/io/onlists/737K-august-2016.codes.gz +0 -0
- seqforge/io/onlists/bd-rhapsody-cls1-384.codes.gz +0 -0
- seqforge/io/onlists/bd-rhapsody-cls1.codes.gz +0 -0
- seqforge/io/onlists/bd-rhapsody-cls2-384.codes.gz +0 -0
- seqforge/io/onlists/bd-rhapsody-cls2.codes.gz +0 -0
- seqforge/io/onlists/bd-rhapsody-cls3-384.codes.gz +0 -0
- seqforge/io/onlists/bd-rhapsody-cls3.codes.gz +0 -0
- seqforge/io/onlists/index.json +74 -0
- seqforge/io/remote.py +659 -0
- seqforge/io/taxonomy.py +194 -0
- seqforge/kb/__init__.py +62 -0
- seqforge/kb/anchor.py +169 -0
- seqforge/kb/generate.py +147 -0
- seqforge/kb/loader.py +152 -0
- seqforge/kb/roundtrip.py +112 -0
- seqforge/kb/schema.py +422 -0
- seqforge/kb/specs/10x-3p-gex/spec.yaml +62 -0
- seqforge/kb/specs/10x-3p-gex-v2/README.md +41 -0
- seqforge/kb/specs/10x-3p-gex-v2/spec.yaml +83 -0
- seqforge/kb/specs/10x-3p-gex-v3/README.md +56 -0
- seqforge/kb/specs/10x-3p-gex-v3/spec.yaml +118 -0
- seqforge/kb/specs/10x-3p-gex-v3.1/README.md +56 -0
- seqforge/kb/specs/10x-3p-gex-v3.1/spec.yaml +124 -0
- seqforge/kb/specs/bd-rhapsody-wta/README.md +103 -0
- seqforge/kb/specs/bd-rhapsody-wta/spec.yaml +130 -0
- seqforge/kb/specs/bd-rhapsody-wta-enhanced/spec.yaml +99 -0
- seqforge/kb/specs/bd-rhapsody-wta-enhanced-v1/spec.yaml +93 -0
- seqforge/kb/specs/bd-rhapsody-wta-enhanced-v2/spec.yaml +81 -0
- seqforge/kb/specs/bulk-rnaseq-pe/README.md +35 -0
- seqforge/kb/specs/bulk-rnaseq-pe/spec.yaml +97 -0
- seqforge/kb/specs/splitseq/README.md +51 -0
- seqforge/kb/specs/splitseq/spec.yaml +157 -0
- seqforge/manifest/__init__.py +61 -0
- seqforge/manifest/fill.py +531 -0
- seqforge/manifest/hash.py +77 -0
- seqforge/manifest/instruct.py +114 -0
- seqforge/manifest/policy.py +409 -0
- seqforge/manifest/validate.py +274 -0
- seqforge/models/__init__.py +268 -0
- seqforge/models/assertion.py +68 -0
- seqforge/models/base.py +100 -0
- seqforge/models/blocker.py +71 -0
- seqforge/models/conflict.py +47 -0
- seqforge/models/dataset.py +320 -0
- seqforge/models/evidenced.py +54 -0
- seqforge/models/observation.py +157 -0
- seqforge/models/processing.py +231 -0
- seqforge/models/records.py +145 -0
- seqforge/models/resolve.py +216 -0
- seqforge/probe/__init__.py +46 -0
- seqforge/probe/core.py +232 -0
- seqforge/probe/signals.py +250 -0
- seqforge/probe/streaming.py +118 -0
- seqforge/project.py +177 -0
- seqforge/py.typed +0 -0
- seqforge/resolve/__init__.py +98 -0
- seqforge/resolve/assign.py +204 -0
- seqforge/resolve/cache.py +119 -0
- seqforge/resolve/confuse.py +215 -0
- seqforge/resolve/engine.py +646 -0
- seqforge/resolve/escalate.py +668 -0
- seqforge/resolve/evaluators.py +306 -0
- seqforge/resolve/geometry.py +89 -0
- seqforge/resolve/group.py +85 -0
- seqforge/resolve/records.py +550 -0
- seqforge/resolve/scoring.py +373 -0
- seqforge/resolve/window.py +206 -0
- seqforge/workflows/__init__.py +234 -0
- seqforge/workflows/cram.py +117 -0
- seqforge/workflows/h5ad.py +368 -0
- seqforge/workflows/map/star.smk +101 -0
- seqforge/workflows/map/starsolo.smk +360 -0
- seqforge/workflows/qc.py +157 -0
- seqforge/workspace.py +125 -0
- seqforge-2026.7.1.dist-info/METADATA +125 -0
- seqforge-2026.7.1.dist-info/RECORD +124 -0
- seqforge-2026.7.1.dist-info/WHEEL +4 -0
- seqforge-2026.7.1.dist-info/entry_points.txt +2 -0
- seqforge-2026.7.1.dist-info/licenses/LICENSE +21 -0
seqforge/io/archive.py
ADDED
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
"""``io records`` — fetch what a public archive declares about a dataset, at all four levels.
|
|
2
|
+
|
|
3
|
+
This is a **transcriber**, not a resolver. It turns an accession into
|
|
4
|
+
:class:`~seqforge.models.records.ArchiveRecordSet` — project, sample, experiment, run — and stops.
|
|
5
|
+
It decides nothing, harmonizes only where NCBI itself harmonized, and never touches a byte of FASTQ.
|
|
6
|
+
What the records *mean* is :mod:`seqforge.resolve.records`'s job.
|
|
7
|
+
|
|
8
|
+
**Why this had to exist at all.** ``io/remote.py`` already asks ENA for 24 fields, and none of them
|
|
9
|
+
told us anything per-sample: ``experiment_title`` and ``sample_title`` are byte-identical across all
|
|
10
|
+
six runs of the pilot ("Model organism or animal sample from Caenorhabditis elegans"), the fields
|
|
11
|
+
that *do* discriminate (``sample_alias``, ``library_name``, ``run_alias``) were never requested, and
|
|
12
|
+
the BioSample attributes that carry ``strain``/``tissue``/``sex``/``dev_stage`` were fetched by zero
|
|
13
|
+
lines of code anywhere in the repo. "We already have this and throw it away" was the comfortable
|
|
14
|
+
assumption, and it was false.
|
|
15
|
+
|
|
16
|
+
**Three calls, and each earns its place:**
|
|
17
|
+
|
|
18
|
+
1. ``labdata.experiments_for`` expands *any* accession — GEO series, BioProject, study, run,
|
|
19
|
+
BioSample — into the experiments underneath it, over NCBI Entrez. It replaced our own ENA/SOFT
|
|
20
|
+
routing because its ``elink`` route traverses a GEO *SuperSeries* (which owns no runs of its own)
|
|
21
|
+
and a BioProject umbrella transitively — the recursion our SOFT parser missed, which lost a whole
|
|
22
|
+
SuperSeries dataset while reporting success.
|
|
23
|
+
2. NCBI's ``efetch db=sra`` returns one ``EXPERIMENT_PACKAGE`` per experiment, and a package is the
|
|
24
|
+
whole hierarchy in one object: STUDY (title, abstract, centre), EXPERIMENT (the protocol prose),
|
|
25
|
+
SAMPLE (alias + attributes), RUN (accession, alias, original filenames). Everything but one thing.
|
|
26
|
+
3. That one thing is ``harmonized_name``. The SRA package gives a sample attribute as the submitter
|
|
27
|
+
typed it (``<TAG>dev_stage</TAG>``); ``efetch db=biosample`` gives NCBI's own harmonization of the
|
|
28
|
+
same attribute (``harmonized_name="dev_stage"``). We want NCBI's, because the alternative is us
|
|
29
|
+
guessing at someone else's vocabulary.
|
|
30
|
+
|
|
31
|
+
``efetch db=sra`` does **not** accept a study accession (``id=SRP502277`` -> "ID list is empty"),
|
|
32
|
+
which is why step 1 is not optional. It does accept a list of experiment accessions, so the six
|
|
33
|
+
packages arrive in one request.
|
|
34
|
+
|
|
35
|
+
Every fetch degrades rather than aborts: an archive that is down, rate-limited, or simply does not
|
|
36
|
+
have a level yields fewer records, and a dataset with no accession at all yields none. That is not a
|
|
37
|
+
fallback — a great deal of sequencing data has never seen an accession and never will.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import os
|
|
43
|
+
import re
|
|
44
|
+
import time
|
|
45
|
+
from xml.etree import ElementTree
|
|
46
|
+
|
|
47
|
+
from ..models.records import ArchiveRecord, ArchiveRecordSet, FreeText, RecordAttribute
|
|
48
|
+
from .attributes import harmonize
|
|
49
|
+
from .remote import _MAX_RETRIES, RemoteError, _get, retry_delay
|
|
50
|
+
|
|
51
|
+
#: A ``LabdataError`` message that names a TRANSIENT NCBI failure (a 5xx, a 429/rate-limit, a timeout)
|
|
52
|
+
#: — worth a backoff-and-retry. Everything else (a malformed accession, a record with no SRA data) is
|
|
53
|
+
#: terminal and raised at once. eutils returns intermittent HTTP 500s under load, and the accession
|
|
54
|
+
#: hop runs through ``labdata``'s own Entrez client, which `_get`'s retry does not wrap (#9).
|
|
55
|
+
_TRANSIENT_LABDATA = re.compile(
|
|
56
|
+
r"\b(?:429|50[0234]|rate.?limit|timed?.?out|temporarily|connection)\b", re.I
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
#: NCBI E-utilities. ``efetch db=sra`` takes experiment accessions; ``db=biosample`` takes SAMN ids.
|
|
60
|
+
EUTILS_EFETCH = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/efetch.fcgi"
|
|
61
|
+
|
|
62
|
+
#: How many ids to put in one efetch. NCBI asks for POST above ~200; we stay well under and page.
|
|
63
|
+
_BATCH = 100
|
|
64
|
+
|
|
65
|
+
SOURCE = "ncbi-sra+biosample"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _efetch(db: str, ids: list[str], **params: str) -> str:
|
|
69
|
+
query = {"db": db, "id": ",".join(ids), "retmode": "xml", **params}
|
|
70
|
+
# NCBI raises the eutils rate limit from 3 to 10 req/sec for a keyed caller. Consume NCBI_API_KEY
|
|
71
|
+
# if the operator's environment sets one (it commonly does); without it we simply stay keyless and
|
|
72
|
+
# lean on `_get`'s 429 backoff (#9). The key stays in the request params only — never logged.
|
|
73
|
+
key = os.environ.get("NCBI_API_KEY")
|
|
74
|
+
if key:
|
|
75
|
+
query["api_key"] = key
|
|
76
|
+
return _get(EUTILS_EFETCH, query)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _text(node: ElementTree.Element | None, path: str) -> str:
|
|
80
|
+
if node is None:
|
|
81
|
+
return ""
|
|
82
|
+
return " ".join((node.findtext(path) or "").split())
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _external_id(node: ElementTree.Element | None, namespace: str) -> str | None:
|
|
86
|
+
"""An ``<EXTERNAL_ID namespace="...">`` under ``node``. How the archive links its own namespaces."""
|
|
87
|
+
if node is None:
|
|
88
|
+
return None
|
|
89
|
+
for ident in node.findall(".//EXTERNAL_ID"):
|
|
90
|
+
if ident.get("namespace") == namespace and (ident.text or "").strip():
|
|
91
|
+
return ident.text.strip()
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _free(label: str, text: str | None) -> list[FreeText]:
|
|
96
|
+
"""One FreeText, or none. Empty prose is absence, not an empty string."""
|
|
97
|
+
cleaned = " ".join((text or "").split())
|
|
98
|
+
return [FreeText(label=label, text=cleaned)] if cleaned else []
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def parse_sra_package_set(xml: str) -> list[ArchiveRecord]:
|
|
102
|
+
"""``efetch db=sra`` XML -> project/sample/experiment/run records.
|
|
103
|
+
|
|
104
|
+
One ``EXPERIMENT_PACKAGE`` carries the whole chain for one experiment, so the same STUDY and the
|
|
105
|
+
same SAMPLE appear in several packages. They are de-duplicated by accession here rather than
|
|
106
|
+
merged later: two packages describing one study describe it identically, and a "merge" would be
|
|
107
|
+
inventing a reconciliation problem that the archive does not have.
|
|
108
|
+
|
|
109
|
+
A sample record is keyed by its **BioSample** accession when the record declares one, because
|
|
110
|
+
that is the id that survives leaving SRA. The experiment's ``parent`` is rewritten to match,
|
|
111
|
+
using the mapping the record itself provides — code following the record, not inferring it.
|
|
112
|
+
"""
|
|
113
|
+
try:
|
|
114
|
+
root = ElementTree.fromstring(xml)
|
|
115
|
+
except ElementTree.ParseError as exc:
|
|
116
|
+
raise RemoteError(f"efetch db=sra returned unparsable XML: {exc}") from exc
|
|
117
|
+
|
|
118
|
+
projects: dict[str, ArchiveRecord] = {}
|
|
119
|
+
samples: dict[str, ArchiveRecord] = {}
|
|
120
|
+
experiments: dict[str, ArchiveRecord] = {}
|
|
121
|
+
runs: dict[str, ArchiveRecord] = {}
|
|
122
|
+
|
|
123
|
+
for pkg in root.findall(".//EXPERIMENT_PACKAGE"):
|
|
124
|
+
study = pkg.find("STUDY")
|
|
125
|
+
study_id = None
|
|
126
|
+
if study is not None:
|
|
127
|
+
study_id = _external_id(study.find("IDENTIFIERS"), "BioProject") or study.get(
|
|
128
|
+
"accession"
|
|
129
|
+
)
|
|
130
|
+
centre = ""
|
|
131
|
+
submission = pkg.find("SUBMISSION")
|
|
132
|
+
if submission is not None:
|
|
133
|
+
centre = submission.get("center_name") or ""
|
|
134
|
+
|
|
135
|
+
if study_id and study_id not in projects:
|
|
136
|
+
projects[study_id] = ArchiveRecord(
|
|
137
|
+
level="project",
|
|
138
|
+
accession=study_id,
|
|
139
|
+
attributes=([RecordAttribute(name="center_name", value=centre)] if centre else []),
|
|
140
|
+
free_text=[
|
|
141
|
+
*_free("study_title", _text(study, "DESCRIPTOR/STUDY_TITLE")),
|
|
142
|
+
*_free("study_abstract", _text(study, "DESCRIPTOR/STUDY_ABSTRACT")),
|
|
143
|
+
],
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
sample = pkg.find("SAMPLE")
|
|
147
|
+
sample_id = None
|
|
148
|
+
if sample is not None:
|
|
149
|
+
sample_id = _external_id(sample.find("IDENTIFIERS"), "BioSample") or sample.get(
|
|
150
|
+
"accession"
|
|
151
|
+
)
|
|
152
|
+
if sample_id and sample_id not in samples:
|
|
153
|
+
samples[sample_id] = ArchiveRecord(
|
|
154
|
+
level="sample",
|
|
155
|
+
accession=sample_id,
|
|
156
|
+
parent=study_id,
|
|
157
|
+
attributes=[
|
|
158
|
+
*_taxonomy(_text(sample, "SAMPLE_NAME/TAXON_ID")),
|
|
159
|
+
*_sample_attributes(sample),
|
|
160
|
+
],
|
|
161
|
+
free_text=_free("sample_alias", sample.get("alias")),
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
experiment = pkg.find("EXPERIMENT")
|
|
165
|
+
exp_id = experiment.get("accession") if experiment is not None else None
|
|
166
|
+
if experiment is not None and exp_id and exp_id not in experiments:
|
|
167
|
+
design = experiment.find("DESIGN")
|
|
168
|
+
lib = experiment.find("DESIGN/LIBRARY_DESCRIPTOR")
|
|
169
|
+
attrs = [
|
|
170
|
+
RecordAttribute(name=name, value=value)
|
|
171
|
+
for name, value in (
|
|
172
|
+
("library_strategy", _text(lib, "LIBRARY_STRATEGY")),
|
|
173
|
+
("library_source", _text(lib, "LIBRARY_SOURCE")),
|
|
174
|
+
("library_selection", _text(lib, "LIBRARY_SELECTION")),
|
|
175
|
+
("instrument_model", _text(experiment, ".//INSTRUMENT_MODEL")),
|
|
176
|
+
)
|
|
177
|
+
if value
|
|
178
|
+
]
|
|
179
|
+
experiments[exp_id] = ArchiveRecord(
|
|
180
|
+
level="experiment",
|
|
181
|
+
accession=exp_id,
|
|
182
|
+
parent=sample_id,
|
|
183
|
+
attributes=attrs,
|
|
184
|
+
free_text=[
|
|
185
|
+
*_free("experiment_title", _text(experiment, "TITLE")),
|
|
186
|
+
*_free("experiment_alias", experiment.get("alias")),
|
|
187
|
+
*_free("library_name", _text(lib, "LIBRARY_NAME")),
|
|
188
|
+
# the protocol paragraph: where "Single Cell 3 v3.1 Reagent Kits ... 28+94 nt
|
|
189
|
+
# pair-end reads" lives. The one piece of prose that describes the chemistry.
|
|
190
|
+
*_free("design_description", _text(design, "DESIGN_DESCRIPTION")),
|
|
191
|
+
],
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
for run in pkg.findall(".//RUN"):
|
|
195
|
+
run_id = run.get("accession")
|
|
196
|
+
if not run_id or run_id in runs:
|
|
197
|
+
continue
|
|
198
|
+
runs[run_id] = ArchiveRecord(
|
|
199
|
+
level="run",
|
|
200
|
+
accession=run_id,
|
|
201
|
+
parent=exp_id,
|
|
202
|
+
free_text=_free("run_alias", run.get("alias")),
|
|
203
|
+
filenames=_original_filenames(run),
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
return [
|
|
207
|
+
*sorted(projects.values(), key=lambda r: r.accession),
|
|
208
|
+
*sorted(samples.values(), key=lambda r: r.accession),
|
|
209
|
+
*sorted(experiments.values(), key=lambda r: r.accession),
|
|
210
|
+
*sorted(runs.values(), key=lambda r: r.accession),
|
|
211
|
+
]
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _original_filenames(run: ElementTree.Element) -> list[str]:
|
|
215
|
+
"""What the submitter's files were called, per the archive.
|
|
216
|
+
|
|
217
|
+
``supertype="Original"`` only: the other entries are SRA's own normalized ``.sra``/``.lite``
|
|
218
|
+
products, which are not files anyone has on disk under that name. These matter because a
|
|
219
|
+
downloaded dataset does not always carry the run accession in its filenames, and then the
|
|
220
|
+
original name is the only thing left that can join a file to its sample.
|
|
221
|
+
"""
|
|
222
|
+
out: list[str] = []
|
|
223
|
+
for f in run.findall(".//SRAFile"):
|
|
224
|
+
if f.get("supertype") == "Original" and f.get("filename"):
|
|
225
|
+
out.append(str(f.get("filename")))
|
|
226
|
+
return sorted(set(out))
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _taxonomy(taxid: str) -> list[RecordAttribute]:
|
|
230
|
+
"""The organism the record declares, as a taxid.
|
|
231
|
+
|
|
232
|
+
``harmonized=False``: NCBI has no harmonized *attribute* called ``taxonomy_id`` — the organism
|
|
233
|
+
lives in the record's structure, not in its attribute list. Recording it here keeps every declared
|
|
234
|
+
fact about a sample in one place while keeping it out of the sample-attribute key space, which the
|
|
235
|
+
960-name vocabulary owns. ``resolve`` reads it by name to fill ``experiment.organism``, which
|
|
236
|
+
until now had to be typed on the command line and cited nothing.
|
|
237
|
+
"""
|
|
238
|
+
return [RecordAttribute(name="taxonomy_id", value=taxid)] if taxid.isdigit() else []
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _sample_attributes(sample: ElementTree.Element) -> list[RecordAttribute]:
|
|
242
|
+
"""SRA's ``<SAMPLE_ATTRIBUTE><TAG>`` pairs, harmonized against NCBI's vocabulary where possible.
|
|
243
|
+
|
|
244
|
+
This is the fallback path. When a BioSample record is reachable, :func:`merge_biosample_attributes`
|
|
245
|
+
overwrites these with NCBI's *own* harmonization, which is authoritative in a way our synonym
|
|
246
|
+
lookup is not. Applying the fallback first keeps a sample whose BioSample is unreachable (or which
|
|
247
|
+
never had one) from silently having no attributes at all.
|
|
248
|
+
"""
|
|
249
|
+
out: list[RecordAttribute] = []
|
|
250
|
+
for attr in sample.findall(".//SAMPLE_ATTRIBUTE"):
|
|
251
|
+
tag = " ".join((attr.findtext("TAG") or "").split())
|
|
252
|
+
value = " ".join((attr.findtext("VALUE") or "").split())
|
|
253
|
+
if not tag or not value:
|
|
254
|
+
continue
|
|
255
|
+
name = harmonize(tag)
|
|
256
|
+
out.append(
|
|
257
|
+
RecordAttribute(
|
|
258
|
+
name=name or tag,
|
|
259
|
+
value=value,
|
|
260
|
+
harmonized=name is not None,
|
|
261
|
+
raw_name=tag if name and name != tag else None,
|
|
262
|
+
)
|
|
263
|
+
)
|
|
264
|
+
return sorted(out, key=lambda a: a.name)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def parse_biosample_set(xml: str) -> dict[str, list[RecordAttribute]]:
|
|
268
|
+
"""``efetch db=biosample`` XML -> BioSample accession -> attributes, using NCBI's harmonization.
|
|
269
|
+
|
|
270
|
+
``harmonized_name`` is NCBI's answer to "what is this attribute really called", computed by the
|
|
271
|
+
people who own the vocabulary. Where it is absent the submitter invented the tag, and it is kept
|
|
272
|
+
unharmonized rather than guessed at.
|
|
273
|
+
"""
|
|
274
|
+
try:
|
|
275
|
+
root = ElementTree.fromstring(xml)
|
|
276
|
+
except ElementTree.ParseError as exc:
|
|
277
|
+
raise RemoteError(f"efetch db=biosample returned unparsable XML: {exc}") from exc
|
|
278
|
+
|
|
279
|
+
out: dict[str, list[RecordAttribute]] = {}
|
|
280
|
+
for bs in root.findall(".//BioSample"):
|
|
281
|
+
acc = bs.get("accession")
|
|
282
|
+
if not acc:
|
|
283
|
+
continue
|
|
284
|
+
attrs: list[RecordAttribute] = []
|
|
285
|
+
for attr in bs.findall(".//Attribute"):
|
|
286
|
+
value = " ".join((attr.text or "").split())
|
|
287
|
+
if not value:
|
|
288
|
+
continue
|
|
289
|
+
raw = attr.get("attribute_name") or ""
|
|
290
|
+
harmonized = attr.get("harmonized_name")
|
|
291
|
+
attrs.append(
|
|
292
|
+
RecordAttribute(
|
|
293
|
+
name=harmonized or raw,
|
|
294
|
+
value=value,
|
|
295
|
+
harmonized=bool(harmonized),
|
|
296
|
+
raw_name=raw if harmonized and raw != harmonized else None,
|
|
297
|
+
)
|
|
298
|
+
)
|
|
299
|
+
organism = bs.find(".//Description/Organism")
|
|
300
|
+
if organism is not None and (organism.get("taxonomy_id") or "").isdigit():
|
|
301
|
+
attrs.extend(_taxonomy(str(organism.get("taxonomy_id"))))
|
|
302
|
+
owner = _text(bs.find("Owner"), "Name")
|
|
303
|
+
if owner:
|
|
304
|
+
attrs.append(RecordAttribute(name="center_name", value=owner))
|
|
305
|
+
package = _text(bs, "Package")
|
|
306
|
+
if package:
|
|
307
|
+
# recorded, never enforced — see io/attributes.py for why narrowing by package is wrong
|
|
308
|
+
attrs.append(RecordAttribute(name="biosample_package", value=package))
|
|
309
|
+
out[acc] = sorted(attrs, key=lambda a: a.name)
|
|
310
|
+
return out
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def merge_biosample_attributes(
|
|
314
|
+
records: list[ArchiveRecord], by_accession: dict[str, list[RecordAttribute]]
|
|
315
|
+
) -> list[ArchiveRecord]:
|
|
316
|
+
"""Replace a sample record's SRA-derived attributes with NCBI's harmonized ones where we have them."""
|
|
317
|
+
out: list[ArchiveRecord] = []
|
|
318
|
+
for rec in records:
|
|
319
|
+
attrs = by_accession.get(rec.accession) if rec.level == "sample" else None
|
|
320
|
+
out.append(rec.model_copy(update={"attributes": attrs}) if attrs else rec)
|
|
321
|
+
return out
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def parse_bioproject_set(xml: str) -> dict[str, list[RecordAttribute]]:
|
|
325
|
+
"""``efetch db=bioproject`` XML -> project accession -> the structured study facts (decision 5).
|
|
326
|
+
|
|
327
|
+
Only the declared facts: data type, and the release/submission date. Title, abstract and centre
|
|
328
|
+
already come from the SRA package, and asking two services for the same string invents a
|
|
329
|
+
disagreement to arbitrate.
|
|
330
|
+
"""
|
|
331
|
+
try:
|
|
332
|
+
root = ElementTree.fromstring(xml)
|
|
333
|
+
except ElementTree.ParseError as exc:
|
|
334
|
+
raise RemoteError(f"efetch db=bioproject returned unparsable XML: {exc}") from exc
|
|
335
|
+
|
|
336
|
+
out: dict[str, list[RecordAttribute]] = {}
|
|
337
|
+
for summary in root.findall(".//DocumentSummary"):
|
|
338
|
+
archive = summary.find(".//ArchiveID")
|
|
339
|
+
acc = archive.get("accession") if archive is not None else None
|
|
340
|
+
if not acc:
|
|
341
|
+
continue
|
|
342
|
+
attrs: list[RecordAttribute] = []
|
|
343
|
+
types = sorted(
|
|
344
|
+
{
|
|
345
|
+
" ".join((d.text or "").split())
|
|
346
|
+
for d in summary.findall(".//ProjectDataTypeSet/DataType")
|
|
347
|
+
if d.text
|
|
348
|
+
}
|
|
349
|
+
)
|
|
350
|
+
if types:
|
|
351
|
+
attrs.append(RecordAttribute(name="data_type", value="; ".join(types)))
|
|
352
|
+
sub = summary.find(".//Submission")
|
|
353
|
+
if sub is not None and sub.get("submitted"):
|
|
354
|
+
attrs.append(RecordAttribute(name="submission_date", value=str(sub.get("submitted"))))
|
|
355
|
+
out[acc] = attrs
|
|
356
|
+
return out
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _experiments_for(accession: str) -> list[str]:
|
|
360
|
+
"""Any accession -> the SRA experiment accessions under it, via ``labdata``.
|
|
361
|
+
|
|
362
|
+
Delegated to :func:`labdata.experiments_for`, whose Entrez ``elink`` route traverses a GEO
|
|
363
|
+
*SuperSeries* (which owns no runs of its own) and a BioProject umbrella transitively — the case
|
|
364
|
+
our own GEO/SOFT recursion missed, silently losing the whole dataset while reporting success.
|
|
365
|
+
``labdata`` is a lab package that ships with ``liulab-genome`` in ``liulab-runtime``, so
|
|
366
|
+
depending on it adds no environment the cluster does not already have.
|
|
367
|
+
|
|
368
|
+
``labdata`` raises on a malformed accession or a failed request and returns an empty list for a
|
|
369
|
+
record with no SRA data; both become a loud :class:`RemoteError` here, because an accession that
|
|
370
|
+
was *given* and resolves to nothing is a refusal, not a silent omission from a permanent manifest.
|
|
371
|
+
"""
|
|
372
|
+
import labdata
|
|
373
|
+
from labdata.exceptions import LabdataError
|
|
374
|
+
|
|
375
|
+
# Retry a TRANSIENT labdata failure (intermittent eutils 5xx/429) with backoff, exactly as
|
|
376
|
+
# `_get` does for seqforge's own eutils calls — otherwise a momentary NCBI blip aborts the whole
|
|
377
|
+
# `records` stage and the dataset cannot compile (seen live: GSE274290 hit a bare HTTP 500). A
|
|
378
|
+
# terminal error (malformed accession) still raises on the first attempt.
|
|
379
|
+
attempt = 0
|
|
380
|
+
while True:
|
|
381
|
+
try:
|
|
382
|
+
experiments = labdata.experiments_for(accession)
|
|
383
|
+
break
|
|
384
|
+
except LabdataError as exc:
|
|
385
|
+
if _TRANSIENT_LABDATA.search(str(exc)) and attempt < _MAX_RETRIES:
|
|
386
|
+
time.sleep(retry_delay(None, attempt))
|
|
387
|
+
attempt += 1
|
|
388
|
+
continue
|
|
389
|
+
raise RemoteError(f"{accession}: could not resolve experiments: {exc}") from exc
|
|
390
|
+
accessions = sorted({e.accession for e in experiments})
|
|
391
|
+
if not accessions:
|
|
392
|
+
raise RemoteError(
|
|
393
|
+
f"{accession}: no experiments found. It may be unreleased (status=hup), or a record "
|
|
394
|
+
"that carries no raw SRA data."
|
|
395
|
+
)
|
|
396
|
+
return accessions
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def fetch_records(accession: str) -> ArchiveRecordSet:
|
|
400
|
+
"""Any accession -> every record the archive holds for it, across all four levels.
|
|
401
|
+
|
|
402
|
+
Never guesses and never half-succeeds silently: a level that cannot be fetched is absent from the
|
|
403
|
+
result, and the caller (``resolve``) is the one entitled to have an opinion about that.
|
|
404
|
+
"""
|
|
405
|
+
experiments = _experiments_for(accession)
|
|
406
|
+
|
|
407
|
+
packages: list[ArchiveRecord] = []
|
|
408
|
+
for i in range(0, len(experiments), _BATCH):
|
|
409
|
+
packages.extend(parse_sra_package_set(_efetch("sra", experiments[i : i + _BATCH])))
|
|
410
|
+
|
|
411
|
+
biosamples = [
|
|
412
|
+
r.accession for r in packages if r.level == "sample" and r.accession.startswith("SAM")
|
|
413
|
+
]
|
|
414
|
+
if biosamples:
|
|
415
|
+
harmonized: dict[str, list[RecordAttribute]] = {}
|
|
416
|
+
for i in range(0, len(biosamples), _BATCH):
|
|
417
|
+
harmonized.update(
|
|
418
|
+
parse_biosample_set(
|
|
419
|
+
_efetch("biosample", biosamples[i : i + _BATCH], rettype="full")
|
|
420
|
+
)
|
|
421
|
+
)
|
|
422
|
+
packages = merge_biosample_attributes(packages, harmonized)
|
|
423
|
+
|
|
424
|
+
projects = [
|
|
425
|
+
r.accession for r in packages if r.level == "project" and r.accession.startswith("PRJ")
|
|
426
|
+
]
|
|
427
|
+
if projects:
|
|
428
|
+
try:
|
|
429
|
+
extra = parse_bioproject_set(_efetch("bioproject", projects))
|
|
430
|
+
except RemoteError:
|
|
431
|
+
extra = {} # the study facts we already have came from the SRA package; this only adds
|
|
432
|
+
packages = [
|
|
433
|
+
r.model_copy(update={"attributes": [*r.attributes, *extra[r.accession]]})
|
|
434
|
+
if r.level == "project" and r.accession in extra
|
|
435
|
+
else r
|
|
436
|
+
for r in packages
|
|
437
|
+
]
|
|
438
|
+
|
|
439
|
+
return ArchiveRecordSet(source=SOURCE, query=accession, records=packages)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
__all__ = [
|
|
443
|
+
"EUTILS_EFETCH",
|
|
444
|
+
"SOURCE",
|
|
445
|
+
"fetch_records",
|
|
446
|
+
"parse_sra_package_set",
|
|
447
|
+
"parse_biosample_set",
|
|
448
|
+
"parse_bioproject_set",
|
|
449
|
+
"merge_biosample_attributes",
|
|
450
|
+
]
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""NCBI's harmonized BioSample attribute vocabulary — the key space for a sample fact.
|
|
2
|
+
|
|
3
|
+
A sample fact needs a key, and the key cannot be ours. ``condition`` was — we invented it, no archive
|
|
4
|
+
uses it, and it is exactly the slot a language model filed worm husbandry into on the pilot, because
|
|
5
|
+
a field named "condition" will accept anything you can call a condition. NCBI already solved this:
|
|
6
|
+
960 curated attribute names, each with a definition, a display name, and the synonyms real submitters
|
|
7
|
+
actually type. This module is that list, and nothing else decides what a sample attribute may be
|
|
8
|
+
called.
|
|
9
|
+
|
|
10
|
+
**Ask a subset, enforce the whole list.** :mod:`seqforge.harvest.fields` picks the handful of
|
|
11
|
+
attributes worth spending prompt tokens on; ``SampleGroup`` validates every key against all 960. The
|
|
12
|
+
two are deliberately different sizes — asking for 960 fields would be absurd, and permitting only the
|
|
13
|
+
handful we ask for would throw away an attribute a record genuinely declares.
|
|
14
|
+
|
|
15
|
+
*A correction worth recording, because the obvious design is wrong.* Every BioSample record declares
|
|
16
|
+
a **package** (the pilot's is ``Model.organism.animal.1.0``), and each of NCBI's attributes lists the
|
|
17
|
+
packages it belongs to — so it looks as though a record's package narrows the key space from 960 to a
|
|
18
|
+
couple of dozen. It does not. The pilot's record declares ``Model.organism.animal.1.0`` and carries
|
|
19
|
+
``strain`` and ``dev_stage``; NCBI lists ``strain`` only under OneHealthEnteric/PHA4GE.wwsurv and
|
|
20
|
+
``dev_stage`` only under Human.1.0/Invertebrate.1.0. Narrowing by package would have dropped
|
|
21
|
+
``strain`` — the one attribute that separates this dataset's two conditions. Packages are therefore
|
|
22
|
+
recorded and never enforced.
|
|
23
|
+
|
|
24
|
+
The data ships (``biosample/attributes.json``, 239 kB of plain diffable JSON) rather than being
|
|
25
|
+
fetched, because enforcement must work offline: a validator that needs the network is a validator
|
|
26
|
+
that fails open on a compute node. It is **generated** by ``seqforge io attributes refresh``, records
|
|
27
|
+
its own source URL and fetch date, and is never hand-edited.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import json
|
|
33
|
+
from dataclasses import dataclass
|
|
34
|
+
from functools import cache
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
#: NCBI's own list, in its own format. The one place this vocabulary comes from.
|
|
38
|
+
ATTRIBUTES_URL = "https://www.ncbi.nlm.nih.gov/biosample/docs/attributes/?format=xml"
|
|
39
|
+
|
|
40
|
+
_DATA = Path(__file__).parent / "biosample" / "attributes.json"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class BioSampleAttribute:
|
|
45
|
+
"""One of NCBI's harmonized attributes: the name, what it means, and what people call it."""
|
|
46
|
+
|
|
47
|
+
#: The harmonized name — the key a ``SampleGroup`` attribute is stored under.
|
|
48
|
+
name: str
|
|
49
|
+
#: NCBI's human-facing label, e.g. ``dev_stage`` -> "development stage".
|
|
50
|
+
display: str
|
|
51
|
+
#: NCBI's definition. Handed to the model verbatim when we ask for this attribute, so the prompt
|
|
52
|
+
#: never carries our paraphrase of someone else's vocabulary. Some are empty in NCBI's own list.
|
|
53
|
+
description: str
|
|
54
|
+
#: What submitters write instead, per NCBI. The only basis on which a raw tag is harmonized.
|
|
55
|
+
synonyms: tuple[str, ...] = ()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class VocabularyMissing(RuntimeError):
|
|
59
|
+
"""The shipped vocabulary is absent or unreadable. Never a silently empty key space."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _key(raw: str) -> str:
|
|
63
|
+
"""Fold a submitter's tag the way a lookup needs: case, spaces and underscores are noise."""
|
|
64
|
+
return raw.strip().lower().replace(" ", "_").replace("-", "_")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@cache
|
|
68
|
+
def load_attributes() -> dict[str, BioSampleAttribute]:
|
|
69
|
+
"""Every harmonized attribute, by name. Cached — it is a read-only 239 kB file."""
|
|
70
|
+
if not _DATA.is_file():
|
|
71
|
+
raise VocabularyMissing(
|
|
72
|
+
f"the NCBI BioSample attribute vocabulary is missing from the package ({_DATA}). "
|
|
73
|
+
f"Regenerate it with `seqforge io attributes refresh`."
|
|
74
|
+
)
|
|
75
|
+
doc = json.loads(_DATA.read_text(encoding="utf-8"))
|
|
76
|
+
return {
|
|
77
|
+
name: BioSampleAttribute(
|
|
78
|
+
name=name,
|
|
79
|
+
display=str(meta.get("display", "")),
|
|
80
|
+
description=str(meta.get("description", "")),
|
|
81
|
+
synonyms=tuple(meta.get("synonyms", ())),
|
|
82
|
+
)
|
|
83
|
+
for name, meta in sorted(doc["attributes"].items())
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@cache
|
|
88
|
+
def _synonym_index() -> dict[str, str]:
|
|
89
|
+
"""Folded tag -> harmonized name, built from NCBI's own synonym lists.
|
|
90
|
+
|
|
91
|
+
A synonym claimed by two attributes is dropped rather than arbitrated: harmonizing it either way
|
|
92
|
+
would be a guess, and an unharmonized attribute is recorded honestly as itself.
|
|
93
|
+
"""
|
|
94
|
+
seen: dict[str, set[str]] = {}
|
|
95
|
+
for attr in load_attributes().values():
|
|
96
|
+
for raw in (attr.name, attr.display, *attr.synonyms):
|
|
97
|
+
if raw:
|
|
98
|
+
seen.setdefault(_key(raw), set()).add(attr.name)
|
|
99
|
+
# a name always wins over another attribute's synonym for the same folded key
|
|
100
|
+
for attr in load_attributes().values():
|
|
101
|
+
seen[_key(attr.name)] = {attr.name}
|
|
102
|
+
return {k: next(iter(v)) for k, v in seen.items() if len(v) == 1}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def source_provenance() -> dict[str, str]:
|
|
106
|
+
"""Where the shipped vocabulary came from and when. Recorded in the file, not in a comment."""
|
|
107
|
+
doc = json.loads(_DATA.read_text(encoding="utf-8"))
|
|
108
|
+
return {"source": str(doc["source"]), "fetched": str(doc["fetched"]), "n": str(doc["n"])}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def is_attribute(name: str) -> bool:
|
|
112
|
+
"""Is ``name`` one of NCBI's harmonized attribute names, exactly? Fail-closed."""
|
|
113
|
+
return name in load_attributes()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def get_attribute(name: str) -> BioSampleAttribute:
|
|
117
|
+
"""The attribute called ``name``. Raises ``KeyError`` — callers should have asked first."""
|
|
118
|
+
return load_attributes()[name]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def harmonize(raw: str) -> str | None:
|
|
122
|
+
"""A submitter's raw tag -> NCBI's harmonized name, or ``None`` if NCBI does not know it.
|
|
123
|
+
|
|
124
|
+
``None`` is a real answer and the common one for a submitter-invented tag. It means "record this
|
|
125
|
+
as an unharmonized attribute", never "drop it" and never "guess the closest name".
|
|
126
|
+
"""
|
|
127
|
+
return _synonym_index().get(_key(raw))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def parse_ncbi_attributes_xml(xml: str) -> dict[str, BioSampleAttribute]:
|
|
131
|
+
"""Parse NCBI's attribute-list XML. The only reader of that format; used by ``refresh``."""
|
|
132
|
+
from xml.etree import ElementTree
|
|
133
|
+
|
|
134
|
+
root = ElementTree.fromstring(xml)
|
|
135
|
+
out: dict[str, BioSampleAttribute] = {}
|
|
136
|
+
for node in root.findall(".//Attribute"):
|
|
137
|
+
name = (node.findtext("HarmonizedName") or "").strip()
|
|
138
|
+
if not name:
|
|
139
|
+
continue
|
|
140
|
+
out[name] = BioSampleAttribute(
|
|
141
|
+
name=name,
|
|
142
|
+
display=" ".join((node.findtext("Name") or "").split()),
|
|
143
|
+
description=" ".join((node.findtext("Description") or "").split()),
|
|
144
|
+
synonyms=tuple(
|
|
145
|
+
sorted(
|
|
146
|
+
{" ".join((s.text or "").split()) for s in node.findall("Synonym") if s.text}
|
|
147
|
+
)
|
|
148
|
+
),
|
|
149
|
+
)
|
|
150
|
+
if not out:
|
|
151
|
+
raise VocabularyMissing(
|
|
152
|
+
"NCBI's attribute list parsed to zero attributes — refusing to ship"
|
|
153
|
+
)
|
|
154
|
+
return out
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def write_attributes(attrs: dict[str, BioSampleAttribute], *, fetched: str) -> Path:
|
|
158
|
+
"""Write the shipped vocabulary. The ONLY writer, so the file cannot drift from NCBI by hand."""
|
|
159
|
+
_DATA.parent.mkdir(parents=True, exist_ok=True)
|
|
160
|
+
doc = {
|
|
161
|
+
"source": ATTRIBUTES_URL,
|
|
162
|
+
"fetched": fetched,
|
|
163
|
+
"n": len(attrs),
|
|
164
|
+
"attributes": {
|
|
165
|
+
a.name: {
|
|
166
|
+
"display": a.display,
|
|
167
|
+
"description": a.description,
|
|
168
|
+
"synonyms": list(a.synonyms),
|
|
169
|
+
}
|
|
170
|
+
for a in sorted(attrs.values(), key=lambda x: x.name)
|
|
171
|
+
},
|
|
172
|
+
}
|
|
173
|
+
_DATA.write_text(json.dumps(doc, indent=1, sort_keys=True, ensure_ascii=False) + "\n")
|
|
174
|
+
load_attributes.cache_clear()
|
|
175
|
+
_synonym_index.cache_clear()
|
|
176
|
+
return _DATA
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
__all__ = [
|
|
180
|
+
"ATTRIBUTES_URL",
|
|
181
|
+
"BioSampleAttribute",
|
|
182
|
+
"VocabularyMissing",
|
|
183
|
+
"load_attributes",
|
|
184
|
+
"source_provenance",
|
|
185
|
+
"is_attribute",
|
|
186
|
+
"get_attribute",
|
|
187
|
+
"harmonize",
|
|
188
|
+
"parse_ncbi_attributes_xml",
|
|
189
|
+
"write_attributes",
|
|
190
|
+
]
|