seqforge 2026.7.1__py3-none-any.whl

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