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/compose/core.py
ADDED
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
"""``compose`` — (dataset, processing) -> ``Snakefile`` + ``config.yaml`` + ``units.tsv``.
|
|
2
|
+
|
|
3
|
+
**Emit data, never code.** The composer selects a hand-written, versioned module and emits its
|
|
4
|
+
*configuration*; it never writes rule source. It is a pure function of **both** its inputs plus two
|
|
5
|
+
versioned ones recorded in provenance (the KB and the workflow modules) — it needs no FASTQ on disk,
|
|
6
|
+
local or remote. Two inputs, still no I/O: a processing manifest is data, not a side channel.
|
|
7
|
+
|
|
8
|
+
Purity across *both* is what makes "same dataset + different processing = different pipeline, same
|
|
9
|
+
dataset hash" a fact rather than a hope. Output is keyed by ``run_id = H(dataset ⊕ processing ⊕ kb ⊕
|
|
10
|
+
workflow)``, so composing one dataset two ways yields two directories instead of the second silently
|
|
11
|
+
overwriting the first — which is what a fixed ``.seqforge/pipeline/`` path used to do, in exactly the
|
|
12
|
+
case the split exists to enable.
|
|
13
|
+
|
|
14
|
+
The machine-independent/machine-specific boundary lives here: the **manifest** carries URIs and a
|
|
15
|
+
``liulab-genome`` assembly id; the **config** it compiles to is the resolved, machine-specific
|
|
16
|
+
instantiation, so paths appear here and only here.
|
|
17
|
+
|
|
18
|
+
The gate has three parts (design §4.1) — this module always runs the deterministic **params** gate;
|
|
19
|
+
``wiring`` (``snakemake -n -p``) now really runs, because `snakemake-minimal` is a declared
|
|
20
|
+
dependency; ``e2e`` (the real count-matrix run) is reported as ``skip``, never silently as ``pass``.
|
|
21
|
+
|
|
22
|
+
**The Snakefile is the deliverable, and it is emitted here, unconditionally.** It used to be written
|
|
23
|
+
inside ``wiring_gate`` — so the product of the compiler was a side effect of a validation step, and
|
|
24
|
+
that step returned early whenever ``snakemake`` was absent from PATH, which it always was. `compose`
|
|
25
|
+
exited 0 and emitted nothing a user could run. A gate may read the deliverable; it may not be the
|
|
26
|
+
reason the deliverable exists.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import shutil
|
|
32
|
+
from dataclasses import dataclass
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
|
|
35
|
+
import yaml
|
|
36
|
+
|
|
37
|
+
from ..io import DEFAULT_REGISTRY, OnlistNotAvailable, OnlistRegistry
|
|
38
|
+
from ..kb import load_spec
|
|
39
|
+
from ..kb.schema import Spec
|
|
40
|
+
from ..manifest.hash import run_id
|
|
41
|
+
from ..models.dataset import INDEX_ROLE, DatasetManifest
|
|
42
|
+
from ..models.processing import (
|
|
43
|
+
DatasetPin,
|
|
44
|
+
ProcessingManifest,
|
|
45
|
+
Quantification,
|
|
46
|
+
RuntimeEnv,
|
|
47
|
+
SoloQuant,
|
|
48
|
+
)
|
|
49
|
+
from ..models.resolve import ComposeResult, ModuleSelection
|
|
50
|
+
from ..resolve.group import run_key
|
|
51
|
+
from ..workflows import WorkflowModule, container_uri, get_module
|
|
52
|
+
from ..workspace import readable, state_dir
|
|
53
|
+
from .params import (
|
|
54
|
+
derived_params,
|
|
55
|
+
find_read_with_role,
|
|
56
|
+
param_block_key,
|
|
57
|
+
params_gate,
|
|
58
|
+
processing_params,
|
|
59
|
+
render_param,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ComposeError(RuntimeError):
|
|
64
|
+
"""The manifest cannot be compiled (unknown chemistry/module, or an unresolvable whitelist)."""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
#: Names of the artifacts a run directory carries, beside the wrapper that reads them.
|
|
68
|
+
_CONFIG_NAME = "config.yaml"
|
|
69
|
+
_UNITS_TSV_NAME = "units.tsv"
|
|
70
|
+
_SNAKEFILE_NAME = "Snakefile"
|
|
71
|
+
|
|
72
|
+
#: The run wrapper: **the deliverable**. A user submits this file.
|
|
73
|
+
#:
|
|
74
|
+
#: It is data, not code: it names a hand-written module and points it at a config. There is no
|
|
75
|
+
#: rule body, no shell block, and no logic here — `test_the_generated_wrapper_contains_no_rule_source`
|
|
76
|
+
#: holds the line.
|
|
77
|
+
#:
|
|
78
|
+
#: **It uses `module` / `use rule *`, not `include:`, and that is load-bearing rather than stylistic.**
|
|
79
|
+
#: Snakemake's default target is the first rule defined in the *main* Snakefile, and an `include:`d
|
|
80
|
+
#: rule is not one — so the old `configfile:` + `include:` wrapper parsed clean, listed all three
|
|
81
|
+
#: rules, and then did **nothing at all** when run: "Nothing to be done", exit 0. Measured 2026-07-15;
|
|
82
|
+
#: the same module content inlined builds 3 jobs, and via `include:` builds 0. `use rule * from m as *`
|
|
83
|
+
#: re-declares the rules in this workflow, which makes `all` a real default target and `snakemake` with
|
|
84
|
+
#: no arguments do the right thing. This is also what design.md §4.1 specified all along.
|
|
85
|
+
_WRAPPER = """# GENERATED by `seqforge compose` -- this file is DATA: it names a hand-written module
|
|
86
|
+
# and points it at a config. Rule source is never generated; the rules live in the module below,
|
|
87
|
+
# `{snakefile}`, COPIED into this directory beside this Snakefile at the exact version this run is
|
|
88
|
+
# pinned to. The copy is deliberate: a run directory carries its own recipe, so it stays readable and
|
|
89
|
+
# reproducible even after it is moved off the machine that composed it -- no path back into an
|
|
90
|
+
# installed package. (Running it still needs `seqforge` and `genome` importable; the copy makes the
|
|
91
|
+
# pipeline legible, not standalone.)
|
|
92
|
+
#
|
|
93
|
+
# Run it: snakemake --profile <your-cluster-profile> --software-deployment-method apptainer
|
|
94
|
+
#
|
|
95
|
+
# The default target is `all`, which produces one <sample>.h5ad per sample in units.tsv (plus a
|
|
96
|
+
# <sample>.velocyto.h5ad when Velocyto is counted).
|
|
97
|
+
#
|
|
98
|
+
# `--software-deployment-method apptainer` is what makes the aligner rule run inside the
|
|
99
|
+
# liulab-runtime image named in config.yaml. WITHOUT IT SNAKEMAKE IGNORES THE `container:` DIRECTIVE
|
|
100
|
+
# ENTIRELY and STAR comes from your PATH -- the run still works, and is no longer pinned. Never
|
|
101
|
+
# needed for the packaging step, which runs in whatever environment has seqforge.
|
|
102
|
+
configfile: "config.yaml"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
module {alias}:
|
|
106
|
+
snakefile:
|
|
107
|
+
"{snakefile}"
|
|
108
|
+
config:
|
|
109
|
+
config
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
use rule * from {alias} as *
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _module_alias(name: str) -> str:
|
|
117
|
+
"""`map/starsolo` -> `starsolo`: a Snakemake module needs a Python-identifier-shaped name."""
|
|
118
|
+
return name.rsplit("/", 1)[-1].replace("-", "_")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def render_wrapper(module_name: str, snakefile: str) -> str:
|
|
122
|
+
"""The run wrapper's text, for the module `module_name` at the LOCAL name `snakefile`.
|
|
123
|
+
|
|
124
|
+
``snakefile`` is a bare filename (``starsolo.smk``), not a package path: the composer copies the
|
|
125
|
+
module into the run directory and the wrapper imports its neighbour, so the `snakefile:` line
|
|
126
|
+
resolves relative to this Snakefile. Snakemake resolves a module's snakefile against the including
|
|
127
|
+
file's directory, which is exactly where the copy lands.
|
|
128
|
+
"""
|
|
129
|
+
return _WRAPPER.format(alias=_module_alias(module_name), snakefile=snakefile)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass(frozen=True)
|
|
133
|
+
class ComposePlan:
|
|
134
|
+
"""The composed artifacts, before they are written (compose is a pure function of the manifest)."""
|
|
135
|
+
|
|
136
|
+
config: dict[str, object]
|
|
137
|
+
units: list[dict[str, str]]
|
|
138
|
+
module: ModuleSelection
|
|
139
|
+
spec: Spec
|
|
140
|
+
#: relative path -> the registry name `rule onlist` will build it from. NOT the barcodes:
|
|
141
|
+
#: see `_resolve_token` and workflows/map/starsolo.smk for why compose does not write 111 MB.
|
|
142
|
+
onlist_files: dict[str, list[str]]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def plan(
|
|
146
|
+
manifest: DatasetManifest,
|
|
147
|
+
processing: ProcessingManifest,
|
|
148
|
+
*,
|
|
149
|
+
registry: OnlistRegistry | None = None,
|
|
150
|
+
outdir: str = "results",
|
|
151
|
+
fastq_dir: str | Path | None = None,
|
|
152
|
+
sif_dir: str | Path | None = None,
|
|
153
|
+
) -> ComposePlan:
|
|
154
|
+
"""Build the config + units + module selection for one (dataset, processing) pair.
|
|
155
|
+
|
|
156
|
+
Both inputs are positional and required. Composing one dataset two ways is literally two calls
|
|
157
|
+
with one argument changed, producing two ``run_id``s over one unchanged ``dataset_hash``.
|
|
158
|
+
|
|
159
|
+
``threads`` is deliberately NOT an argument: it lives in ``processing.resources.threads``. Having
|
|
160
|
+
it in both places was the same two-sources-of-truth disease this whole change is curing.
|
|
161
|
+
``outdir`` and ``fastq_dir`` stay arguments, because they are paths — machine facts, which a
|
|
162
|
+
machine-independent manifest forbids carrying. ``threads`` is intent; a path is an invocation.
|
|
163
|
+
|
|
164
|
+
``fastq_dir`` is where this machine keeps the bytes. A manifest URI is a *relative* name, so
|
|
165
|
+
without it ``units.tsv`` carries bare basenames and the pipeline resolves them against the run
|
|
166
|
+
directory — where the FASTQs are not. That is not a stricter kind of purity, it is a pipeline that
|
|
167
|
+
cannot find its input. Given a directory, ``units.tsv`` carries resolved absolute paths: the
|
|
168
|
+
manifest stays machine-independent and the *config* becomes the machine-specific instantiation,
|
|
169
|
+
which is the boundary this module's docstring has always drawn. Still no I/O — the path is joined,
|
|
170
|
+
never read, so ``plan`` stays pure and needs no FASTQ on disk.
|
|
171
|
+
"""
|
|
172
|
+
registry = registry if registry is not None else DEFAULT_REGISTRY
|
|
173
|
+
_check_pin(manifest, processing)
|
|
174
|
+
chemistry = manifest.library.chemistry.value
|
|
175
|
+
if not chemistry:
|
|
176
|
+
raise ComposeError("manifest.library.chemistry is empty; nothing to compile")
|
|
177
|
+
# A processing-equivalent class emits identical params by construction (§12), so the first
|
|
178
|
+
# member compiles for all of them — that is exactly what makes the ambiguity benign.
|
|
179
|
+
spec = load_spec(chemistry[0])
|
|
180
|
+
try:
|
|
181
|
+
module = get_module(spec.require_backend().module)
|
|
182
|
+
except KeyError as exc:
|
|
183
|
+
raise ComposeError(str(exc)) from exc
|
|
184
|
+
|
|
185
|
+
onlist_files: dict[str, list[str]] = {}
|
|
186
|
+
intent = processing.processing
|
|
187
|
+
_check_env(intent.environment.value, module)
|
|
188
|
+
# Two owners, one block. The KB says how to PARSE; the processing manifest says what to
|
|
189
|
+
# COUNT. params_gate proves the key sets stay disjoint and that both halves arrive verbatim.
|
|
190
|
+
params = _resolve_params(manifest, spec, registry, onlist_files)
|
|
191
|
+
# Derived before processing, both after the KB: three owners, one key each, and `param_owners`
|
|
192
|
+
# is the definition all three agree on. A complex chemistry locates its barcodes by quadruple,
|
|
193
|
+
# computed from the element model rather than hand-entered (see `derived_params`).
|
|
194
|
+
params.update(derived_params(spec))
|
|
195
|
+
params.update(
|
|
196
|
+
{k: render_param(v) for k, v in processing_params(intent.quantification.value).items()}
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
config: dict[str, object] = {
|
|
200
|
+
"chemistry": list(chemistry),
|
|
201
|
+
"genome": {
|
|
202
|
+
"assembly": intent.genome.value.assembly,
|
|
203
|
+
"annotation": intent.genome.value.annotation_name,
|
|
204
|
+
},
|
|
205
|
+
# The env name RESOLVED to an image this machine can run. `config["env"]` used to sit here:
|
|
206
|
+
# emitted, and read by no rule — the env was recorded and then ignored, so a pipeline ran
|
|
207
|
+
# against whatever STAR happened to be on PATH. One key, one owner, and now something reads
|
|
208
|
+
# it. The manifest still carries the env *name*; this is its machine-specific rendering,
|
|
209
|
+
# exactly like a fastq path.
|
|
210
|
+
"container": container_uri(intent.environment.value, sif_dir),
|
|
211
|
+
"threads": intent.resources.threads,
|
|
212
|
+
# The advisory memory hint, in MB, so the finalize CRAM sort can be given a real `-m` budget
|
|
213
|
+
# rather than single-threading samtools' default. An intent knob (like threads), never a
|
|
214
|
+
# machine fact — the scheduler honours it when the user submits.
|
|
215
|
+
"mem_mb": intent.resources.mem_gb * 1024,
|
|
216
|
+
"outdir": outdir,
|
|
217
|
+
"read_files_in": _read_files_in(manifest, module),
|
|
218
|
+
"samples": [s.sample_id for s in manifest.experiment.samples],
|
|
219
|
+
# Emitted by the composer like every other key. It used to be injected by the run wrapper at
|
|
220
|
+
# parse time, and `required_config` documented that arrangement — but the wrapper was written
|
|
221
|
+
# inside a gate that could not run, so the injector named by the contract did not exist and
|
|
222
|
+
# every compiled pipeline was one `KeyError` from the start. One owner, no special case.
|
|
223
|
+
"units_tsv": _UNITS_TSV_NAME,
|
|
224
|
+
}
|
|
225
|
+
config[param_block_key(spec)] = params
|
|
226
|
+
primary = _primary_feature(intent.quantification.value)
|
|
227
|
+
if primary is not None:
|
|
228
|
+
# Top level, NOT inside config["solo"] — that block must stay "every key is a STAR CLI flag"
|
|
229
|
+
# for the gate's coverage check, and STARsolo has no --primaryFeature. Order in the manifest
|
|
230
|
+
# is a seqforge-side annotation with no aligner-side referent (STARsolo writes one
|
|
231
|
+
# Solo.out/<Feature>/ per value and does not care about order), so it gets projected out to
|
|
232
|
+
# an explicit value rather than leaving a positional convention load-bearing for every
|
|
233
|
+
# downstream reader.
|
|
234
|
+
config["primary_feature"] = primary
|
|
235
|
+
|
|
236
|
+
return ComposePlan(
|
|
237
|
+
config=config,
|
|
238
|
+
units=_units(manifest, fastq_dir),
|
|
239
|
+
module=ModuleSelection(name=module.name, version=module.version, env=module.env),
|
|
240
|
+
spec=spec,
|
|
241
|
+
onlist_files=onlist_files,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _primary_feature(quant: Quantification) -> str | None:
|
|
246
|
+
"""Which ``Solo.out/<Feature>/`` is THE matrix for this run. ``None`` for bulk (no such split)."""
|
|
247
|
+
return quant.features[0] if isinstance(quant, SoloQuant) else None
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _check_env(env: RuntimeEnv, module: WorkflowModule) -> None:
|
|
251
|
+
"""The recipe's runtime env must be the one the module needs. Refuse; never quietly correct.
|
|
252
|
+
|
|
253
|
+
The env is read off the processing manifest, not off the module, because that is the artifact
|
|
254
|
+
that *records the decision* — and `ProcessingSection` forbids unknown keys precisely so that a
|
|
255
|
+
user's stated intent is never silently dropped. But a module knows what software it needs, and
|
|
256
|
+
``map/starsolo`` in an env with no STAR is not a preference, it is a run that dies on a node.
|
|
257
|
+
|
|
258
|
+
So both are read and they must agree. Until `container:` landed, `processing.environment` was
|
|
259
|
+
emitted into the config and read by nothing, while policy hardcoded ``"align-rna"`` beside a
|
|
260
|
+
module that also declared it — two owners who could not disagree only because nobody asked
|
|
261
|
+
either. Policy now asks the module, which makes this check redundant *for the default path* and
|
|
262
|
+
load-bearing for the one where a user hand-writes a processing.yaml.
|
|
263
|
+
"""
|
|
264
|
+
if env != module.env:
|
|
265
|
+
raise ComposeError(
|
|
266
|
+
f"processing.environment is {env!r} but module {module.name!r} needs {module.env!r}. "
|
|
267
|
+
f"The env is what supplies the aligner, so this run would start in a container without "
|
|
268
|
+
f"one. Drop the field to take the module's own env, or pick a module that wants {env!r}."
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _check_pin(manifest: DatasetManifest, processing: ProcessingManifest) -> None:
|
|
273
|
+
"""A BOUND processing manifest must name this dataset. Never auto-repin."""
|
|
274
|
+
pin = processing.dataset
|
|
275
|
+
if pin is None:
|
|
276
|
+
return # a template: portable by design — that is what drives a corpus
|
|
277
|
+
if pin.dataset_hash != manifest.provenance.dataset_hash:
|
|
278
|
+
raise ComposeError(
|
|
279
|
+
f"processing manifest {processing.processing_id!r} is pinned to dataset "
|
|
280
|
+
f"{pin.dataset_hash[:12]}… but this dataset is "
|
|
281
|
+
f"{manifest.provenance.dataset_hash[:12]}…. Re-run `seqforge processing new` against "
|
|
282
|
+
f"this dataset, or drop the pin to make it a template."
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def compose(
|
|
287
|
+
manifest: DatasetManifest,
|
|
288
|
+
processing: ProcessingManifest,
|
|
289
|
+
*,
|
|
290
|
+
registry: OnlistRegistry | None = None,
|
|
291
|
+
workspace: str | Path = ".",
|
|
292
|
+
outdir: str = "results",
|
|
293
|
+
fastq_dir: str | Path | None = None,
|
|
294
|
+
sif_dir: str | Path | None = None,
|
|
295
|
+
run_wiring_gate: bool = True,
|
|
296
|
+
subdir: str | None = None,
|
|
297
|
+
) -> ComposeResult:
|
|
298
|
+
"""Compile a (dataset, processing) pair into a runnable configuration + run the compose gates.
|
|
299
|
+
|
|
300
|
+
``subdir`` roots the output one level deeper — ``seqforge/<subdir>/pipeline/…`` instead of
|
|
301
|
+
``seqforge/pipeline/…`` — so a heterogeneous project can compile one assay per subdirectory. It
|
|
302
|
+
defaults to ``None`` (the flat, single-assay layout), which is byte-identical to before.
|
|
303
|
+
"""
|
|
304
|
+
p = plan(
|
|
305
|
+
manifest, processing, registry=registry, outdir=outdir, fastq_dir=fastq_dir, sif_dir=sif_dir
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
# Keyed by the RUN, not by the workspace. A fixed `.seqforge/pipeline/` path meant recipe B
|
|
309
|
+
# silently overwrote recipe A's config, units, and materialized onlists — and "one dataset, many
|
|
310
|
+
# recipes" is precisely the case this whole change exists to enable.
|
|
311
|
+
rid = run_id(
|
|
312
|
+
dataset_hash=manifest.provenance.dataset_hash,
|
|
313
|
+
processing_hash=processing.provenance.processing_hash,
|
|
314
|
+
kb_version=manifest.provenance.kb_version,
|
|
315
|
+
workflow_version=processing.provenance.workflow_version,
|
|
316
|
+
)
|
|
317
|
+
# `pipeline/default-a3f8c19d2b04/`, not `pipeline/a3f8c19d…696/`. The run_id stays the identity
|
|
318
|
+
# -- it is what keeps two recipes over one dataset apart -- but the recipe already has a name and
|
|
319
|
+
# printing it costs nothing. The pilot's workspace was a directory of 64-hex names in which
|
|
320
|
+
# nothing said which pipeline was which.
|
|
321
|
+
root_parts = (subdir,) if subdir else ()
|
|
322
|
+
pipeline_dir = state_dir(
|
|
323
|
+
workspace, *root_parts, "pipeline", readable(processing.processing_id, rid)
|
|
324
|
+
)
|
|
325
|
+
pipeline_dir.mkdir(parents=True, exist_ok=True)
|
|
326
|
+
config_path = pipeline_dir / _CONFIG_NAME
|
|
327
|
+
units_path = pipeline_dir / _UNITS_TSV_NAME
|
|
328
|
+
|
|
329
|
+
# The whitelists are NOT written here. `rule onlist` builds each one when a job needs it and
|
|
330
|
+
# `temp()` deletes it after — see workflows/map/starsolo.smk. Compose used to expand 6 794 880
|
|
331
|
+
# packed barcodes into 111 MB of text per run directory, at compile time, for a file STAR opens
|
|
332
|
+
# once; compiling one dataset three ways cost a third of a gigabyte of identical bytes that
|
|
333
|
+
# nothing ever cleaned up. What compose owes the pipeline is the whitelist's NAME, which is a
|
|
334
|
+
# decision, and `_resolve_token` still verifies the registry can produce it before emitting one.
|
|
335
|
+
config_path.write_text(yaml.safe_dump(p.config, sort_keys=True))
|
|
336
|
+
units_path.write_text(_units_tsv(p.units))
|
|
337
|
+
# The Snakefile is THE DELIVERABLE, so the composer writes it — always, unconditionally, like the
|
|
338
|
+
# config beside it. It used to be written inside `wiring_gate`, which returned early when
|
|
339
|
+
# `snakemake` was absent from PATH, and `snakemake` was in no dependency table: the product was a
|
|
340
|
+
# side effect of a validation step that could not fire, so `compose` emitted no Snakefile at all.
|
|
341
|
+
# A gate may now read this file; it may not be the reason it exists.
|
|
342
|
+
# Copy the hand-written module in beside the wrapper, and point the wrapper at the local copy.
|
|
343
|
+
# This is still "emit data, never code": the module is checked-in source COPIED verbatim, not
|
|
344
|
+
# generated -- `test_shipped_modules_are_hand_written_not_generated` guards the source, and the
|
|
345
|
+
# wrapper itself still carries no rule body. What the copy buys is a run directory that reads and
|
|
346
|
+
# reproduces on its own, instead of a `snakefile:` line pointing at an absolute path inside
|
|
347
|
+
# whatever seqforge checkout happened to compose it.
|
|
348
|
+
module = get_module(p.module.name)
|
|
349
|
+
local_module_name = module.snakefile.name
|
|
350
|
+
shutil.copy2(module.snakefile, pipeline_dir / local_module_name)
|
|
351
|
+
snakefile_path = pipeline_dir / _SNAKEFILE_NAME
|
|
352
|
+
snakefile_path.write_text(render_wrapper(module.name, local_module_name))
|
|
353
|
+
# The resolved, dataset-BOUND processing manifest that produced this config, beside it. Disk is
|
|
354
|
+
# state, not input: the default path takes no --processing and must still
|
|
355
|
+
# leave behind exactly what decided the run.
|
|
356
|
+
bound = (
|
|
357
|
+
processing
|
|
358
|
+
if processing.dataset is not None
|
|
359
|
+
else processing.model_copy(
|
|
360
|
+
update={
|
|
361
|
+
"dataset": DatasetPin(
|
|
362
|
+
dataset_hash=manifest.provenance.dataset_hash,
|
|
363
|
+
accessions=list(manifest.experiment.accessions.value),
|
|
364
|
+
)
|
|
365
|
+
}
|
|
366
|
+
)
|
|
367
|
+
)
|
|
368
|
+
(pipeline_dir / "processing.lock.yaml").write_text(
|
|
369
|
+
yaml.safe_dump(bound.model_dump(mode="json"), sort_keys=True)
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
from .gates import e2e_gate, wiring_gate
|
|
373
|
+
|
|
374
|
+
status, problems = params_gate(manifest, processing, p.spec, p.config)
|
|
375
|
+
gate: dict[str, str] = {
|
|
376
|
+
"params": status,
|
|
377
|
+
"wiring": wiring_gate(pipeline_dir, p) if run_wiring_gate else "skip",
|
|
378
|
+
"e2e": e2e_gate(),
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
preview: dict[str, object] = dict(p.config)
|
|
382
|
+
# a failing gate must say WHY; the verdict alone is not actionable
|
|
383
|
+
preview["params_problems"] = problems
|
|
384
|
+
|
|
385
|
+
return ComposeResult(
|
|
386
|
+
modules=[p.module],
|
|
387
|
+
snakefile_path=str(snakefile_path.relative_to(Path(workspace))),
|
|
388
|
+
config_path=str(config_path.relative_to(Path(workspace))),
|
|
389
|
+
units_path=str(units_path.relative_to(Path(workspace))),
|
|
390
|
+
gate=gate, # type: ignore[arg-type]
|
|
391
|
+
params_preview=preview,
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _resolve_params(
|
|
396
|
+
manifest: DatasetManifest,
|
|
397
|
+
spec: Spec,
|
|
398
|
+
registry: OnlistRegistry,
|
|
399
|
+
onlist_files: dict[str, list[str]],
|
|
400
|
+
) -> dict[str, object]:
|
|
401
|
+
"""Render the KB backend params for a CLI, resolving every ``{onlist:alias}`` to a real path."""
|
|
402
|
+
out: dict[str, object] = {}
|
|
403
|
+
for key, value in spec.require_backend().params.items():
|
|
404
|
+
if isinstance(value, list):
|
|
405
|
+
rendered = [_resolve_token(v, spec, registry, onlist_files) for v in value]
|
|
406
|
+
out[key] = " ".join(str(r) for r in rendered)
|
|
407
|
+
else:
|
|
408
|
+
out[key] = render_param(_resolve_token(value, spec, registry, onlist_files))
|
|
409
|
+
return out
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _resolve_token(
|
|
413
|
+
value: object, spec: Spec, registry: OnlistRegistry, onlist_files: dict[str, list[str]]
|
|
414
|
+
) -> object:
|
|
415
|
+
if not (isinstance(value, str) and value.startswith("{onlist:") and value.endswith("}")):
|
|
416
|
+
return value
|
|
417
|
+
alias = value[len("{onlist:") : -1]
|
|
418
|
+
ref = spec.onlists.get(alias)
|
|
419
|
+
if ref is None:
|
|
420
|
+
raise ComposeError(f"backend references undeclared onlist alias {alias!r}")
|
|
421
|
+
name = ref.registry
|
|
422
|
+
rel = f"onlists/{name}.txt"
|
|
423
|
+
try:
|
|
424
|
+
registry.packed(name)
|
|
425
|
+
except OnlistNotAvailable as exc:
|
|
426
|
+
raise ComposeError(
|
|
427
|
+
f"onlist {name!r} is not materialized, so --soloCBwhitelist cannot be emitted: {exc}. "
|
|
428
|
+
"Register it (URL + sha256) or run with a registry that can fetch it."
|
|
429
|
+
) from exc
|
|
430
|
+
# Verified, not materialized. `registry.packed(name)` above is what proves the whitelist EXISTS
|
|
431
|
+
# and is the declared barcode set -- refusing here, at compile time, is the rung-3 promise
|
|
432
|
+
# `compose` makes. What it must not do is write the 111 MB: that is `rule onlist`'s job, and
|
|
433
|
+
# `temp()`'s. The name is recorded so the gate and the tests can still see which list was chosen.
|
|
434
|
+
onlist_files[rel] = [name]
|
|
435
|
+
return rel
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _read_files_in(manifest: DatasetManifest, module: WorkflowModule) -> dict[str, str]:
|
|
439
|
+
"""Map the manifest's reads to the keys the module hands its aligner.
|
|
440
|
+
|
|
441
|
+
Dispatch is on the module's declared ``read_layout_kind``, **not** on its name. This was
|
|
442
|
+
`if spec.backend.module == "map/starsolo": ... else: <bulk>`, which is a bug rather than an
|
|
443
|
+
abstraction gap: it makes "not starsolo" mean "bulk paired-end", so any third module would have
|
|
444
|
+
silently emitted `mate1`/`mate2` for a chemistry that has neither and produced a wrong command
|
|
445
|
+
line with no error anywhere.
|
|
446
|
+
"""
|
|
447
|
+
if module.read_layout_kind == "barcoded":
|
|
448
|
+
cdna = find_read_with_role(manifest, "cDNA") or find_read_with_role(manifest, "gDNA")
|
|
449
|
+
barcode = find_read_with_role(manifest, "CB")
|
|
450
|
+
if cdna is None or barcode is None:
|
|
451
|
+
raise ComposeError("a barcoded chemistry needs both a cDNA read and a CB-bearing read")
|
|
452
|
+
return {"cdna": cdna.read_id, "barcode": barcode.read_id}
|
|
453
|
+
reads = manifest.library.read_layout.reads
|
|
454
|
+
if len(reads) < 2:
|
|
455
|
+
raise ComposeError(f"bulk paired-end needs 2 reads, found {len(reads)}")
|
|
456
|
+
return {"mate1": reads[0].read_id, "mate2": reads[1].read_id}
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _resolve_uri(uri: str, fastq_dir: str | Path | None) -> str:
|
|
460
|
+
"""A manifest URI -> the path this machine will actually open.
|
|
461
|
+
|
|
462
|
+
A non-file scheme (``s3://``, ``https://``, a bare accession) is passed through untouched: it is
|
|
463
|
+
not ours to resolve, and Snakemake storage plugins or a future `fetch` module own that. Only a
|
|
464
|
+
relative local name gets joined to ``fastq_dir``.
|
|
465
|
+
"""
|
|
466
|
+
if "://" in uri or fastq_dir is None:
|
|
467
|
+
return uri
|
|
468
|
+
return str((Path(fastq_dir) / uri).resolve())
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _units(manifest: DatasetManifest, fastq_dir: str | Path | None = None) -> list[dict[str, str]]:
|
|
472
|
+
"""One row per (sample, run, read role, file). Falls back to a single implicit sample.
|
|
473
|
+
|
|
474
|
+
``run`` is which sequencing run a file came from, from the SAME `resolve.group.run_key` that
|
|
475
|
+
grouped the dataset into runs during resolution. Recording it here is what lets the mapping module
|
|
476
|
+
pair a pooled sample's mates by run: STAR reads `--readFilesIn` mate-by-mate and desyncs if cDNA of
|
|
477
|
+
run K is joined with barcodes of run J, and `sample.file_uris` gives no order guarantee. Deriving
|
|
478
|
+
the run once, in the one function that owns the notion, keeps the workflow from re-parsing names.
|
|
479
|
+
"""
|
|
480
|
+
by_uri = {f.uri: f for f in manifest.library.files}
|
|
481
|
+
rows: list[dict[str, str]] = []
|
|
482
|
+
samples = manifest.experiment.samples
|
|
483
|
+
if not samples:
|
|
484
|
+
for f in manifest.library.files:
|
|
485
|
+
if f.read_id is not None and f.read_id != INDEX_ROLE:
|
|
486
|
+
rows.append(
|
|
487
|
+
{
|
|
488
|
+
"sample_id": "sample1",
|
|
489
|
+
"run": run_key(f.uri),
|
|
490
|
+
"read_id": f.read_id,
|
|
491
|
+
"path": _resolve_uri(f.uri, fastq_dir),
|
|
492
|
+
}
|
|
493
|
+
)
|
|
494
|
+
return rows
|
|
495
|
+
for sample in samples:
|
|
496
|
+
for uri in sample.file_uris:
|
|
497
|
+
item = by_uri.get(uri)
|
|
498
|
+
if item is None or item.read_id is None or item.read_id == INDEX_ROLE:
|
|
499
|
+
continue # unassigned files, and set-aside technical index reads, never become units
|
|
500
|
+
rows.append(
|
|
501
|
+
{
|
|
502
|
+
"sample_id": sample.sample_id,
|
|
503
|
+
"run": run_key(item.uri),
|
|
504
|
+
"read_id": item.read_id,
|
|
505
|
+
"path": _resolve_uri(item.uri, fastq_dir),
|
|
506
|
+
}
|
|
507
|
+
)
|
|
508
|
+
return rows
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _units_tsv(rows: list[dict[str, str]]) -> str:
|
|
512
|
+
header = ["sample_id", "run", "read_id", "path"]
|
|
513
|
+
lines = ["\t".join(header)]
|
|
514
|
+
lines += ["\t".join(r[h] for h in header) for r in rows]
|
|
515
|
+
return "\n".join(lines) + "\n"
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""The compose gate's other two parts: **wiring** and **e2e** (design §4.1, parts 1 and 3).
|
|
2
|
+
|
|
3
|
+
Both depend on a toolchain seqforge does not own, so both report **``skip``** — never ``pass`` — when
|
|
4
|
+
that toolchain is absent. A gate that silently reports ``pass`` because it did not run is worse than
|
|
5
|
+
no gate at all: green CI would then be mistaken for coverage.
|
|
6
|
+
|
|
7
|
+
- **wiring** (`snakemake -n -p`): needs the `snakemake` binary, which is now a declared dependency
|
|
8
|
+
(`[tool.pixi.feature.wf]`), so this gate actually runs. It works in a **throwaway copy** of the run
|
|
9
|
+
directory and never writes into the run directory itself — see the incident note on `_replica`.
|
|
10
|
+
- **e2e** (the real count-matrix run): needs STAR + liulab-genome + network. It is a Linux/cluster
|
|
11
|
+
operation, deliberately NOT run inside `compose`; `seqforge kb e2e` owns it.
|
|
12
|
+
|
|
13
|
+
**Why `-p`, and why no `--lint`.** Both were measured on 2026-07-15 rather than reasoned about:
|
|
14
|
+
|
|
15
|
+
- `-p` forces Snakemake to *format* every `shell:` block while planning. Without it a dry run never
|
|
16
|
+
renders the command, so a `KeyError` on a missing param — `starsolo.smk` dereferencing
|
|
17
|
+
`soloCBstart` for a `CB_UMI_Complex` chemistry that has no such key — plans clean and dies on a
|
|
18
|
+
compute node. `-p` is the difference between this gate catching that and rubber-stamping it.
|
|
19
|
+
- `--lint` was in this gate and is now gone. It fails on *every* rule we ship, for a missing `log:`
|
|
20
|
+
directive and "mixed rules and functions in same snakefile" — style opinions, not wiring facts. A
|
|
21
|
+
gate that is red for a correct config teaches people to ignore it, and then it guards nothing.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import shutil
|
|
27
|
+
import subprocess
|
|
28
|
+
import tempfile
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import TYPE_CHECKING
|
|
31
|
+
|
|
32
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
33
|
+
from .core import ComposePlan
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def have(binary: str) -> bool:
|
|
37
|
+
return shutil.which(binary) is not None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _replica(pipeline_dir: Path, plan: ComposePlan) -> Path:
|
|
41
|
+
"""Copy the compiled artifacts to a scratch dir and stand in zero-byte FASTQs **there**.
|
|
42
|
+
|
|
43
|
+
This gate needs its inputs to exist — `snakemake -n` raises `MissingInputException` otherwise —
|
|
44
|
+
and `compose` is a pure function that runs with no FASTQ on disk. So the dry run is given
|
|
45
|
+
zero-byte stand-ins: it validates *wiring*, not data.
|
|
46
|
+
|
|
47
|
+
**The stand-ins go in a throwaway copy, and that is the whole point of this function.** They used
|
|
48
|
+
to be touched straight into the run directory, at `pipeline_dir / row["path"]`, and never removed.
|
|
49
|
+
That was invisible only because `snakemake` was in no dependency table, so this gate never ran. The
|
|
50
|
+
moment it did, the run directory would contain zero-byte files named exactly like the FASTQs, STAR
|
|
51
|
+
would read them, and the pipeline would emit an empty matrix and **exit 0** — a silent, plausible,
|
|
52
|
+
wrong answer, which is the one failure class this project exists to prevent.
|
|
53
|
+
|
|
54
|
+
An **absolute** unit path is skipped rather than stood in: it names a real FASTQ the caller pointed
|
|
55
|
+
us at with `--fastq-dir`, and `scratch / "/abs/path"` is `/abs/path`, so touching it would create a
|
|
56
|
+
zero-byte file *at the real location*. If such a file is genuinely missing the dry run fails, and
|
|
57
|
+
failing is right — the caller said it was there.
|
|
58
|
+
"""
|
|
59
|
+
scratch = Path(tempfile.mkdtemp(prefix="seqforge-wiring-"))
|
|
60
|
+
for item in pipeline_dir.iterdir():
|
|
61
|
+
if item.name == ".snakemake":
|
|
62
|
+
continue
|
|
63
|
+
dst = scratch / item.name
|
|
64
|
+
if item.is_dir():
|
|
65
|
+
shutil.copytree(item, dst)
|
|
66
|
+
else:
|
|
67
|
+
shutil.copy2(item, dst)
|
|
68
|
+
for row in plan.units:
|
|
69
|
+
path = Path(row["path"])
|
|
70
|
+
if path.is_absolute():
|
|
71
|
+
continue
|
|
72
|
+
target = scratch / path
|
|
73
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
target.touch(exist_ok=True)
|
|
75
|
+
return scratch
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def wiring_gate(pipeline_dir: Path, plan: ComposePlan) -> str:
|
|
79
|
+
"""`snakemake -n -p` over a throwaway replica. ``skip`` only if snakemake is absent."""
|
|
80
|
+
if not have("snakemake"):
|
|
81
|
+
return "skip"
|
|
82
|
+
scratch = _replica(pipeline_dir, plan)
|
|
83
|
+
try:
|
|
84
|
+
proc = subprocess.run(
|
|
85
|
+
["snakemake", "-d", str(scratch), "-s", str(scratch / "Snakefile"), "-n", "-p"],
|
|
86
|
+
capture_output=True,
|
|
87
|
+
text=True,
|
|
88
|
+
timeout=300,
|
|
89
|
+
)
|
|
90
|
+
if proc.returncode != 0:
|
|
91
|
+
return "fail"
|
|
92
|
+
# A dry run that plans NOTHING exits 0 and says "Nothing to be done", which is exactly what a
|
|
93
|
+
# workflow with no reachable target does — and for most of this repo's life that is what the
|
|
94
|
+
# generated wrapper produced, because an `include:`d `rule all` is not a default target. A
|
|
95
|
+
# gate cannot tell "correct" from "planned nothing" by exit code, so it must look.
|
|
96
|
+
if "Nothing to be done" in (proc.stdout or ""):
|
|
97
|
+
return "fail"
|
|
98
|
+
return "pass"
|
|
99
|
+
finally:
|
|
100
|
+
shutil.rmtree(scratch, ignore_errors=True)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def e2e_gate() -> str:
|
|
104
|
+
"""The real count-matrix run is owned by ``seqforge kb e2e`` — never implicitly run by compose."""
|
|
105
|
+
if not (have("STAR") and _have_genome()):
|
|
106
|
+
return "skip"
|
|
107
|
+
return "skip" # available but deliberately not run here; invoke `seqforge kb e2e`
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _have_genome() -> bool:
|
|
111
|
+
import importlib.util
|
|
112
|
+
|
|
113
|
+
return importlib.util.find_spec("genome") is not None
|