seqforge 2026.7.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- seqforge/__init__.py +16 -0
- seqforge/cli/__init__.py +38 -0
- seqforge/cli/__main__.py +8 -0
- seqforge/cli/_common.py +105 -0
- seqforge/cli/compose.py +119 -0
- seqforge/cli/eval.py +103 -0
- seqforge/cli/harvest.py +417 -0
- seqforge/cli/hook.py +247 -0
- seqforge/cli/io.py +502 -0
- seqforge/cli/kb.py +348 -0
- seqforge/cli/manifest.py +536 -0
- seqforge/cli/probe.py +43 -0
- seqforge/cli/processing.py +192 -0
- seqforge/cli/project.py +52 -0
- seqforge/cli/resolve.py +55 -0
- seqforge/cli/root.py +66 -0
- seqforge/cli/run.py +463 -0
- seqforge/cli/schema.py +41 -0
- seqforge/compose/__init__.py +28 -0
- seqforge/compose/core.py +515 -0
- seqforge/compose/gates.py +113 -0
- seqforge/compose/params.py +447 -0
- seqforge/e2e.py +1926 -0
- seqforge/evals/__init__.py +78 -0
- seqforge/evals/case.py +382 -0
- seqforge/evals/grade.py +300 -0
- seqforge/evals/run.py +420 -0
- seqforge/harvest/__init__.py +121 -0
- seqforge/harvest/extract.py +319 -0
- seqforge/harvest/fields.py +212 -0
- seqforge/harvest/normalize.py +537 -0
- seqforge/harvest/prep.py +41 -0
- seqforge/harvest/providers.py +321 -0
- seqforge/harvest/verify.py +251 -0
- seqforge/hooks/__init__.py +33 -0
- seqforge/hooks/guards.py +214 -0
- seqforge/io/__init__.py +61 -0
- seqforge/io/archive.py +450 -0
- seqforge/io/attributes.py +190 -0
- seqforge/io/biosample/attributes.json +6341 -0
- seqforge/io/efo/labels.json +55 -0
- seqforge/io/efo.py +138 -0
- seqforge/io/onlist.py +661 -0
- seqforge/io/onlists/3M-february-2018.codes.gz +0 -0
- seqforge/io/onlists/737K-arc-v1.codes.gz +0 -0
- seqforge/io/onlists/737K-august-2016.codes.gz +0 -0
- seqforge/io/onlists/bd-rhapsody-cls1-384.codes.gz +0 -0
- seqforge/io/onlists/bd-rhapsody-cls1.codes.gz +0 -0
- seqforge/io/onlists/bd-rhapsody-cls2-384.codes.gz +0 -0
- seqforge/io/onlists/bd-rhapsody-cls2.codes.gz +0 -0
- seqforge/io/onlists/bd-rhapsody-cls3-384.codes.gz +0 -0
- seqforge/io/onlists/bd-rhapsody-cls3.codes.gz +0 -0
- seqforge/io/onlists/index.json +74 -0
- seqforge/io/remote.py +659 -0
- seqforge/io/taxonomy.py +194 -0
- seqforge/kb/__init__.py +62 -0
- seqforge/kb/anchor.py +169 -0
- seqforge/kb/generate.py +147 -0
- seqforge/kb/loader.py +152 -0
- seqforge/kb/roundtrip.py +112 -0
- seqforge/kb/schema.py +422 -0
- seqforge/kb/specs/10x-3p-gex/spec.yaml +62 -0
- seqforge/kb/specs/10x-3p-gex-v2/README.md +41 -0
- seqforge/kb/specs/10x-3p-gex-v2/spec.yaml +83 -0
- seqforge/kb/specs/10x-3p-gex-v3/README.md +56 -0
- seqforge/kb/specs/10x-3p-gex-v3/spec.yaml +118 -0
- seqforge/kb/specs/10x-3p-gex-v3.1/README.md +56 -0
- seqforge/kb/specs/10x-3p-gex-v3.1/spec.yaml +124 -0
- seqforge/kb/specs/bd-rhapsody-wta/README.md +103 -0
- seqforge/kb/specs/bd-rhapsody-wta/spec.yaml +130 -0
- seqforge/kb/specs/bd-rhapsody-wta-enhanced/spec.yaml +99 -0
- seqforge/kb/specs/bd-rhapsody-wta-enhanced-v1/spec.yaml +93 -0
- seqforge/kb/specs/bd-rhapsody-wta-enhanced-v2/spec.yaml +81 -0
- seqforge/kb/specs/bulk-rnaseq-pe/README.md +35 -0
- seqforge/kb/specs/bulk-rnaseq-pe/spec.yaml +97 -0
- seqforge/kb/specs/splitseq/README.md +51 -0
- seqforge/kb/specs/splitseq/spec.yaml +157 -0
- seqforge/manifest/__init__.py +61 -0
- seqforge/manifest/fill.py +531 -0
- seqforge/manifest/hash.py +77 -0
- seqforge/manifest/instruct.py +114 -0
- seqforge/manifest/policy.py +409 -0
- seqforge/manifest/validate.py +274 -0
- seqforge/models/__init__.py +268 -0
- seqforge/models/assertion.py +68 -0
- seqforge/models/base.py +100 -0
- seqforge/models/blocker.py +71 -0
- seqforge/models/conflict.py +47 -0
- seqforge/models/dataset.py +320 -0
- seqforge/models/evidenced.py +54 -0
- seqforge/models/observation.py +157 -0
- seqforge/models/processing.py +231 -0
- seqforge/models/records.py +145 -0
- seqforge/models/resolve.py +216 -0
- seqforge/probe/__init__.py +46 -0
- seqforge/probe/core.py +232 -0
- seqforge/probe/signals.py +250 -0
- seqforge/probe/streaming.py +118 -0
- seqforge/project.py +177 -0
- seqforge/py.typed +0 -0
- seqforge/resolve/__init__.py +98 -0
- seqforge/resolve/assign.py +204 -0
- seqforge/resolve/cache.py +119 -0
- seqforge/resolve/confuse.py +215 -0
- seqforge/resolve/engine.py +646 -0
- seqforge/resolve/escalate.py +668 -0
- seqforge/resolve/evaluators.py +306 -0
- seqforge/resolve/geometry.py +89 -0
- seqforge/resolve/group.py +85 -0
- seqforge/resolve/records.py +550 -0
- seqforge/resolve/scoring.py +373 -0
- seqforge/resolve/window.py +206 -0
- seqforge/workflows/__init__.py +234 -0
- seqforge/workflows/cram.py +117 -0
- seqforge/workflows/h5ad.py +368 -0
- seqforge/workflows/map/star.smk +101 -0
- seqforge/workflows/map/starsolo.smk +360 -0
- seqforge/workflows/qc.py +157 -0
- seqforge/workspace.py +125 -0
- seqforge-2026.7.1.dist-info/METADATA +125 -0
- seqforge-2026.7.1.dist-info/RECORD +124 -0
- seqforge-2026.7.1.dist-info/WHEEL +4 -0
- seqforge-2026.7.1.dist-info/entry_points.txt +2 -0
- seqforge-2026.7.1.dist-info/licenses/LICENSE +21 -0
seqforge/io/onlist.py
ADDED
|
@@ -0,0 +1,661 @@
|
|
|
1
|
+
"""Barcode-whitelist (onlist) registry, width-generic packing, and the hit-rate test.
|
|
2
|
+
|
|
3
|
+
The registry maps a *name* (``3M-february-2018``, ``737K-august-2016``, …) to a
|
|
4
|
+
:class:`RegistryEntry` (``uri``, ``sha256``, barcode ``width``, ``orientation``). **The real lists
|
|
5
|
+
ship with the package, pre-packed**, so a 10x dataset resolves with no network and no setup; a list we
|
|
6
|
+
do not ship falls back to ``--onlist-dir`` and then to ``pooch``. A *synthetic* list can be registered
|
|
7
|
+
in-memory from the generator's barcode pool, so rung-3 evidence is exercised in tests without the real
|
|
8
|
+
files.
|
|
9
|
+
|
|
10
|
+
Shipping them is affordable because of what a barcode is: 2 bits per base, so 10x's 6 794 880-entry v3
|
|
11
|
+
list is a sorted ``uint32`` array — and *sorted* is what pays, since 6.8 M draws from 4^16 leave ~630
|
|
12
|
+
gaps, the deltas need ~10 bits, and gzip does the rest. **522 kB**, against 12 MB for 10x's own
|
|
13
|
+
``.txt.gz``. See :func:`encode_codes`.
|
|
14
|
+
|
|
15
|
+
Identity is the barcode **set**, never the file: :func:`codes_sha256`. See it for why the two obvious
|
|
16
|
+
alternatives are both wrong.
|
|
17
|
+
|
|
18
|
+
Barcodes are 2-bit packed into a width-generic integer array (``uint32`` for <=16 bp, ``uint64`` for
|
|
19
|
+
<=32 bp — never a hardcoded 16 bp), which gives O(1) membership for :func:`onlist_hit_rate` and
|
|
20
|
+
``np.intersect1d`` set-intersection for the confusability check (§2.4). ``onlist_hit_rate`` tests the
|
|
21
|
+
window **forward and reverse-complement** across a **small positional-offset scan** and records the
|
|
22
|
+
winning ``(orientation, offset)`` — a revcomp hit means the barcode read is on the other strand.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import gzip
|
|
28
|
+
import hashlib
|
|
29
|
+
import json
|
|
30
|
+
import os
|
|
31
|
+
from dataclasses import dataclass
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Literal
|
|
34
|
+
|
|
35
|
+
import numpy as np
|
|
36
|
+
|
|
37
|
+
Orientation = Literal["forward", "revcomp", "either"]
|
|
38
|
+
Strand = Literal["forward", "revcomp"]
|
|
39
|
+
|
|
40
|
+
_BASE_TO_BITS = {"A": 0, "C": 1, "G": 2, "T": 3}
|
|
41
|
+
_COMPLEMENT = str.maketrans("ACGT", "TGCA")
|
|
42
|
+
|
|
43
|
+
#: ``ord(base) -> 2-bit code`` for uppercase ACGT (matching ``_BASE_TO_BITS``); everything else — N,
|
|
44
|
+
#: lowercase, any non-ACGT, and pad — maps to 255, the "unpackable" sentinel. It is ``>= 4``, so any
|
|
45
|
+
#: window touching one drops out of the packable set exactly as ``pack_barcode`` returns ``None``.
|
|
46
|
+
_ORD_TO_BITS = np.full(256, 255, dtype=np.uint8)
|
|
47
|
+
for _base, _bits in _BASE_TO_BITS.items():
|
|
48
|
+
_ORD_TO_BITS[ord(_base)] = _bits
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def revcomp(seq: str) -> str:
|
|
52
|
+
"""Reverse-complement an ACGT string (non-ACGT chars pass through unchanged)."""
|
|
53
|
+
return seq.translate(_COMPLEMENT)[::-1]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def pack_barcode(seq: str) -> int | None:
|
|
57
|
+
"""2-bit pack an ACGT barcode into an integer, or ``None`` if it contains a non-ACGT base."""
|
|
58
|
+
code = 0
|
|
59
|
+
for ch in seq:
|
|
60
|
+
bits = _BASE_TO_BITS.get(ch)
|
|
61
|
+
if bits is None: # N or any non-ACGT base -> unpackable, never a whitelist hit
|
|
62
|
+
return None
|
|
63
|
+
code = (code << 2) | bits
|
|
64
|
+
return code
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _dtype_for_width(width: int) -> type[np.unsignedinteger]:
|
|
68
|
+
"""Pick the narrowest unsigned integer that holds a ``2*width``-bit packed barcode."""
|
|
69
|
+
if width <= 0:
|
|
70
|
+
raise ValueError(f"barcode width must be positive, got {width}")
|
|
71
|
+
if width <= 16:
|
|
72
|
+
return np.uint32
|
|
73
|
+
if width <= 32:
|
|
74
|
+
return np.uint64
|
|
75
|
+
raise ValueError(f"barcode width {width} > 32 bp exceeds the uint64 pack budget")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class PackedOnlist:
|
|
79
|
+
"""A width-generic, 2-bit-packed barcode whitelist: O(1) membership + set intersection."""
|
|
80
|
+
|
|
81
|
+
def __init__(self, width: int, codes: np.ndarray) -> None:
|
|
82
|
+
self.width = width
|
|
83
|
+
#: sorted, unique packed codes (dtype uint32 for <=16 bp, uint64 for <=32 bp). This IS the
|
|
84
|
+
#: membership index: it is sorted, so `np.searchsorted` answers containment in O(log n) with
|
|
85
|
+
#: ~27 MB, and there is no reason to also materialize a 6.8M-entry Python `frozenset` (which
|
|
86
|
+
#: cost ~700 MB and was the resolver's whole memory ceiling). Vectorized membership over a
|
|
87
|
+
#: read sample is one `searchsorted` call — see `onlist_hit_rate`.
|
|
88
|
+
self.codes = codes
|
|
89
|
+
|
|
90
|
+
@classmethod
|
|
91
|
+
def from_barcodes(cls, barcodes: list[str]) -> PackedOnlist:
|
|
92
|
+
"""Pack a list of equal-width ACGT barcodes into a sorted, de-duplicated array."""
|
|
93
|
+
if not barcodes:
|
|
94
|
+
raise ValueError("cannot build a PackedOnlist from an empty barcode list")
|
|
95
|
+
widths = {len(b) for b in barcodes}
|
|
96
|
+
if len(widths) != 1:
|
|
97
|
+
raise ValueError(f"onlist barcodes are not uniform width: {sorted(widths)}")
|
|
98
|
+
width = widths.pop()
|
|
99
|
+
dtype = _dtype_for_width(width)
|
|
100
|
+
packed: list[int] = []
|
|
101
|
+
for b in barcodes:
|
|
102
|
+
code = pack_barcode(b.upper())
|
|
103
|
+
if code is None:
|
|
104
|
+
raise ValueError(f"onlist barcode {b!r} contains a non-ACGT base")
|
|
105
|
+
packed.append(code)
|
|
106
|
+
arr = np.array(sorted(set(packed)), dtype=dtype)
|
|
107
|
+
return cls(width, arr)
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def n_entries(self) -> int:
|
|
111
|
+
"""Number of distinct barcodes in the whitelist."""
|
|
112
|
+
return int(self.codes.size)
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def floor(self) -> float:
|
|
116
|
+
"""Chance hit-rate: probability a uniform-random barcode lands in the whitelist."""
|
|
117
|
+
return self.n_entries / (4.0**self.width)
|
|
118
|
+
|
|
119
|
+
def contains(self, code: int) -> bool:
|
|
120
|
+
"""Membership test for an already-packed barcode code, by binary search on the sorted codes."""
|
|
121
|
+
i = int(np.searchsorted(self.codes, code))
|
|
122
|
+
return i < self.codes.size and int(self.codes[i]) == int(code)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def unpack_barcodes(packed: PackedOnlist) -> list[str]:
|
|
126
|
+
"""A packed whitelist -> the barcode text ``--soloCBwhitelist`` expects. Inverse of packing.
|
|
127
|
+
|
|
128
|
+
Lives here, beside :func:`pack_barcode`, because it is the same fact read backwards. It used to
|
|
129
|
+
live in ``compose``, which is how ``compose`` came to write a 111 MB file: the code that could
|
|
130
|
+
produce the text was in the module that decided when to.
|
|
131
|
+
"""
|
|
132
|
+
bases = "ACGT"
|
|
133
|
+
width = int(packed.width)
|
|
134
|
+
out: list[str] = []
|
|
135
|
+
for code in packed.codes.tolist():
|
|
136
|
+
chars = []
|
|
137
|
+
c = int(code)
|
|
138
|
+
for _ in range(width):
|
|
139
|
+
chars.append(bases[c & 0b11])
|
|
140
|
+
c >>= 2
|
|
141
|
+
out.append("".join(reversed(chars)))
|
|
142
|
+
return sorted(out)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def write_onlist_text(registry: OnlistRegistry, name: str, path: str | Path) -> int:
|
|
146
|
+
"""Materialize a whitelist as text at ``path``. Returns the number of barcodes written.
|
|
147
|
+
|
|
148
|
+
**This is a build step, not a compile step**, and that distinction is the whole point of it
|
|
149
|
+
existing. 10x's v3 whitelist is 6 794 880 barcodes = 111 MB of text, and ``compose`` used to
|
|
150
|
+
write it into every run directory: one dataset compiled three ways cost a third of a gigabyte of
|
|
151
|
+
identical bytes, sitting there permanently, for a file STAR reads once. Now a Snakemake rule
|
|
152
|
+
builds it, STAR reads it, and ``temp()`` deletes it — which is exactly what ``temp()`` is for and
|
|
153
|
+
what the shipped ``.smk`` now declares.
|
|
154
|
+
|
|
155
|
+
Writes to a sibling temp file and renames, so a killed run leaves no half-written whitelist for
|
|
156
|
+
the next one to read. A truncated whitelist does not fail loudly: STARsolo exits 0 and emits a
|
|
157
|
+
matrix that merely looks like a thin dataset — the same failure shape as an inverted strand.
|
|
158
|
+
"""
|
|
159
|
+
packed = registry.packed(name)
|
|
160
|
+
barcodes = unpack_barcodes(packed)
|
|
161
|
+
target = Path(path)
|
|
162
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
163
|
+
tmp = target.with_suffix(target.suffix + ".partial")
|
|
164
|
+
tmp.write_text("\n".join(barcodes) + "\n")
|
|
165
|
+
tmp.replace(target)
|
|
166
|
+
return len(barcodes)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def intersect_fraction(a: PackedOnlist, b: PackedOnlist) -> float:
|
|
170
|
+
"""Fraction of the smaller whitelist that also appears in the larger (onlist_separable, §2.4).
|
|
171
|
+
|
|
172
|
+
Computed by actual set-intersection over the packed arrays (not sha256 inequality — different
|
|
173
|
+
file hashes prove the files differ, not that the barcode *sets* differ). Different-width lists
|
|
174
|
+
cannot collide, so the intersection is 0 by construction.
|
|
175
|
+
"""
|
|
176
|
+
if a.width != b.width:
|
|
177
|
+
return 0.0
|
|
178
|
+
inter = np.intersect1d(a.codes, b.codes, assume_unique=True)
|
|
179
|
+
denom = min(a.n_entries, b.n_entries)
|
|
180
|
+
return (int(inter.size) / denom) if denom else 0.0
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@dataclass(frozen=True)
|
|
184
|
+
class HitResult:
|
|
185
|
+
"""The best onlist hit found over the orientation x offset scan (§3.1)."""
|
|
186
|
+
|
|
187
|
+
hit_rate: float
|
|
188
|
+
orientation: Strand
|
|
189
|
+
offset: int
|
|
190
|
+
n_tested: int
|
|
191
|
+
floor: float
|
|
192
|
+
|
|
193
|
+
def score(self, min_rate: float) -> float:
|
|
194
|
+
"""Normalized support score in ``[0, 1]``: ``clip((best - floor)/(min - floor), 0, 1)``."""
|
|
195
|
+
span = min_rate - self.floor
|
|
196
|
+
if span <= 0:
|
|
197
|
+
return 1.0 if self.hit_rate >= min_rate else 0.0
|
|
198
|
+
return max(0.0, min(1.0, (self.hit_rate - self.floor) / span))
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _encode_sample(sample: list[str]) -> tuple[np.ndarray, np.ndarray]:
|
|
202
|
+
"""Sample reads -> a padded ``(n, maxlen)`` uint8 matrix of 2-bit base codes + a lengths vector.
|
|
203
|
+
|
|
204
|
+
Built ONCE and reused across every ``(strand, offset)`` window — that reuse is the speedup. A
|
|
205
|
+
window scan then becomes a column slice, a vectorized pack, and one ``searchsorted``, in place of
|
|
206
|
+
``strands * offsets * n`` Python ``pack_barcode`` calls. Non-ACGT bases and pad positions are 255,
|
|
207
|
+
which is ``>= 4``, so any window touching one is unpackable exactly as ``pack_barcode`` is ``None``.
|
|
208
|
+
"""
|
|
209
|
+
n = len(sample)
|
|
210
|
+
lengths = np.fromiter((len(s) for s in sample), dtype=np.int64, count=n)
|
|
211
|
+
maxlen = int(lengths.max()) if n else 0
|
|
212
|
+
mat = np.full((n, maxlen), 255, dtype=np.uint8)
|
|
213
|
+
for i, seq in enumerate(sample):
|
|
214
|
+
if seq:
|
|
215
|
+
raw = np.frombuffer(seq.encode("ascii", "replace"), dtype=np.uint8)
|
|
216
|
+
mat[i, : raw.size] = _ORD_TO_BITS[raw]
|
|
217
|
+
return mat, lengths
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _pack_window(mat: np.ndarray, s: int, width: int, *, rc: bool) -> tuple[np.ndarray, np.ndarray]:
|
|
221
|
+
"""Pack column window ``[s, s+width)`` into codes + an all-ACGT validity mask, both vectorized.
|
|
222
|
+
|
|
223
|
+
Forward packing matches ``pack_barcode``: the first base is most significant (big-endian base-4).
|
|
224
|
+
Reverse-complement is ``3 - base`` (A<->T, C<->G) on the reversed window — reverse-then-complement
|
|
225
|
+
equals complement-then-reverse since the complement is per-base, and it is computed on the encoded
|
|
226
|
+
matrix, never by re-reading strings. ``valid`` is taken from the forward window (reversal does not
|
|
227
|
+
change which reads are all-ACGT), so an invalid row's garbage code is dropped by the caller.
|
|
228
|
+
"""
|
|
229
|
+
win = mat[:, s : s + width].astype(np.uint64)
|
|
230
|
+
valid = (win < 4).all(axis=1)
|
|
231
|
+
if rc:
|
|
232
|
+
win = np.uint64(3) - win[:, ::-1]
|
|
233
|
+
weights = np.uint64(4) ** np.arange(width - 1, -1, -1, dtype=np.uint64)
|
|
234
|
+
packed = (win * weights).sum(axis=1, dtype=np.uint64)
|
|
235
|
+
return packed, valid
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def onlist_hit_rate(
|
|
239
|
+
seqs: list[str],
|
|
240
|
+
start: int,
|
|
241
|
+
onlist: PackedOnlist,
|
|
242
|
+
*,
|
|
243
|
+
orientation: Orientation = "either",
|
|
244
|
+
offset_scan: int = 2,
|
|
245
|
+
max_reads: int = 50_000,
|
|
246
|
+
) -> HitResult:
|
|
247
|
+
"""Best whitelist hit-rate for a barcode anchored at ``start`` (width taken from ``onlist``).
|
|
248
|
+
|
|
249
|
+
Tests forward and/or reverse-complement (per ``orientation``) across offsets
|
|
250
|
+
``[-offset_scan, +offset_scan]`` and returns the winning ``(orientation, offset, hit_rate)``.
|
|
251
|
+
Bounded by ``max_reads``: the sample is already head-limited, and this caps the work again.
|
|
252
|
+
|
|
253
|
+
Vectorized: the sample is encoded once into a base-code matrix, and each window is a slice + a
|
|
254
|
+
``searchsorted`` against the onlist's sorted codes. ``n_tested`` counts reads long enough to hold
|
|
255
|
+
the window (as before, including non-ACGT reads that cannot hit); ``hit_rate = hits / n_tested``.
|
|
256
|
+
"""
|
|
257
|
+
width = onlist.width
|
|
258
|
+
strands: list[Strand]
|
|
259
|
+
if orientation == "forward":
|
|
260
|
+
strands = ["forward"]
|
|
261
|
+
elif orientation == "revcomp":
|
|
262
|
+
strands = ["revcomp"]
|
|
263
|
+
else:
|
|
264
|
+
strands = ["forward", "revcomp"]
|
|
265
|
+
|
|
266
|
+
sample = seqs[:max_reads]
|
|
267
|
+
best = HitResult(hit_rate=0.0, orientation="forward", offset=0, n_tested=0, floor=onlist.floor)
|
|
268
|
+
if not sample:
|
|
269
|
+
return best
|
|
270
|
+
|
|
271
|
+
mat, lengths = _encode_sample(sample)
|
|
272
|
+
maxcol = mat.shape[1]
|
|
273
|
+
wl = onlist.codes # sorted, unique
|
|
274
|
+
for strand in strands:
|
|
275
|
+
for delta in range(-offset_scan, offset_scan + 1):
|
|
276
|
+
s = start + delta
|
|
277
|
+
e = s + width
|
|
278
|
+
# Guard the column range: e > maxcol means no read is long enough (inrange would be empty
|
|
279
|
+
# anyway), but slicing past the matrix would silently return a NARROWER window and pack the
|
|
280
|
+
# wrong width. s < 0 is off the read's 5' end.
|
|
281
|
+
if s < 0 or e > maxcol:
|
|
282
|
+
continue
|
|
283
|
+
inrange = lengths >= e
|
|
284
|
+
tested = int(inrange.sum())
|
|
285
|
+
if tested == 0:
|
|
286
|
+
continue
|
|
287
|
+
packed, valid = _pack_window(mat[inrange], s, width, rc=strand == "revcomp")
|
|
288
|
+
packable = packed[valid]
|
|
289
|
+
if packable.size:
|
|
290
|
+
idx = np.clip(np.searchsorted(wl, packable), 0, wl.size - 1)
|
|
291
|
+
hits = int((wl[idx] == packable).sum())
|
|
292
|
+
else:
|
|
293
|
+
hits = 0
|
|
294
|
+
rate = hits / tested
|
|
295
|
+
if rate > best.hit_rate:
|
|
296
|
+
best = HitResult(
|
|
297
|
+
hit_rate=rate,
|
|
298
|
+
orientation=strand,
|
|
299
|
+
offset=delta,
|
|
300
|
+
n_tested=tested,
|
|
301
|
+
floor=onlist.floor,
|
|
302
|
+
)
|
|
303
|
+
return best
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
#: Where the shipped, pre-packed whitelists live (package data).
|
|
307
|
+
_ONLIST_DATA = Path(__file__).parent / "onlists"
|
|
308
|
+
|
|
309
|
+
#: Suffix of the shipped form: gzipped little-endian uint32 **deltas** of the sorted code array.
|
|
310
|
+
_PACKED_SUFFIX = ".codes.gz"
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def encode_codes(codes: np.ndarray) -> bytes:
|
|
314
|
+
"""Serialize a sorted, unique packed-code array to the shipped form.
|
|
315
|
+
|
|
316
|
+
**Delta-then-gzip, and the numbers are why.** The v3 whitelist is 6 794 880 sorted draws from
|
|
317
|
+
4^16, so consecutive codes differ by ~630 on average: the deltas need ~10 bits where the values
|
|
318
|
+
need 32, and gzip finishes the job. Measured 2026-07-15:
|
|
319
|
+
|
|
320
|
+
raw uint32 27.2 MB .npy 27.2 MB
|
|
321
|
+
.npz 9.7 MB 10x's .txt.gz 12.2 MB
|
|
322
|
+
delta+gzip 0.5 MB <- this
|
|
323
|
+
|
|
324
|
+
45x smaller than the text it came from, so all three lists ship in well under a megabyte. A `.npy`
|
|
325
|
+
would have been *bigger* than the file we were trying to avoid vendoring.
|
|
326
|
+
"""
|
|
327
|
+
deltas = np.diff(codes, prepend=codes.dtype.type(0)).astype(codes.dtype.newbyteorder("<"))
|
|
328
|
+
return gzip.compress(deltas.tobytes(), 6)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def decode_codes(blob: bytes, width: int) -> np.ndarray:
|
|
332
|
+
"""Inverse of :func:`encode_codes`: gunzip, then prefix-sum the deltas back to codes."""
|
|
333
|
+
dtype = np.dtype(_dtype_for_width(width)).newbyteorder("<")
|
|
334
|
+
deltas = np.frombuffer(gzip.decompress(blob), dtype=dtype)
|
|
335
|
+
# dtype= pins the accumulator: numpy's default would widen to int64 and hand back a different
|
|
336
|
+
# array than the one that was encoded.
|
|
337
|
+
return np.cumsum(deltas, dtype=dtype)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def codes_sha256(codes: np.ndarray) -> str:
|
|
341
|
+
"""The canonical identity of a barcode SET: sha256 over sorted, unique, little-endian codes.
|
|
342
|
+
|
|
343
|
+
This is a better identity than hashing the file, and the difference is not academic:
|
|
344
|
+
|
|
345
|
+
- A `.gz` hash pins the **packaging**. The same 6 794 880 barcodes arrive as a 12 MB `.gz` from
|
|
346
|
+
10x's Cell Ranger and an 18 MB `.gz` from the scg_lib_structs mirror, agreeing on no bytes.
|
|
347
|
+
- A hash of the decompressed **text** pins the byte order and line endings. Real: `737K-arc-v1`
|
|
348
|
+
has no trailing newline, so a well-meaning re-write that adds one changes the hash while
|
|
349
|
+
changing no barcode.
|
|
350
|
+
- This pins the **barcodes**, and nothing else. Order-independent, duplicate-independent,
|
|
351
|
+
compression-independent, newline-independent. It answers exactly the question we ask of a
|
|
352
|
+
whitelist: *is this the same set?*
|
|
353
|
+
"""
|
|
354
|
+
return hashlib.sha256(
|
|
355
|
+
np.ascontiguousarray(codes, dtype=codes.dtype.newbyteorder("<")).tobytes()
|
|
356
|
+
).hexdigest()
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
@dataclass(frozen=True)
|
|
360
|
+
class RegistryEntry:
|
|
361
|
+
"""A registry record for one onlist.
|
|
362
|
+
|
|
363
|
+
``sha256`` is the **code-set hash** (:func:`codes_sha256`), not a file hash — see that function
|
|
364
|
+
for why. ``uri`` records where the list came from and is a fallback source; a shipped list needs
|
|
365
|
+
no network at all.
|
|
366
|
+
"""
|
|
367
|
+
|
|
368
|
+
name: str
|
|
369
|
+
uri: str
|
|
370
|
+
#: :func:`codes_sha256` of the barcode set. Empty = unverifiable (declared, not pinned).
|
|
371
|
+
sha256: str
|
|
372
|
+
width: int
|
|
373
|
+
orientation: Orientation = "forward"
|
|
374
|
+
n_entries: int | None = None
|
|
375
|
+
#: Where the packed data lives, if it ships with the package. Set by :func:`shipped_entries`.
|
|
376
|
+
packed_path: Path | None = None
|
|
377
|
+
#: Provenance of the text this was packed from — recorded, never checked at run time (the packed
|
|
378
|
+
#: form is sorted and de-duplicated, so the original byte order is gone and this hash cannot be
|
|
379
|
+
#: recomputed from it). It is here so a human can re-derive the data and prove where it came from.
|
|
380
|
+
source_sha256: str = ""
|
|
381
|
+
|
|
382
|
+
@property
|
|
383
|
+
def shipped(self) -> bool:
|
|
384
|
+
return self.packed_path is not None and self.packed_path.is_file()
|
|
385
|
+
|
|
386
|
+
@property
|
|
387
|
+
def fetchable(self) -> bool:
|
|
388
|
+
"""Can this be materialized without a local copy? **Derived, never declared.**
|
|
389
|
+
|
|
390
|
+
It was a hand-set field that no code branched on — written in three places, read only by
|
|
391
|
+
`io onlist list` for display, and it said `fetchable=False` on entries whose real problem was
|
|
392
|
+
an empty `uri`. A flag that describes behaviour without causing it is a comment with a bool's
|
|
393
|
+
syntax. Now it cannot disagree with the registry.
|
|
394
|
+
"""
|
|
395
|
+
return self.shipped or (bool(self.uri) and not self.uri.startswith("synthetic:"))
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
class OnlistNotAvailable(RuntimeError):
|
|
399
|
+
"""Raised when an onlist cannot be materialized (unknown, or offline + not cached)."""
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
class OnlistRegistry:
|
|
403
|
+
"""Named onlists -> packed whitelists, via pooch (real) or in-memory (synthetic)."""
|
|
404
|
+
|
|
405
|
+
def __init__(
|
|
406
|
+
self,
|
|
407
|
+
*,
|
|
408
|
+
cache_dir: str | Path | None = None,
|
|
409
|
+
offline: bool = False,
|
|
410
|
+
local_dir: str | Path | None = None,
|
|
411
|
+
) -> None:
|
|
412
|
+
self.offline = offline
|
|
413
|
+
self.cache_dir = str(cache_dir) if cache_dir is not None else None
|
|
414
|
+
#: A directory of already-downloaded lists, looked up as `<local_dir>/<name>.txt[.gz]`.
|
|
415
|
+
#: The escape hatch for a compute node with no internet — which is most of them, and where a
|
|
416
|
+
#: registry that can only fetch is a registry that cannot work. Checked BEFORE the network,
|
|
417
|
+
#: so a present local copy makes `offline` irrelevant rather than fatal. It is still verified
|
|
418
|
+
#: against the content hash, so "local" buys convenience, never trust.
|
|
419
|
+
self.local_dir = str(local_dir) if local_dir is not None else None
|
|
420
|
+
self._entries: dict[str, RegistryEntry] = {}
|
|
421
|
+
self._synthetic: dict[str, list[str]] = {}
|
|
422
|
+
self._packed: dict[str, PackedOnlist] = {}
|
|
423
|
+
|
|
424
|
+
def register(self, entry: RegistryEntry) -> None:
|
|
425
|
+
"""Declare a (typically real, pooch-fetchable) onlist without materializing it."""
|
|
426
|
+
self._entries[entry.name] = entry
|
|
427
|
+
|
|
428
|
+
def register_synthetic(
|
|
429
|
+
self, name: str, barcodes: list[str], *, orientation: Orientation = "forward"
|
|
430
|
+
) -> RegistryEntry:
|
|
431
|
+
"""Register an in-memory synthetic onlist from a barcode pool (the pilot fixture)."""
|
|
432
|
+
if not barcodes:
|
|
433
|
+
raise ValueError(f"synthetic onlist {name!r} needs at least one barcode")
|
|
434
|
+
width = len(barcodes[0])
|
|
435
|
+
text = "\n".join(barcodes) + "\n"
|
|
436
|
+
sha = hashlib.sha256(text.encode()).hexdigest()
|
|
437
|
+
entry = RegistryEntry(
|
|
438
|
+
name=name,
|
|
439
|
+
uri=f"synthetic:{name}",
|
|
440
|
+
sha256=sha,
|
|
441
|
+
width=width,
|
|
442
|
+
orientation=orientation,
|
|
443
|
+
n_entries=len(barcodes),
|
|
444
|
+
)
|
|
445
|
+
self._entries[name] = entry
|
|
446
|
+
self._synthetic[name] = list(barcodes)
|
|
447
|
+
self._packed.pop(name, None)
|
|
448
|
+
return entry
|
|
449
|
+
|
|
450
|
+
def has(self, name: str) -> bool:
|
|
451
|
+
return name in self._entries
|
|
452
|
+
|
|
453
|
+
def names(self) -> list[str]:
|
|
454
|
+
return sorted(self._entries)
|
|
455
|
+
|
|
456
|
+
def get(self, name: str) -> RegistryEntry:
|
|
457
|
+
try:
|
|
458
|
+
return self._entries[name]
|
|
459
|
+
except KeyError as exc:
|
|
460
|
+
raise OnlistNotAvailable(f"unknown onlist {name!r}") from exc
|
|
461
|
+
|
|
462
|
+
def packed(self, name: str) -> PackedOnlist:
|
|
463
|
+
"""Return the packed whitelist for ``name``, materializing + verifying it if needed."""
|
|
464
|
+
if name in self._packed:
|
|
465
|
+
return self._packed[name]
|
|
466
|
+
entry = self.get(name)
|
|
467
|
+
if name in self._synthetic:
|
|
468
|
+
packed = PackedOnlist.from_barcodes(self._synthetic[name])
|
|
469
|
+
else:
|
|
470
|
+
packed = PackedOnlist(entry.width, self._load_codes(entry))
|
|
471
|
+
self._packed[name] = packed
|
|
472
|
+
return packed
|
|
473
|
+
|
|
474
|
+
def _local_path(self, entry: RegistryEntry) -> Path | None:
|
|
475
|
+
"""An already-downloaded copy under ``local_dir``, if there is one."""
|
|
476
|
+
if not self.local_dir:
|
|
477
|
+
return None
|
|
478
|
+
for suffix in (".txt.gz", ".txt", ".gz", ""):
|
|
479
|
+
candidate = Path(self.local_dir) / f"{entry.name}{suffix}"
|
|
480
|
+
if candidate.is_file():
|
|
481
|
+
return candidate
|
|
482
|
+
return None
|
|
483
|
+
|
|
484
|
+
def _load_codes(self, entry: RegistryEntry) -> np.ndarray:
|
|
485
|
+
"""Materialize an onlist's packed codes: shipped data, then a local copy, then the network.
|
|
486
|
+
|
|
487
|
+
Shipped first because it is the common case and costs nothing — no network, no unpacking of a
|
|
488
|
+
27 MB text file, no `--onlist-dir` for the user to know about.
|
|
489
|
+
"""
|
|
490
|
+
if entry.shipped:
|
|
491
|
+
assert entry.packed_path is not None
|
|
492
|
+
codes = decode_codes(entry.packed_path.read_bytes(), entry.width)
|
|
493
|
+
return self._verify(entry, codes, entry.packed_path)
|
|
494
|
+
|
|
495
|
+
path = self._local_path(entry)
|
|
496
|
+
if path is None:
|
|
497
|
+
if self.offline:
|
|
498
|
+
raise OnlistNotAvailable(
|
|
499
|
+
f"onlist {entry.name!r} is not shipped or cached, and this registry is offline. "
|
|
500
|
+
f"Point --onlist-dir at a directory containing {entry.name}.txt.gz, or use a "
|
|
501
|
+
f"registry that may fetch."
|
|
502
|
+
)
|
|
503
|
+
if not entry.uri:
|
|
504
|
+
raise OnlistNotAvailable(
|
|
505
|
+
f"onlist {entry.name!r} is not shipped and has no source URL, so it cannot be "
|
|
506
|
+
f"fetched. Download it and point --onlist-dir at the directory holding "
|
|
507
|
+
f"{entry.name}.txt.gz."
|
|
508
|
+
)
|
|
509
|
+
path = Path(self._fetch(entry))
|
|
510
|
+
return self._verify(entry, self._codes_from_text(path), path)
|
|
511
|
+
|
|
512
|
+
def _verify(self, entry: RegistryEntry, codes: np.ndarray, source: Path) -> np.ndarray:
|
|
513
|
+
"""Refuse a whitelist that is not the declared barcode set.
|
|
514
|
+
|
|
515
|
+
Refusing rather than warning, because a wrong whitelist does not error downstream: STARsolo
|
|
516
|
+
exits 0 and emits a matrix that merely looks like a thin dataset — the same failure shape as
|
|
517
|
+
an inverted strand (§5), and the reason eager verification exists at all.
|
|
518
|
+
"""
|
|
519
|
+
digest = codes_sha256(codes)
|
|
520
|
+
if entry.sha256 and digest != entry.sha256:
|
|
521
|
+
raise OnlistNotAvailable(
|
|
522
|
+
f"onlist {entry.name!r} from {source} is not the same barcodes the registry "
|
|
523
|
+
f"declares: got code-set sha256 {digest} ({codes.size} barcodes), expected "
|
|
524
|
+
f"{entry.sha256}" + (f" ({entry.n_entries} barcodes)" if entry.n_entries else "")
|
|
525
|
+
)
|
|
526
|
+
return codes
|
|
527
|
+
|
|
528
|
+
def _codes_from_text(self, path: Path) -> np.ndarray:
|
|
529
|
+
"""Read a whitelist's text (gzipped or not) and pack it.
|
|
530
|
+
|
|
531
|
+
The gzip magic decides, not the file extension: a mirror serving `.txt` that is actually
|
|
532
|
+
gzipped (or the reverse) is a packaging detail, and guessing from the name is how you get a
|
|
533
|
+
whitelist of one line that starts with `\\x1f\\x8b`.
|
|
534
|
+
"""
|
|
535
|
+
raw = path.read_bytes()
|
|
536
|
+
if raw[:2] == b"\x1f\x8b":
|
|
537
|
+
raw = gzip.decompress(raw)
|
|
538
|
+
barcodes = [line.strip() for line in raw.decode().splitlines() if line.strip()]
|
|
539
|
+
if not barcodes:
|
|
540
|
+
raise OnlistNotAvailable(f"{path} contains no barcodes")
|
|
541
|
+
return PackedOnlist.from_barcodes(barcodes).codes
|
|
542
|
+
|
|
543
|
+
def _fetch(self, entry: RegistryEntry) -> str:
|
|
544
|
+
"""Fetch a real onlist via pooch (the only network touch here).
|
|
545
|
+
|
|
546
|
+
``known_hash=None`` on purpose, and it is not a hole: `_barcodes_from` verifies the
|
|
547
|
+
**decompressed** bytes against `entry.sha256` immediately after. Pooch can only hash what it
|
|
548
|
+
downloaded, and a download hash pins the *packaging* — the same 6 794 880 barcodes arrive as a
|
|
549
|
+
12 MB `.gz` from 10x and an 18 MB `.gz` from the scg_lib_structs mirror, agreeing on no bytes
|
|
550
|
+
at all. Pinning that would break on a recompression while proving nothing about the barcodes.
|
|
551
|
+
"""
|
|
552
|
+
import pooch # local import: keep the module importable offline / without pooch resolved
|
|
553
|
+
|
|
554
|
+
return str(
|
|
555
|
+
pooch.retrieve(
|
|
556
|
+
url=entry.uri,
|
|
557
|
+
known_hash=None,
|
|
558
|
+
path=self.cache_dir,
|
|
559
|
+
progressbar=False,
|
|
560
|
+
)
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def synthetic_registry(pools: dict[str, list[str]]) -> OnlistRegistry:
|
|
565
|
+
"""Build a registry whose registry-names are backed by the generator's synthetic pools.
|
|
566
|
+
|
|
567
|
+
``pools`` maps a *registry name* (e.g. ``3M-february-2018``) to its barcode list. Used by the
|
|
568
|
+
resolve tests so rung-3 onlist evidence fires against the same barcodes the reads were drawn from.
|
|
569
|
+
"""
|
|
570
|
+
reg = OnlistRegistry(offline=True)
|
|
571
|
+
for name, barcodes in pools.items():
|
|
572
|
+
reg.register_synthetic(name, barcodes)
|
|
573
|
+
return reg
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
#: The shipped whitelists' index: `onlists/index.json`, **generated** by `seqforge io onlist pack`
|
|
577
|
+
#: alongside the `.codes.gz` blobs it describes.
|
|
578
|
+
#:
|
|
579
|
+
#: It is a file beside the data it describes, which is the shape this repo distrusts — so it is never
|
|
580
|
+
#: hand-edited, and `test_the_shipped_onlist_index_matches_the_shipped_data` decodes every blob and
|
|
581
|
+
#: checks the index against **what is actually in it**. The index cannot claim a width, a count or a
|
|
582
|
+
#: hash the data disagrees with, and a blob with no index entry (or an entry with no blob) is an error.
|
|
583
|
+
#: Adding a whitelist is: run the verb, commit both files. Nothing to remember.
|
|
584
|
+
_INDEX = _ONLIST_DATA / "index.json"
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def shipped_entries() -> list[RegistryEntry]:
|
|
588
|
+
"""Every whitelist that ships with the package, discovered from `onlists/index.json`."""
|
|
589
|
+
if not _INDEX.is_file():
|
|
590
|
+
return []
|
|
591
|
+
index = json.loads(_INDEX.read_text())
|
|
592
|
+
out: list[RegistryEntry] = []
|
|
593
|
+
for name, meta in sorted(index.items()):
|
|
594
|
+
out.append(
|
|
595
|
+
RegistryEntry(
|
|
596
|
+
name=name,
|
|
597
|
+
uri=str(meta.get("uri", "")),
|
|
598
|
+
sha256=str(meta["sha256"]),
|
|
599
|
+
width=int(meta["width"]),
|
|
600
|
+
orientation=meta.get("orientation", "forward"),
|
|
601
|
+
n_entries=int(meta["n_entries"]),
|
|
602
|
+
packed_path=_ONLIST_DATA / f"{name}{_PACKED_SUFFIX}",
|
|
603
|
+
source_sha256=str(meta.get("source_sha256", "")),
|
|
604
|
+
)
|
|
605
|
+
)
|
|
606
|
+
return out
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def write_shipped(
|
|
610
|
+
name: str,
|
|
611
|
+
codes: np.ndarray,
|
|
612
|
+
*,
|
|
613
|
+
width: int,
|
|
614
|
+
uri: str = "",
|
|
615
|
+
orientation: Orientation = "forward",
|
|
616
|
+
source_sha256: str = "",
|
|
617
|
+
) -> Path:
|
|
618
|
+
"""Pack `codes` into the shipped form and record it in the index. Used by `io onlist pack`.
|
|
619
|
+
|
|
620
|
+
The maintenance half of the "adding a whitelist is dropping a file in" promise: this is the only
|
|
621
|
+
writer of `index.json`, so the index cannot drift from the blobs by hand.
|
|
622
|
+
"""
|
|
623
|
+
_ONLIST_DATA.mkdir(parents=True, exist_ok=True)
|
|
624
|
+
blob_path = _ONLIST_DATA / f"{name}{_PACKED_SUFFIX}"
|
|
625
|
+
blob_path.write_bytes(encode_codes(codes))
|
|
626
|
+
index = json.loads(_INDEX.read_text()) if _INDEX.is_file() else {}
|
|
627
|
+
index[name] = {
|
|
628
|
+
"width": int(width),
|
|
629
|
+
"n_entries": int(codes.size),
|
|
630
|
+
"sha256": codes_sha256(codes),
|
|
631
|
+
"orientation": orientation,
|
|
632
|
+
"uri": uri,
|
|
633
|
+
"source_sha256": source_sha256,
|
|
634
|
+
}
|
|
635
|
+
_INDEX.write_text(json.dumps(dict(sorted(index.items())), indent=2, sort_keys=True) + "\n")
|
|
636
|
+
return blob_path
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def default_registry(
|
|
640
|
+
*, offline: bool = True, local_dir: str | Path | None = None
|
|
641
|
+
) -> OnlistRegistry:
|
|
642
|
+
"""A registry carrying every shipped whitelist, ready to use with no network and no setup.
|
|
643
|
+
|
|
644
|
+
``offline`` still defaults True, and now costs almost nothing: the shipped lists need no network,
|
|
645
|
+
so ``offline`` only governs the fallback for a list we do not ship. ``local_dir`` (or
|
|
646
|
+
``$SEQFORGE_ONLIST_DIR``) points at already-downloaded text copies and is checked before the
|
|
647
|
+
network — the escape hatch for a whitelist that ships with neither us nor a public URL.
|
|
648
|
+
"""
|
|
649
|
+
if local_dir is None:
|
|
650
|
+
local_dir = os.environ.get("SEQFORGE_ONLIST_DIR") or None
|
|
651
|
+
reg = OnlistRegistry(offline=offline, local_dir=local_dir)
|
|
652
|
+
for entry in shipped_entries():
|
|
653
|
+
reg.register(entry)
|
|
654
|
+
return reg
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
#: A shared default registry for the CLI. The shipped whitelists resolve with no network and no
|
|
658
|
+
#: setup; anything else falls back to `--onlist-dir` / `$SEQFORGE_ONLIST_DIR`, and failing that the
|
|
659
|
+
#: resolver ABSTAINs rather than guessing and `compose` exits 3 rather than emitting a
|
|
660
|
+
#: `--soloCBwhitelist` that points at nothing.
|
|
661
|
+
DEFAULT_REGISTRY: OnlistRegistry = default_registry()
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|