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/remote.py ADDED
@@ -0,0 +1,659 @@
1
+ """``io peek`` / ``io resolve`` / ``io probe-remote`` — the ONLY network surface (design §4).
2
+
3
+ Three verbs, all bounded:
4
+
5
+ - ``resolve ACC`` — any GEO/SRA/ENA/BioProject accession -> a run inventory + declared metadata.
6
+ - ``peek URI`` — the first records of a remote gzipped FASTQ via an HTTP Range request. Never the
7
+ whole file: a 517 MB run yields its leading records from a **64 KB** read (0.013 % of it).
8
+ - ``probe_remote URI`` — the same bounded Range read turned into a full role-free
9
+ :class:`~seqforge.models.observation.Observation`, so ``resolve`` fingerprints a library straight
10
+ from a URL with no local file; the provider md5 (ENA ``fastq_md5``) is the content-address (#39).
11
+
12
+ **The most useful thing here is not fetching — it is detecting what the archive threw away.**
13
+
14
+ SRA normalizes runs, and ``fasterq-dump`` **skips technical reads by default**
15
+ (``skip_tech = !(include-technical)``), so a 10x barcode read routinely vanishes from the
16
+ archive-generated FASTQ while remaining inside the ``.sra``. What is published then looks like plain
17
+ single-end RNA-seq and is silently unprocessable as single-cell. :func:`run_statistics` reads SRA's
18
+ own per-read table and :func:`dropped_reads` compares it against what ENA actually published — so we
19
+ learn this from two metadata calls, **before** downloading a byte. That is the rung-0 check.
20
+
21
+ The comparison is a genuine disagreement rather than a bug: NCBI and ENA report different
22
+ ``base_count`` for the same run (8 757 663 750 vs 3 980 756 250 for SRR9170959) because they are two
23
+ different truths about what the file contains. The disagreement IS the signal.
24
+
25
+ Endpoint shapes here were verified against the live services, and several widely-repeated assumptions
26
+ proved wrong (see the constants). Some endpoints we depend on are undocumented — which is exactly why
27
+ they are pinned behind small parsers with offline tests.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import re
33
+ import time
34
+ import zlib
35
+ from dataclasses import dataclass, field
36
+ from typing import TYPE_CHECKING, Any
37
+ from xml.etree import ElementTree
38
+
39
+ import requests
40
+
41
+ from ..probe import (
42
+ DEFAULT_MAX_BYTES,
43
+ DEFAULT_MAX_READS,
44
+ build_observation,
45
+ content_key_from_md5,
46
+ remote_content_key,
47
+ )
48
+ from ..probe.streaming import sample_fastq_stream
49
+
50
+ if TYPE_CHECKING:
51
+ from ..models.observation import Observation
52
+
53
+ #: ENA Portal API. Documented. GEO accessions are REJECTED (HTTP 400) — resolve GSE->SRP first.
54
+ ENA_FILEREPORT = "https://www.ebi.ac.uk/ena/portal/api/filereport"
55
+
56
+ #: SRA's per-run read table. **Undocumented**, but authoritative and irreplaceable: it is the only
57
+ #: place that exposes reads-per-spot and per-read length, i.e. the only way to see a dropped read.
58
+ NCBI_RUN_NEW = "https://trace.ncbi.nlm.nih.gov/Traces/sra-db-be/run_new"
59
+
60
+ #: GEO SOFT. Preferred over eutils for GSE->SRP: no rate limit (eutils is 3/sec keyless, by IP), and
61
+ #: it is the only source that reveals SuperSeries membership.
62
+ GEO_ACC = "https://www.ncbi.nlm.nih.gov/geo/query/acc.cgi"
63
+
64
+ #: SRA Data Locator. GET/POST **form-encoded, not JSON**; `version` is in the path, not a param. To
65
+ #: request originals, OMIT `filetype` — there is no `filetype=src`, and `filetype=all` returns
66
+ #: nothing (`all`/`any` are client-side sentinels sra-tools strips). Errors arrive as HTTP 200 with a
67
+ #: per-accession `status` in the body, so the HTTP code means nothing. `sra-pub-src-1`/`-2` are
68
+ #: PUBLIC and anonymously readable — the "requester-pays" folklore is wrong for them.
69
+ SDL_RETRIEVE = "https://locate.ncbi.nlm.nih.gov/sdl/2/retrieve"
70
+
71
+ #: What to ask ENA for. NB ENA has NO per-read-length field at all — only `read_count` (spots) and
72
+ #: `base_count` (total). That absence is the whole reason NCBI_RUN_NEW is needed.
73
+ ENA_FIELDS = (
74
+ "run_accession,experiment_accession,study_accession,sample_accession,scientific_name,tax_id,"
75
+ "instrument_platform,instrument_model,library_strategy,library_source,library_selection,"
76
+ "library_layout,read_count,base_count,fastq_ftp,fastq_md5,fastq_bytes,submitted_ftp,"
77
+ "submitted_bytes,submitted_format,sra_ftp,experiment_title,sample_title,first_public"
78
+ )
79
+
80
+ #: Patterns lifted from ENA's own HTTP 400 response bodies, so they match what the API accepts.
81
+ _ACCESSION_KINDS: tuple[tuple[str, str], ...] = (
82
+ (r"^GSE\d+$", "geo_series"),
83
+ (r"^GSM\d+$", "geo_sample"),
84
+ (r"^PRJ[A-Z]{2}\d+$", "bioproject"),
85
+ (r"^[EDSR]RP\d{6,}$", "study"),
86
+ (r"^[ESDR]RX\d{6,}$", "experiment"),
87
+ (r"^[ESDR]RR\d{6,}$", "run"),
88
+ (r"^[ESDR]RS\d{6,}$", "sample"),
89
+ (r"^(SAME[A]?\d{6,}|SAM[ND]\d{8})$", "biosample"),
90
+ (r"^[EDS]RA\d{6,}$", "submission"),
91
+ )
92
+
93
+ _SRP_IN_SOFT = re.compile(r"term=([EDSR]RP\d+)")
94
+ _SUPERSERIES_OF = re.compile(r"^!Series_relation = SuperSeries of: (GSE\d+)", re.MULTILINE)
95
+
96
+ _DEFAULT_TIMEOUT = 30
97
+
98
+ #: Transient HTTP statuses worth a retry rather than a hard abort. 429 is NCBI eutils' rate-limit reply
99
+ #: (3 req/sec keyless, by IP), which used to abort the whole `records` stage on a busy accession (#9);
100
+ #: the 5xx family covers a momentary gateway/service blip. Everything else is a real, terminal error.
101
+ _RETRY_STATUS = frozenset({429, 500, 502, 503, 504})
102
+ _MAX_RETRIES = 4
103
+ _BACKOFF_BASE = 1.0 # seconds; grows 1, 2, 4, 8 …
104
+ _MAX_BACKOFF = 16.0
105
+
106
+
107
+ def retry_delay(retry_after: str | None, attempt: int) -> float:
108
+ """Seconds to wait before retry ``attempt`` (0-indexed): an integer ``Retry-After`` if the server
109
+ sent one, else capped exponential backoff. Shape-agnostic (a header string) so both the ``requests``
110
+ client here and taxonomy's ``urllib`` client can share one policy."""
111
+ if retry_after and retry_after.strip().isdigit():
112
+ return min(float(retry_after.strip()), _MAX_BACKOFF)
113
+ return min(_BACKOFF_BASE * (2.0**attempt), _MAX_BACKOFF)
114
+
115
+
116
+ class NotYetImplemented(RuntimeError):
117
+ """A declared verb whose stage has not landed yet (distinct from a domain refusal)."""
118
+
119
+
120
+ class RemoteError(RuntimeError):
121
+ """The network surface failed, or an accession is unknown. Never a silent empty result."""
122
+
123
+
124
+ def classify_accession(accession: str) -> str:
125
+ """Which archive namespace is this? ``unknown`` is a first-class answer, not an exception."""
126
+ acc = accession.strip()
127
+ for pattern, kind in _ACCESSION_KINDS:
128
+ if re.match(pattern, acc, re.IGNORECASE):
129
+ return kind
130
+ return "unknown"
131
+
132
+
133
+ def _get(url: str, params: dict[str, str] | None = None, timeout: int = _DEFAULT_TIMEOUT) -> str:
134
+ """A bounded HTTP GET that RETRIES a transient status rather than aborting the stage.
135
+
136
+ A single 429 from eutils used to fail the whole `records` fetch, so an accession with many
137
+ experiments could not compile (#9). A transient status now backs off (honoring `Retry-After`) and
138
+ retries a few times; a non-transient status, or an exhausted budget, still raises `RemoteError`.
139
+ The api_key that lifts eutils' cap is added by the caller (`archive._efetch`) — it is a secret and
140
+ belongs only where the request is built, never in this shared error path (`url` here is the base,
141
+ so a key in `params` never reaches a log line).
142
+ """
143
+ attempt = 0
144
+ while True: # exits only by return (200) or raise (terminal status/error / exhausted budget)
145
+ try:
146
+ response = requests.get(url, params=params, timeout=timeout)
147
+ except (requests.ConnectionError, requests.Timeout) as exc:
148
+ # A dropped connection or a timeout is the transport-level twin of a 5xx: NCBI resets
149
+ # connections under load ("Connection reset by peer" aborted GSE310667's records fetch
150
+ # live). Back off and retry rather than abort the stage; a genuinely dead endpoint still
151
+ # fails once the budget is spent.
152
+ if attempt < _MAX_RETRIES:
153
+ time.sleep(retry_delay(None, attempt))
154
+ attempt += 1
155
+ continue
156
+ raise RemoteError(f"GET {url} failed: {exc}") from exc
157
+ except requests.RequestException as exc:
158
+ raise RemoteError(f"GET {url} failed: {exc}") from exc
159
+ if response.status_code == 200:
160
+ return response.text
161
+ if response.status_code in _RETRY_STATUS and attempt < _MAX_RETRIES:
162
+ time.sleep(retry_delay(response.headers.get("Retry-After"), attempt))
163
+ attempt += 1
164
+ continue
165
+ raise RemoteError(f"GET {url} -> HTTP {response.status_code}: {response.text[:200]}")
166
+
167
+
168
+ def parse_soft_srp(soft: str) -> list[str]:
169
+ """SRP study accessions declared in a GEO SOFT record."""
170
+ return sorted(set(_SRP_IN_SOFT.findall(soft)))
171
+
172
+
173
+ def parse_soft_superseries(soft: str) -> list[str]:
174
+ """Sub-series of a SuperSeries.
175
+
176
+ **A SuperSeries owns no runs.** eutils and runinfo both return zero for one, silently — so a
177
+ resolver that does not recurse loses the whole dataset while reporting success. That is the worst
178
+ kind of wrong, and it is why SuperSeries membership is parsed explicitly instead of trusted away.
179
+ """
180
+ return sorted(set(_SUPERSERIES_OF.findall(soft)))
181
+
182
+
183
+ def geo_soft(accession: str) -> str:
184
+ """Fetch a brief GEO SOFT record."""
185
+ return _get(GEO_ACC, {"acc": accession, "targ": "self", "form": "text", "view": "brief"})
186
+
187
+
188
+ def geo_to_studies(accession: str, *, _depth: int = 0) -> list[str]:
189
+ """GSE -> SRP list, recursing through SuperSeries (which otherwise resolve to nothing)."""
190
+ if _depth > 3:
191
+ raise RemoteError(f"{accession}: SuperSeries nesting too deep; refusing to recurse further")
192
+ soft = geo_soft(accession)
193
+ studies = parse_soft_srp(soft)
194
+ if studies:
195
+ return studies
196
+ subs = parse_soft_superseries(soft)
197
+ if not subs:
198
+ raise RemoteError(
199
+ f"{accession}: no SRA study in the GEO record. It may be a SuperSeries with no declared "
200
+ "sub-series, unreleased (status=hup), or carry no raw data."
201
+ )
202
+ found: list[str] = []
203
+ for sub in subs:
204
+ found.extend(geo_to_studies(sub, _depth=_depth + 1))
205
+ return sorted(set(found))
206
+
207
+
208
+ def parse_filereport(tsv: str) -> list[dict[str, str]]:
209
+ """Parse ENA's TSV. A header-only response is a legitimate empty answer, not an error."""
210
+ lines = [ln for ln in tsv.splitlines() if ln.strip()]
211
+ if not lines:
212
+ return []
213
+ header = lines[0].split("\t")
214
+ return [dict(zip(header, ln.split("\t"), strict=False)) for ln in lines[1:]]
215
+
216
+
217
+ def ena_filereport(accession: str, *, fields: str = ENA_FIELDS) -> list[dict[str, str]]:
218
+ """ENA's run inventory for a study/run/experiment/sample accession."""
219
+ tsv = _get(
220
+ ENA_FILEREPORT,
221
+ {
222
+ "accession": accession,
223
+ "result": "read_run",
224
+ "fields": fields,
225
+ "format": "tsv",
226
+ "limit": "0",
227
+ },
228
+ )
229
+ return parse_filereport(tsv)
230
+
231
+
232
+ def fastq_urls(run: dict[str, str]) -> list[str]:
233
+ """ENA's ``fastq_ftp`` -> https URLs.
234
+
235
+ Semicolon-separated, no scheme. **Do not assume ``_1``/``_2``**: with one application read the
236
+ file may be ``ACC.fastq.gz`` *or* ``ACC_1.fastq.gz`` — ENA's own docs say it is not deterministic
237
+ — and ordering is not guaranteed, hence the sort. An empty list is meaningful rather than a
238
+ failure: ENA generates no FASTQ at all for cellranger/longranger BAMs, or BAMs carrying
239
+ CB/CR/CY/RX/QX tags, which is precisely the 10x case.
240
+ """
241
+ raw = (run.get("fastq_ftp") or "").strip()
242
+ if not raw:
243
+ return []
244
+ return sorted(f"https://{p}" for p in raw.split(";") if p.strip())
245
+
246
+
247
+ def fastq_targets(run: dict[str, str]) -> list[tuple[str, str]]:
248
+ """ENA's ``fastq_ftp`` paired with its ``fastq_md5`` -> ``[(https_url, md5), ...]`` (issue #39).
249
+
250
+ ENA publishes both fields semicolon-joined and **index-aligned** (the md5 of ``fastq_ftp[i]`` is
251
+ ``fastq_md5[i]``), so the join is positional and done *before* any sort — this is the one place a
252
+ URL and its content hash arrive together, which is what lets the remote probe use the provider md5
253
+ as the content-address without any local-file bridge. A run whose md5 list is missing or a different
254
+ length yields no pairs rather than a mis-aligned guess: an empty list is a legitimate answer (ENA
255
+ generates no FASTQ for the 10x-BAM case), never a silent mispairing. Sorted by URL for determinism.
256
+ """
257
+ urls = [p.strip() for p in (run.get("fastq_ftp") or "").split(";") if p.strip()]
258
+ md5s = [m.strip() for m in (run.get("fastq_md5") or "").split(";") if m.strip()]
259
+ if not urls or len(urls) != len(md5s):
260
+ return []
261
+ return sorted((f"https://{u}", m) for u, m in zip(urls, md5s, strict=True))
262
+
263
+
264
+ @dataclass(frozen=True)
265
+ class ReadStat:
266
+ """One read within a spot, as SRA itself describes it."""
267
+
268
+ index: int
269
+ average_length: int
270
+ count: int
271
+
272
+
273
+ @dataclass
274
+ class RunStatistics:
275
+ """SRA's per-read table for a run — the only exposure of reads-per-spot anywhere."""
276
+
277
+ accession: str
278
+ n_reads: int = 0
279
+ reads: list[ReadStat] = field(default_factory=list)
280
+ #: e.g. "TBT" = Technical/Biological/Technical. Only present for fastq-load.py submissions.
281
+ read_types: str | None = None
282
+
283
+ @property
284
+ def spot_length(self) -> int:
285
+ return sum(r.average_length for r in self.reads)
286
+
287
+ def to_json(self) -> dict[str, Any]:
288
+ return {
289
+ "accession": self.accession,
290
+ "n_reads": self.n_reads,
291
+ "spot_length": self.spot_length,
292
+ "reads": [
293
+ {"index": r.index, "average_length": r.average_length, "count": r.count}
294
+ for r in self.reads
295
+ ],
296
+ "read_types": self.read_types,
297
+ }
298
+
299
+
300
+ def parse_run_new(xml: str, accession: str = "") -> RunStatistics:
301
+ """Parse SRA's ``run_new`` XML into a per-read table.
302
+
303
+ The endpoint is undocumented, so every field is treated as optional: a missing ``readTypes`` is
304
+ normal (it appears only for fastq-load.py submissions) and must never be an error.
305
+ """
306
+ stats = RunStatistics(accession=accession)
307
+ try:
308
+ root = ElementTree.fromstring(xml)
309
+ except ElementTree.ParseError as exc:
310
+ raise RemoteError(f"{accession}: run_new returned unparsable XML: {exc}") from exc
311
+
312
+ node = root.find(".//Statistics")
313
+ if node is not None:
314
+ stats.n_reads = int(node.get("nreads") or 0)
315
+ for read in node.findall("Read"):
316
+ stats.reads.append(
317
+ ReadStat(
318
+ index=int(read.get("index") or 0),
319
+ average_length=int(float(read.get("average") or 0)),
320
+ count=int(read.get("count") or 0),
321
+ )
322
+ )
323
+ for attr in root.findall(".//RUN_ATTRIBUTE"):
324
+ if (attr.findtext("TAG") or "") == "options":
325
+ match = re.search(r"readTypes=(\w+)", attr.findtext("VALUE") or "")
326
+ if match:
327
+ stats.read_types = match.group(1)
328
+ return stats
329
+
330
+
331
+ def run_statistics(accession: str) -> RunStatistics:
332
+ """Fetch SRA's per-read table for one run (undocumented endpoint; see the module docstring)."""
333
+ return parse_run_new(_get(NCBI_RUN_NEW, {"acc": accession}), accession=accession)
334
+
335
+
336
+ @dataclass(frozen=True)
337
+ class DroppedReads:
338
+ """Evidence that the archive published fewer bases per spot than the run actually holds."""
339
+
340
+ sra_spot_length: int
341
+ ena_spot_length: float
342
+ missing_bases: float
343
+ n_reads_sra: int
344
+ n_files_ena: int
345
+ read_types: str | None
346
+
347
+ def to_json(self) -> dict[str, Any]:
348
+ return {
349
+ "sra_spot_length": self.sra_spot_length,
350
+ "ena_spot_length": round(self.ena_spot_length, 2),
351
+ "missing_bases": round(self.missing_bases, 2),
352
+ "n_reads_sra": self.n_reads_sra,
353
+ "n_files_ena": self.n_files_ena,
354
+ "read_types": self.read_types,
355
+ }
356
+
357
+
358
+ def dropped_reads(run: dict[str, str], stats: RunStatistics) -> DroppedReads | None:
359
+ """Did the archive drop a read? Compare SRA's own spot length to what ENA published.
360
+
361
+ Two metadata calls, no bytes — the rung-0 check. A dropped 10x barcode read turns a
362
+ single-cell dataset into something that merely looks like single-end RNA-seq, and no amount of
363
+ downstream cleverness recovers it from the published FASTQ.
364
+
365
+ ``None`` when they agree, or when SRA reports nothing usable: an ABSTAIN, never a false accusation.
366
+ """
367
+ read_count = float(run.get("read_count") or 0)
368
+ base_count = float(run.get("base_count") or 0)
369
+ if not read_count or not base_count or not stats.reads:
370
+ return None
371
+ ena_spot = base_count / read_count
372
+ sra_spot = stats.spot_length
373
+ if sra_spot <= ena_spot + 1: # +1 absorbs rounding in ENA's averages
374
+ return None
375
+ return DroppedReads(
376
+ sra_spot_length=sra_spot,
377
+ ena_spot_length=ena_spot,
378
+ missing_bases=sra_spot - ena_spot,
379
+ n_reads_sra=stats.n_reads,
380
+ n_files_ena=len(fastq_urls(run)),
381
+ read_types=stats.read_types,
382
+ )
383
+
384
+
385
+ def technical_read_remedy(accession: str) -> str:
386
+ """The operable remedy for a dropped technical read (design §1.5: remedies must be actionable).
387
+
388
+ ``fasterq-dump --include-technical`` is the real fix; SDL is a fallback. Originals
389
+ (``sra-pub-src-*``) are published for "select high value and newly-released studies" only, and
390
+ most runs return just ``type=sra`` — so naming SDL first would send people down a dead end.
391
+ """
392
+ return (
393
+ "the technical read is still inside the .sra — fasterq-dump skips it BY DEFAULT. Re-fetch "
394
+ f"with `fasterq-dump --include-technical --split-files {accession}` (ENA's generated FASTQ "
395
+ "omits it, so do not use that). Fallback only: the original submitted files may exist via "
396
+ f"the SRA Data Locator ({SDL_RETRIEVE}; omit `filetype` to request originals), but they are "
397
+ "published for select studies only."
398
+ )
399
+
400
+
401
+ def _annotate(run: dict[str, str]) -> dict[str, Any]:
402
+ """One ENA run + our derived facts. Degrades on error; an undocumented endpoint must not abort."""
403
+ entry: dict[str, Any] = dict(run)
404
+ entry["fastq_urls"] = fastq_urls(run)
405
+ entry["technical_read_dropped"] = False
406
+
407
+ if not entry["fastq_urls"] and (run.get("submitted_ftp") or "").strip():
408
+ entry["note"] = (
409
+ "ENA generated no FASTQ for this run — it does not for cellranger/longranger BAMs, or "
410
+ "BAMs carrying CB/CR/CY/RX/QX tags (the 10x case). Only submitted files exist."
411
+ )
412
+
413
+ run_acc = (run.get("run_accession") or "").strip()
414
+ if run_acc.upper().startswith("SRR"): # run_new is an NCBI endpoint; ERR/DRR are not served
415
+ try:
416
+ stats = run_statistics(run_acc)
417
+ entry["run_statistics"] = stats.to_json()
418
+ dropped = dropped_reads(run, stats)
419
+ if dropped is not None:
420
+ entry["technical_read_dropped"] = True
421
+ entry["dropped"] = dropped.to_json()
422
+ entry["remedy"] = technical_read_remedy(run_acc)
423
+ except RemoteError as exc:
424
+ entry["run_statistics_error"] = str(exc)
425
+ return entry
426
+
427
+
428
+ def resolve_accession(accession: str, *, check_reads: bool = True) -> dict[str, Any]:
429
+ """Expand any accession into runs + declared metadata + a dropped-technical-read verdict.
430
+
431
+ GEO is resolved to SRP first (ENA rejects GSE outright), recursing through SuperSeries. This
432
+ reports declared facts and abstains loudly; it never guesses a chemistry — that is resolve's job,
433
+ from bytes.
434
+ """
435
+ acc = accession.strip()
436
+ kind = classify_accession(acc)
437
+ if kind == "unknown":
438
+ raise RemoteError(
439
+ f"unrecognized accession {acc!r}. Known: GSE/GSM, PRJNA/PRJEB, SRP/ERP, SRX/ERX, "
440
+ "SRR/ERR, SRS/ERS, SAMN/SAMEA."
441
+ )
442
+
443
+ studies: list[str] = []
444
+ if kind in ("geo_series", "geo_sample"):
445
+ studies = geo_to_studies(acc)
446
+ runs = [r for s in studies for r in ena_filereport(s)]
447
+ else:
448
+ runs = ena_filereport(acc)
449
+
450
+ if not runs:
451
+ raise RemoteError(
452
+ f"{acc}: ENA returned no runs. It may be unreleased (status=hup), or a SuperSeries whose "
453
+ "sub-series must be resolved individually."
454
+ )
455
+
456
+ entries = (
457
+ [_annotate(r) for r in runs]
458
+ if check_reads
459
+ else [{**r, "fastq_urls": fastq_urls(r)} for r in runs]
460
+ )
461
+ return {
462
+ "accession": acc,
463
+ "kind": kind,
464
+ "studies": studies,
465
+ "n_runs": len(entries),
466
+ "n_runs_missing_technical_read": sum(1 for e in entries if e.get("technical_read_dropped")),
467
+ "runs": entries,
468
+ }
469
+
470
+
471
+ @dataclass
472
+ class PeekResult:
473
+ """What a bounded range-read saw. ``compressed_bytes_read`` is the bounded-read receipt."""
474
+
475
+ uri: str
476
+ compressed_bytes_read: int
477
+ decompressed_bytes: int
478
+ n_records: int
479
+ read_lengths: list[int] = field(default_factory=list)
480
+ example_header: str | None = None
481
+
482
+ def to_json(self) -> dict[str, Any]:
483
+ return {
484
+ "uri": self.uri,
485
+ "compressed_bytes_read": self.compressed_bytes_read,
486
+ "decompressed_bytes": self.decompressed_bytes,
487
+ "n_records": self.n_records,
488
+ "read_lengths": self.read_lengths,
489
+ "example_header": self.example_header,
490
+ }
491
+
492
+
493
+ def decompress_prefix(blob: bytes, *, max_bytes: int) -> bytes:
494
+ """Inflate a gzip PREFIX, tolerating the truncated tail, capped at ``max_bytes``.
495
+
496
+ ``wbits=31`` = 16 (gzip wrapper) + 15 (window). A truncated deflate stream does **not** raise —
497
+ you simply get fewer bytes back and ``eof`` stays False. So "handling the truncation" means
498
+ stopping and dropping the last partial record, nothing more. ``max_length`` caps expansion per
499
+ call, which makes the budget a real *decompressed*-byte bound rather than a compressed-byte
500
+ proxy — and incidentally makes a zip bomb a non-event.
501
+ """
502
+ decomp = zlib.decompressobj(31)
503
+ try:
504
+ return bytes(decomp.decompress(blob, max_bytes))
505
+ except zlib.error as exc: # a corrupt member, as opposed to a merely truncated one
506
+ raise RemoteError(f"gzip stream is not readable: {exc}") from exc
507
+
508
+
509
+ def parse_fastq_prefix(text: str, *, max_reads: int) -> tuple[list[str], list[int]]:
510
+ """Read whole FASTQ records from a decompressed prefix, discarding the trailing partial one."""
511
+ lines = text.split("\n")
512
+ if lines and not text.endswith("\n"):
513
+ lines = lines[:-1] # the final line was cut mid-write by the range boundary
514
+ headers: list[str] = []
515
+ lengths: list[int] = []
516
+ for i in range(0, max(0, len(lines) - 3), 4):
517
+ if not lines[i].startswith("@"):
518
+ continue
519
+ headers.append(lines[i])
520
+ lengths.append(len(lines[i + 1]))
521
+ if len(headers) >= max_reads:
522
+ break
523
+ return headers, lengths
524
+
525
+
526
+ #: How many COMPRESSED bytes a remote fingerprint range-reads by default. Large enough to decompress to
527
+ #: a strong head sample (the read/byte budgets still stop decompression early), small enough to never
528
+ #: approach a whole file: 8 MB is ~1.5 % of a 517 MB run. The network cost of a probe is this one GET.
529
+ DEFAULT_REMOTE_COMPRESSED_BYTES = 8 << 20
530
+
531
+ _CONTENT_RANGE = re.compile(r"bytes\s+\d+-\d+/(\d+)", re.IGNORECASE)
532
+
533
+
534
+ def _content_range_total(headers: Any) -> int | None:
535
+ """The total file size a 206 declares in ``Content-Range: bytes 0-N/TOTAL`` (``None`` if absent).
536
+
537
+ A server may answer ``.../*`` when it does not know the length; then the size is unknown and the
538
+ caller falls back to the bytes actually read.
539
+ """
540
+ match = _CONTENT_RANGE.search(headers.get("Content-Range", "") or "")
541
+ return int(match.group(1)) if match else None
542
+
543
+
544
+ def _range_get(uri: str, *, max_bytes: int) -> tuple[bytes, int | None]:
545
+ """Range-read the first ``max_bytes`` of a remote file. Returns ``(blob, total_size_or_None)``.
546
+
547
+ The one bounded-fetch primitive behind both :func:`peek` and :func:`probe_remote`. We assert
548
+ **HTTP 206**, not the presence of ``Accept-Ranges``: a server may advertise ranges, ignore the
549
+ header, and answer 200 with the entire file, and trusting the advertisement is exactly how a
550
+ "bounded" read becomes a multi-GB download. The status code is the contract, so a 200 is a refusal.
551
+ """
552
+ try:
553
+ response = requests.get(
554
+ uri,
555
+ headers={"Range": f"bytes=0-{max_bytes - 1}"},
556
+ timeout=_DEFAULT_TIMEOUT,
557
+ stream=True,
558
+ )
559
+ except requests.RequestException as exc:
560
+ raise RemoteError(f"GET {uri} failed: {exc}") from exc
561
+
562
+ try:
563
+ if response.status_code == 200:
564
+ raise RemoteError(
565
+ f"{uri}: the server ignored our Range header and answered 200 — that is the whole "
566
+ "file. Refusing to read it: 'bounded' means bounded by the server, not by our intentions. "
567
+ "Fetch it deliberately, or use a host that honours Range."
568
+ )
569
+ if response.status_code != 206:
570
+ raise RemoteError(
571
+ f"GET {uri} -> HTTP {response.status_code} (expected 206 Partial Content)"
572
+ )
573
+ blob = response.content[:max_bytes]
574
+ total = _content_range_total(response.headers)
575
+ finally:
576
+ response.close()
577
+ return blob, total
578
+
579
+
580
+ def peek(
581
+ uri: str, *, max_reads: int = 4, max_bytes: int = 1 << 16, decompressed_cap: int = 1 << 22
582
+ ) -> dict[str, Any]:
583
+ """Range-read the head of a remote gzipped FASTQ. Never downloads the file.
584
+
585
+ The defaults fetch 64 KB — 0.013 % of a 517 MB run, and several thousand reads' worth. This is the
586
+ diagnostic (headers + read lengths); :func:`probe_remote` is the same bounded read turned into a
587
+ full :class:`~seqforge.models.observation.Observation` that ``resolve`` can score.
588
+ """
589
+ blob, _total = _range_get(uri, max_bytes=max_bytes)
590
+ text = decompress_prefix(blob, max_bytes=decompressed_cap).decode("utf-8", errors="replace")
591
+ headers, lengths = parse_fastq_prefix(text, max_reads=max_reads)
592
+ return PeekResult(
593
+ uri=uri,
594
+ compressed_bytes_read=len(blob),
595
+ decompressed_bytes=len(text),
596
+ n_records=len(headers),
597
+ read_lengths=lengths,
598
+ example_header=headers[0] if headers else None,
599
+ ).to_json()
600
+
601
+
602
+ def _uri_basename(uri: str) -> str:
603
+ """The filename a URL ends in — the remote analog of a local ``Path.name``.
604
+
605
+ Strips a query/fragment and a trailing slash. Feeds a no-md5 remote content key exactly as
606
+ ``Path.name`` feeds the local one; when a provider md5 is known it is irrelevant (the md5 carries
607
+ no name, by design — for hosted bytes an identical md5 means identical content).
608
+ """
609
+ tail = uri.split("?", 1)[0].split("#", 1)[0].rstrip("/")
610
+ return tail.rsplit("/", 1)[-1] or uri
611
+
612
+
613
+ def probe_remote(
614
+ uri: str,
615
+ *,
616
+ md5: str | None = None,
617
+ max_reads: int = DEFAULT_MAX_READS,
618
+ max_bytes: int = DEFAULT_MAX_BYTES,
619
+ max_compressed_bytes: int = DEFAULT_REMOTE_COMPRESSED_BYTES,
620
+ ) -> tuple[Observation, list[str]]:
621
+ """Fingerprint a remote gzipped FASTQ into ``(Observation, seqs)`` WITHOUT staging it (issue #39).
622
+
623
+ The remote twin of ``probe.probe_sample``: one bounded HTTP Range read (``max_compressed_bytes``)
624
+ is decompressed under the same head budget (``max_reads`` / ``max_bytes``) and fed through the
625
+ identical Tier-A pipeline (``probe.build_observation``), so a URL resolves to a library exactly as a
626
+ local file does — the returned pair drops straight into ``resolve.resolve_dataset`` via its
627
+ ``_probed`` map, no local file anywhere.
628
+
629
+ When the provider ``md5`` (ENA ``fastq_md5``, paired with the URL by :func:`fastq_targets`) is
630
+ known it IS the content-address (``content_key_from_md5``), matching the hosted bytes with zero read
631
+ of the body beyond the head; otherwise a bounded remote key over (basename + total size + head) is
632
+ derived. ``size_bytes`` is the total the 206 declares in Content-Range, else the bytes read.
633
+ """
634
+ from io import BytesIO
635
+
636
+ blob, total = _range_get(uri, max_bytes=max_compressed_bytes)
637
+ if not blob:
638
+ raise RemoteError(f"{uri}: range read returned no bytes")
639
+ sample = sample_fastq_stream(BytesIO(blob), max_reads, max_bytes)
640
+ if not sample.seqs:
641
+ raise RemoteError(
642
+ f"{uri}: no FASTQ records in the {len(blob)}-byte head — not a gzipped FASTQ, or the "
643
+ "range was too small to hold one record."
644
+ )
645
+ size_bytes = total if total and total > 0 else len(blob)
646
+ basename = _uri_basename(uri)
647
+ sha256 = (
648
+ content_key_from_md5(md5) if md5 else remote_content_key(basename, size_bytes, sample.seqs)
649
+ )
650
+ return build_observation(
651
+ sample,
652
+ size_bytes=size_bytes,
653
+ sha256=sha256,
654
+ basename=basename,
655
+ local_uri=None,
656
+ isize=None,
657
+ max_reads=max_reads,
658
+ max_bytes=max_bytes,
659
+ )