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/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
|
+
)
|