dnakit 0.1.0.dev0__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.
- dnakit/__init__.py +108 -0
- dnakit/_version.py +5 -0
- dnakit/alignment/__init__.py +6 -0
- dnakit/alignment/pairwise.py +512 -0
- dnakit/alignment/results.py +188 -0
- dnakit/backends/__init__.py +80 -0
- dnakit/backends/external.py +517 -0
- dnakit/backends/registry.py +207 -0
- dnakit/batch.py +452 -0
- dnakit/cache/__init__.py +5 -0
- dnakit/cache/store.py +424 -0
- dnakit/chunking.py +920 -0
- dnakit/cli/__init__.py +5 -0
- dnakit/cli/__main__.py +6 -0
- dnakit/cli/app.py +814 -0
- dnakit/cli/workflow.py +94 -0
- dnakit/config/__init__.py +5 -0
- dnakit/config/loader.py +381 -0
- dnakit/core/__init__.py +80 -0
- dnakit/core/_json.py +206 -0
- dnakit/core/backend_info.py +70 -0
- dnakit/core/collection.py +163 -0
- dnakit/core/coordinates.py +295 -0
- dnakit/core/enums.py +95 -0
- dnakit/core/facade.py +719 -0
- dnakit/core/feature.py +90 -0
- dnakit/core/gap.py +82 -0
- dnakit/core/issues.py +62 -0
- dnakit/core/provenance.py +305 -0
- dnakit/core/record.py +161 -0
- dnakit/core/results.py +134 -0
- dnakit/core/sequence.py +327 -0
- dnakit/datasets/__init__.py +100 -0
- dnakit/datasets/_advanced_shared.py +198 -0
- dnakit/datasets/_metadata.py +33 -0
- dnakit/datasets/advanced_deduplicate.py +183 -0
- dnakit/datasets/advanced_split.py +302 -0
- dnakit/datasets/clustering.py +286 -0
- dnakit/datasets/config.py +531 -0
- dnakit/datasets/deduplicate.py +303 -0
- dnakit/datasets/evaluation.py +244 -0
- dnakit/datasets/filter.py +135 -0
- dnakit/datasets/neural_clustering.py +221 -0
- dnakit/datasets/results.py +520 -0
- dnakit/datasets/split.py +450 -0
- dnakit/descriptors/__init__.py +102 -0
- dnakit/descriptors/_dinucleotide.py +345 -0
- dnakit/descriptors/_shared.py +180 -0
- dnakit/descriptors/all.py +917 -0
- dnakit/descriptors/basic.py +193 -0
- dnakit/descriptors/codon.py +195 -0
- dnakit/descriptors/complexity.py +201 -0
- dnakit/descriptors/entropy.py +101 -0
- dnakit/descriptors/homopolymer.py +105 -0
- dnakit/descriptors/kmer.py +88 -0
- dnakit/descriptors/results.py +286 -0
- dnakit/descriptors/schema.py +414 -0
- dnakit/descriptors/window.py +203 -0
- dnakit/download/__init__.py +54 -0
- dnakit/download/catalogs.py +166 -0
- dnakit/download/ena.py +131 -0
- dnakit/download/encode.py +103 -0
- dnakit/download/files.py +469 -0
- dnakit/download/indexes.py +233 -0
- dnakit/download/metadata.py +164 -0
- dnakit/download/models.py +227 -0
- dnakit/download/ncbi.py +333 -0
- dnakit/download/sequence.py +133 -0
- dnakit/download/ucsc.py +99 -0
- dnakit/evaluation/__init__.py +81 -0
- dnakit/evaluation/_shared.py +288 -0
- dnakit/evaluation/collection.py +270 -0
- dnakit/evaluation/config.py +591 -0
- dnakit/evaluation/distribution.py +287 -0
- dnakit/evaluation/frechet.py +223 -0
- dnakit/evaluation/generative.py +381 -0
- dnakit/evaluation/reference.py +409 -0
- dnakit/evaluation/results.py +137 -0
- dnakit/evaluation/scorecard.py +211 -0
- dnakit/evaluation/sequence.py +403 -0
- dnakit/evaluation/synthesis.py +249 -0
- dnakit/exceptions.py +189 -0
- dnakit/fingerprints/__init__.py +109 -0
- dnakit/fingerprints/_shared.py +116 -0
- dnakit/fingerprints/advanced.py +829 -0
- dnakit/fingerprints/bit.py +229 -0
- dnakit/fingerprints/encoding.py +351 -0
- dnakit/fingerprints/kmer.py +170 -0
- dnakit/fingerprints/results.py +531 -0
- dnakit/fingerprints/sketch.py +246 -0
- dnakit/io/__init__.py +112 -0
- dnakit/io/_advanced_common.py +105 -0
- dnakit/io/_formats.py +994 -0
- dnakit/io/annotations.py +1179 -0
- dnakit/io/api.py +759 -0
- dnakit/io/config.py +315 -0
- dnakit/io/genbank.py +523 -0
- dnakit/io/indexed.py +1146 -0
- dnakit/io/metadata.py +452 -0
- dnakit/io/parquet.py +56 -0
- dnakit/io/results.py +131 -0
- dnakit/io/source.py +138 -0
- dnakit/io/tables.py +1237 -0
- dnakit/molbio/__init__.py +101 -0
- dnakit/molbio/_shared.py +266 -0
- dnakit/molbio/assembly.py +279 -0
- dnakit/molbio/crispr.py +181 -0
- dnakit/molbio/optimization.py +897 -0
- dnakit/molbio/primer3_design.py +667 -0
- dnakit/molbio/primers.py +899 -0
- dnakit/molbio/restriction.py +573 -0
- dnakit/molbio/results.py +583 -0
- dnakit/ops/__init__.py +588 -0
- dnakit/ops/_common.py +201 -0
- dnakit/ops/circular.py +193 -0
- dnakit/ops/concat.py +204 -0
- dnakit/ops/direction.py +27 -0
- dnakit/ops/edit.py +443 -0
- dnakit/ops/evolution.py +991 -0
- dnakit/ops/mutation.py +151 -0
- dnakit/ops/records.py +1012 -0
- dnakit/ops/translation.py +257 -0
- dnakit/patterns/__init__.py +81 -0
- dnakit/patterns/_shared.py +282 -0
- dnakit/patterns/coding.py +360 -0
- dnakit/patterns/crispr.py +337 -0
- dnakit/patterns/motif.py +721 -0
- dnakit/patterns/regions.py +341 -0
- dnakit/patterns/repeats.py +480 -0
- dnakit/patterns/restriction.py +281 -0
- dnakit/patterns/results.py +411 -0
- dnakit/py.typed +1 -0
- dnakit/references/__init__.py +27 -0
- dnakit/references/models.py +155 -0
- dnakit/references/ncbi.py +785 -0
- dnakit/representations/__init__.py +29 -0
- dnakit/representations/backends.py +928 -0
- dnakit/representations/checkpoints.py +356 -0
- dnakit/representations/extraction.py +220 -0
- dnakit/representations/models.py +395 -0
- dnakit/representations/results.py +79 -0
- dnakit/search/__init__.py +132 -0
- dnakit/search/_http.py +271 -0
- dnakit/search/_shared.py +108 -0
- dnakit/search/blast.py +516 -0
- dnakit/search/ena.py +245 -0
- dnakit/search/encode.py +102 -0
- dnakit/search/ensembl.py +874 -0
- dnakit/search/models.py +159 -0
- dnakit/search/ncbi.py +756 -0
- dnakit/search/ucsc.py +388 -0
- dnakit/secondary_structure/__init__.py +38 -0
- dnakit/secondary_structure/dotbracket.py +410 -0
- dnakit/secondary_structure/nupack.py +589 -0
- dnakit/secondary_structure/results.py +142 -0
- dnakit/similarity/__init__.py +125 -0
- dnakit/similarity/_shared.py +152 -0
- dnakit/similarity/approximate.py +258 -0
- dnakit/similarity/compare.py +162 -0
- dnakit/similarity/dashing.py +936 -0
- dnakit/similarity/distance.py +279 -0
- dnakit/similarity/index.py +365 -0
- dnakit/similarity/matrix.py +254 -0
- dnakit/similarity/results.py +676 -0
- dnakit/similarity/search.py +286 -0
- dnakit/similarity/sketch.py +96 -0
- dnakit/similarity/vector.py +662 -0
- dnakit/standardize/__init__.py +77 -0
- dnakit/standardize/_shared.py +100 -0
- dnakit/standardize/config.py +411 -0
- dnakit/standardize/gaps.py +1060 -0
- dnakit/standardize/normalize.py +565 -0
- dnakit/standardize/results.py +247 -0
- dnakit/standardize/validate.py +487 -0
- dnakit/structure3d/__init__.py +47 -0
- dnakit/structure3d/_shared.py +47 -0
- dnakit/structure3d/geometry.py +773 -0
- dnakit/structure3d/pdb.py +253 -0
- dnakit/structure3d/results.py +233 -0
- dnakit/structure3d/threedna.py +174 -0
- dnakit/thermodynamics/__init__.py +112 -0
- dnakit/thermodynamics/_shared.py +92 -0
- dnakit/thermodynamics/backends.py +653 -0
- dnakit/thermodynamics/calculations.py +837 -0
- dnakit/thermodynamics/config.py +175 -0
- dnakit/thermodynamics/equilibrium.py +340 -0
- dnakit/thermodynamics/optics.py +385 -0
- dnakit/thermodynamics/parameters.py +79 -0
- dnakit/thermodynamics/results.py +445 -0
- dnakit/visualization/__init__.py +43 -0
- dnakit/visualization/_svg.py +185 -0
- dnakit/visualization/advanced.py +314 -0
- dnakit/visualization/config.py +255 -0
- dnakit/visualization/export.py +349 -0
- dnakit/visualization/matrix.py +279 -0
- dnakit/visualization/report.py +431 -0
- dnakit/visualization/results.py +236 -0
- dnakit/visualization/sequence.py +459 -0
- dnakit/workflows/__init__.py +45 -0
- dnakit/workflows/manifest.py +365 -0
- dnakit/workflows/runner.py +1548 -0
- dnakit/workflows/schema.py +898 -0
- dnakit-0.1.0.dev0.dist-info/METADATA +126 -0
- dnakit-0.1.0.dev0.dist-info/RECORD +210 -0
- dnakit-0.1.0.dev0.dist-info/WHEEL +5 -0
- dnakit-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- dnakit-0.1.0.dev0.dist-info/licenses/DISCLAIMER.md +26 -0
- dnakit-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
- dnakit-0.1.0.dev0.dist-info/licenses/THIRD_PARTY_NOTICES.md +73 -0
- dnakit-0.1.0.dev0.dist-info/top_level.txt +1 -0
dnakit/io/indexed.py
ADDED
|
@@ -0,0 +1,1146 @@
|
|
|
1
|
+
"""Bounded chunk iteration and persistent random access for plain FASTA."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import io
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from collections.abc import Iterable, Iterator, Mapping
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from types import MappingProxyType
|
|
13
|
+
from typing import Any, Literal, TypeVar, cast
|
|
14
|
+
|
|
15
|
+
from dnakit.core._json import to_json_compatible
|
|
16
|
+
from dnakit.core.enums import Topology
|
|
17
|
+
from dnakit.core.record import DNARecord
|
|
18
|
+
from dnakit.core.sequence import IUPAC_SYMBOLS, DNASequence
|
|
19
|
+
from dnakit.exceptions import ConfigurationError, DuplicateIDError, InputFormatError
|
|
20
|
+
|
|
21
|
+
from ._advanced_common import write_text_path
|
|
22
|
+
from .api import read_one
|
|
23
|
+
|
|
24
|
+
T = TypeVar("T")
|
|
25
|
+
_INDEX_SCHEMA = "dnakit.fasta-index.v1"
|
|
26
|
+
_FASTQ_INDEX_SCHEMA = "dnakit.fastq-index.v1"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def iter_chunks(values: Iterable[T], *, chunk_size: int) -> Iterator[tuple[T, ...]]:
|
|
30
|
+
"""Yield stable input-order chunks without materializing the input."""
|
|
31
|
+
|
|
32
|
+
if isinstance(chunk_size, bool) or not isinstance(chunk_size, int):
|
|
33
|
+
raise TypeError("chunk_size must be an integer.")
|
|
34
|
+
if chunk_size < 1:
|
|
35
|
+
raise ConfigurationError(
|
|
36
|
+
"chunk_size must be positive.",
|
|
37
|
+
code="INVALID_CHUNK_SIZE",
|
|
38
|
+
context={"chunk_size": chunk_size},
|
|
39
|
+
)
|
|
40
|
+
chunk: list[T] = []
|
|
41
|
+
for value in values:
|
|
42
|
+
chunk.append(value)
|
|
43
|
+
if len(chunk) == chunk_size:
|
|
44
|
+
yield tuple(chunk)
|
|
45
|
+
chunk.clear()
|
|
46
|
+
if chunk:
|
|
47
|
+
yield tuple(chunk)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True, slots=True)
|
|
51
|
+
class FastaIndexEntry:
|
|
52
|
+
"""Byte range and decoded symbol count for one FASTA record."""
|
|
53
|
+
|
|
54
|
+
record_id: str
|
|
55
|
+
byte_start: int
|
|
56
|
+
byte_end: int
|
|
57
|
+
sequence_length: int
|
|
58
|
+
|
|
59
|
+
def __post_init__(self) -> None:
|
|
60
|
+
if (
|
|
61
|
+
not isinstance(self.record_id, str)
|
|
62
|
+
or not self.record_id
|
|
63
|
+
or any(symbol.isspace() for symbol in self.record_id)
|
|
64
|
+
):
|
|
65
|
+
raise ConfigurationError(
|
|
66
|
+
"FASTA index record_id must be non-empty.", code="INVALID_FASTA_INDEX"
|
|
67
|
+
)
|
|
68
|
+
values = (self.byte_start, self.byte_end, self.sequence_length)
|
|
69
|
+
if any(
|
|
70
|
+
isinstance(value, bool) or not isinstance(value, int) or value < 0 for value in values
|
|
71
|
+
):
|
|
72
|
+
raise ConfigurationError(
|
|
73
|
+
"FASTA index offsets and lengths must be non-negative integers.",
|
|
74
|
+
code="INVALID_FASTA_INDEX",
|
|
75
|
+
)
|
|
76
|
+
if self.byte_end <= self.byte_start:
|
|
77
|
+
raise ConfigurationError(
|
|
78
|
+
"FASTA index byte range must be non-empty.", code="INVALID_FASTA_INDEX"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True, slots=True)
|
|
83
|
+
class FastaIndex:
|
|
84
|
+
"""Verified immutable index for an uncompressed FASTA path."""
|
|
85
|
+
|
|
86
|
+
source_path: str
|
|
87
|
+
source_sha256: str
|
|
88
|
+
source_size: int
|
|
89
|
+
source_mtime_ns: int
|
|
90
|
+
entries: tuple[FastaIndexEntry, ...]
|
|
91
|
+
schema_version: str = _INDEX_SCHEMA
|
|
92
|
+
_by_id: Mapping[str, FastaIndexEntry] = field(init=False, repr=False, compare=False)
|
|
93
|
+
|
|
94
|
+
def __post_init__(self) -> None:
|
|
95
|
+
if self.schema_version != _INDEX_SCHEMA:
|
|
96
|
+
raise ConfigurationError(
|
|
97
|
+
"Unsupported FASTA index schema.", code="UNSUPPORTED_FASTA_INDEX_SCHEMA"
|
|
98
|
+
)
|
|
99
|
+
if not isinstance(self.source_path, str) or not self.source_path:
|
|
100
|
+
raise ConfigurationError(
|
|
101
|
+
"FASTA index source_path must be non-empty.", code="INVALID_FASTA_INDEX"
|
|
102
|
+
)
|
|
103
|
+
if not re_full_sha256(self.source_sha256):
|
|
104
|
+
raise ConfigurationError(
|
|
105
|
+
"FASTA index checksum must be lowercase SHA-256.", code="INVALID_FASTA_INDEX"
|
|
106
|
+
)
|
|
107
|
+
if any(
|
|
108
|
+
isinstance(value, bool) or not isinstance(value, int) or value < 0
|
|
109
|
+
for value in (self.source_size, self.source_mtime_ns)
|
|
110
|
+
):
|
|
111
|
+
raise ConfigurationError(
|
|
112
|
+
"FASTA index source size and mtime must be non-negative integers.",
|
|
113
|
+
code="INVALID_FASTA_INDEX",
|
|
114
|
+
)
|
|
115
|
+
if not isinstance(self.entries, tuple) or any(
|
|
116
|
+
not isinstance(entry, FastaIndexEntry) for entry in self.entries
|
|
117
|
+
):
|
|
118
|
+
raise ConfigurationError(
|
|
119
|
+
"FASTA index entries have invalid types.", code="INVALID_FASTA_INDEX"
|
|
120
|
+
)
|
|
121
|
+
lookup: dict[str, FastaIndexEntry] = {}
|
|
122
|
+
previous_end = 0
|
|
123
|
+
for entry in self.entries:
|
|
124
|
+
if entry.record_id in lookup:
|
|
125
|
+
raise DuplicateIDError(
|
|
126
|
+
"FASTA index contains duplicate record IDs.",
|
|
127
|
+
context={"record_id": entry.record_id},
|
|
128
|
+
)
|
|
129
|
+
if entry.byte_start < previous_end or entry.byte_end > self.source_size:
|
|
130
|
+
raise ConfigurationError(
|
|
131
|
+
"FASTA index byte ranges are invalid or overlap.", code="INVALID_FASTA_INDEX"
|
|
132
|
+
)
|
|
133
|
+
lookup[entry.record_id] = entry
|
|
134
|
+
previous_end = entry.byte_end
|
|
135
|
+
object.__setattr__(self, "_by_id", MappingProxyType(lookup))
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def ids(self) -> tuple[str, ...]:
|
|
139
|
+
return tuple(entry.record_id for entry in self.entries)
|
|
140
|
+
|
|
141
|
+
def to_dict(self) -> dict[str, Any]:
|
|
142
|
+
return {
|
|
143
|
+
"schema_version": self.schema_version,
|
|
144
|
+
"source_path": self.source_path,
|
|
145
|
+
"source_sha256": self.source_sha256,
|
|
146
|
+
"source_size": self.source_size,
|
|
147
|
+
"source_mtime_ns": self.source_mtime_ns,
|
|
148
|
+
"entries": [cast(dict[str, Any], to_json_compatible(entry)) for entry in self.entries],
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
def fetch(
|
|
152
|
+
self,
|
|
153
|
+
record_id: str,
|
|
154
|
+
*,
|
|
155
|
+
start: int | None = None,
|
|
156
|
+
end: int | None = None,
|
|
157
|
+
strand: Literal["+", "-", "forward", "reverse"] = "+",
|
|
158
|
+
max_record_bytes: int = 100_000_000,
|
|
159
|
+
) -> DNARecord:
|
|
160
|
+
"""Fetch one record or one 0-based half-open subsequence by ID."""
|
|
161
|
+
|
|
162
|
+
if not isinstance(record_id, str) or not record_id:
|
|
163
|
+
raise TypeError("record_id must be non-empty text.")
|
|
164
|
+
try:
|
|
165
|
+
entry = self._by_id[record_id]
|
|
166
|
+
except KeyError as exc:
|
|
167
|
+
raise KeyError(record_id) from exc
|
|
168
|
+
if (
|
|
169
|
+
isinstance(max_record_bytes, bool)
|
|
170
|
+
or not isinstance(max_record_bytes, int)
|
|
171
|
+
or max_record_bytes < 1
|
|
172
|
+
):
|
|
173
|
+
raise ConfigurationError(
|
|
174
|
+
"max_record_bytes must be positive.", code="INVALID_FASTA_INDEX_LIMIT"
|
|
175
|
+
)
|
|
176
|
+
byte_count = entry.byte_end - entry.byte_start
|
|
177
|
+
if byte_count > max_record_bytes:
|
|
178
|
+
raise InputFormatError(
|
|
179
|
+
"Indexed FASTA record exceeds max_record_bytes.",
|
|
180
|
+
code="FASTA_RECORD_LIMIT_EXCEEDED",
|
|
181
|
+
context={
|
|
182
|
+
"record_id": record_id,
|
|
183
|
+
"byte_count": byte_count,
|
|
184
|
+
"max_record_bytes": max_record_bytes,
|
|
185
|
+
},
|
|
186
|
+
)
|
|
187
|
+
path = Path(self.source_path)
|
|
188
|
+
try:
|
|
189
|
+
stat = path.stat()
|
|
190
|
+
if stat.st_size != self.source_size or stat.st_mtime_ns != self.source_mtime_ns:
|
|
191
|
+
raise InputFormatError(
|
|
192
|
+
"Indexed FASTA changed after index verification.",
|
|
193
|
+
code="STALE_FASTA_INDEX",
|
|
194
|
+
context={"source": str(path)},
|
|
195
|
+
)
|
|
196
|
+
with path.open("rb") as handle:
|
|
197
|
+
handle.seek(entry.byte_start)
|
|
198
|
+
payload = handle.read(byte_count)
|
|
199
|
+
final_stat = path.stat()
|
|
200
|
+
if final_stat.st_size != stat.st_size or final_stat.st_mtime_ns != stat.st_mtime_ns:
|
|
201
|
+
raise InputFormatError(
|
|
202
|
+
"Indexed FASTA changed while a record was being fetched.",
|
|
203
|
+
code="STALE_FASTA_INDEX",
|
|
204
|
+
context={"source": str(path)},
|
|
205
|
+
)
|
|
206
|
+
except OSError as exc:
|
|
207
|
+
raise InputFormatError(
|
|
208
|
+
"Could not read indexed FASTA source.",
|
|
209
|
+
code="INPUT_READ_FAILED",
|
|
210
|
+
context={"source": str(path), "reason": str(exc)},
|
|
211
|
+
) from exc
|
|
212
|
+
if len(payload) != byte_count:
|
|
213
|
+
raise InputFormatError(
|
|
214
|
+
"Indexed FASTA byte range is truncated.",
|
|
215
|
+
code="STALE_FASTA_INDEX",
|
|
216
|
+
context={"record_id": record_id},
|
|
217
|
+
)
|
|
218
|
+
try:
|
|
219
|
+
text = payload.decode("utf-8")
|
|
220
|
+
except UnicodeDecodeError as exc:
|
|
221
|
+
raise InputFormatError(
|
|
222
|
+
"Indexed FASTA record is not UTF-8.",
|
|
223
|
+
code="INPUT_DECODE_FAILED",
|
|
224
|
+
context={"record_id": record_id},
|
|
225
|
+
) from exc
|
|
226
|
+
record = read_one(io.StringIO(text), format="fasta")
|
|
227
|
+
if record.id != record_id or record.sequence.symbol_length != entry.sequence_length:
|
|
228
|
+
raise InputFormatError(
|
|
229
|
+
"Indexed FASTA content no longer matches the index.",
|
|
230
|
+
code="STALE_FASTA_INDEX",
|
|
231
|
+
context={"record_id": record_id},
|
|
232
|
+
)
|
|
233
|
+
if start is None and end is None and strand in {"+", "forward"}:
|
|
234
|
+
return record
|
|
235
|
+
resolved_start = 0 if start is None else start
|
|
236
|
+
resolved_end = entry.sequence_length if end is None else end
|
|
237
|
+
for name, value in (("start", resolved_start), ("end", resolved_end)):
|
|
238
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
239
|
+
raise TypeError(f"{name} must be an integer or None.")
|
|
240
|
+
if (
|
|
241
|
+
resolved_start < 0
|
|
242
|
+
or resolved_end < resolved_start
|
|
243
|
+
or resolved_end > entry.sequence_length
|
|
244
|
+
):
|
|
245
|
+
raise InputFormatError(
|
|
246
|
+
"Indexed FASTA query coordinates are outside the record.",
|
|
247
|
+
code="FASTA_QUERY_OUT_OF_BOUNDS",
|
|
248
|
+
context={
|
|
249
|
+
"record_id": record_id,
|
|
250
|
+
"start": resolved_start,
|
|
251
|
+
"end": resolved_end,
|
|
252
|
+
"sequence_length": entry.sequence_length,
|
|
253
|
+
},
|
|
254
|
+
)
|
|
255
|
+
if strand not in {"+", "-", "forward", "reverse"}:
|
|
256
|
+
raise ConfigurationError(
|
|
257
|
+
"strand must be '+', '-', 'forward', or 'reverse'.",
|
|
258
|
+
code="INVALID_FASTA_QUERY_STRAND",
|
|
259
|
+
)
|
|
260
|
+
selected = record.sequence.to_string()[resolved_start:resolved_end]
|
|
261
|
+
sequence = DNASequence(
|
|
262
|
+
selected,
|
|
263
|
+
alphabet=record.sequence.alphabet,
|
|
264
|
+
topology=Topology.LINEAR,
|
|
265
|
+
strandedness=record.sequence.strandedness,
|
|
266
|
+
)
|
|
267
|
+
if strand in {"-", "reverse"}:
|
|
268
|
+
sequence = sequence.reverse_complement()
|
|
269
|
+
return DNARecord(
|
|
270
|
+
sequence,
|
|
271
|
+
record.id,
|
|
272
|
+
description=record.description,
|
|
273
|
+
metadata={
|
|
274
|
+
"fasta_index_query": {
|
|
275
|
+
"start": resolved_start,
|
|
276
|
+
"end": resolved_end,
|
|
277
|
+
"strand": "reverse" if strand in {"-", "reverse"} else "forward",
|
|
278
|
+
"coordinate_system": "0-based-half-open",
|
|
279
|
+
}
|
|
280
|
+
},
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def re_full_sha256(value: object) -> bool:
|
|
285
|
+
return (
|
|
286
|
+
isinstance(value, str)
|
|
287
|
+
and len(value) == 64
|
|
288
|
+
and all(symbol in "0123456789abcdef" for symbol in value)
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _digest_path(path: Path, *, chunk_size: int = 1024 * 1024) -> str:
|
|
293
|
+
digest = hashlib.sha256()
|
|
294
|
+
with path.open("rb") as handle:
|
|
295
|
+
for chunk in iter(lambda: handle.read(chunk_size), b""):
|
|
296
|
+
digest.update(chunk)
|
|
297
|
+
return digest.hexdigest()
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _json_non_negative_integer(value: object, *, field: str) -> int:
|
|
301
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
|
302
|
+
raise ValueError(f"{field} is not a non-negative integer")
|
|
303
|
+
return value
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _json_string(value: object, *, field: str) -> str:
|
|
307
|
+
if not isinstance(value, str):
|
|
308
|
+
raise ValueError(f"{field} is not a string")
|
|
309
|
+
return value
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _json_object_without_duplicates(pairs: list[tuple[str, object]]) -> dict[str, object]:
|
|
313
|
+
result: dict[str, object] = {}
|
|
314
|
+
for key, value in pairs:
|
|
315
|
+
if key in result:
|
|
316
|
+
raise ValueError(f"duplicate JSON object key: {key}")
|
|
317
|
+
result[key] = value
|
|
318
|
+
return result
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _read_bounded_index_text(path: Path, *, max_index_bytes: int, limit_error_code: str) -> str:
|
|
322
|
+
try:
|
|
323
|
+
with path.open("rb") as handle:
|
|
324
|
+
payload = handle.read(max_index_bytes + 1)
|
|
325
|
+
except OSError as exc:
|
|
326
|
+
raise InputFormatError(
|
|
327
|
+
"Could not read index input.",
|
|
328
|
+
code="INPUT_READ_FAILED",
|
|
329
|
+
context={"source": str(path), "reason": str(exc)},
|
|
330
|
+
) from exc
|
|
331
|
+
if len(payload) > max_index_bytes:
|
|
332
|
+
raise InputFormatError(
|
|
333
|
+
"Index input exceeds max_index_bytes.",
|
|
334
|
+
code=limit_error_code,
|
|
335
|
+
context={"max_index_bytes": max_index_bytes},
|
|
336
|
+
)
|
|
337
|
+
try:
|
|
338
|
+
return payload.decode("utf-8")
|
|
339
|
+
except UnicodeDecodeError as exc:
|
|
340
|
+
raise InputFormatError(
|
|
341
|
+
"Index input is not UTF-8.",
|
|
342
|
+
code="INPUT_DECODE_FAILED",
|
|
343
|
+
context={"source": str(path)},
|
|
344
|
+
) from exc
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def build_fasta_index(
|
|
348
|
+
source_path: str | os.PathLike[str],
|
|
349
|
+
index_path: str | os.PathLike[str] | None = None,
|
|
350
|
+
*,
|
|
351
|
+
overwrite: bool = False,
|
|
352
|
+
max_records: int = 10_000_000,
|
|
353
|
+
max_line_length: int = 10_000_000,
|
|
354
|
+
) -> FastaIndex:
|
|
355
|
+
"""Scan and persist an ID index for an uncompressed UTF-8 FASTA file."""
|
|
356
|
+
|
|
357
|
+
for name, value in (("max_records", max_records), ("max_line_length", max_line_length)):
|
|
358
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
|
|
359
|
+
raise ConfigurationError(f"{name} must be positive.", code="INVALID_FASTA_INDEX_LIMIT")
|
|
360
|
+
source = Path(source_path).resolve()
|
|
361
|
+
if not source.is_file():
|
|
362
|
+
raise InputFormatError(
|
|
363
|
+
"FASTA index source must be an existing file.",
|
|
364
|
+
code="INPUT_OPEN_FAILED",
|
|
365
|
+
context={"source": str(source)},
|
|
366
|
+
)
|
|
367
|
+
initial_stat = source.stat()
|
|
368
|
+
with source.open("rb") as probe:
|
|
369
|
+
if probe.read(2) == b"\x1f\x8b":
|
|
370
|
+
raise InputFormatError(
|
|
371
|
+
"Persistent FASTA indexing supports only uncompressed files.",
|
|
372
|
+
code="COMPRESSED_FASTA_INDEX_UNSUPPORTED",
|
|
373
|
+
)
|
|
374
|
+
entries: list[FastaIndexEntry] = []
|
|
375
|
+
seen: set[str] = set()
|
|
376
|
+
current_id: str | None = None
|
|
377
|
+
current_start = 0
|
|
378
|
+
current_length = 0
|
|
379
|
+
offset = 0
|
|
380
|
+
with source.open("rb") as handle:
|
|
381
|
+
for line_number, raw in enumerate(handle, start=1):
|
|
382
|
+
if len(raw.rstrip(b"\r\n")) > max_line_length:
|
|
383
|
+
raise InputFormatError(
|
|
384
|
+
"FASTA line exceeds max_line_length.",
|
|
385
|
+
code="FASTA_LINE_TOO_LONG",
|
|
386
|
+
context={"line_number": line_number, "max_line_length": max_line_length},
|
|
387
|
+
)
|
|
388
|
+
if raw.startswith(b">"):
|
|
389
|
+
if current_id is not None:
|
|
390
|
+
entries.append(
|
|
391
|
+
FastaIndexEntry(current_id, current_start, offset, current_length)
|
|
392
|
+
)
|
|
393
|
+
if len(entries) >= max_records:
|
|
394
|
+
raise InputFormatError(
|
|
395
|
+
"FASTA input exceeds max_records.",
|
|
396
|
+
code="FASTA_INDEX_RECORD_LIMIT_EXCEEDED",
|
|
397
|
+
context={"max_records": max_records},
|
|
398
|
+
)
|
|
399
|
+
try:
|
|
400
|
+
header = raw[1:].decode("utf-8").strip()
|
|
401
|
+
except UnicodeDecodeError as exc:
|
|
402
|
+
raise InputFormatError(
|
|
403
|
+
"FASTA header is not UTF-8.",
|
|
404
|
+
code="INPUT_DECODE_FAILED",
|
|
405
|
+
context={"line_number": line_number},
|
|
406
|
+
) from exc
|
|
407
|
+
if not header:
|
|
408
|
+
raise InputFormatError(
|
|
409
|
+
"FASTA header must contain an ID.",
|
|
410
|
+
code="EMPTY_FASTA_HEADER",
|
|
411
|
+
context={"line_number": line_number},
|
|
412
|
+
)
|
|
413
|
+
current_id = header.split(maxsplit=1)[0]
|
|
414
|
+
if current_id in seen:
|
|
415
|
+
raise DuplicateIDError(
|
|
416
|
+
"FASTA indexing requires unique record IDs.",
|
|
417
|
+
context={"record_id": current_id, "line_number": line_number},
|
|
418
|
+
)
|
|
419
|
+
seen.add(current_id)
|
|
420
|
+
current_start = offset
|
|
421
|
+
current_length = 0
|
|
422
|
+
else:
|
|
423
|
+
body = raw.rstrip(b"\r\n")
|
|
424
|
+
if not body:
|
|
425
|
+
offset += len(raw)
|
|
426
|
+
continue
|
|
427
|
+
if current_id is None:
|
|
428
|
+
raise InputFormatError(
|
|
429
|
+
"FASTA sequence data appears before the first header.",
|
|
430
|
+
code="FASTA_MISSING_HEADER",
|
|
431
|
+
context={"line_number": line_number},
|
|
432
|
+
)
|
|
433
|
+
try:
|
|
434
|
+
symbols = body.decode("ascii").upper()
|
|
435
|
+
except UnicodeDecodeError as exc:
|
|
436
|
+
raise InputFormatError(
|
|
437
|
+
"FASTA sequence is not ASCII DNA text.",
|
|
438
|
+
code="INVALID_SEQUENCE_CONTENT",
|
|
439
|
+
context={"line_number": line_number},
|
|
440
|
+
) from exc
|
|
441
|
+
if any(symbol not in IUPAC_SYMBOLS for symbol in symbols):
|
|
442
|
+
raise InputFormatError(
|
|
443
|
+
"FASTA sequence contains invalid DNA symbols.",
|
|
444
|
+
code="INVALID_SEQUENCE_CONTENT",
|
|
445
|
+
context={"line_number": line_number},
|
|
446
|
+
)
|
|
447
|
+
current_length += len(symbols)
|
|
448
|
+
offset += len(raw)
|
|
449
|
+
if current_id is not None:
|
|
450
|
+
entries.append(FastaIndexEntry(current_id, current_start, offset, current_length))
|
|
451
|
+
digest = _digest_path(source)
|
|
452
|
+
final_stat = source.stat()
|
|
453
|
+
if (
|
|
454
|
+
initial_stat.st_size != final_stat.st_size
|
|
455
|
+
or initial_stat.st_mtime_ns != final_stat.st_mtime_ns
|
|
456
|
+
):
|
|
457
|
+
raise InputFormatError(
|
|
458
|
+
"FASTA source changed while its index was being built.",
|
|
459
|
+
code="FASTA_CHANGED_DURING_INDEX",
|
|
460
|
+
context={"source": str(source)},
|
|
461
|
+
)
|
|
462
|
+
index = FastaIndex(
|
|
463
|
+
str(source),
|
|
464
|
+
digest,
|
|
465
|
+
final_stat.st_size,
|
|
466
|
+
final_stat.st_mtime_ns,
|
|
467
|
+
tuple(entries),
|
|
468
|
+
)
|
|
469
|
+
destination = (
|
|
470
|
+
Path(index_path) if index_path is not None else Path(str(source) + ".dnakit.fai.json")
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
def writer(handle: Any) -> int:
|
|
474
|
+
json.dump(
|
|
475
|
+
index.to_dict(), handle, ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
|
476
|
+
)
|
|
477
|
+
handle.write("\n")
|
|
478
|
+
return len(entries)
|
|
479
|
+
|
|
480
|
+
write_text_path(destination, writer, overwrite=overwrite, create_parents=False)
|
|
481
|
+
return index
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def load_fasta_index(
|
|
485
|
+
index_path: str | os.PathLike[str],
|
|
486
|
+
*,
|
|
487
|
+
source_path: str | os.PathLike[str] | None = None,
|
|
488
|
+
verify_checksum: bool = True,
|
|
489
|
+
max_index_bytes: int = 64 * 1024 * 1024,
|
|
490
|
+
) -> FastaIndex:
|
|
491
|
+
"""Load and optionally checksum-verify a persistent DNAKit FASTA index."""
|
|
492
|
+
|
|
493
|
+
if not isinstance(verify_checksum, bool):
|
|
494
|
+
raise TypeError("verify_checksum must be a boolean.")
|
|
495
|
+
path = Path(index_path)
|
|
496
|
+
if (
|
|
497
|
+
isinstance(max_index_bytes, bool)
|
|
498
|
+
or not isinstance(max_index_bytes, int)
|
|
499
|
+
or max_index_bytes < 1
|
|
500
|
+
):
|
|
501
|
+
raise ConfigurationError(
|
|
502
|
+
"max_index_bytes must be positive.", code="INVALID_FASTA_INDEX_LIMIT"
|
|
503
|
+
)
|
|
504
|
+
try:
|
|
505
|
+
payload = json.loads(
|
|
506
|
+
_read_bounded_index_text(
|
|
507
|
+
path,
|
|
508
|
+
max_index_bytes=max_index_bytes,
|
|
509
|
+
limit_error_code="FASTA_INDEX_FILE_LIMIT_EXCEEDED",
|
|
510
|
+
),
|
|
511
|
+
object_pairs_hook=_json_object_without_duplicates,
|
|
512
|
+
)
|
|
513
|
+
except InputFormatError:
|
|
514
|
+
raise
|
|
515
|
+
except (OSError, UnicodeError, json.JSONDecodeError, RecursionError, ValueError) as exc:
|
|
516
|
+
raise InputFormatError(
|
|
517
|
+
"Could not decode FASTA index JSON.",
|
|
518
|
+
code="INVALID_FASTA_INDEX",
|
|
519
|
+
context={"source": str(path)},
|
|
520
|
+
) from exc
|
|
521
|
+
if not isinstance(payload, Mapping) or not isinstance(payload.get("entries"), list):
|
|
522
|
+
raise InputFormatError("FASTA index root/schema is invalid.", code="INVALID_FASTA_INDEX")
|
|
523
|
+
try:
|
|
524
|
+
entries = tuple(
|
|
525
|
+
FastaIndexEntry(
|
|
526
|
+
_json_string(item["record_id"], field="record_id"),
|
|
527
|
+
_json_non_negative_integer(item["byte_start"], field="byte_start"),
|
|
528
|
+
_json_non_negative_integer(item["byte_end"], field="byte_end"),
|
|
529
|
+
_json_non_negative_integer(item["sequence_length"], field="sequence_length"),
|
|
530
|
+
)
|
|
531
|
+
for item in payload["entries"]
|
|
532
|
+
if isinstance(item, Mapping)
|
|
533
|
+
)
|
|
534
|
+
if len(entries) != len(payload["entries"]):
|
|
535
|
+
raise ValueError("non-object entry")
|
|
536
|
+
stored_source = _json_string(payload["source_path"], field="source_path")
|
|
537
|
+
resolved_source = (
|
|
538
|
+
str(Path(source_path).resolve()) if source_path is not None else stored_source
|
|
539
|
+
)
|
|
540
|
+
index = FastaIndex(
|
|
541
|
+
resolved_source,
|
|
542
|
+
_json_string(payload["source_sha256"], field="source_sha256"),
|
|
543
|
+
_json_non_negative_integer(payload["source_size"], field="source_size"),
|
|
544
|
+
_json_non_negative_integer(payload["source_mtime_ns"], field="source_mtime_ns"),
|
|
545
|
+
entries,
|
|
546
|
+
_json_string(payload["schema_version"], field="schema_version"),
|
|
547
|
+
)
|
|
548
|
+
except (KeyError, TypeError, ValueError, ConfigurationError, DuplicateIDError) as exc:
|
|
549
|
+
raise InputFormatError(
|
|
550
|
+
"FASTA index fields are invalid.", code="INVALID_FASTA_INDEX"
|
|
551
|
+
) from exc
|
|
552
|
+
source = Path(index.source_path)
|
|
553
|
+
try:
|
|
554
|
+
stat = source.stat()
|
|
555
|
+
if stat.st_size != index.source_size:
|
|
556
|
+
raise InputFormatError(
|
|
557
|
+
"FASTA source size no longer matches its index.",
|
|
558
|
+
code="STALE_FASTA_INDEX",
|
|
559
|
+
context={"source": str(source)},
|
|
560
|
+
)
|
|
561
|
+
if not verify_checksum and stat.st_mtime_ns != index.source_mtime_ns:
|
|
562
|
+
raise InputFormatError(
|
|
563
|
+
"FASTA source mtime no longer matches its index.",
|
|
564
|
+
code="STALE_FASTA_INDEX",
|
|
565
|
+
context={"source": str(source)},
|
|
566
|
+
)
|
|
567
|
+
if verify_checksum:
|
|
568
|
+
if _digest_path(source) != index.source_sha256:
|
|
569
|
+
raise InputFormatError(
|
|
570
|
+
"FASTA source checksum no longer matches its index.",
|
|
571
|
+
code="STALE_FASTA_INDEX",
|
|
572
|
+
context={"source": str(source)},
|
|
573
|
+
)
|
|
574
|
+
if stat.st_mtime_ns != index.source_mtime_ns:
|
|
575
|
+
index = FastaIndex(
|
|
576
|
+
index.source_path,
|
|
577
|
+
index.source_sha256,
|
|
578
|
+
index.source_size,
|
|
579
|
+
stat.st_mtime_ns,
|
|
580
|
+
index.entries,
|
|
581
|
+
index.schema_version,
|
|
582
|
+
)
|
|
583
|
+
except InputFormatError:
|
|
584
|
+
raise
|
|
585
|
+
except OSError as exc:
|
|
586
|
+
raise InputFormatError(
|
|
587
|
+
"Indexed FASTA source is unavailable.",
|
|
588
|
+
code="INPUT_OPEN_FAILED",
|
|
589
|
+
context={"source": str(source)},
|
|
590
|
+
) from exc
|
|
591
|
+
return index
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
@dataclass(frozen=True, slots=True)
|
|
595
|
+
class FastqIndexEntry:
|
|
596
|
+
"""Byte range and symbol count for one strict four-line FASTQ record."""
|
|
597
|
+
|
|
598
|
+
record_id: str
|
|
599
|
+
byte_start: int
|
|
600
|
+
byte_end: int
|
|
601
|
+
sequence_length: int
|
|
602
|
+
|
|
603
|
+
def __post_init__(self) -> None:
|
|
604
|
+
if (
|
|
605
|
+
not isinstance(self.record_id, str)
|
|
606
|
+
or not self.record_id
|
|
607
|
+
or any(symbol.isspace() for symbol in self.record_id)
|
|
608
|
+
):
|
|
609
|
+
raise ConfigurationError(
|
|
610
|
+
"FASTQ index record_id must be non-empty without whitespace.",
|
|
611
|
+
code="INVALID_FASTQ_INDEX",
|
|
612
|
+
)
|
|
613
|
+
if any(
|
|
614
|
+
isinstance(value, bool) or not isinstance(value, int) or value < 0
|
|
615
|
+
for value in (self.byte_start, self.byte_end, self.sequence_length)
|
|
616
|
+
):
|
|
617
|
+
raise ConfigurationError(
|
|
618
|
+
"FASTQ index offsets and lengths must be non-negative integers.",
|
|
619
|
+
code="INVALID_FASTQ_INDEX",
|
|
620
|
+
)
|
|
621
|
+
if self.byte_end <= self.byte_start:
|
|
622
|
+
raise ConfigurationError(
|
|
623
|
+
"FASTQ index byte range must be non-empty.", code="INVALID_FASTQ_INDEX"
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
@dataclass(frozen=True, slots=True)
|
|
628
|
+
class FastqIndex:
|
|
629
|
+
"""Verified immutable index for an uncompressed four-line FASTQ path."""
|
|
630
|
+
|
|
631
|
+
source_path: str
|
|
632
|
+
source_sha256: str
|
|
633
|
+
source_size: int
|
|
634
|
+
source_mtime_ns: int
|
|
635
|
+
entries: tuple[FastqIndexEntry, ...]
|
|
636
|
+
phred_offset: int = 33
|
|
637
|
+
schema_version: str = _FASTQ_INDEX_SCHEMA
|
|
638
|
+
_by_id: Mapping[str, FastqIndexEntry] = field(init=False, repr=False, compare=False)
|
|
639
|
+
|
|
640
|
+
def __post_init__(self) -> None:
|
|
641
|
+
if self.schema_version != _FASTQ_INDEX_SCHEMA:
|
|
642
|
+
raise ConfigurationError(
|
|
643
|
+
"Unsupported FASTQ index schema.", code="UNSUPPORTED_FASTQ_INDEX_SCHEMA"
|
|
644
|
+
)
|
|
645
|
+
if not isinstance(self.source_path, str) or not self.source_path:
|
|
646
|
+
raise ConfigurationError(
|
|
647
|
+
"FASTQ index source_path must be non-empty.", code="INVALID_FASTQ_INDEX"
|
|
648
|
+
)
|
|
649
|
+
if not re_full_sha256(self.source_sha256):
|
|
650
|
+
raise ConfigurationError(
|
|
651
|
+
"FASTQ index checksum must be lowercase SHA-256.", code="INVALID_FASTQ_INDEX"
|
|
652
|
+
)
|
|
653
|
+
if any(
|
|
654
|
+
isinstance(value, bool) or not isinstance(value, int) or value < 0
|
|
655
|
+
for value in (self.source_size, self.source_mtime_ns)
|
|
656
|
+
):
|
|
657
|
+
raise ConfigurationError(
|
|
658
|
+
"FASTQ index source size and mtime must be non-negative integers.",
|
|
659
|
+
code="INVALID_FASTQ_INDEX",
|
|
660
|
+
)
|
|
661
|
+
if (
|
|
662
|
+
isinstance(self.phred_offset, bool)
|
|
663
|
+
or not isinstance(self.phred_offset, int)
|
|
664
|
+
or not 33 <= self.phred_offset <= 64
|
|
665
|
+
):
|
|
666
|
+
raise ConfigurationError(
|
|
667
|
+
"FASTQ index phred_offset must be between 33 and 64.",
|
|
668
|
+
code="INVALID_FASTQ_INDEX",
|
|
669
|
+
)
|
|
670
|
+
if not isinstance(self.entries, tuple) or any(
|
|
671
|
+
not isinstance(entry, FastqIndexEntry) for entry in self.entries
|
|
672
|
+
):
|
|
673
|
+
raise ConfigurationError(
|
|
674
|
+
"FASTQ index entries have invalid types.", code="INVALID_FASTQ_INDEX"
|
|
675
|
+
)
|
|
676
|
+
lookup: dict[str, FastqIndexEntry] = {}
|
|
677
|
+
previous_end = 0
|
|
678
|
+
for entry in self.entries:
|
|
679
|
+
if entry.record_id in lookup:
|
|
680
|
+
raise DuplicateIDError(
|
|
681
|
+
"FASTQ index contains duplicate record IDs.",
|
|
682
|
+
context={"record_id": entry.record_id},
|
|
683
|
+
)
|
|
684
|
+
if entry.byte_start != previous_end or entry.byte_end > self.source_size:
|
|
685
|
+
raise ConfigurationError(
|
|
686
|
+
"FASTQ index byte ranges must be valid, contiguous, and non-overlapping.",
|
|
687
|
+
code="INVALID_FASTQ_INDEX",
|
|
688
|
+
)
|
|
689
|
+
lookup[entry.record_id] = entry
|
|
690
|
+
previous_end = entry.byte_end
|
|
691
|
+
if previous_end != self.source_size:
|
|
692
|
+
raise ConfigurationError(
|
|
693
|
+
"FASTQ index entries must cover the complete source.",
|
|
694
|
+
code="INVALID_FASTQ_INDEX",
|
|
695
|
+
)
|
|
696
|
+
object.__setattr__(self, "_by_id", MappingProxyType(lookup))
|
|
697
|
+
|
|
698
|
+
@property
|
|
699
|
+
def ids(self) -> tuple[str, ...]:
|
|
700
|
+
return tuple(entry.record_id for entry in self.entries)
|
|
701
|
+
|
|
702
|
+
def to_dict(self) -> dict[str, Any]:
|
|
703
|
+
return {
|
|
704
|
+
"schema_version": self.schema_version,
|
|
705
|
+
"source_path": self.source_path,
|
|
706
|
+
"source_sha256": self.source_sha256,
|
|
707
|
+
"source_size": self.source_size,
|
|
708
|
+
"source_mtime_ns": self.source_mtime_ns,
|
|
709
|
+
"phred_offset": self.phred_offset,
|
|
710
|
+
"entries": [cast(dict[str, Any], to_json_compatible(entry)) for entry in self.entries],
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
def fetch(
|
|
714
|
+
self,
|
|
715
|
+
record_id: str,
|
|
716
|
+
*,
|
|
717
|
+
start: int | None = None,
|
|
718
|
+
end: int | None = None,
|
|
719
|
+
strand: Literal["+", "-", "forward", "reverse"] = "+",
|
|
720
|
+
max_record_bytes: int = 100_000_000,
|
|
721
|
+
) -> DNARecord:
|
|
722
|
+
"""Fetch a FASTQ record or quality-synchronized 0-based subsequence."""
|
|
723
|
+
|
|
724
|
+
if not isinstance(record_id, str) or not record_id:
|
|
725
|
+
raise TypeError("record_id must be non-empty text.")
|
|
726
|
+
try:
|
|
727
|
+
entry = self._by_id[record_id]
|
|
728
|
+
except KeyError as exc:
|
|
729
|
+
raise KeyError(record_id) from exc
|
|
730
|
+
if (
|
|
731
|
+
isinstance(max_record_bytes, bool)
|
|
732
|
+
or not isinstance(max_record_bytes, int)
|
|
733
|
+
or max_record_bytes < 1
|
|
734
|
+
):
|
|
735
|
+
raise ConfigurationError(
|
|
736
|
+
"max_record_bytes must be positive.", code="INVALID_FASTQ_INDEX_LIMIT"
|
|
737
|
+
)
|
|
738
|
+
byte_count = entry.byte_end - entry.byte_start
|
|
739
|
+
if byte_count > max_record_bytes:
|
|
740
|
+
raise InputFormatError(
|
|
741
|
+
"Indexed FASTQ record exceeds max_record_bytes.",
|
|
742
|
+
code="FASTQ_RECORD_LIMIT_EXCEEDED",
|
|
743
|
+
context={"record_id": record_id, "max_record_bytes": max_record_bytes},
|
|
744
|
+
)
|
|
745
|
+
path = Path(self.source_path)
|
|
746
|
+
try:
|
|
747
|
+
stat = path.stat()
|
|
748
|
+
if stat.st_size != self.source_size or stat.st_mtime_ns != self.source_mtime_ns:
|
|
749
|
+
raise InputFormatError(
|
|
750
|
+
"Indexed FASTQ changed after index verification.",
|
|
751
|
+
code="STALE_FASTQ_INDEX",
|
|
752
|
+
context={"source": str(path)},
|
|
753
|
+
)
|
|
754
|
+
with path.open("rb") as handle:
|
|
755
|
+
handle.seek(entry.byte_start)
|
|
756
|
+
payload = handle.read(byte_count)
|
|
757
|
+
final_stat = path.stat()
|
|
758
|
+
if final_stat.st_size != stat.st_size or final_stat.st_mtime_ns != stat.st_mtime_ns:
|
|
759
|
+
raise InputFormatError(
|
|
760
|
+
"Indexed FASTQ changed while a record was being fetched.",
|
|
761
|
+
code="STALE_FASTQ_INDEX",
|
|
762
|
+
context={"source": str(path)},
|
|
763
|
+
)
|
|
764
|
+
except InputFormatError:
|
|
765
|
+
raise
|
|
766
|
+
except OSError as exc:
|
|
767
|
+
raise InputFormatError(
|
|
768
|
+
"Could not read indexed FASTQ source.",
|
|
769
|
+
code="INPUT_READ_FAILED",
|
|
770
|
+
context={"source": str(path), "reason": str(exc)},
|
|
771
|
+
) from exc
|
|
772
|
+
if len(payload) != byte_count:
|
|
773
|
+
raise InputFormatError(
|
|
774
|
+
"Indexed FASTQ byte range is truncated.",
|
|
775
|
+
code="STALE_FASTQ_INDEX",
|
|
776
|
+
context={"record_id": record_id},
|
|
777
|
+
)
|
|
778
|
+
try:
|
|
779
|
+
text = payload.decode("utf-8")
|
|
780
|
+
except UnicodeDecodeError as exc:
|
|
781
|
+
raise InputFormatError(
|
|
782
|
+
"Indexed FASTQ record is not UTF-8.",
|
|
783
|
+
code="INPUT_DECODE_FAILED",
|
|
784
|
+
context={"record_id": record_id},
|
|
785
|
+
) from exc
|
|
786
|
+
record = read_one(
|
|
787
|
+
io.StringIO(text),
|
|
788
|
+
format="fastq",
|
|
789
|
+
config=_fastq_read_config(self.phred_offset, entry.sequence_length, byte_count),
|
|
790
|
+
)
|
|
791
|
+
if record.id != record_id or record.sequence.symbol_length != entry.sequence_length:
|
|
792
|
+
raise InputFormatError(
|
|
793
|
+
"Indexed FASTQ content no longer matches the index.",
|
|
794
|
+
code="STALE_FASTQ_INDEX",
|
|
795
|
+
context={"record_id": record_id},
|
|
796
|
+
)
|
|
797
|
+
if start is None and end is None and strand in {"+", "forward"}:
|
|
798
|
+
return record
|
|
799
|
+
resolved_start, resolved_end = _resolve_fastq_query(
|
|
800
|
+
record_id=record_id,
|
|
801
|
+
sequence_length=entry.sequence_length,
|
|
802
|
+
start=start,
|
|
803
|
+
end=end,
|
|
804
|
+
strand=strand,
|
|
805
|
+
)
|
|
806
|
+
selected = record.sequence.to_string()[resolved_start:resolved_end]
|
|
807
|
+
sequence = DNASequence(
|
|
808
|
+
selected,
|
|
809
|
+
alphabet=record.sequence.alphabet,
|
|
810
|
+
topology=Topology.LINEAR,
|
|
811
|
+
strandedness=record.sequence.strandedness,
|
|
812
|
+
)
|
|
813
|
+
qualities = tuple(record.letter_annotations["phred_quality"])[resolved_start:resolved_end]
|
|
814
|
+
if strand in {"-", "reverse"}:
|
|
815
|
+
sequence = sequence.reverse_complement()
|
|
816
|
+
qualities = tuple(reversed(qualities))
|
|
817
|
+
return DNARecord(
|
|
818
|
+
sequence,
|
|
819
|
+
record.id,
|
|
820
|
+
description=record.description,
|
|
821
|
+
metadata={
|
|
822
|
+
"fastq_index_query": {
|
|
823
|
+
"start": resolved_start,
|
|
824
|
+
"end": resolved_end,
|
|
825
|
+
"strand": "reverse" if strand in {"-", "reverse"} else "forward",
|
|
826
|
+
"coordinate_system": "0-based-half-open",
|
|
827
|
+
}
|
|
828
|
+
},
|
|
829
|
+
letter_annotations={"phred_quality": qualities},
|
|
830
|
+
)
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
def _fastq_read_config(phred_offset: int, sequence_length: int, byte_count: int) -> Any:
|
|
834
|
+
from .config import ReadConfig
|
|
835
|
+
|
|
836
|
+
return ReadConfig(
|
|
837
|
+
phred_offset=phred_offset,
|
|
838
|
+
max_sequence_symbols=max(1, sequence_length),
|
|
839
|
+
max_input_bytes=max(1, byte_count),
|
|
840
|
+
)
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
def _resolve_fastq_query(
|
|
844
|
+
*,
|
|
845
|
+
record_id: str,
|
|
846
|
+
sequence_length: int,
|
|
847
|
+
start: int | None,
|
|
848
|
+
end: int | None,
|
|
849
|
+
strand: str,
|
|
850
|
+
) -> tuple[int, int]:
|
|
851
|
+
resolved_start = 0 if start is None else start
|
|
852
|
+
resolved_end = sequence_length if end is None else end
|
|
853
|
+
for name, value in (("start", resolved_start), ("end", resolved_end)):
|
|
854
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
855
|
+
raise TypeError(f"{name} must be an integer or None.")
|
|
856
|
+
if resolved_start < 0 or resolved_end < resolved_start or resolved_end > sequence_length:
|
|
857
|
+
raise InputFormatError(
|
|
858
|
+
"Indexed FASTQ query coordinates are outside the record.",
|
|
859
|
+
code="FASTQ_QUERY_OUT_OF_BOUNDS",
|
|
860
|
+
context={
|
|
861
|
+
"record_id": record_id,
|
|
862
|
+
"start": resolved_start,
|
|
863
|
+
"end": resolved_end,
|
|
864
|
+
"sequence_length": sequence_length,
|
|
865
|
+
},
|
|
866
|
+
)
|
|
867
|
+
if strand not in {"+", "-", "forward", "reverse"}:
|
|
868
|
+
raise ConfigurationError(
|
|
869
|
+
"strand must be '+', '-', 'forward', or 'reverse'.",
|
|
870
|
+
code="INVALID_FASTQ_QUERY_STRAND",
|
|
871
|
+
)
|
|
872
|
+
return resolved_start, resolved_end
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
def build_fastq_index(
|
|
876
|
+
source_path: str | os.PathLike[str],
|
|
877
|
+
index_path: str | os.PathLike[str] | None = None,
|
|
878
|
+
*,
|
|
879
|
+
overwrite: bool = False,
|
|
880
|
+
phred_offset: int = 33,
|
|
881
|
+
max_records: int = 10_000_000,
|
|
882
|
+
max_line_length: int = 10_000_000,
|
|
883
|
+
max_record_bytes: int = 100_000_000,
|
|
884
|
+
max_source_bytes: int = 1_000_000_000,
|
|
885
|
+
) -> FastqIndex:
|
|
886
|
+
"""Scan and persist an ID index for strict uncompressed four-line FASTQ."""
|
|
887
|
+
|
|
888
|
+
for name, value in (
|
|
889
|
+
("max_records", max_records),
|
|
890
|
+
("max_line_length", max_line_length),
|
|
891
|
+
("max_record_bytes", max_record_bytes),
|
|
892
|
+
("max_source_bytes", max_source_bytes),
|
|
893
|
+
):
|
|
894
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
|
|
895
|
+
raise ConfigurationError(f"{name} must be positive.", code="INVALID_FASTQ_INDEX_LIMIT")
|
|
896
|
+
if (
|
|
897
|
+
isinstance(phred_offset, bool)
|
|
898
|
+
or not isinstance(phred_offset, int)
|
|
899
|
+
or not 33 <= phred_offset <= 64
|
|
900
|
+
):
|
|
901
|
+
raise ConfigurationError(
|
|
902
|
+
"phred_offset must be between 33 and 64.", code="INVALID_FASTQ_INDEX_LIMIT"
|
|
903
|
+
)
|
|
904
|
+
source = Path(source_path).resolve()
|
|
905
|
+
if not source.is_file():
|
|
906
|
+
raise InputFormatError(
|
|
907
|
+
"FASTQ index source must be an existing file.",
|
|
908
|
+
code="INPUT_OPEN_FAILED",
|
|
909
|
+
context={"source": str(source)},
|
|
910
|
+
)
|
|
911
|
+
initial_stat = source.stat()
|
|
912
|
+
if initial_stat.st_size > max_source_bytes:
|
|
913
|
+
raise InputFormatError(
|
|
914
|
+
"FASTQ source exceeds max_source_bytes.",
|
|
915
|
+
code="FASTQ_SOURCE_LIMIT_EXCEEDED",
|
|
916
|
+
context={"max_source_bytes": max_source_bytes},
|
|
917
|
+
)
|
|
918
|
+
entries: list[FastqIndexEntry] = []
|
|
919
|
+
seen: set[str] = set()
|
|
920
|
+
with source.open("rb") as handle:
|
|
921
|
+
if handle.read(2) == b"\x1f\x8b":
|
|
922
|
+
raise InputFormatError(
|
|
923
|
+
"Persistent FASTQ indexing supports only uncompressed files.",
|
|
924
|
+
code="COMPRESSED_FASTQ_INDEX_UNSUPPORTED",
|
|
925
|
+
)
|
|
926
|
+
handle.seek(0)
|
|
927
|
+
while True:
|
|
928
|
+
start = handle.tell()
|
|
929
|
+
header = handle.readline()
|
|
930
|
+
if not header:
|
|
931
|
+
break
|
|
932
|
+
if len(entries) >= max_records:
|
|
933
|
+
raise InputFormatError(
|
|
934
|
+
"FASTQ input exceeds max_records.",
|
|
935
|
+
code="FASTQ_INDEX_RECORD_LIMIT_EXCEEDED",
|
|
936
|
+
context={"max_records": max_records},
|
|
937
|
+
)
|
|
938
|
+
lines = (header, handle.readline(), handle.readline(), handle.readline())
|
|
939
|
+
if any(not line for line in lines):
|
|
940
|
+
raise InputFormatError(
|
|
941
|
+
"FASTQ record ended before all four lines were present.",
|
|
942
|
+
code="TRUNCATED_FASTQ_RECORD",
|
|
943
|
+
context={"byte_start": start},
|
|
944
|
+
)
|
|
945
|
+
for offset, line in enumerate(lines):
|
|
946
|
+
if len(line.rstrip(b"\r\n")) > max_line_length:
|
|
947
|
+
raise InputFormatError(
|
|
948
|
+
"FASTQ line exceeds max_line_length.",
|
|
949
|
+
code="FASTQ_LINE_TOO_LONG",
|
|
950
|
+
context={"byte_start": start, "record_line": offset + 1},
|
|
951
|
+
)
|
|
952
|
+
end = handle.tell()
|
|
953
|
+
if end - start > max_record_bytes:
|
|
954
|
+
raise InputFormatError(
|
|
955
|
+
"FASTQ record exceeds max_record_bytes.",
|
|
956
|
+
code="FASTQ_RECORD_LIMIT_EXCEEDED",
|
|
957
|
+
context={"byte_start": start, "max_record_bytes": max_record_bytes},
|
|
958
|
+
)
|
|
959
|
+
payload = b"".join(lines)
|
|
960
|
+
try:
|
|
961
|
+
text = payload.decode("utf-8")
|
|
962
|
+
except UnicodeDecodeError as exc:
|
|
963
|
+
raise InputFormatError(
|
|
964
|
+
"FASTQ index source is not UTF-8.", code="INPUT_DECODE_FAILED"
|
|
965
|
+
) from exc
|
|
966
|
+
record = read_one(
|
|
967
|
+
io.StringIO(text),
|
|
968
|
+
format="fastq",
|
|
969
|
+
config=_fastq_read_config(phred_offset, max_line_length, len(payload)),
|
|
970
|
+
)
|
|
971
|
+
if record.id in seen:
|
|
972
|
+
raise DuplicateIDError(
|
|
973
|
+
"FASTQ indexing requires unique record IDs.",
|
|
974
|
+
context={"record_id": record.id},
|
|
975
|
+
)
|
|
976
|
+
seen.add(record.id)
|
|
977
|
+
entries.append(FastqIndexEntry(record.id, start, end, record.sequence.symbol_length))
|
|
978
|
+
digest = _digest_path(source)
|
|
979
|
+
final_stat = source.stat()
|
|
980
|
+
if (
|
|
981
|
+
initial_stat.st_size != final_stat.st_size
|
|
982
|
+
or initial_stat.st_mtime_ns != final_stat.st_mtime_ns
|
|
983
|
+
):
|
|
984
|
+
raise InputFormatError(
|
|
985
|
+
"FASTQ source changed while its index was being built.",
|
|
986
|
+
code="FASTQ_CHANGED_DURING_INDEX",
|
|
987
|
+
context={"source": str(source)},
|
|
988
|
+
)
|
|
989
|
+
index = FastqIndex(
|
|
990
|
+
str(source),
|
|
991
|
+
digest,
|
|
992
|
+
final_stat.st_size,
|
|
993
|
+
final_stat.st_mtime_ns,
|
|
994
|
+
tuple(entries),
|
|
995
|
+
phred_offset,
|
|
996
|
+
)
|
|
997
|
+
destination = (
|
|
998
|
+
Path(index_path) if index_path is not None else Path(str(source) + ".dnakit.fqi.json")
|
|
999
|
+
)
|
|
1000
|
+
|
|
1001
|
+
def writer(handle: Any) -> int:
|
|
1002
|
+
json.dump(
|
|
1003
|
+
index.to_dict(), handle, ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
|
1004
|
+
)
|
|
1005
|
+
handle.write("\n")
|
|
1006
|
+
return len(entries)
|
|
1007
|
+
|
|
1008
|
+
write_text_path(destination, writer, overwrite=overwrite, create_parents=False)
|
|
1009
|
+
return index
|
|
1010
|
+
|
|
1011
|
+
|
|
1012
|
+
def load_fastq_index(
|
|
1013
|
+
index_path: str | os.PathLike[str],
|
|
1014
|
+
*,
|
|
1015
|
+
source_path: str | os.PathLike[str] | None = None,
|
|
1016
|
+
verify_checksum: bool = True,
|
|
1017
|
+
max_index_bytes: int = 64 * 1024 * 1024,
|
|
1018
|
+
max_entries: int = 10_000_000,
|
|
1019
|
+
) -> FastqIndex:
|
|
1020
|
+
"""Load and checksum-verify a persistent DNAKit FASTQ index."""
|
|
1021
|
+
|
|
1022
|
+
if not isinstance(verify_checksum, bool):
|
|
1023
|
+
raise TypeError("verify_checksum must be a boolean.")
|
|
1024
|
+
for name, value in (("max_index_bytes", max_index_bytes), ("max_entries", max_entries)):
|
|
1025
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
|
|
1026
|
+
raise ConfigurationError(f"{name} must be positive.", code="INVALID_FASTQ_INDEX_LIMIT")
|
|
1027
|
+
path = Path(index_path)
|
|
1028
|
+
try:
|
|
1029
|
+
payload = json.loads(
|
|
1030
|
+
_read_bounded_index_text(
|
|
1031
|
+
path,
|
|
1032
|
+
max_index_bytes=max_index_bytes,
|
|
1033
|
+
limit_error_code="FASTQ_INDEX_FILE_LIMIT_EXCEEDED",
|
|
1034
|
+
),
|
|
1035
|
+
object_pairs_hook=_json_object_without_duplicates,
|
|
1036
|
+
)
|
|
1037
|
+
except InputFormatError:
|
|
1038
|
+
raise
|
|
1039
|
+
except (OSError, UnicodeError, json.JSONDecodeError, RecursionError, ValueError) as exc:
|
|
1040
|
+
raise InputFormatError(
|
|
1041
|
+
"Could not decode FASTQ index JSON.",
|
|
1042
|
+
code="INVALID_FASTQ_INDEX",
|
|
1043
|
+
context={"source": str(path)},
|
|
1044
|
+
) from exc
|
|
1045
|
+
required = {
|
|
1046
|
+
"schema_version",
|
|
1047
|
+
"source_path",
|
|
1048
|
+
"source_sha256",
|
|
1049
|
+
"source_size",
|
|
1050
|
+
"source_mtime_ns",
|
|
1051
|
+
"phred_offset",
|
|
1052
|
+
"entries",
|
|
1053
|
+
}
|
|
1054
|
+
if (
|
|
1055
|
+
not isinstance(payload, Mapping)
|
|
1056
|
+
or set(payload) != required
|
|
1057
|
+
or not isinstance(payload.get("entries"), list)
|
|
1058
|
+
):
|
|
1059
|
+
raise InputFormatError("FASTQ index root/schema is invalid.", code="INVALID_FASTQ_INDEX")
|
|
1060
|
+
raw_entries = cast(list[object], payload["entries"])
|
|
1061
|
+
if len(raw_entries) > max_entries:
|
|
1062
|
+
raise InputFormatError(
|
|
1063
|
+
"FASTQ index exceeds max_entries.",
|
|
1064
|
+
code="FASTQ_INDEX_ENTRY_LIMIT_EXCEEDED",
|
|
1065
|
+
context={"max_entries": max_entries},
|
|
1066
|
+
)
|
|
1067
|
+
try:
|
|
1068
|
+
entries: list[FastqIndexEntry] = []
|
|
1069
|
+
for item in raw_entries:
|
|
1070
|
+
if not isinstance(item, Mapping) or set(item) != {
|
|
1071
|
+
"record_id",
|
|
1072
|
+
"byte_start",
|
|
1073
|
+
"byte_end",
|
|
1074
|
+
"sequence_length",
|
|
1075
|
+
}:
|
|
1076
|
+
raise ValueError("invalid FASTQ index entry schema")
|
|
1077
|
+
entries.append(
|
|
1078
|
+
FastqIndexEntry(
|
|
1079
|
+
_json_string(item["record_id"], field="record_id"),
|
|
1080
|
+
_json_non_negative_integer(item["byte_start"], field="byte_start"),
|
|
1081
|
+
_json_non_negative_integer(item["byte_end"], field="byte_end"),
|
|
1082
|
+
_json_non_negative_integer(item["sequence_length"], field="sequence_length"),
|
|
1083
|
+
)
|
|
1084
|
+
)
|
|
1085
|
+
stored_source = _json_string(payload["source_path"], field="source_path")
|
|
1086
|
+
resolved_source = (
|
|
1087
|
+
str(Path(source_path).resolve()) if source_path is not None else stored_source
|
|
1088
|
+
)
|
|
1089
|
+
index = FastqIndex(
|
|
1090
|
+
resolved_source,
|
|
1091
|
+
_json_string(payload["source_sha256"], field="source_sha256"),
|
|
1092
|
+
_json_non_negative_integer(payload["source_size"], field="source_size"),
|
|
1093
|
+
_json_non_negative_integer(payload["source_mtime_ns"], field="source_mtime_ns"),
|
|
1094
|
+
tuple(entries),
|
|
1095
|
+
_json_non_negative_integer(payload["phred_offset"], field="phred_offset"),
|
|
1096
|
+
_json_string(payload["schema_version"], field="schema_version"),
|
|
1097
|
+
)
|
|
1098
|
+
except (KeyError, TypeError, ValueError, ConfigurationError, DuplicateIDError) as exc:
|
|
1099
|
+
raise InputFormatError(
|
|
1100
|
+
"FASTQ index fields are invalid.", code="INVALID_FASTQ_INDEX"
|
|
1101
|
+
) from exc
|
|
1102
|
+
source = Path(index.source_path)
|
|
1103
|
+
try:
|
|
1104
|
+
stat = source.stat()
|
|
1105
|
+
if stat.st_size != index.source_size:
|
|
1106
|
+
raise InputFormatError(
|
|
1107
|
+
"FASTQ source size no longer matches its index.", code="STALE_FASTQ_INDEX"
|
|
1108
|
+
)
|
|
1109
|
+
if stat.st_mtime_ns != index.source_mtime_ns:
|
|
1110
|
+
raise InputFormatError(
|
|
1111
|
+
"FASTQ source mtime no longer matches its index.", code="STALE_FASTQ_INDEX"
|
|
1112
|
+
)
|
|
1113
|
+
if verify_checksum:
|
|
1114
|
+
if _digest_path(source) != index.source_sha256:
|
|
1115
|
+
raise InputFormatError(
|
|
1116
|
+
"FASTQ source checksum no longer matches its index.",
|
|
1117
|
+
code="STALE_FASTQ_INDEX",
|
|
1118
|
+
)
|
|
1119
|
+
final_stat = source.stat()
|
|
1120
|
+
if final_stat.st_size != stat.st_size or final_stat.st_mtime_ns != stat.st_mtime_ns:
|
|
1121
|
+
raise InputFormatError(
|
|
1122
|
+
"FASTQ source changed during checksum verification.",
|
|
1123
|
+
code="STALE_FASTQ_INDEX",
|
|
1124
|
+
)
|
|
1125
|
+
except InputFormatError:
|
|
1126
|
+
raise
|
|
1127
|
+
except OSError as exc:
|
|
1128
|
+
raise InputFormatError(
|
|
1129
|
+
"Indexed FASTQ source is unavailable.",
|
|
1130
|
+
code="INPUT_OPEN_FAILED",
|
|
1131
|
+
context={"source": str(source)},
|
|
1132
|
+
) from exc
|
|
1133
|
+
return index
|
|
1134
|
+
|
|
1135
|
+
|
|
1136
|
+
__all__ = [
|
|
1137
|
+
"FastaIndex",
|
|
1138
|
+
"FastaIndexEntry",
|
|
1139
|
+
"FastqIndex",
|
|
1140
|
+
"FastqIndexEntry",
|
|
1141
|
+
"build_fasta_index",
|
|
1142
|
+
"build_fastq_index",
|
|
1143
|
+
"iter_chunks",
|
|
1144
|
+
"load_fasta_index",
|
|
1145
|
+
"load_fastq_index",
|
|
1146
|
+
]
|