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
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""``workflows`` — hand-written, versioned, CI-tested Snakemake modules (NEVER generated).
|
|
2
|
+
|
|
3
|
+
The composer selects a module by id and emits its ``config.yaml`` + ``units.tsv``; it never writes
|
|
4
|
+
rule source. Aligner *environments* and genome *indexes* belong to ``liulab-runtime`` / ``liulab-genome``
|
|
5
|
+
and resolve at run time — a module names an env and an assembly id, never a path.
|
|
6
|
+
|
|
7
|
+
``WORKFLOW_VERSION`` is CalVer and is folded into a manifest's provenance so a compiled config is
|
|
8
|
+
bound to the exact module source that will run it.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from functools import cache
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Literal
|
|
18
|
+
|
|
19
|
+
from ..models.processing import RuntimeEnv
|
|
20
|
+
|
|
21
|
+
#: CalVer YYYY.M.PATCH; bump when any shipped module's rules/params change.
|
|
22
|
+
#: 2026.7.11 — starsolo.smk gains an always-on finalize: `starsolo_count` now declares its stats,
|
|
23
|
+
#: logs, filtered/ tree and BAM as `temp()` outputs; new `solo_to_cram` (BAM -> sorted CRAM via
|
|
24
|
+
#: `seqforge io cram`) and `qc_bundle` (stats+logs -> one gzipped JSON via `seqforge io qc-bundle`)
|
|
25
|
+
#: consume them, so the raw matrices, filtered copies, scattered stats and BAM are all deleted once
|
|
26
|
+
#: the retained deliverables (h5ad, cram, qc.json.gz) land. No new config key (reads only the
|
|
27
|
+
#: already-required `genome.assembly` + `threads`).
|
|
28
|
+
#: 2026.7.7 — `genome_index` (starsolo.smk + star.smk) now *resolves* the STAR index via
|
|
29
|
+
#: liulab-genome's `get_star_index` (a lookup that raises if none is built) instead of
|
|
30
|
+
#: `build_star_index` (build-if-missing). Building is liulab-genome's concern, done ahead of the run;
|
|
31
|
+
#: the pipeline consumes the artifact and never decides when it is built. No STAR on PATH needed here.
|
|
32
|
+
#: 2026.7.6 — `starsolo_count` passes `--soloBarcodeReadLength` when the chemistry declares it. 10x
|
|
33
|
+
#: v2/v3/v3.1 set it to 0, which disables STARsolo's default check that the barcode read is exactly
|
|
34
|
+
#: CB+UMI long — their R1 is routinely sequenced longer (a 150 nt R1) and the default FATALs on the
|
|
35
|
+
#: excess. Read with `SOLO.get(...)` so it stays OPTIONAL: a chemistry that omits it (SPLiT-seq) keeps
|
|
36
|
+
#: STAR's default and the flag is not a `required_config` key it would then have to emit.
|
|
37
|
+
#: 2026.7.5 — `starsolo_count` declares `container:`, so the recorded env name is load-bearing at
|
|
38
|
+
#: last instead of emitted and ignored. `config["env"]` is REPLACED by `config["container"]`: the
|
|
39
|
+
#: manifest carries the env name, and the config carries this machine's rendering of it (the
|
|
40
|
+
#: machine-independence boundary, same as fastq paths). Only the STAR rule gets one — `genome_index` is a `run:` block,
|
|
41
|
+
#: and Snakemake wraps containers in `shell.py`, so a `container:` there is silently ignored.
|
|
42
|
+
#: 2026.7.4 — `starsolo_count` declares its per-feature matrices as NAMED outputs instead of
|
|
43
|
+
#: `directory(Solo.out)`, and `solo_to_h5ad` packages them: the default target is the deliverable.
|
|
44
|
+
#: 2026.7.3 — `required_config` is COMPUTED from the module source instead of typed beside it, so
|
|
45
|
+
#: over- and under-declaration are both impossible rather than one being tested. `units_tsv` joins it
|
|
46
|
+
#: (the composer emits it now; no wrapper injects it). `read_layout_kind` replaces the hardcoded
|
|
47
|
+
#: `module == "map/starsolo"` branch in the composer.
|
|
48
|
+
#: 2026.7.2 — starsolo's required_config gains the four soloCB/UMI keys starsolo.smk has always
|
|
49
|
+
#: dereferenced and never declared. The contract was wrong, not the module.
|
|
50
|
+
#: 2026.7.1 — star.smk hardcodes --outSAMtype (it is a module detail, and starsolo.smk always
|
|
51
|
+
#: hardcoded it); required_config gains primary_feature and drops bulk.outSAMtype.
|
|
52
|
+
WORKFLOW_VERSION = "2026.7.12"
|
|
53
|
+
|
|
54
|
+
_MODULE_DIR = Path(__file__).parent
|
|
55
|
+
|
|
56
|
+
#: liulab-runtime's published image. **A reference to their artifact, never a definition of one**:
|
|
57
|
+
#: we name a tag they build and push, and this repo still contains no conda YAML, no
|
|
58
|
+
#: Dockerfile, and no aligner in any dependency table. `align-rna` is where STAR comes from.
|
|
59
|
+
RUNTIME_IMAGE = "ghcr.io/liuhlab/liulab-runtime"
|
|
60
|
+
|
|
61
|
+
#: How liulab-runtime names a prebuilt Singularity image. Read off their own `build-sifs.sh` on
|
|
62
|
+
#: 2026-07-15 (`$LIU_LAB_PACKAGES/liulab-runtime_<env>.sif`), not remembered — the four files there
|
|
63
|
+
#: are exactly the four `RuntimeEnv` names, which is an independent confirmation of that literal.
|
|
64
|
+
_SIF_NAME = "liulab-runtime_{env}.sif"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def container_uri(env: RuntimeEnv, sif_dir: str | Path | None = None) -> str:
|
|
68
|
+
"""The container image for ``env``: a ghcr tag, or a prebuilt ``.sif`` if one is on this machine.
|
|
69
|
+
|
|
70
|
+
``docker://`` by default, which is portable and needs no setup — Snakemake pulls it. But a
|
|
71
|
+
compute node that cannot reach ghcr.io cannot pull anything, and the lab already builds these
|
|
72
|
+
images ahead of time, so ``sif_dir`` names where. Missing dir or missing file falls back to the
|
|
73
|
+
ghcr tag rather than emitting a path to nothing: a config naming an absent SIF fails at run time
|
|
74
|
+
on a node, while the tag at least tries.
|
|
75
|
+
|
|
76
|
+
This is a **machine fact**, so it belongs in the config and never in the manifest — same
|
|
77
|
+
boundary as ``--fastq-dir`` and ``--onlist-dir``, and the same escape hatch for the same reason.
|
|
78
|
+
"""
|
|
79
|
+
if sif_dir is not None:
|
|
80
|
+
sif = Path(sif_dir) / _SIF_NAME.format(env=env)
|
|
81
|
+
if sif.is_file():
|
|
82
|
+
return str(sif.resolve())
|
|
83
|
+
return f"docker://{RUNTIME_IMAGE}:{env}"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@cache
|
|
87
|
+
def keys_read_by(snakefile: Path) -> frozenset[str]:
|
|
88
|
+
"""The dotted config keys a module actually reads, **derived from its source**.
|
|
89
|
+
|
|
90
|
+
Two forms, because the modules use both: `config["a"]["b"]` directly, and the indirection
|
|
91
|
+
`params: solo=config["solo"]` followed by `{params.solo[soloCBlen]}` in the shell block.
|
|
92
|
+
|
|
93
|
+
Comments are stripped first, and that is not fussiness — starsolo.smk's own header prose says
|
|
94
|
+
"every chemistry-defining knob arrives via `config["solo"]`", which a naive scan reads as a bare
|
|
95
|
+
read of the whole block. A check that cries wolf gets deleted.
|
|
96
|
+
"""
|
|
97
|
+
code = "\n".join(line.split("#")[0] for line in snakefile.read_text().splitlines())
|
|
98
|
+
keys: set[str] = set()
|
|
99
|
+
|
|
100
|
+
# A bare `<name> = config["<section>"]` binds the whole block to a name. Track those, including
|
|
101
|
+
# one rebinding hop (`SOLO = config["solo"]` at module level, then `solo=SOLO` in a params
|
|
102
|
+
# block), because that chain is exactly how the shell reaches `{params.solo[soloType]}`.
|
|
103
|
+
# The lookahead matters: `ASSEMBLY = config["genome"]["assembly"]` is a nested read, not a
|
|
104
|
+
# binding, and must fall through to the direct scan below.
|
|
105
|
+
bound: dict[str, str] = dict(re.findall(r'(\w+)\s*=\s*config\["(\w+)"\](?!\[)', code))
|
|
106
|
+
for name, src in re.findall(r"^\s*(\w+)\s*=\s*(\w+)\s*,?\s*$", code, re.M):
|
|
107
|
+
if src in bound:
|
|
108
|
+
bound.setdefault(name, bound[src])
|
|
109
|
+
|
|
110
|
+
for name, section in bound.items():
|
|
111
|
+
# `{params.<name>[<key>]}` in a shell block, or `<NAME>["<key>"]` in Python.
|
|
112
|
+
subscripts = set(re.findall(rf"\{{params\.{name}\[(\w+)\]\}}", code)) | set(
|
|
113
|
+
re.findall(rf"""\b{name}\[["'](\w+)["']\]""", code)
|
|
114
|
+
)
|
|
115
|
+
# Subscripted -> it is a block alias and each subscript is the real read. Never subscripted
|
|
116
|
+
# -> it was a scalar read all along (`OUTDIR = config["outdir"]`), so the section IS the key.
|
|
117
|
+
keys |= {f"{section}.{k}" for k in subscripts} or {section}
|
|
118
|
+
|
|
119
|
+
# Direct reads: config["a"]["b"] -> a.b | config["a"] -> a. Binding sites are already accounted
|
|
120
|
+
# for above, so drop them here rather than double-count the block as a bare key.
|
|
121
|
+
direct = re.sub(r'\w+\s*=\s*config\["\w+"\](?!\[)', "", code)
|
|
122
|
+
for section, sub in re.findall(r'config\["(\w+)"\](?:\["(\w+)"\])?', direct):
|
|
123
|
+
keys.add(f"{section}.{sub}" if sub else section)
|
|
124
|
+
|
|
125
|
+
return frozenset(keys)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass(frozen=True)
|
|
129
|
+
class WorkflowModule:
|
|
130
|
+
"""One selectable workflow module: its id, version, runtime env, Snakefile, and config contract."""
|
|
131
|
+
|
|
132
|
+
name: str
|
|
133
|
+
version: str
|
|
134
|
+
env: RuntimeEnv
|
|
135
|
+
snakefile: Path
|
|
136
|
+
#: How this module wants its reads handed to the aligner:
|
|
137
|
+
#:
|
|
138
|
+
#: - ``barcoded`` — ``{cdna, barcode}``, chosen by ROLE (a barcoded single-cell chemistry).
|
|
139
|
+
#: - ``paired`` — ``{mate1, mate2}``, chosen by ORDER (a bulk paired-end library).
|
|
140
|
+
#:
|
|
141
|
+
#: This is deliberately a small closed literal and **not** a general plugin interface: there are
|
|
142
|
+
#: two modules and both are STAR, so any richer abstraction would be generalised from a sample
|
|
143
|
+
#: size of one. What it does buy is that the composer no longer branches on
|
|
144
|
+
#: ``spec.backend.module == "map/starsolo"`` — a string compare in which every module that was not
|
|
145
|
+
#: starsolo silently fell into the bulk mate1/mate2 branch and emitted a wrong command line. A
|
|
146
|
+
#: third module must now pick a kind, or add one, and either is a typed and visible choice.
|
|
147
|
+
read_layout_kind: Literal["barcoded", "paired"]
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def required_config(self) -> tuple[str, ...]:
|
|
151
|
+
"""Dotted config keys the module reads — the composer must emit every one.
|
|
152
|
+
|
|
153
|
+
**Computed from the module source, never declared.** This was a hand-written tuple, checked in
|
|
154
|
+
one direction against a scanner that lived in the test suite. It under-declared the four
|
|
155
|
+
soloCB/UMI keys `starsolo.smk` has always dereferenced (a `KeyError` on a compute node, long
|
|
156
|
+
after compose exited 0), and it over-declared `primary_feature` and `env`, which no rule
|
|
157
|
+
reads. Both directions now close by construction: there is one list, and the module source is
|
|
158
|
+
it. A hand-maintained list of what the code does is a comment with a tuple's syntax.
|
|
159
|
+
|
|
160
|
+
Deriving is only safe because the module now *executes*: `kb e2e` runs this Snakefile against
|
|
161
|
+
real reads and a ground-truth matrix, so a key this scanner misses fails loudly there. The two
|
|
162
|
+
are complementary — `kb e2e` exercises one chemistry's branch, this covers both statically.
|
|
163
|
+
"""
|
|
164
|
+
return tuple(sorted(keys_read_by(self.snakefile)))
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def param_block(self) -> str:
|
|
168
|
+
"""Which config block carries this module's aligner params. **Read off the module source.**
|
|
169
|
+
|
|
170
|
+
`starsolo.smk` dereferences `config["solo"]`; `star.smk` dereferences `config["bulk"]`. That
|
|
171
|
+
is not a preference anyone declares — it is what the file does — so it is derived from
|
|
172
|
+
`required_config`, which is itself scanned out of the module.
|
|
173
|
+
|
|
174
|
+
It was `"solo" if spec.backend.module == "map/starsolo" else "bulk"`, the last surviving
|
|
175
|
+
string compare against a module name, and it is the same bug `read_layout_kind` was created
|
|
176
|
+
to kill: every module that is not starsolo silently means bulk. A third module would have had
|
|
177
|
+
its params written into a `bulk:` block it never reads, and the params gate — which uses this
|
|
178
|
+
same function — would have agreed with the composer, because both were wrong in the same
|
|
179
|
+
direction. Two things wrong identically is what a shared bug looks like from inside a test.
|
|
180
|
+
|
|
181
|
+
A module that reads neither block, or both, raises. That is a module whose config contract we
|
|
182
|
+
do not understand, and guessing would be how the wrong params reach an aligner.
|
|
183
|
+
"""
|
|
184
|
+
blocks = sorted({k.split(".")[0] for k in self.required_config} & {"solo", "bulk"})
|
|
185
|
+
if len(blocks) != 1:
|
|
186
|
+
raise ValueError(
|
|
187
|
+
f"{self.name} reads {blocks or 'no'} aligner-param block(s) in its config; expected "
|
|
188
|
+
f"exactly one of solo/bulk. A module whose contract is unreadable must not be "
|
|
189
|
+
f"guessed at — add the block it reads, or teach `param_block` the new shape."
|
|
190
|
+
)
|
|
191
|
+
return blocks[0]
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
MODULES: dict[str, WorkflowModule] = {
|
|
195
|
+
"map/starsolo": WorkflowModule(
|
|
196
|
+
name="map/starsolo",
|
|
197
|
+
version=WORKFLOW_VERSION,
|
|
198
|
+
env="align-rna",
|
|
199
|
+
snakefile=_MODULE_DIR / "map" / "starsolo.smk",
|
|
200
|
+
read_layout_kind="barcoded",
|
|
201
|
+
),
|
|
202
|
+
"map/star": WorkflowModule(
|
|
203
|
+
name="map/star",
|
|
204
|
+
version=WORKFLOW_VERSION,
|
|
205
|
+
env="align-rna",
|
|
206
|
+
snakefile=_MODULE_DIR / "map" / "star.smk",
|
|
207
|
+
read_layout_kind="paired",
|
|
208
|
+
),
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def get_module(name: str) -> WorkflowModule:
|
|
213
|
+
"""Return the workflow module registered under ``name`` (raises ``KeyError`` if unknown)."""
|
|
214
|
+
try:
|
|
215
|
+
return MODULES[name]
|
|
216
|
+
except KeyError as exc:
|
|
217
|
+
known = ", ".join(sorted(MODULES))
|
|
218
|
+
raise KeyError(f"unknown workflow module {name!r}; known: {known}") from exc
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def list_modules() -> list[str]:
|
|
222
|
+
return sorted(MODULES)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
__all__ = [
|
|
226
|
+
"WORKFLOW_VERSION",
|
|
227
|
+
"RUNTIME_IMAGE",
|
|
228
|
+
"WorkflowModule",
|
|
229
|
+
"MODULES",
|
|
230
|
+
"container_uri",
|
|
231
|
+
"get_module",
|
|
232
|
+
"keys_read_by",
|
|
233
|
+
"list_modules",
|
|
234
|
+
]
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Convert STAR's ``Aligned.out.bam`` to a coordinate-sorted CRAM — the finalize step that shrinks
|
|
2
|
+
the retained alignment.
|
|
3
|
+
|
|
4
|
+
CRAM stores each read as its *difference* from the reference, so it is markedly smaller than BAM. The
|
|
5
|
+
reference is not embedded (``embed_ref`` is deliberately off): ``samtools view -C -T <ref>`` records
|
|
6
|
+
each sequence's MD5 in the header's ``@SQ … M5:`` tags, and seqforge's reference is a UCSC assembly id
|
|
7
|
+
that ``liulab-genome`` resolves deterministically forever — so the checksum plus the assembly id
|
|
8
|
+
recorded in the QC bundle are enough to recover the exact reference. Not embedding is the smaller,
|
|
9
|
+
standard choice, and the user's call.
|
|
10
|
+
|
|
11
|
+
**This takes a resolved FASTA path, never an assembly id.** Resolving ``assembly -> fasta_path`` needs
|
|
12
|
+
``liulab-genome``, which ships no type stubs; keeping that import in the (untyped) CLI verb lets this
|
|
13
|
+
module stay under ``mypy --strict`` and stay unit-testable with a throwaway FASTA. Same split as
|
|
14
|
+
``h5ad``: the strict workflow module does the work, the thin verb wires the environment.
|
|
15
|
+
|
|
16
|
+
samtools is **not** a dependency of this package. It is a runtime binary the ``align-rna`` image
|
|
17
|
+
carries (in its base layer), and the ``solo_to_cram`` rule names that image with ``container:`` —
|
|
18
|
+
exactly as ``starsolo_count`` does for STAR. So this module shells out to the samtools the pinned
|
|
19
|
+
image provides, never one seqforge installed; that is the same "consume the runtime, don't redefine
|
|
20
|
+
it" line that keeps STAR out of every dependency table here.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import subprocess
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class CramError(RuntimeError):
|
|
30
|
+
"""The BAM could not be converted (missing input, samtools failure, unreadable reference)."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _run(cmd: list[str]) -> None:
|
|
34
|
+
try:
|
|
35
|
+
subprocess.run(cmd, check=True)
|
|
36
|
+
except FileNotFoundError as exc: # samtools not on PATH
|
|
37
|
+
raise CramError(f"{cmd[0]} is not installed; CRAM conversion needs samtools") from exc
|
|
38
|
+
except subprocess.CalledProcessError as exc:
|
|
39
|
+
raise CramError(f"{' '.join(cmd)} exited {exc.returncode}") from exc
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _ensure_fai(fasta: Path, workdir: Path) -> Path:
|
|
43
|
+
"""A FASTA with a readable ``.fai`` beside it, creating one in ``workdir`` if the store is read-only.
|
|
44
|
+
|
|
45
|
+
``samtools view -C -T`` needs ``<ref>.fai``. ``liulab-genome``'s reference store is frequently
|
|
46
|
+
read-only, so writing the index next to the FASTA fails. If an index already exists we use the
|
|
47
|
+
FASTA in place; otherwise we mirror the FASTA into ``workdir`` (a symlink — no bytes copied) and
|
|
48
|
+
index *that*, so the ``.fai`` lands somewhere writable.
|
|
49
|
+
"""
|
|
50
|
+
if fasta.with_name(fasta.name + ".fai").exists():
|
|
51
|
+
return fasta
|
|
52
|
+
workdir.mkdir(parents=True, exist_ok=True)
|
|
53
|
+
local = workdir / fasta.name
|
|
54
|
+
if not local.exists():
|
|
55
|
+
local.symlink_to(fasta)
|
|
56
|
+
_run(["samtools", "faidx", str(local)])
|
|
57
|
+
return local
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
#: Floor for samtools sort's per-thread memory (``-m``). Below this, sort spills to many tiny temp
|
|
61
|
+
#: files and thrashes; this keeps a sane minimum even when the budget divided by threads is small.
|
|
62
|
+
_MIN_SORT_MEM_MB = 256
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _sort_mem_per_thread_mb(sort_mem_mb: int | None, threads: int) -> int | None:
|
|
66
|
+
"""Per-thread ``-m`` for ``samtools sort`` from a TOTAL budget, or ``None`` to use its default.
|
|
67
|
+
|
|
68
|
+
samtools sort holds ``-m`` bytes **per thread** before spilling, so the total is ``threads * m``.
|
|
69
|
+
We spend ~3/4 of the budget on the sort (leaving headroom for the CRAM encoder running in the same
|
|
70
|
+
pipe, plus the OS) and split that across threads — so more cores *and* more memory both make it
|
|
71
|
+
finish faster, which is the whole point of setting it rather than single-threading the default.
|
|
72
|
+
"""
|
|
73
|
+
if sort_mem_mb is None:
|
|
74
|
+
return None
|
|
75
|
+
return max(_MIN_SORT_MEM_MB, (sort_mem_mb * 3 // 4) // max(1, threads))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def bam_to_cram(
|
|
79
|
+
bam: Path, fasta: Path, out: Path, threads: int = 1, sort_mem_mb: int | None = None
|
|
80
|
+
) -> Path:
|
|
81
|
+
"""``Aligned.out.bam`` -> coordinate-sorted ``out`` (CRAM) + ``out.crai``. Returns ``out``.
|
|
82
|
+
|
|
83
|
+
Sorted so the CRAM is indexable (random access by region) and so like reads compress together.
|
|
84
|
+
Every samtools stage runs multi-threaded (``-@ threads``) and the sort is given a real memory
|
|
85
|
+
budget (``-m`` per thread, derived from ``sort_mem_mb``) so a fat node is actually used — never a
|
|
86
|
+
single-threaded default. The BAM is left in place; the caller (a Snakemake ``temp()`` output) owns
|
|
87
|
+
its deletion.
|
|
88
|
+
"""
|
|
89
|
+
if not bam.exists():
|
|
90
|
+
raise CramError(f"{bam} is missing; the STAR run that should have written it did not")
|
|
91
|
+
if not fasta.exists():
|
|
92
|
+
raise CramError(f"reference FASTA {fasta} does not exist")
|
|
93
|
+
ref = _ensure_fai(fasta, out.parent)
|
|
94
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
95
|
+
# sort (BAM is written --outSAMtype BAM Unsorted) piped straight into the CRAM encoder, so no
|
|
96
|
+
# intermediate sorted BAM lands on disk. `-T` names the reference; no embed_ref -> smallest CRAM.
|
|
97
|
+
sort = ["samtools", "sort", "-@", str(threads), "-O", "bam"]
|
|
98
|
+
per_thread = _sort_mem_per_thread_mb(sort_mem_mb, threads)
|
|
99
|
+
if per_thread is not None:
|
|
100
|
+
sort += ["-m", f"{per_thread}M"]
|
|
101
|
+
sort.append(str(bam))
|
|
102
|
+
view = ["samtools", "view", "-C", "-T", str(ref), "-@", str(threads), "-o", str(out), "-"]
|
|
103
|
+
try:
|
|
104
|
+
with subprocess.Popen(sort, stdout=subprocess.PIPE) as sorter:
|
|
105
|
+
assert sorter.stdout is not None
|
|
106
|
+
view_proc = subprocess.run(view, stdin=sorter.stdout, check=False)
|
|
107
|
+
if sorter.returncode:
|
|
108
|
+
raise CramError(f"samtools sort exited {sorter.returncode}")
|
|
109
|
+
if view_proc.returncode:
|
|
110
|
+
raise CramError(f"samtools view (CRAM) exited {view_proc.returncode}")
|
|
111
|
+
except FileNotFoundError as exc:
|
|
112
|
+
raise CramError("samtools is not installed; CRAM conversion needs samtools") from exc
|
|
113
|
+
_run(["samtools", "index", "-@", str(threads), str(out)])
|
|
114
|
+
return out
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
__all__ = ["CramError", "bam_to_cram"]
|