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.
Files changed (124) hide show
  1. seqforge/__init__.py +16 -0
  2. seqforge/cli/__init__.py +38 -0
  3. seqforge/cli/__main__.py +8 -0
  4. seqforge/cli/_common.py +105 -0
  5. seqforge/cli/compose.py +119 -0
  6. seqforge/cli/eval.py +103 -0
  7. seqforge/cli/harvest.py +417 -0
  8. seqforge/cli/hook.py +247 -0
  9. seqforge/cli/io.py +502 -0
  10. seqforge/cli/kb.py +348 -0
  11. seqforge/cli/manifest.py +536 -0
  12. seqforge/cli/probe.py +43 -0
  13. seqforge/cli/processing.py +192 -0
  14. seqforge/cli/project.py +52 -0
  15. seqforge/cli/resolve.py +55 -0
  16. seqforge/cli/root.py +66 -0
  17. seqforge/cli/run.py +463 -0
  18. seqforge/cli/schema.py +41 -0
  19. seqforge/compose/__init__.py +28 -0
  20. seqforge/compose/core.py +515 -0
  21. seqforge/compose/gates.py +113 -0
  22. seqforge/compose/params.py +447 -0
  23. seqforge/e2e.py +1926 -0
  24. seqforge/evals/__init__.py +78 -0
  25. seqforge/evals/case.py +382 -0
  26. seqforge/evals/grade.py +300 -0
  27. seqforge/evals/run.py +420 -0
  28. seqforge/harvest/__init__.py +121 -0
  29. seqforge/harvest/extract.py +319 -0
  30. seqforge/harvest/fields.py +212 -0
  31. seqforge/harvest/normalize.py +537 -0
  32. seqforge/harvest/prep.py +41 -0
  33. seqforge/harvest/providers.py +321 -0
  34. seqforge/harvest/verify.py +251 -0
  35. seqforge/hooks/__init__.py +33 -0
  36. seqforge/hooks/guards.py +214 -0
  37. seqforge/io/__init__.py +61 -0
  38. seqforge/io/archive.py +450 -0
  39. seqforge/io/attributes.py +190 -0
  40. seqforge/io/biosample/attributes.json +6341 -0
  41. seqforge/io/efo/labels.json +55 -0
  42. seqforge/io/efo.py +138 -0
  43. seqforge/io/onlist.py +661 -0
  44. seqforge/io/onlists/3M-february-2018.codes.gz +0 -0
  45. seqforge/io/onlists/737K-arc-v1.codes.gz +0 -0
  46. seqforge/io/onlists/737K-august-2016.codes.gz +0 -0
  47. seqforge/io/onlists/bd-rhapsody-cls1-384.codes.gz +0 -0
  48. seqforge/io/onlists/bd-rhapsody-cls1.codes.gz +0 -0
  49. seqforge/io/onlists/bd-rhapsody-cls2-384.codes.gz +0 -0
  50. seqforge/io/onlists/bd-rhapsody-cls2.codes.gz +0 -0
  51. seqforge/io/onlists/bd-rhapsody-cls3-384.codes.gz +0 -0
  52. seqforge/io/onlists/bd-rhapsody-cls3.codes.gz +0 -0
  53. seqforge/io/onlists/index.json +74 -0
  54. seqforge/io/remote.py +659 -0
  55. seqforge/io/taxonomy.py +194 -0
  56. seqforge/kb/__init__.py +62 -0
  57. seqforge/kb/anchor.py +169 -0
  58. seqforge/kb/generate.py +147 -0
  59. seqforge/kb/loader.py +152 -0
  60. seqforge/kb/roundtrip.py +112 -0
  61. seqforge/kb/schema.py +422 -0
  62. seqforge/kb/specs/10x-3p-gex/spec.yaml +62 -0
  63. seqforge/kb/specs/10x-3p-gex-v2/README.md +41 -0
  64. seqforge/kb/specs/10x-3p-gex-v2/spec.yaml +83 -0
  65. seqforge/kb/specs/10x-3p-gex-v3/README.md +56 -0
  66. seqforge/kb/specs/10x-3p-gex-v3/spec.yaml +118 -0
  67. seqforge/kb/specs/10x-3p-gex-v3.1/README.md +56 -0
  68. seqforge/kb/specs/10x-3p-gex-v3.1/spec.yaml +124 -0
  69. seqforge/kb/specs/bd-rhapsody-wta/README.md +103 -0
  70. seqforge/kb/specs/bd-rhapsody-wta/spec.yaml +130 -0
  71. seqforge/kb/specs/bd-rhapsody-wta-enhanced/spec.yaml +99 -0
  72. seqforge/kb/specs/bd-rhapsody-wta-enhanced-v1/spec.yaml +93 -0
  73. seqforge/kb/specs/bd-rhapsody-wta-enhanced-v2/spec.yaml +81 -0
  74. seqforge/kb/specs/bulk-rnaseq-pe/README.md +35 -0
  75. seqforge/kb/specs/bulk-rnaseq-pe/spec.yaml +97 -0
  76. seqforge/kb/specs/splitseq/README.md +51 -0
  77. seqforge/kb/specs/splitseq/spec.yaml +157 -0
  78. seqforge/manifest/__init__.py +61 -0
  79. seqforge/manifest/fill.py +531 -0
  80. seqforge/manifest/hash.py +77 -0
  81. seqforge/manifest/instruct.py +114 -0
  82. seqforge/manifest/policy.py +409 -0
  83. seqforge/manifest/validate.py +274 -0
  84. seqforge/models/__init__.py +268 -0
  85. seqforge/models/assertion.py +68 -0
  86. seqforge/models/base.py +100 -0
  87. seqforge/models/blocker.py +71 -0
  88. seqforge/models/conflict.py +47 -0
  89. seqforge/models/dataset.py +320 -0
  90. seqforge/models/evidenced.py +54 -0
  91. seqforge/models/observation.py +157 -0
  92. seqforge/models/processing.py +231 -0
  93. seqforge/models/records.py +145 -0
  94. seqforge/models/resolve.py +216 -0
  95. seqforge/probe/__init__.py +46 -0
  96. seqforge/probe/core.py +232 -0
  97. seqforge/probe/signals.py +250 -0
  98. seqforge/probe/streaming.py +118 -0
  99. seqforge/project.py +177 -0
  100. seqforge/py.typed +0 -0
  101. seqforge/resolve/__init__.py +98 -0
  102. seqforge/resolve/assign.py +204 -0
  103. seqforge/resolve/cache.py +119 -0
  104. seqforge/resolve/confuse.py +215 -0
  105. seqforge/resolve/engine.py +646 -0
  106. seqforge/resolve/escalate.py +668 -0
  107. seqforge/resolve/evaluators.py +306 -0
  108. seqforge/resolve/geometry.py +89 -0
  109. seqforge/resolve/group.py +85 -0
  110. seqforge/resolve/records.py +550 -0
  111. seqforge/resolve/scoring.py +373 -0
  112. seqforge/resolve/window.py +206 -0
  113. seqforge/workflows/__init__.py +234 -0
  114. seqforge/workflows/cram.py +117 -0
  115. seqforge/workflows/h5ad.py +368 -0
  116. seqforge/workflows/map/star.smk +101 -0
  117. seqforge/workflows/map/starsolo.smk +360 -0
  118. seqforge/workflows/qc.py +157 -0
  119. seqforge/workspace.py +125 -0
  120. seqforge-2026.7.1.dist-info/METADATA +125 -0
  121. seqforge-2026.7.1.dist-info/RECORD +124 -0
  122. seqforge-2026.7.1.dist-info/WHEEL +4 -0
  123. seqforge-2026.7.1.dist-info/entry_points.txt +2 -0
  124. 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
+ ]