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/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
|
+
]
|