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/cli/io.py
ADDED
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
"""`seqforge io` -- the network + onlist surface: peek, accession resolution, records, vocabularies."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import cast
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from ..io import DEFAULT_REGISTRY, default_registry
|
|
12
|
+
from ..io.remote import NotYetImplemented, peek, resolve_accession
|
|
13
|
+
from ..workspace import records_dir
|
|
14
|
+
from ._common import _today
|
|
15
|
+
from .root import io_app, onlist_app
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@onlist_app.command("list")
|
|
19
|
+
def io_onlist_list() -> None:
|
|
20
|
+
"""List the onlists in the default registry. Shipped ones need no network and no setup."""
|
|
21
|
+
rows = []
|
|
22
|
+
for name in DEFAULT_REGISTRY.names():
|
|
23
|
+
entry = DEFAULT_REGISTRY.get(name)
|
|
24
|
+
rows.append(
|
|
25
|
+
{
|
|
26
|
+
"name": entry.name,
|
|
27
|
+
"width": entry.width,
|
|
28
|
+
"orientation": entry.orientation,
|
|
29
|
+
"n_entries": entry.n_entries,
|
|
30
|
+
"shipped": entry.shipped,
|
|
31
|
+
"fetchable": entry.fetchable,
|
|
32
|
+
}
|
|
33
|
+
)
|
|
34
|
+
typer.echo(json.dumps({"onlists": rows}, indent=2))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@onlist_app.command("show")
|
|
38
|
+
def io_onlist_show(
|
|
39
|
+
name: str = typer.Argument(..., help="Registry name, e.g. 3M-february-2018."),
|
|
40
|
+
) -> None:
|
|
41
|
+
"""Show one onlist registry entry as JSON."""
|
|
42
|
+
if not DEFAULT_REGISTRY.has(name):
|
|
43
|
+
typer.echo(f"unknown onlist {name!r}", err=True)
|
|
44
|
+
raise typer.Exit(2)
|
|
45
|
+
entry = DEFAULT_REGISTRY.get(name)
|
|
46
|
+
typer.echo(
|
|
47
|
+
json.dumps(
|
|
48
|
+
{
|
|
49
|
+
"name": entry.name,
|
|
50
|
+
"uri": entry.uri,
|
|
51
|
+
"sha256": entry.sha256,
|
|
52
|
+
"width": entry.width,
|
|
53
|
+
"orientation": entry.orientation,
|
|
54
|
+
"n_entries": entry.n_entries,
|
|
55
|
+
"shipped": entry.shipped,
|
|
56
|
+
"fetchable": entry.fetchable,
|
|
57
|
+
"source_sha256": entry.source_sha256,
|
|
58
|
+
},
|
|
59
|
+
indent=2,
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@onlist_app.command("write")
|
|
65
|
+
def io_onlist_write(
|
|
66
|
+
name: str = typer.Argument(..., help="Registry name, e.g. 3M-february-2018."),
|
|
67
|
+
out: Path = typer.Option(..., "--out", "-o", help="Where to write the barcode text."),
|
|
68
|
+
onlist_dir: Path | None = typer.Option(
|
|
69
|
+
None, "--onlist-dir", help="A directory of already-downloaded whitelists."
|
|
70
|
+
),
|
|
71
|
+
) -> None:
|
|
72
|
+
"""Materialize a whitelist as the text STARsolo reads. Called BY the composed Snakefile.
|
|
73
|
+
|
|
74
|
+
This is the verb behind `rule onlist`, and the reason that rule exists. 10x's v3 whitelist is
|
|
75
|
+
6 794 880 barcodes = 111 MB of text, and `compose` used to write it into every run directory --
|
|
76
|
+
so one dataset compiled three ways cost a third of a gigabyte of identical bytes, permanently,
|
|
77
|
+
for a file STAR opens once. Now the pipeline builds it, uses it, and `temp()` deletes it.
|
|
78
|
+
|
|
79
|
+
The shipped form is 522 kB of packed deltas; this is the expansion. Nothing is fetched when the
|
|
80
|
+
list ships with the package, which is the case for every 10x whitelist.
|
|
81
|
+
"""
|
|
82
|
+
from ..io.onlist import OnlistNotAvailable, write_onlist_text
|
|
83
|
+
|
|
84
|
+
registry = (
|
|
85
|
+
default_registry(offline=False, local_dir=onlist_dir) if onlist_dir else DEFAULT_REGISTRY
|
|
86
|
+
)
|
|
87
|
+
try:
|
|
88
|
+
n = write_onlist_text(registry, name, out)
|
|
89
|
+
except OnlistNotAvailable as exc:
|
|
90
|
+
typer.echo(
|
|
91
|
+
json.dumps({"error": "onlist_unavailable", "detail": str(exc)}, indent=2), err=True
|
|
92
|
+
)
|
|
93
|
+
raise typer.Exit(3) from exc
|
|
94
|
+
typer.echo(json.dumps({"onlist": name, "path": str(out), "n_entries": n}, indent=2))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@onlist_app.command("pack")
|
|
98
|
+
def io_onlist_pack(
|
|
99
|
+
text: Path = typer.Argument(..., help="The whitelist as text (.txt or .txt.gz)."),
|
|
100
|
+
name: str = typer.Option(..., "--name", help="Registry name, e.g. 3M-february-2018."),
|
|
101
|
+
uri: str = typer.Option(
|
|
102
|
+
"", "--uri", help="Where this list came from, recorded for provenance."
|
|
103
|
+
),
|
|
104
|
+
orientation: str = typer.Option("forward", "--orientation", help="forward | revcomp | either."),
|
|
105
|
+
) -> None:
|
|
106
|
+
"""**Maintenance verb.** Pack a whitelist into the shipped form and record it in the index.
|
|
107
|
+
|
|
108
|
+
This is how a new barcode list joins the package: `pack` it, commit the `.codes.gz` and the
|
|
109
|
+
updated `index.json`, done. Nothing else to remember and nothing to hand-edit -- this verb is the
|
|
110
|
+
only writer of `index.json`, which is what stops the index drifting from the blobs beside it.
|
|
111
|
+
|
|
112
|
+
The shipped form is 2-bit-packed, sorted, de-duplicated, delta-encoded and gzipped: 10x's
|
|
113
|
+
6 794 880-barcode v3 list is 522 kB here against 12 MB as their `.txt.gz`. That is why shipping
|
|
114
|
+
them is cheap, and it also closes the `.npy` precompilation §14 has wanted since the beginning --
|
|
115
|
+
nothing re-packs 6.8M barcodes per process any more.
|
|
116
|
+
"""
|
|
117
|
+
import gzip as _gzip
|
|
118
|
+
import hashlib as _hashlib
|
|
119
|
+
|
|
120
|
+
from ..io.onlist import PackedOnlist, write_shipped
|
|
121
|
+
|
|
122
|
+
raw = text.read_bytes()
|
|
123
|
+
if raw[:2] == b"\x1f\x8b":
|
|
124
|
+
raw = _gzip.decompress(raw)
|
|
125
|
+
source_sha = _hashlib.sha256(raw).hexdigest()
|
|
126
|
+
barcodes = [line.strip() for line in raw.decode().splitlines() if line.strip()]
|
|
127
|
+
if not barcodes:
|
|
128
|
+
typer.echo(f"{text} contains no barcodes", err=True)
|
|
129
|
+
raise typer.Exit(2)
|
|
130
|
+
packed = PackedOnlist.from_barcodes(barcodes)
|
|
131
|
+
blob = write_shipped(
|
|
132
|
+
name,
|
|
133
|
+
packed.codes,
|
|
134
|
+
width=packed.width,
|
|
135
|
+
uri=uri,
|
|
136
|
+
orientation=orientation, # type: ignore[arg-type]
|
|
137
|
+
source_sha256=source_sha,
|
|
138
|
+
)
|
|
139
|
+
typer.echo(
|
|
140
|
+
json.dumps(
|
|
141
|
+
{
|
|
142
|
+
"name": name,
|
|
143
|
+
"packed": str(blob),
|
|
144
|
+
"bytes": blob.stat().st_size,
|
|
145
|
+
"n_entries": packed.n_entries,
|
|
146
|
+
"width": packed.width,
|
|
147
|
+
"source_sha256": source_sha,
|
|
148
|
+
},
|
|
149
|
+
indent=2,
|
|
150
|
+
)
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@io_app.command("h5ad")
|
|
155
|
+
def io_h5ad(
|
|
156
|
+
solo_dir: Path = typer.Option(..., "--solo-dir", help="A STARsolo `Solo.out` directory."),
|
|
157
|
+
features: str = typer.Option(
|
|
158
|
+
..., "--features", help="The run's --soloFeatures, space-separated (e.g. 'Gene GeneFull')."
|
|
159
|
+
),
|
|
160
|
+
primary: str = typer.Option(
|
|
161
|
+
..., "--primary", help="Which feature becomes X (the rest become layers)."
|
|
162
|
+
),
|
|
163
|
+
out_prefix: Path = typer.Option(
|
|
164
|
+
..., "--out-prefix", help="Output path prefix; '.h5ad' / '.velocyto.h5ad' are appended."
|
|
165
|
+
),
|
|
166
|
+
) -> None:
|
|
167
|
+
"""Package a Solo.out's raw matrices as .h5ad — the last step of the composed pipeline.
|
|
168
|
+
|
|
169
|
+
Called by `starsolo.smk`'s `solo_to_h5ad` rule, which is why it is a verb and not a `run:` block:
|
|
170
|
+
a `shell:` is rendered by `snakemake -n -p`, so compose's wiring gate can see it.
|
|
171
|
+
|
|
172
|
+
Exit 3 on a Blocker-shaped refusal — the axes of the features being stacked disagree, or a matrix
|
|
173
|
+
STAR was supposed to write is absent.
|
|
174
|
+
"""
|
|
175
|
+
from ..models.processing import SoloFeature
|
|
176
|
+
from ..workflows.h5ad import SOLO_FEATURE_OUTPUT, H5adError, write_h5ad
|
|
177
|
+
|
|
178
|
+
requested = features.split()
|
|
179
|
+
unknown = [f for f in [*requested, primary] if f not in SOLO_FEATURE_OUTPUT]
|
|
180
|
+
if unknown:
|
|
181
|
+
typer.echo(
|
|
182
|
+
json.dumps({"error": f"unknown --soloFeatures value(s): {sorted(set(unknown))}"}),
|
|
183
|
+
err=True,
|
|
184
|
+
)
|
|
185
|
+
raise typer.Exit(2)
|
|
186
|
+
try:
|
|
187
|
+
written = write_h5ad(
|
|
188
|
+
solo_dir,
|
|
189
|
+
cast(list[SoloFeature], requested),
|
|
190
|
+
cast(SoloFeature, primary),
|
|
191
|
+
out_prefix,
|
|
192
|
+
)
|
|
193
|
+
except H5adError as exc:
|
|
194
|
+
typer.echo(json.dumps({"error": str(exc)}), err=True)
|
|
195
|
+
raise typer.Exit(3) from exc
|
|
196
|
+
typer.echo(json.dumps({"written": [str(p) for p in written]}, indent=2))
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@io_app.command("qc-bundle")
|
|
200
|
+
def io_qc_bundle(
|
|
201
|
+
solo_dir: Path = typer.Option(..., "--solo-dir", help="A STARsolo `Solo.out` directory."),
|
|
202
|
+
run_dir: Path = typer.Option(
|
|
203
|
+
..., "--run-dir", help="The sample directory holding STAR's Log.*.out / SJ.out.tab."
|
|
204
|
+
),
|
|
205
|
+
features: str = typer.Option(
|
|
206
|
+
..., "--features", help="The run's --soloFeatures, space-separated."
|
|
207
|
+
),
|
|
208
|
+
sample: str = typer.Option(..., "--sample", help="Sample id, recorded in the bundle."),
|
|
209
|
+
out: Path = typer.Option(..., "--out", help="Output path for the gzipped JSON bundle."),
|
|
210
|
+
assembly: str | None = typer.Option(
|
|
211
|
+
None, "--assembly", help="UCSC assembly id, recorded for CRAM-reference provenance."
|
|
212
|
+
),
|
|
213
|
+
) -> None:
|
|
214
|
+
"""Bundle STARsolo's stats + run logs into one gzipped JSON — a finalize step of the pipeline.
|
|
215
|
+
|
|
216
|
+
Called by `starsolo.smk`'s `qc_bundle` rule (a `shell:`, so compose's wiring gate sees it). Exit 3
|
|
217
|
+
if a file STAR was supposed to write is missing.
|
|
218
|
+
"""
|
|
219
|
+
from ..models.processing import SoloFeature
|
|
220
|
+
from ..workflows.h5ad import SOLO_FEATURE_OUTPUT
|
|
221
|
+
from ..workflows.qc import QcError, write_qc_bundle
|
|
222
|
+
|
|
223
|
+
requested = features.split()
|
|
224
|
+
unknown = [f for f in requested if f not in SOLO_FEATURE_OUTPUT]
|
|
225
|
+
if unknown:
|
|
226
|
+
typer.echo(
|
|
227
|
+
json.dumps({"error": f"unknown --soloFeatures value(s): {sorted(set(unknown))}"}),
|
|
228
|
+
err=True,
|
|
229
|
+
)
|
|
230
|
+
raise typer.Exit(2)
|
|
231
|
+
try:
|
|
232
|
+
written = write_qc_bundle(
|
|
233
|
+
solo_dir,
|
|
234
|
+
run_dir,
|
|
235
|
+
cast(list[SoloFeature], requested),
|
|
236
|
+
out,
|
|
237
|
+
sample=sample,
|
|
238
|
+
assembly=assembly,
|
|
239
|
+
)
|
|
240
|
+
except QcError as exc:
|
|
241
|
+
typer.echo(json.dumps({"error": str(exc)}), err=True)
|
|
242
|
+
raise typer.Exit(3) from exc
|
|
243
|
+
typer.echo(json.dumps({"written": str(written)}, indent=2))
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
@io_app.command("cram")
|
|
247
|
+
def io_cram(
|
|
248
|
+
bam: Path = typer.Option(..., "--bam", help="STAR's Aligned.out.bam."),
|
|
249
|
+
assembly: str = typer.Option(..., "--assembly", help="UCSC assembly id; the CRAM reference."),
|
|
250
|
+
out: Path = typer.Option(..., "--out", help="Output CRAM path ('.crai' is written beside it)."),
|
|
251
|
+
threads: int = typer.Option(1, "--threads", help="samtools sort/view/index threads."),
|
|
252
|
+
sort_mem_mb: int | None = typer.Option(
|
|
253
|
+
None, "--sort-mem-mb", help="Total memory budget (MB) for the sort; split across threads."
|
|
254
|
+
),
|
|
255
|
+
) -> None:
|
|
256
|
+
"""Convert STAR's BAM to a coordinate-sorted CRAM against the liulab-genome reference.
|
|
257
|
+
|
|
258
|
+
Called by `starsolo.smk`'s `solo_to_cram` rule. The reference FASTA is resolved at run time from
|
|
259
|
+
the assembly id via `liulab-genome` (never a baked path), exactly as `rule genome_index` resolves
|
|
260
|
+
the STAR index. Exit 3 on a samtools failure or an unresolvable reference.
|
|
261
|
+
"""
|
|
262
|
+
from ..workflows.cram import CramError, bam_to_cram
|
|
263
|
+
|
|
264
|
+
try:
|
|
265
|
+
from genome import (
|
|
266
|
+
Genome, # untyped lab package; resolved here, off the strict workflow path
|
|
267
|
+
)
|
|
268
|
+
except ImportError as exc: # pragma: no cover - depends on the host
|
|
269
|
+
typer.echo(json.dumps({"error": f"liulab-genome is not importable: {exc}"}), err=True)
|
|
270
|
+
raise typer.Exit(3) from exc
|
|
271
|
+
try:
|
|
272
|
+
fasta = Path(str(Genome(assembly).fasta_path))
|
|
273
|
+
written = bam_to_cram(bam, fasta, out, threads=threads, sort_mem_mb=sort_mem_mb)
|
|
274
|
+
except CramError as exc:
|
|
275
|
+
typer.echo(json.dumps({"error": str(exc)}), err=True)
|
|
276
|
+
raise typer.Exit(3) from exc
|
|
277
|
+
typer.echo(json.dumps({"written": str(written)}, indent=2))
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
@io_app.command("peek")
|
|
281
|
+
def io_peek(
|
|
282
|
+
uri: str = typer.Argument(..., help="Remote FASTQ URI to range-read."),
|
|
283
|
+
max_reads: int = typer.Option(4, help="Records to report from the fetched prefix."),
|
|
284
|
+
max_bytes: int = typer.Option(1 << 16, help="Compressed bytes to range-read (budget)."),
|
|
285
|
+
) -> None:
|
|
286
|
+
"""Range-read the head of a remote gzipped FASTQ. Never downloads the file.
|
|
287
|
+
|
|
288
|
+
64 KB is ~0.013% of a 517 MB run. Exit 1 if the host ignores Range and answers 200 with the whole
|
|
289
|
+
file — bounded means bounded by the server, not by our intentions.
|
|
290
|
+
"""
|
|
291
|
+
from ..io.remote import RemoteError
|
|
292
|
+
|
|
293
|
+
try:
|
|
294
|
+
typer.echo(json.dumps(peek(uri, max_reads=max_reads, max_bytes=max_bytes), indent=2))
|
|
295
|
+
except (NotYetImplemented, RemoteError) as exc:
|
|
296
|
+
typer.echo(json.dumps({"error": str(exc)}, indent=2), err=True)
|
|
297
|
+
raise typer.Exit(1) from exc
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
@io_app.command("probe-remote")
|
|
301
|
+
def io_probe_remote(
|
|
302
|
+
uri: str = typer.Argument(..., help="Remote gzipped FASTQ URL to fingerprint."),
|
|
303
|
+
md5: str | None = typer.Option(
|
|
304
|
+
None, "--md5", help="Provider md5 (ENA fastq_md5) — becomes the content-address."
|
|
305
|
+
),
|
|
306
|
+
max_reads: int = typer.Option(200_000, help="Bounded head read budget."),
|
|
307
|
+
max_bytes: int = typer.Option(256 * 1024 * 1024, help="Bounded decompressed-byte cap."),
|
|
308
|
+
max_compressed_bytes: int = typer.Option(
|
|
309
|
+
8 << 20, help="Compressed bytes to range-read in one GET (the network budget)."
|
|
310
|
+
),
|
|
311
|
+
) -> None:
|
|
312
|
+
"""Fingerprint a remote FASTQ into an Observation WITHOUT downloading it (issue #39).
|
|
313
|
+
|
|
314
|
+
The remote twin of `probe`: one bounded HTTP Range read is fed through the same Tier-A pipeline, so
|
|
315
|
+
a URL resolves to a library exactly as a local file does — no staging, no local copy. With `--md5`
|
|
316
|
+
the provider's hash is the content-address, matching the hosted bytes with zero body read. Exit 1 if
|
|
317
|
+
the host ignores Range and answers 200 with the whole file (bounded means bounded by the server).
|
|
318
|
+
"""
|
|
319
|
+
from ..io.remote import RemoteError, probe_remote
|
|
320
|
+
|
|
321
|
+
try:
|
|
322
|
+
obs, _seqs = probe_remote(
|
|
323
|
+
uri,
|
|
324
|
+
md5=md5,
|
|
325
|
+
max_reads=max_reads,
|
|
326
|
+
max_bytes=max_bytes,
|
|
327
|
+
max_compressed_bytes=max_compressed_bytes,
|
|
328
|
+
)
|
|
329
|
+
except (NotYetImplemented, RemoteError, ValueError) as exc:
|
|
330
|
+
typer.echo(json.dumps({"error": str(exc)}, indent=2), err=True)
|
|
331
|
+
raise typer.Exit(1) from exc
|
|
332
|
+
typer.echo(json.dumps(obs.model_dump(mode="json"), indent=2))
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
@io_app.command("resolve")
|
|
336
|
+
def io_resolve(
|
|
337
|
+
accession: str = typer.Argument(..., help="GSE/GSM, PRJNA/PRJEB, SRP/SRX/SRR, SAMN..."),
|
|
338
|
+
check_reads: bool = typer.Option(
|
|
339
|
+
True,
|
|
340
|
+
"--check-reads/--no-check-reads",
|
|
341
|
+
help="Compare SRA's per-read table to what ENA published (detects a dropped technical read).",
|
|
342
|
+
),
|
|
343
|
+
) -> None:
|
|
344
|
+
"""Expand an accession into runs + declared metadata, and flag a dropped technical read.
|
|
345
|
+
|
|
346
|
+
The important part is the flag, not the inventory: fasterq-dump skips technical reads BY DEFAULT,
|
|
347
|
+
so a 10x barcode read routinely vanishes from the published FASTQ while staying inside the .sra —
|
|
348
|
+
leaving a dataset that looks like plain single-end RNA-seq. Two metadata calls catch it before a
|
|
349
|
+
byte is downloaded (rung 0). Exit 4 if any run is missing one: a human must re-fetch it.
|
|
350
|
+
"""
|
|
351
|
+
from ..io.remote import RemoteError
|
|
352
|
+
|
|
353
|
+
try:
|
|
354
|
+
result = resolve_accession(accession, check_reads=check_reads)
|
|
355
|
+
except (NotYetImplemented, RemoteError) as exc:
|
|
356
|
+
typer.echo(json.dumps({"error": str(exc)}, indent=2), err=True)
|
|
357
|
+
raise typer.Exit(1) from exc
|
|
358
|
+
typer.echo(json.dumps(result, indent=2))
|
|
359
|
+
if result.get("n_runs_missing_technical_read"):
|
|
360
|
+
raise typer.Exit(4)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
@io_app.command("records")
|
|
364
|
+
def io_records(
|
|
365
|
+
accession: str = typer.Argument(..., help="GSE/GSM, PRJNA/PRJEB, SRP/SRX/SRR, SAMN..."),
|
|
366
|
+
workspace: Path = typer.Option(
|
|
367
|
+
Path("."), "-C", "--workspace", help="Root for seqforge/ state."
|
|
368
|
+
),
|
|
369
|
+
) -> None:
|
|
370
|
+
"""Fetch what the archive DECLARES about a dataset: project, sample, experiment, run.
|
|
371
|
+
|
|
372
|
+
A transcriber, not a resolver. It reports the record and stops — `resolve` decides what any of it
|
|
373
|
+
means. This is where per-sample metadata comes from: `strain`, `tissue`, `sex`, `dev_stage` live
|
|
374
|
+
on the BioSample record and were fetched by no code at all until now, which is why the pilot's six
|
|
375
|
+
samples all said `tissue: null`.
|
|
376
|
+
|
|
377
|
+
Cached under `seqforge/records/`: a record is a fact about the archive at a moment, so
|
|
378
|
+
re-fetching it should be a choice.
|
|
379
|
+
"""
|
|
380
|
+
from ..io.archive import fetch_records
|
|
381
|
+
from ..io.remote import RemoteError
|
|
382
|
+
|
|
383
|
+
try:
|
|
384
|
+
records = fetch_records(accession)
|
|
385
|
+
except RemoteError as exc:
|
|
386
|
+
typer.echo(json.dumps({"error": str(exc)}, indent=2), err=True)
|
|
387
|
+
raise typer.Exit(1) from exc
|
|
388
|
+
|
|
389
|
+
state = records_dir(workspace)
|
|
390
|
+
state.mkdir(parents=True, exist_ok=True)
|
|
391
|
+
target = state / f"{accession}.json"
|
|
392
|
+
target.write_text(json.dumps(records.model_dump(mode="json"), indent=2))
|
|
393
|
+
typer.echo(
|
|
394
|
+
json.dumps(
|
|
395
|
+
{
|
|
396
|
+
"records": str(target),
|
|
397
|
+
"query": records.query,
|
|
398
|
+
"source": records.source,
|
|
399
|
+
"n": {
|
|
400
|
+
level: len(records.at(level)) # type: ignore[arg-type]
|
|
401
|
+
for level in ("project", "sample", "experiment", "run")
|
|
402
|
+
},
|
|
403
|
+
},
|
|
404
|
+
indent=2,
|
|
405
|
+
)
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
@io_app.command("attributes")
|
|
410
|
+
def io_attributes(
|
|
411
|
+
name: str | None = typer.Argument(None, help="Show one attribute; omit to list them all."),
|
|
412
|
+
refresh: bool = typer.Option(
|
|
413
|
+
False, "--refresh", help="Re-fetch NCBI's list and rewrite the shipped vocabulary."
|
|
414
|
+
),
|
|
415
|
+
) -> None:
|
|
416
|
+
"""NCBI's harmonized BioSample attribute names — the key space a sample fact must use.
|
|
417
|
+
|
|
418
|
+
960 curated names with NCBI's own definitions. We enforce against all of them and ask a model for
|
|
419
|
+
a hand-picked few. `condition` is NOT one of them, which is why it is no longer one of ours.
|
|
420
|
+
"""
|
|
421
|
+
from ..io.attributes import (
|
|
422
|
+
ATTRIBUTES_URL,
|
|
423
|
+
get_attribute,
|
|
424
|
+
load_attributes,
|
|
425
|
+
parse_ncbi_attributes_xml,
|
|
426
|
+
source_provenance,
|
|
427
|
+
write_attributes,
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
if refresh:
|
|
431
|
+
from ..io.remote import _get
|
|
432
|
+
|
|
433
|
+
attrs = parse_ncbi_attributes_xml(_get(ATTRIBUTES_URL, timeout=120))
|
|
434
|
+
path = write_attributes(attrs, fetched=_today())
|
|
435
|
+
typer.echo(json.dumps({"wrote": str(path), "n": len(attrs)}, indent=2))
|
|
436
|
+
return
|
|
437
|
+
|
|
438
|
+
if name:
|
|
439
|
+
attr = get_attribute(name)
|
|
440
|
+
typer.echo(
|
|
441
|
+
json.dumps(
|
|
442
|
+
{
|
|
443
|
+
"name": attr.name,
|
|
444
|
+
"display": attr.display,
|
|
445
|
+
"description": attr.description,
|
|
446
|
+
"synonyms": list(attr.synonyms),
|
|
447
|
+
},
|
|
448
|
+
indent=2,
|
|
449
|
+
)
|
|
450
|
+
)
|
|
451
|
+
return
|
|
452
|
+
typer.echo(
|
|
453
|
+
json.dumps(
|
|
454
|
+
{**source_provenance(), "names": sorted(load_attributes())},
|
|
455
|
+
indent=2,
|
|
456
|
+
)
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
@io_app.command("efo")
|
|
461
|
+
def io_efo(
|
|
462
|
+
refresh: bool = typer.Option(
|
|
463
|
+
False, "--refresh", help="Re-fetch labels for every CURIE the KB declares."
|
|
464
|
+
),
|
|
465
|
+
) -> None:
|
|
466
|
+
"""The EFO labels behind `library.assay` — what `EFO:0009922` is actually called.
|
|
467
|
+
|
|
468
|
+
`assay: EFO:0009922` is good standardization and unreadable. The name comes from EFO via EBI's
|
|
469
|
+
OLS4, never from us: a label we maintain by hand drifts from the ontology it claims to quote.
|
|
470
|
+
`--refresh` re-fetches every CURIE the KB's specs declare, so adding a technology is: add the
|
|
471
|
+
spec, run this, commit.
|
|
472
|
+
"""
|
|
473
|
+
import json as _json
|
|
474
|
+
import urllib.parse
|
|
475
|
+
import urllib.request
|
|
476
|
+
|
|
477
|
+
from ..io.efo import OLS4_TERMS, EfoTerm, iri_for, load_terms, parse_ols4_term, write_terms
|
|
478
|
+
from ..kb import load_all_specs
|
|
479
|
+
|
|
480
|
+
if not refresh:
|
|
481
|
+
typer.echo(
|
|
482
|
+
_json.dumps(
|
|
483
|
+
{c: {"name": t.name, "iri": t.iri} for c, t in sorted(load_terms().items())},
|
|
484
|
+
indent=2,
|
|
485
|
+
)
|
|
486
|
+
)
|
|
487
|
+
return
|
|
488
|
+
|
|
489
|
+
curies = sorted({c for spec in load_all_specs().values() for c in spec.identity.assay_ontology})
|
|
490
|
+
terms: dict[str, EfoTerm] = {}
|
|
491
|
+
for curie in curies:
|
|
492
|
+
# OLS4 wants the IRI **double**-URL-encoded in the path. A singly-encoded one 404s, which is
|
|
493
|
+
# the kind of thing that belongs in code rather than in someone's memory.
|
|
494
|
+
quoted = urllib.parse.quote(urllib.parse.quote(iri_for(curie), safe=""), safe="")
|
|
495
|
+
with urllib.request.urlopen(OLS4_TERMS + quoted, timeout=60) as response: # noqa: S310
|
|
496
|
+
terms[curie] = parse_ols4_term(_json.load(response))
|
|
497
|
+
path = write_terms(terms, fetched=_today())
|
|
498
|
+
typer.echo(
|
|
499
|
+
_json.dumps(
|
|
500
|
+
{"wrote": str(path), "terms": {c: t.name for c, t in sorted(terms.items())}}, indent=2
|
|
501
|
+
)
|
|
502
|
+
)
|