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.
Files changed (210) hide show
  1. dnakit/__init__.py +108 -0
  2. dnakit/_version.py +5 -0
  3. dnakit/alignment/__init__.py +6 -0
  4. dnakit/alignment/pairwise.py +512 -0
  5. dnakit/alignment/results.py +188 -0
  6. dnakit/backends/__init__.py +80 -0
  7. dnakit/backends/external.py +517 -0
  8. dnakit/backends/registry.py +207 -0
  9. dnakit/batch.py +452 -0
  10. dnakit/cache/__init__.py +5 -0
  11. dnakit/cache/store.py +424 -0
  12. dnakit/chunking.py +920 -0
  13. dnakit/cli/__init__.py +5 -0
  14. dnakit/cli/__main__.py +6 -0
  15. dnakit/cli/app.py +814 -0
  16. dnakit/cli/workflow.py +94 -0
  17. dnakit/config/__init__.py +5 -0
  18. dnakit/config/loader.py +381 -0
  19. dnakit/core/__init__.py +80 -0
  20. dnakit/core/_json.py +206 -0
  21. dnakit/core/backend_info.py +70 -0
  22. dnakit/core/collection.py +163 -0
  23. dnakit/core/coordinates.py +295 -0
  24. dnakit/core/enums.py +95 -0
  25. dnakit/core/facade.py +719 -0
  26. dnakit/core/feature.py +90 -0
  27. dnakit/core/gap.py +82 -0
  28. dnakit/core/issues.py +62 -0
  29. dnakit/core/provenance.py +305 -0
  30. dnakit/core/record.py +161 -0
  31. dnakit/core/results.py +134 -0
  32. dnakit/core/sequence.py +327 -0
  33. dnakit/datasets/__init__.py +100 -0
  34. dnakit/datasets/_advanced_shared.py +198 -0
  35. dnakit/datasets/_metadata.py +33 -0
  36. dnakit/datasets/advanced_deduplicate.py +183 -0
  37. dnakit/datasets/advanced_split.py +302 -0
  38. dnakit/datasets/clustering.py +286 -0
  39. dnakit/datasets/config.py +531 -0
  40. dnakit/datasets/deduplicate.py +303 -0
  41. dnakit/datasets/evaluation.py +244 -0
  42. dnakit/datasets/filter.py +135 -0
  43. dnakit/datasets/neural_clustering.py +221 -0
  44. dnakit/datasets/results.py +520 -0
  45. dnakit/datasets/split.py +450 -0
  46. dnakit/descriptors/__init__.py +102 -0
  47. dnakit/descriptors/_dinucleotide.py +345 -0
  48. dnakit/descriptors/_shared.py +180 -0
  49. dnakit/descriptors/all.py +917 -0
  50. dnakit/descriptors/basic.py +193 -0
  51. dnakit/descriptors/codon.py +195 -0
  52. dnakit/descriptors/complexity.py +201 -0
  53. dnakit/descriptors/entropy.py +101 -0
  54. dnakit/descriptors/homopolymer.py +105 -0
  55. dnakit/descriptors/kmer.py +88 -0
  56. dnakit/descriptors/results.py +286 -0
  57. dnakit/descriptors/schema.py +414 -0
  58. dnakit/descriptors/window.py +203 -0
  59. dnakit/download/__init__.py +54 -0
  60. dnakit/download/catalogs.py +166 -0
  61. dnakit/download/ena.py +131 -0
  62. dnakit/download/encode.py +103 -0
  63. dnakit/download/files.py +469 -0
  64. dnakit/download/indexes.py +233 -0
  65. dnakit/download/metadata.py +164 -0
  66. dnakit/download/models.py +227 -0
  67. dnakit/download/ncbi.py +333 -0
  68. dnakit/download/sequence.py +133 -0
  69. dnakit/download/ucsc.py +99 -0
  70. dnakit/evaluation/__init__.py +81 -0
  71. dnakit/evaluation/_shared.py +288 -0
  72. dnakit/evaluation/collection.py +270 -0
  73. dnakit/evaluation/config.py +591 -0
  74. dnakit/evaluation/distribution.py +287 -0
  75. dnakit/evaluation/frechet.py +223 -0
  76. dnakit/evaluation/generative.py +381 -0
  77. dnakit/evaluation/reference.py +409 -0
  78. dnakit/evaluation/results.py +137 -0
  79. dnakit/evaluation/scorecard.py +211 -0
  80. dnakit/evaluation/sequence.py +403 -0
  81. dnakit/evaluation/synthesis.py +249 -0
  82. dnakit/exceptions.py +189 -0
  83. dnakit/fingerprints/__init__.py +109 -0
  84. dnakit/fingerprints/_shared.py +116 -0
  85. dnakit/fingerprints/advanced.py +829 -0
  86. dnakit/fingerprints/bit.py +229 -0
  87. dnakit/fingerprints/encoding.py +351 -0
  88. dnakit/fingerprints/kmer.py +170 -0
  89. dnakit/fingerprints/results.py +531 -0
  90. dnakit/fingerprints/sketch.py +246 -0
  91. dnakit/io/__init__.py +112 -0
  92. dnakit/io/_advanced_common.py +105 -0
  93. dnakit/io/_formats.py +994 -0
  94. dnakit/io/annotations.py +1179 -0
  95. dnakit/io/api.py +759 -0
  96. dnakit/io/config.py +315 -0
  97. dnakit/io/genbank.py +523 -0
  98. dnakit/io/indexed.py +1146 -0
  99. dnakit/io/metadata.py +452 -0
  100. dnakit/io/parquet.py +56 -0
  101. dnakit/io/results.py +131 -0
  102. dnakit/io/source.py +138 -0
  103. dnakit/io/tables.py +1237 -0
  104. dnakit/molbio/__init__.py +101 -0
  105. dnakit/molbio/_shared.py +266 -0
  106. dnakit/molbio/assembly.py +279 -0
  107. dnakit/molbio/crispr.py +181 -0
  108. dnakit/molbio/optimization.py +897 -0
  109. dnakit/molbio/primer3_design.py +667 -0
  110. dnakit/molbio/primers.py +899 -0
  111. dnakit/molbio/restriction.py +573 -0
  112. dnakit/molbio/results.py +583 -0
  113. dnakit/ops/__init__.py +588 -0
  114. dnakit/ops/_common.py +201 -0
  115. dnakit/ops/circular.py +193 -0
  116. dnakit/ops/concat.py +204 -0
  117. dnakit/ops/direction.py +27 -0
  118. dnakit/ops/edit.py +443 -0
  119. dnakit/ops/evolution.py +991 -0
  120. dnakit/ops/mutation.py +151 -0
  121. dnakit/ops/records.py +1012 -0
  122. dnakit/ops/translation.py +257 -0
  123. dnakit/patterns/__init__.py +81 -0
  124. dnakit/patterns/_shared.py +282 -0
  125. dnakit/patterns/coding.py +360 -0
  126. dnakit/patterns/crispr.py +337 -0
  127. dnakit/patterns/motif.py +721 -0
  128. dnakit/patterns/regions.py +341 -0
  129. dnakit/patterns/repeats.py +480 -0
  130. dnakit/patterns/restriction.py +281 -0
  131. dnakit/patterns/results.py +411 -0
  132. dnakit/py.typed +1 -0
  133. dnakit/references/__init__.py +27 -0
  134. dnakit/references/models.py +155 -0
  135. dnakit/references/ncbi.py +785 -0
  136. dnakit/representations/__init__.py +29 -0
  137. dnakit/representations/backends.py +928 -0
  138. dnakit/representations/checkpoints.py +356 -0
  139. dnakit/representations/extraction.py +220 -0
  140. dnakit/representations/models.py +395 -0
  141. dnakit/representations/results.py +79 -0
  142. dnakit/search/__init__.py +132 -0
  143. dnakit/search/_http.py +271 -0
  144. dnakit/search/_shared.py +108 -0
  145. dnakit/search/blast.py +516 -0
  146. dnakit/search/ena.py +245 -0
  147. dnakit/search/encode.py +102 -0
  148. dnakit/search/ensembl.py +874 -0
  149. dnakit/search/models.py +159 -0
  150. dnakit/search/ncbi.py +756 -0
  151. dnakit/search/ucsc.py +388 -0
  152. dnakit/secondary_structure/__init__.py +38 -0
  153. dnakit/secondary_structure/dotbracket.py +410 -0
  154. dnakit/secondary_structure/nupack.py +589 -0
  155. dnakit/secondary_structure/results.py +142 -0
  156. dnakit/similarity/__init__.py +125 -0
  157. dnakit/similarity/_shared.py +152 -0
  158. dnakit/similarity/approximate.py +258 -0
  159. dnakit/similarity/compare.py +162 -0
  160. dnakit/similarity/dashing.py +936 -0
  161. dnakit/similarity/distance.py +279 -0
  162. dnakit/similarity/index.py +365 -0
  163. dnakit/similarity/matrix.py +254 -0
  164. dnakit/similarity/results.py +676 -0
  165. dnakit/similarity/search.py +286 -0
  166. dnakit/similarity/sketch.py +96 -0
  167. dnakit/similarity/vector.py +662 -0
  168. dnakit/standardize/__init__.py +77 -0
  169. dnakit/standardize/_shared.py +100 -0
  170. dnakit/standardize/config.py +411 -0
  171. dnakit/standardize/gaps.py +1060 -0
  172. dnakit/standardize/normalize.py +565 -0
  173. dnakit/standardize/results.py +247 -0
  174. dnakit/standardize/validate.py +487 -0
  175. dnakit/structure3d/__init__.py +47 -0
  176. dnakit/structure3d/_shared.py +47 -0
  177. dnakit/structure3d/geometry.py +773 -0
  178. dnakit/structure3d/pdb.py +253 -0
  179. dnakit/structure3d/results.py +233 -0
  180. dnakit/structure3d/threedna.py +174 -0
  181. dnakit/thermodynamics/__init__.py +112 -0
  182. dnakit/thermodynamics/_shared.py +92 -0
  183. dnakit/thermodynamics/backends.py +653 -0
  184. dnakit/thermodynamics/calculations.py +837 -0
  185. dnakit/thermodynamics/config.py +175 -0
  186. dnakit/thermodynamics/equilibrium.py +340 -0
  187. dnakit/thermodynamics/optics.py +385 -0
  188. dnakit/thermodynamics/parameters.py +79 -0
  189. dnakit/thermodynamics/results.py +445 -0
  190. dnakit/visualization/__init__.py +43 -0
  191. dnakit/visualization/_svg.py +185 -0
  192. dnakit/visualization/advanced.py +314 -0
  193. dnakit/visualization/config.py +255 -0
  194. dnakit/visualization/export.py +349 -0
  195. dnakit/visualization/matrix.py +279 -0
  196. dnakit/visualization/report.py +431 -0
  197. dnakit/visualization/results.py +236 -0
  198. dnakit/visualization/sequence.py +459 -0
  199. dnakit/workflows/__init__.py +45 -0
  200. dnakit/workflows/manifest.py +365 -0
  201. dnakit/workflows/runner.py +1548 -0
  202. dnakit/workflows/schema.py +898 -0
  203. dnakit-0.1.0.dev0.dist-info/METADATA +126 -0
  204. dnakit-0.1.0.dev0.dist-info/RECORD +210 -0
  205. dnakit-0.1.0.dev0.dist-info/WHEEL +5 -0
  206. dnakit-0.1.0.dev0.dist-info/entry_points.txt +2 -0
  207. dnakit-0.1.0.dev0.dist-info/licenses/DISCLAIMER.md +26 -0
  208. dnakit-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
  209. dnakit-0.1.0.dev0.dist-info/licenses/THIRD_PARTY_NOTICES.md +73 -0
  210. 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
+ ]