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/ops/edit.py ADDED
@@ -0,0 +1,443 @@
1
+ """Coordinate-safe edits on immutable DNA sequences."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+ from dataclasses import dataclass
7
+ from itertools import pairwise
8
+ from typing import Literal, TypeAlias
9
+
10
+ from dnakit.core import DNASequence, Gap, Interval, Topology
11
+ from dnakit.exceptions import ConfigurationError, CoordinateError, UnsupportedGapOperationError
12
+ from dnakit.ops._common import (
13
+ coerce_fragment,
14
+ combine_parts,
15
+ copy_gap,
16
+ infer_alphabet,
17
+ position_inside_gap,
18
+ promote_alphabet,
19
+ reject_gap_overlap,
20
+ require_sequence,
21
+ resolved_span,
22
+ validate_interval,
23
+ validate_position,
24
+ )
25
+
26
+ EditKind: TypeAlias = Literal["insert", "delete", "substitute", "mask"]
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class Edit:
31
+ """One applied edit in internal zero-based, half-open coordinates."""
32
+
33
+ kind: EditKind
34
+ start: int
35
+ end: int
36
+ replacement_parts: tuple[str | Gap, ...] = ()
37
+ removed_parts: tuple[str | Gap, ...] = ()
38
+
39
+ @property
40
+ def replacement_symbols(self) -> str:
41
+ """Replacement nucleotide symbols; inspect ``replacement_parts`` for gaps."""
42
+
43
+ return "".join(part for part in self.replacement_parts if isinstance(part, str))
44
+
45
+ @property
46
+ def removed_symbols(self) -> str:
47
+ """Removed nucleotide symbols; inspect ``removed_parts`` for gaps."""
48
+
49
+ return "".join(part for part in self.removed_parts if isinstance(part, str))
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class EditResult:
54
+ """A new sequence and ordered atomic edits in original coordinates."""
55
+
56
+ sequence: DNASequence
57
+ edits: tuple[Edit, ...]
58
+
59
+
60
+ def _split_at(sequence: DNASequence, position: int) -> tuple[list[str | Gap], list[str | Gap]]:
61
+ """Split resolved parts at a nucleotide or gap boundary."""
62
+
63
+ left: list[str | Gap] = []
64
+ right: list[str | Gap] = []
65
+ cursor = 0
66
+ on_right = False
67
+ for part in sequence.parts:
68
+ part_length = len(part) if isinstance(part, str) else part.length
69
+ assert part_length is not None
70
+ part_end = cursor + part_length
71
+ if on_right:
72
+ right.append(part)
73
+ elif position == cursor:
74
+ right.append(part)
75
+ on_right = True
76
+ elif position == part_end:
77
+ left.append(part)
78
+ elif cursor < position < part_end:
79
+ offset = position - cursor
80
+ if isinstance(part, Gap):
81
+ raise UnsupportedGapOperationError(
82
+ "A sequence cannot be split inside a Gap.",
83
+ code="EDIT_INSIDE_GAP",
84
+ context={"position": position},
85
+ )
86
+ left.append(part[:offset])
87
+ right.append(part[offset:])
88
+ on_right = True
89
+ else:
90
+ left.append(part)
91
+ cursor = part_end
92
+ return left, right
93
+
94
+
95
+ def _remove_interval(
96
+ sequence: DNASequence,
97
+ start: int,
98
+ end: int,
99
+ ) -> tuple[list[str | Gap], tuple[str | Gap, ...]]:
100
+ """Remove a gap-free coordinate interval and return retained/removed parts."""
101
+
102
+ left, after_start = _split_at(sequence, start)
103
+ tail_sequence = DNASequence(
104
+ after_start,
105
+ alphabet=sequence.alphabet,
106
+ topology=Topology.LINEAR,
107
+ strandedness=sequence.strandedness,
108
+ )
109
+ removed_length = end - start
110
+ removed_parts, right = _split_at(tail_sequence, removed_length)
111
+ return [*left, *right], tuple(removed_parts)
112
+
113
+
114
+ def _require_ungapped_fragment(fragment: DNASequence, *, operation: str) -> DNASequence:
115
+ if fragment.is_gapped:
116
+ raise UnsupportedGapOperationError(
117
+ f"{operation} does not accept a gapped replacement fragment.",
118
+ code="GAPPED_EDIT_FRAGMENT",
119
+ context={"operation": operation},
120
+ hint="Use concat(..., gap=...) to assemble fragments around an explicit Gap.",
121
+ )
122
+ return fragment
123
+
124
+
125
+ def insert(
126
+ sequence: DNASequence,
127
+ position: int,
128
+ fragment: DNASequence | str,
129
+ ) -> EditResult:
130
+ """Insert a normalized fragment at a zero-based coordinate boundary."""
131
+
132
+ source = require_sequence(sequence)
133
+ span = resolved_span(source, operation="insert")
134
+ validate_position(position, span=span)
135
+ if position_inside_gap(source, position):
136
+ raise UnsupportedGapOperationError(
137
+ "Insertion inside a Gap is not supported.",
138
+ code="EDIT_INSIDE_GAP",
139
+ context={"position": position},
140
+ )
141
+ replacement = _require_ungapped_fragment(
142
+ coerce_fragment(
143
+ fragment,
144
+ fallback_alphabet=source.alphabet,
145
+ strandedness=source.strandedness,
146
+ ),
147
+ operation="insert",
148
+ )
149
+ left, right = _split_at(source, position)
150
+ alphabet = promote_alphabet(source.alphabet, replacement.alphabet)
151
+ result = combine_parts(
152
+ (*left, *replacement.parts, *right),
153
+ alphabet=alphabet,
154
+ topology=source.topology,
155
+ strandedness=source.strandedness,
156
+ )
157
+ return EditResult(
158
+ result,
159
+ (Edit("insert", position, position, replacement.parts),),
160
+ )
161
+
162
+
163
+ def delete(sequence: DNASequence, start: int, end: int) -> EditResult:
164
+ """Delete a gap-free zero-based, half-open interval."""
165
+
166
+ source = require_sequence(sequence)
167
+ span = resolved_span(source, operation="delete")
168
+ interval = validate_interval(start, end, span=span)
169
+ reject_gap_overlap(source, interval, operation="delete")
170
+ new_parts, removed_parts = _remove_interval(source, start, end)
171
+ result = combine_parts(
172
+ new_parts,
173
+ alphabet=source.alphabet,
174
+ topology=source.topology,
175
+ strandedness=source.strandedness,
176
+ )
177
+ return EditResult(result, (Edit("delete", start, end, removed_parts=removed_parts),))
178
+
179
+
180
+ def substitute(
181
+ sequence: DNASequence,
182
+ start: int,
183
+ end: int,
184
+ fragment: DNASequence | str,
185
+ ) -> EditResult:
186
+ """Replace a gap-free zero-based, half-open interval with a fragment."""
187
+
188
+ source = require_sequence(sequence)
189
+ span = resolved_span(source, operation="substitute")
190
+ interval = validate_interval(start, end, span=span)
191
+ reject_gap_overlap(source, interval, operation="substitute")
192
+ replacement = _require_ungapped_fragment(
193
+ coerce_fragment(
194
+ fragment,
195
+ fallback_alphabet=source.alphabet,
196
+ strandedness=source.strandedness,
197
+ ),
198
+ operation="substitute",
199
+ )
200
+ retained_parts, removed_parts = _remove_interval(source, start, end)
201
+ interim = combine_parts(
202
+ retained_parts,
203
+ alphabet=source.alphabet,
204
+ topology=Topology.LINEAR,
205
+ strandedness=source.strandedness,
206
+ )
207
+ inserted = insert(interim, start, replacement)
208
+ alphabet = promote_alphabet(source.alphabet, replacement.alphabet)
209
+ result = combine_parts(
210
+ inserted.sequence.parts,
211
+ alphabet=alphabet,
212
+ topology=source.topology,
213
+ strandedness=source.strandedness,
214
+ )
215
+ return EditResult(
216
+ result,
217
+ (Edit("substitute", start, end, replacement.parts, removed_parts),),
218
+ )
219
+
220
+
221
+ def trim(sequence: DNASequence, *, left: int = 0, right: int = 0) -> EditResult:
222
+ """Remove exact coordinate lengths from both ends.
223
+
224
+ Known gaps may be shortened by trimming. Unknown gaps are rejected because
225
+ the requested boundaries cannot be resolved.
226
+ """
227
+
228
+ source = require_sequence(sequence)
229
+ if source.topology is Topology.CIRCULAR:
230
+ raise ConfigurationError(
231
+ "Trimming a circular sequence is not supported in the MVP.",
232
+ code="CIRCULAR_TRIM_NOT_SUPPORTED",
233
+ hint="Extract an explicit linear subsequence before trimming.",
234
+ )
235
+ span = resolved_span(source, operation="trim")
236
+ for name, value in (("left", left), ("right", right)):
237
+ if isinstance(value, bool) or not isinstance(value, int) or value < 0:
238
+ raise ConfigurationError(
239
+ f"{name} trim length must be a non-negative integer.",
240
+ code="INVALID_TRIM_LENGTH",
241
+ context={name: value},
242
+ )
243
+ if left + right > span:
244
+ raise ConfigurationError(
245
+ "Combined trim lengths exceed the sequence span.",
246
+ code="TRIM_EXCEEDS_SEQUENCE",
247
+ context={"left": left, "right": right, "sequence_span": span},
248
+ )
249
+ result = subsequence(source, left, span - right, allow_gaps=True)
250
+ edits: list[Edit] = []
251
+ if left:
252
+ edits.append(Edit("delete", 0, left, removed_parts=tuple(_slice_parts(source, 0, left))))
253
+ if right:
254
+ edits.append(
255
+ Edit(
256
+ "delete",
257
+ span - right,
258
+ span,
259
+ removed_parts=tuple(_slice_parts(source, span - right, span)),
260
+ )
261
+ )
262
+ return EditResult(result, tuple(edits))
263
+
264
+
265
+ def _slice_parts(sequence: DNASequence, start: int, end: int) -> list[str | Gap]:
266
+ selected: list[str | Gap] = []
267
+ cursor = 0
268
+ for part in sequence.parts:
269
+ part_length = len(part) if isinstance(part, str) else part.length
270
+ assert part_length is not None
271
+ part_end = cursor + part_length
272
+ overlap_start = max(start, cursor)
273
+ overlap_end = min(end, part_end)
274
+ if overlap_start < overlap_end:
275
+ local_start = overlap_start - cursor
276
+ local_end = overlap_end - cursor
277
+ if isinstance(part, str):
278
+ selected.append(part[local_start:local_end])
279
+ else:
280
+ selected.append(copy_gap(part, length=local_end - local_start))
281
+ cursor = part_end
282
+ return selected
283
+
284
+
285
+ def subsequence(
286
+ sequence: DNASequence,
287
+ start: int,
288
+ end: int,
289
+ *,
290
+ allow_gaps: bool = False,
291
+ ) -> DNASequence:
292
+ """Extract a zero-based, half-open interval, optionally across an origin.
293
+
294
+ On a circular sequence, ``start > end`` means ``[start, span) + [0, end)``.
295
+ ``start == end`` remains an empty interval; request ``0..span`` for a full
296
+ circle. Linear sequences reject reversed bounds. Unknown-length gaps are
297
+ always rejected. Known gaps are included only when ``allow_gaps=True``;
298
+ partial overlap creates a shorter Gap with retained metadata.
299
+ """
300
+
301
+ source = require_sequence(sequence)
302
+ span = resolved_span(source, operation="subsequence")
303
+ validate_position(start, span=span)
304
+ validate_position(end, span=span)
305
+ intervals: tuple[Interval, ...]
306
+ if start <= end:
307
+ intervals = (Interval(start, end),)
308
+ elif source.topology is Topology.CIRCULAR:
309
+ intervals = tuple(
310
+ interval for interval in (Interval(start, span), Interval(0, end)) if len(interval) > 0
311
+ )
312
+ else:
313
+ raise CoordinateError(
314
+ "A linear subsequence cannot have end smaller than start.",
315
+ code="LINEAR_SUBSEQUENCE_REVERSED",
316
+ context={"start": start, "end": end, "sequence_span": span},
317
+ hint="Use a circular DNASequence for origin-spanning extraction.",
318
+ )
319
+ if not allow_gaps:
320
+ for interval in intervals:
321
+ reject_gap_overlap(source, interval, operation="subsequence")
322
+ parts = [
323
+ part
324
+ for interval in intervals
325
+ for part in _slice_parts(source, interval.start, interval.end)
326
+ ]
327
+ return combine_parts(
328
+ parts,
329
+ alphabet=source.alphabet,
330
+ topology=Topology.LINEAR,
331
+ strandedness=source.strandedness,
332
+ )
333
+
334
+
335
+ def circular_subsequence(
336
+ sequence: DNASequence,
337
+ start: int,
338
+ end: int,
339
+ *,
340
+ allow_gaps: bool = False,
341
+ ) -> DNASequence:
342
+ """Extract from a circular sequence, including an optional origin wrap."""
343
+
344
+ source = require_sequence(sequence)
345
+ if source.topology is not Topology.CIRCULAR:
346
+ raise ConfigurationError(
347
+ "circular_subsequence() requires topology='circular'.",
348
+ code="CIRCULAR_TOPOLOGY_REQUIRED",
349
+ )
350
+ return subsequence(source, start, end, allow_gaps=allow_gaps)
351
+
352
+
353
+ def mask(
354
+ sequence: DNASequence,
355
+ intervals: Iterable[tuple[int, int]],
356
+ *,
357
+ symbol: str = "N",
358
+ ) -> EditResult:
359
+ """Replace one or more gap-free intervals with a one-character IUPAC mask."""
360
+
361
+ source = require_sequence(sequence)
362
+ if not isinstance(symbol, str) or len(symbol) != 1 or symbol not in "ACGTRYSWKMBDHVN":
363
+ raise ConfigurationError(
364
+ "Mask symbol must be one uppercase DNA IUPAC character.",
365
+ code="INVALID_MASK_SYMBOL",
366
+ context={"symbol": symbol},
367
+ )
368
+ span = resolved_span(source, operation="mask")
369
+ try:
370
+ raw_intervals = tuple(intervals)
371
+ except TypeError as exc:
372
+ raise ConfigurationError(
373
+ "intervals must be an iterable of (start, end) pairs.",
374
+ code="INVALID_MASK_INTERVALS",
375
+ ) from exc
376
+ resolved_items: list[Interval] = []
377
+ for index, item in enumerate(raw_intervals):
378
+ if not isinstance(item, (tuple, list)) or len(item) != 2:
379
+ raise ConfigurationError(
380
+ "Each mask interval must be a (start, end) pair.",
381
+ code="INVALID_MASK_INTERVAL",
382
+ context={"index": index},
383
+ )
384
+ resolved_items.append(validate_interval(item[0], item[1], span=span))
385
+ resolved = tuple(interval for interval in resolved_items if len(interval) > 0)
386
+ ordered = tuple(sorted(resolved, key=lambda item: (item.start, item.end)))
387
+ if not ordered:
388
+ result = combine_parts(
389
+ source.parts,
390
+ alphabet=source.alphabet,
391
+ topology=source.topology,
392
+ strandedness=source.strandedness,
393
+ )
394
+ return EditResult(result, ())
395
+ if any(current.start < previous.end for previous, current in pairwise(ordered)):
396
+ raise ConfigurationError(
397
+ "Mask intervals must not overlap.",
398
+ code="OVERLAPPING_MASK_INTERVALS",
399
+ )
400
+ for interval in ordered:
401
+ reject_gap_overlap(source, interval, operation="mask")
402
+
403
+ current = source
404
+ edits: list[Edit] = []
405
+ for interval in reversed(ordered):
406
+ substituted = substitute(
407
+ current,
408
+ interval.start,
409
+ interval.end,
410
+ symbol * len(interval),
411
+ )
412
+ atomic = substituted.edits[0]
413
+ edits.append(
414
+ Edit(
415
+ "mask",
416
+ atomic.start,
417
+ atomic.end,
418
+ atomic.replacement_parts,
419
+ atomic.removed_parts,
420
+ )
421
+ )
422
+ current = substituted.sequence
423
+ alphabet = promote_alphabet(source.alphabet, infer_alphabet(symbol, fallback=source.alphabet))
424
+ current = combine_parts(
425
+ current.parts,
426
+ alphabet=alphabet,
427
+ topology=source.topology,
428
+ strandedness=source.strandedness,
429
+ )
430
+ return EditResult(current, tuple(reversed(edits)))
431
+
432
+
433
+ __all__ = [
434
+ "Edit",
435
+ "EditResult",
436
+ "circular_subsequence",
437
+ "delete",
438
+ "insert",
439
+ "mask",
440
+ "subsequence",
441
+ "substitute",
442
+ "trim",
443
+ ]