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/records.py ADDED
@@ -0,0 +1,1012 @@
1
+ """Feature- and letter-annotation-aware operations on immutable DNA records."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable, Mapping
6
+ from dataclasses import dataclass
7
+ from typing import Any, Literal, TypeAlias, cast
8
+
9
+ from dnakit.core import (
10
+ CompoundLocation,
11
+ DNAFeature,
12
+ DNARecord,
13
+ DNASequence,
14
+ Interval,
15
+ Location,
16
+ Strand,
17
+ UnresolvedLocation,
18
+ reverse_strand_location,
19
+ )
20
+ from dnakit.core._json import FrozenDict, to_json_compatible
21
+ from dnakit.exceptions import ConfigurationError, UnknownLengthError
22
+ from dnakit.ops.circular import canonical_origin, rotate
23
+ from dnakit.ops.edit import Edit, delete, insert, mask, substitute, trim
24
+
25
+ FeatureOverlapPolicy: TypeAlias = Literal["preserve", "truncate", "delete", "split", "unresolved"]
26
+ LetterAnnotationPolicy: TypeAlias = Literal["error", "drop"]
27
+ FeatureChangeAction: TypeAlias = Literal[
28
+ "preserved",
29
+ "shifted",
30
+ "resized",
31
+ "truncated",
32
+ "split",
33
+ "unresolved",
34
+ "deleted",
35
+ "reverse_complemented",
36
+ "rotated",
37
+ ]
38
+ LetterAnnotationAction: TypeAlias = Literal[
39
+ "absent",
40
+ "preserved",
41
+ "updated",
42
+ "dropped",
43
+ "trimmed",
44
+ "reversed",
45
+ "rotated",
46
+ ]
47
+ RecordOperation: TypeAlias = Literal[
48
+ "insert",
49
+ "delete",
50
+ "substitute",
51
+ "mask",
52
+ "trim",
53
+ "reverse_complement",
54
+ "rotate",
55
+ "canonical_origin",
56
+ ]
57
+
58
+ _FEATURE_POLICIES = frozenset({"preserve", "truncate", "delete", "split", "unresolved"})
59
+ _ANNOTATION_POLICIES = frozenset({"error", "drop"})
60
+ _FEATURE_ACTIONS = frozenset(
61
+ {
62
+ "preserved",
63
+ "shifted",
64
+ "resized",
65
+ "truncated",
66
+ "split",
67
+ "unresolved",
68
+ "deleted",
69
+ "reverse_complemented",
70
+ "rotated",
71
+ }
72
+ )
73
+ _ANNOTATION_ACTIONS = frozenset(
74
+ {"absent", "preserved", "updated", "dropped", "trimmed", "reversed", "rotated"}
75
+ )
76
+
77
+
78
+ @dataclass(frozen=True, slots=True)
79
+ class FeatureChange:
80
+ """One feature-coordinate decision made by a record operation."""
81
+
82
+ feature_index: int
83
+ feature_id: str | None
84
+ feature_type: str
85
+ action: FeatureChangeAction
86
+ original_location: Location
87
+ new_location: Location | None
88
+ policy: FeatureOverlapPolicy
89
+ affected_edit_indices: tuple[int, ...] = ()
90
+ reason: str | None = None
91
+
92
+ def __post_init__(self) -> None:
93
+ if (
94
+ isinstance(self.feature_index, bool)
95
+ or not isinstance(self.feature_index, int)
96
+ or self.feature_index < 0
97
+ ):
98
+ raise ConfigurationError("FeatureChange feature_index must be non-negative.")
99
+ if not isinstance(self.feature_type, str) or not self.feature_type.strip():
100
+ raise ConfigurationError("FeatureChange feature_type must be non-empty.")
101
+ if self.action not in _FEATURE_ACTIONS:
102
+ raise ConfigurationError("FeatureChange action is invalid.")
103
+ if self.policy not in _FEATURE_POLICIES:
104
+ raise ConfigurationError("FeatureChange policy is invalid.")
105
+ if any(
106
+ isinstance(index, bool) or not isinstance(index, int) or index < 0
107
+ for index in self.affected_edit_indices
108
+ ):
109
+ raise ConfigurationError("FeatureChange edit indices must be non-negative integers.")
110
+ if self.reason is not None and (
111
+ not isinstance(self.reason, str) or not self.reason.strip()
112
+ ):
113
+ raise ConfigurationError("FeatureChange reason must be non-empty or None.")
114
+
115
+ def to_dict(self) -> dict[str, Any]:
116
+ """Return this decision as JSON-compatible primitives."""
117
+
118
+ return cast(dict[str, Any], to_json_compatible(self))
119
+
120
+
121
+ @dataclass(frozen=True, slots=True)
122
+ class RecordOperationResult:
123
+ """A transformed record plus complete coordinate and annotation audit."""
124
+
125
+ record: DNARecord
126
+ operation: RecordOperation
127
+ edits: tuple[Edit, ...]
128
+ feature_policy: FeatureOverlapPolicy
129
+ feature_changes: tuple[FeatureChange, ...]
130
+ letter_annotation_action: LetterAnnotationAction
131
+ parameters: FrozenDict
132
+
133
+ def __post_init__(self) -> None:
134
+ if not isinstance(self.record, DNARecord):
135
+ raise ConfigurationError("RecordOperationResult record must be DNARecord.")
136
+ if self.operation not in {
137
+ "insert",
138
+ "delete",
139
+ "substitute",
140
+ "mask",
141
+ "trim",
142
+ "reverse_complement",
143
+ "rotate",
144
+ "canonical_origin",
145
+ }:
146
+ raise ConfigurationError("RecordOperationResult operation is invalid.")
147
+ if any(not isinstance(edit, Edit) for edit in self.edits):
148
+ raise ConfigurationError("RecordOperationResult edits must contain Edit objects.")
149
+ if self.feature_policy not in _FEATURE_POLICIES:
150
+ raise ConfigurationError("RecordOperationResult feature_policy is invalid.")
151
+ if any(not isinstance(change, FeatureChange) for change in self.feature_changes):
152
+ raise ConfigurationError(
153
+ "RecordOperationResult feature_changes must contain FeatureChange objects."
154
+ )
155
+ if self.letter_annotation_action not in _ANNOTATION_ACTIONS:
156
+ raise ConfigurationError("letter_annotation_action is invalid.")
157
+ if not isinstance(self.parameters, FrozenDict):
158
+ raise ConfigurationError("RecordOperationResult parameters must be FrozenDict.")
159
+
160
+ @property
161
+ def sequence(self) -> DNASequence:
162
+ """Convenience alias for the transformed record sequence."""
163
+
164
+ return self.record.sequence
165
+
166
+ @property
167
+ def deleted_feature_count(self) -> int:
168
+ return sum(change.action == "deleted" for change in self.feature_changes)
169
+
170
+ def to_dict(self) -> dict[str, Any]:
171
+ """Return an audit payload without duplicating the full output sequence."""
172
+
173
+ payload = {
174
+ "record_id": self.record.id,
175
+ "operation": self.operation,
176
+ "edits": self.edits,
177
+ "feature_policy": self.feature_policy,
178
+ "feature_changes": self.feature_changes,
179
+ "deleted_feature_count": self.deleted_feature_count,
180
+ "letter_annotation_action": self.letter_annotation_action,
181
+ "parameters": self.parameters,
182
+ "output_symbol_length": self.record.sequence.symbol_length,
183
+ "output_coordinate_span": self.record.sequence.coordinate_span,
184
+ "output_topology": self.record.sequence.topology,
185
+ }
186
+ return cast(dict[str, Any], to_json_compatible(payload))
187
+
188
+
189
+ def _require_record(record: DNARecord) -> DNARecord:
190
+ if not isinstance(record, DNARecord):
191
+ raise ConfigurationError(
192
+ "A DNARecord object is required.",
193
+ code="INVALID_RECORD_OPERATION_ARGUMENT",
194
+ context={"type": type(record).__name__},
195
+ )
196
+ return record
197
+
198
+
199
+ def _feature_policy(value: FeatureOverlapPolicy) -> FeatureOverlapPolicy:
200
+ if not isinstance(value, str) or value not in _FEATURE_POLICIES:
201
+ raise ConfigurationError(
202
+ "Unknown feature overlap policy.",
203
+ code="INVALID_FEATURE_OVERLAP_POLICY",
204
+ context={"feature_policy": value},
205
+ hint="Choose preserve, truncate, delete, split, or unresolved.",
206
+ )
207
+ return value
208
+
209
+
210
+ def _annotation_policy(value: LetterAnnotationPolicy) -> LetterAnnotationPolicy:
211
+ if not isinstance(value, str) or value not in _ANNOTATION_POLICIES:
212
+ raise ConfigurationError(
213
+ "Unknown letter annotation policy.",
214
+ code="INVALID_LETTER_ANNOTATION_POLICY",
215
+ context={"letter_annotation_policy": value},
216
+ hint="Choose error or drop.",
217
+ )
218
+ return value
219
+
220
+
221
+ def _copy_feature(feature: DNAFeature, location: Location) -> DNAFeature:
222
+ return DNAFeature(
223
+ feature.type,
224
+ location,
225
+ id=feature.id,
226
+ strand=feature.strand,
227
+ label=feature.label,
228
+ score=feature.score,
229
+ phase=feature.phase,
230
+ qualifiers=feature.qualifiers,
231
+ source=feature.source,
232
+ )
233
+
234
+
235
+ def _location_parts(location: Location) -> tuple[Interval, ...]:
236
+ if isinstance(location, Interval):
237
+ return (location,)
238
+ if isinstance(location, CompoundLocation):
239
+ return location.parts
240
+ return location.anchors
241
+
242
+
243
+ def _resolved_location(parts: Iterable[Interval]) -> Location | None:
244
+ materialized = tuple(parts)
245
+ if not materialized:
246
+ return None
247
+ return materialized[0] if len(materialized) == 1 else CompoundLocation(materialized)
248
+
249
+
250
+ def _edit_delta(edit: Edit) -> int:
251
+ replacement_length = sum(
252
+ len(part) if isinstance(part, str) else cast(int, part.length)
253
+ for part in edit.replacement_parts
254
+ )
255
+ return replacement_length - (edit.end - edit.start)
256
+
257
+
258
+ def _replacement_length(edit: Edit) -> int:
259
+ return edit.end - edit.start + _edit_delta(edit)
260
+
261
+
262
+ def _interval_affected(interval: Interval, edit: Edit) -> bool:
263
+ if edit.start == edit.end:
264
+ return _replacement_length(edit) > 0 and interval.start < edit.start < interval.end
265
+ return interval.start < edit.end and edit.start < interval.end
266
+
267
+
268
+ def _map_start(position: int, edit: Edit) -> int:
269
+ delta = _edit_delta(edit)
270
+ if position < edit.start:
271
+ return position
272
+ if position >= edit.end:
273
+ return position + delta
274
+ return edit.start
275
+
276
+
277
+ def _map_end(position: int, edit: Edit) -> int:
278
+ delta = _edit_delta(edit)
279
+ if position <= edit.start:
280
+ return position
281
+ if position >= edit.end:
282
+ return position + delta
283
+ return edit.start + _replacement_length(edit)
284
+
285
+
286
+ def _preserved_interval(interval: Interval, edit: Edit) -> Interval:
287
+ if edit.start == edit.end:
288
+ delta = _edit_delta(edit)
289
+ if len(interval) == 0:
290
+ mapped = interval.start + delta if interval.start >= edit.start else interval.start
291
+ return Interval(mapped, mapped)
292
+ start = interval.start + delta if interval.start >= edit.start else interval.start
293
+ end = interval.end + delta if interval.end > edit.start else interval.end
294
+ return Interval(start, end)
295
+ if len(interval) == 0:
296
+ position = interval.start
297
+ delta = _edit_delta(edit)
298
+ if position <= edit.start:
299
+ mapped = position
300
+ elif position >= edit.end:
301
+ mapped = position + delta
302
+ else:
303
+ mapped = edit.start
304
+ return Interval(mapped, mapped)
305
+ return Interval(_map_start(interval.start, edit), _map_end(interval.end, edit))
306
+
307
+
308
+ def _surviving_intervals(interval: Interval, edit: Edit) -> tuple[Interval, ...]:
309
+ survivors: list[Interval] = []
310
+ before_end = min(interval.end, edit.start)
311
+ if interval.start < before_end:
312
+ survivors.append(Interval(interval.start, before_end))
313
+ after_start = max(interval.start, edit.end)
314
+ if after_start < interval.end:
315
+ delta = _edit_delta(edit)
316
+ survivors.append(Interval(after_start + delta, interval.end + delta))
317
+ return tuple(survivors)
318
+
319
+
320
+ def _sync_feature_for_edit(
321
+ feature: DNAFeature,
322
+ edit: Edit,
323
+ policy: FeatureOverlapPolicy,
324
+ ) -> tuple[DNAFeature | None, FeatureChangeAction, str | None]:
325
+ location = feature.location
326
+ parts = _location_parts(location)
327
+ affected = any(_interval_affected(part, edit) for part in parts)
328
+ if isinstance(location, UnresolvedLocation):
329
+ mapped = tuple(_preserved_interval(anchor, edit) for anchor in parts)
330
+ reason = f"{location.reason}; coordinates transformed across {edit.kind}"
331
+ return _copy_feature(feature, UnresolvedLocation(reason, mapped)), "unresolved", reason
332
+
333
+ if not affected:
334
+ mapped_location = _resolved_location(_preserved_interval(part, edit) for part in parts)
335
+ assert mapped_location is not None
336
+ action: FeatureChangeAction = "preserved" if mapped_location == location else "shifted"
337
+ return _copy_feature(feature, mapped_location), action, None
338
+
339
+ if policy == "delete":
340
+ return None, "deleted", f"feature overlaps {edit.kind}"
341
+
342
+ survivors = tuple(survivor for part in parts for survivor in _surviving_intervals(part, edit))
343
+ if policy == "unresolved":
344
+ reason = f"feature overlaps {edit.kind}; exact biological extent is unresolved"
345
+ return _copy_feature(feature, UnresolvedLocation(reason, survivors)), "unresolved", reason
346
+ if policy == "truncate":
347
+ if not survivors:
348
+ return None, "deleted", f"feature is completely replaced by {edit.kind}"
349
+ selected = min(survivors, key=lambda part: (-len(part), part.start, part.end))
350
+ return _copy_feature(feature, selected), "truncated", "longest surviving segment retained"
351
+ if policy == "split":
352
+ mapped_location = _resolved_location(survivors)
353
+ if mapped_location is None:
354
+ return None, "deleted", f"feature is completely replaced by {edit.kind}"
355
+ action = "split" if len(survivors) > 1 else "truncated"
356
+ return _copy_feature(feature, mapped_location), action, "replacement coordinates excluded"
357
+
358
+ mapped_location = _resolved_location(_preserved_interval(part, edit) for part in parts)
359
+ assert mapped_location is not None
360
+ action = "preserved" if mapped_location == location else "resized"
361
+ return _copy_feature(feature, mapped_location), action, "replacement coordinates included"
362
+
363
+
364
+ def _sync_features_for_edits(
365
+ features: tuple[DNAFeature, ...],
366
+ edits: tuple[Edit, ...],
367
+ policy: FeatureOverlapPolicy,
368
+ ) -> tuple[tuple[DNAFeature, ...], tuple[FeatureChange, ...]]:
369
+ output: list[DNAFeature] = []
370
+ changes: list[FeatureChange] = []
371
+ indexed_edits = tuple(
372
+ sorted(enumerate(edits), key=lambda item: (item[1].start, item[1].end), reverse=True)
373
+ )
374
+ for feature_index, original in enumerate(features):
375
+ current: DNAFeature | None = original
376
+ actions: list[FeatureChangeAction] = []
377
+ reasons: list[str] = []
378
+ affected_indices: list[int] = []
379
+ for edit_index, edit in indexed_edits:
380
+ if current is None:
381
+ break
382
+ was_affected = any(
383
+ _interval_affected(part, edit) for part in _location_parts(current.location)
384
+ )
385
+ current, action, reason = _sync_feature_for_edit(current, edit, policy)
386
+ actions.append(action)
387
+ if was_affected:
388
+ affected_indices.append(edit_index)
389
+ if reason is not None:
390
+ reasons.append(reason)
391
+ if current is not None:
392
+ output.append(current)
393
+ significant = next(
394
+ (action for action in reversed(actions) if action not in {"preserved", "shifted"}),
395
+ "shifted" if "shifted" in actions else "preserved",
396
+ )
397
+ changes.append(
398
+ FeatureChange(
399
+ feature_index,
400
+ original.id,
401
+ original.type,
402
+ "deleted" if current is None else significant,
403
+ original.location,
404
+ None if current is None else current.location,
405
+ policy,
406
+ tuple(affected_indices),
407
+ "; ".join(reasons) or None,
408
+ )
409
+ )
410
+ return tuple(output), tuple(changes)
411
+
412
+
413
+ def _symbol_boundary(sequence: DNASequence, coordinate: int) -> int:
414
+ coordinate_cursor = 0
415
+ symbol_cursor = 0
416
+ for part in sequence.parts:
417
+ if isinstance(part, str):
418
+ part_end = coordinate_cursor + len(part)
419
+ if coordinate_cursor <= coordinate <= part_end:
420
+ return symbol_cursor + coordinate - coordinate_cursor
421
+ coordinate_cursor = part_end
422
+ symbol_cursor += len(part)
423
+ else:
424
+ if part.length is None:
425
+ raise UnknownLengthError(
426
+ "Letter annotations cannot be synchronized across an unknown-length Gap.",
427
+ code="UNRESOLVED_LETTER_ANNOTATION_COORDINATES",
428
+ )
429
+ part_end = coordinate_cursor + part.length
430
+ if coordinate_cursor <= coordinate <= part_end:
431
+ return symbol_cursor
432
+ coordinate_cursor = part_end
433
+ if coordinate == coordinate_cursor:
434
+ return symbol_cursor
435
+ raise ConfigurationError(
436
+ "Letter annotation coordinate exceeds the sequence span.",
437
+ code="LETTER_ANNOTATION_COORDINATE_OUT_OF_RANGE",
438
+ )
439
+
440
+
441
+ def _replacement_annotation_values(
442
+ record: DNARecord,
443
+ replacement_length: int,
444
+ replacement_annotations: Mapping[str, Iterable[int | float]] | None,
445
+ policy: LetterAnnotationPolicy,
446
+ ) -> tuple[Mapping[str, tuple[int | float, ...]], str]:
447
+ if policy == "drop":
448
+ if replacement_annotations:
449
+ raise ConfigurationError(
450
+ "replacement_annotations cannot be used when annotations are dropped.",
451
+ code="UNEXPECTED_REPLACEMENT_ANNOTATIONS",
452
+ )
453
+ return {}, "dropped" if record.letter_annotations else "absent"
454
+ if not record.letter_annotations:
455
+ if replacement_annotations:
456
+ raise ConfigurationError(
457
+ "Cannot create partial letter annotations for an unannotated record.",
458
+ code="PARTIAL_REPLACEMENT_ANNOTATIONS",
459
+ )
460
+ return {}, "absent"
461
+ if replacement_length == 0 and replacement_annotations is None:
462
+ return {}, "updated"
463
+ if replacement_annotations is None:
464
+ raise ConfigurationError(
465
+ "Inserted or substituted symbols require replacement letter annotations.",
466
+ code="REPLACEMENT_ANNOTATIONS_REQUIRED",
467
+ context={"replacement_length": replacement_length},
468
+ hint="Provide every existing annotation key or choose letter_annotation_policy='drop'.",
469
+ )
470
+ if set(replacement_annotations) != set(record.letter_annotations):
471
+ raise ConfigurationError(
472
+ "Replacement annotation keys must exactly match existing annotation keys.",
473
+ code="REPLACEMENT_ANNOTATION_KEYS_MISMATCH",
474
+ context={
475
+ "expected": sorted(record.letter_annotations),
476
+ "observed": sorted(replacement_annotations),
477
+ },
478
+ )
479
+ resolved: dict[str, tuple[int | float, ...]] = {}
480
+ for name, values in replacement_annotations.items():
481
+ materialized = tuple(values)
482
+ if len(materialized) != replacement_length:
483
+ raise ConfigurationError(
484
+ "Replacement annotation length must equal replacement symbol length.",
485
+ code="REPLACEMENT_ANNOTATION_LENGTH_MISMATCH",
486
+ context={
487
+ "name": name,
488
+ "annotation_length": len(materialized),
489
+ "replacement_length": replacement_length,
490
+ },
491
+ )
492
+ resolved[name] = materialized
493
+ return resolved, "updated"
494
+
495
+
496
+ def _edit_annotations(
497
+ record: DNARecord,
498
+ edit: Edit,
499
+ replacement_annotations: Mapping[str, Iterable[int | float]] | None,
500
+ policy: LetterAnnotationPolicy,
501
+ ) -> tuple[Mapping[str, tuple[int | float, ...]], LetterAnnotationAction]:
502
+ replacement_length = len(edit.replacement_symbols)
503
+ replacements, action = _replacement_annotation_values(
504
+ record, replacement_length, replacement_annotations, policy
505
+ )
506
+ if policy == "drop" or not record.letter_annotations:
507
+ return {}, cast(LetterAnnotationAction, action)
508
+ start = _symbol_boundary(record.sequence, edit.start)
509
+ end = _symbol_boundary(record.sequence, edit.end)
510
+ return (
511
+ {
512
+ name: (*values[:start], *replacements.get(name, ()), *values[end:])
513
+ for name, values in record.letter_annotations.items()
514
+ },
515
+ cast(LetterAnnotationAction, action),
516
+ )
517
+
518
+
519
+ def _result(
520
+ record: DNARecord,
521
+ sequence: DNASequence,
522
+ operation: RecordOperation,
523
+ edits: tuple[Edit, ...],
524
+ feature_policy: FeatureOverlapPolicy,
525
+ annotations: Mapping[str, Iterable[int | float]],
526
+ annotation_action: LetterAnnotationAction,
527
+ parameters: Mapping[str, object],
528
+ ) -> RecordOperationResult:
529
+ features, changes = _sync_features_for_edits(record.features, edits, feature_policy)
530
+ transformed = DNARecord(
531
+ sequence,
532
+ record.id,
533
+ description=record.description,
534
+ features=features,
535
+ metadata=record.metadata,
536
+ letter_annotations=annotations,
537
+ )
538
+ return RecordOperationResult(
539
+ transformed,
540
+ operation,
541
+ edits,
542
+ feature_policy,
543
+ changes,
544
+ annotation_action,
545
+ FrozenDict(
546
+ {
547
+ **parameters,
548
+ "qualifiers": "preserved",
549
+ "feature_phase": "preserved_not_recomputed",
550
+ }
551
+ ),
552
+ )
553
+
554
+
555
+ def insert_record(
556
+ record: DNARecord,
557
+ position: int,
558
+ fragment: DNASequence | str,
559
+ *,
560
+ feature_policy: FeatureOverlapPolicy = "preserve",
561
+ replacement_annotations: Mapping[str, Iterable[int | float]] | None = None,
562
+ letter_annotation_policy: LetterAnnotationPolicy = "error",
563
+ ) -> RecordOperationResult:
564
+ """Insert sequence and synchronize record annotations explicitly."""
565
+
566
+ source = _require_record(record)
567
+ policy = _feature_policy(feature_policy)
568
+ annotation_policy = _annotation_policy(letter_annotation_policy)
569
+ edited = insert(source.sequence, position, fragment)
570
+ annotations, action = _edit_annotations(
571
+ source, edited.edits[0], replacement_annotations, annotation_policy
572
+ )
573
+ return _result(
574
+ source,
575
+ edited.sequence,
576
+ "insert",
577
+ edited.edits,
578
+ policy,
579
+ annotations,
580
+ action,
581
+ {"position": position, "letter_annotation_policy": annotation_policy},
582
+ )
583
+
584
+
585
+ def delete_record(
586
+ record: DNARecord,
587
+ start: int,
588
+ end: int,
589
+ *,
590
+ feature_policy: FeatureOverlapPolicy = "preserve",
591
+ letter_annotation_policy: LetterAnnotationPolicy = "error",
592
+ ) -> RecordOperationResult:
593
+ """Delete sequence and map or resolve every affected feature."""
594
+
595
+ source = _require_record(record)
596
+ policy = _feature_policy(feature_policy)
597
+ annotation_policy = _annotation_policy(letter_annotation_policy)
598
+ edited = delete(source.sequence, start, end)
599
+ annotations, action = _edit_annotations(source, edited.edits[0], None, annotation_policy)
600
+ return _result(
601
+ source,
602
+ edited.sequence,
603
+ "delete",
604
+ edited.edits,
605
+ policy,
606
+ annotations,
607
+ action,
608
+ {"start": start, "end": end, "letter_annotation_policy": annotation_policy},
609
+ )
610
+
611
+
612
+ def substitute_record(
613
+ record: DNARecord,
614
+ start: int,
615
+ end: int,
616
+ fragment: DNASequence | str,
617
+ *,
618
+ feature_policy: FeatureOverlapPolicy = "preserve",
619
+ replacement_annotations: Mapping[str, Iterable[int | float]] | None = None,
620
+ letter_annotation_policy: LetterAnnotationPolicy = "error",
621
+ ) -> RecordOperationResult:
622
+ """Substitute sequence and synchronize features and letter annotations."""
623
+
624
+ source = _require_record(record)
625
+ policy = _feature_policy(feature_policy)
626
+ annotation_policy = _annotation_policy(letter_annotation_policy)
627
+ edited = substitute(source.sequence, start, end, fragment)
628
+ annotations, action = _edit_annotations(
629
+ source, edited.edits[0], replacement_annotations, annotation_policy
630
+ )
631
+ return _result(
632
+ source,
633
+ edited.sequence,
634
+ "substitute",
635
+ edited.edits,
636
+ policy,
637
+ annotations,
638
+ action,
639
+ {"start": start, "end": end, "letter_annotation_policy": annotation_policy},
640
+ )
641
+
642
+
643
+ def mask_record(
644
+ record: DNARecord,
645
+ intervals: Iterable[tuple[int, int]],
646
+ *,
647
+ symbol: str = "N",
648
+ feature_policy: FeatureOverlapPolicy = "preserve",
649
+ letter_annotation_policy: LetterAnnotationPolicy = "error",
650
+ ) -> RecordOperationResult:
651
+ """Mask symbols while preserving aligned letter values unless explicitly dropped."""
652
+
653
+ source = _require_record(record)
654
+ policy = _feature_policy(feature_policy)
655
+ annotation_policy = _annotation_policy(letter_annotation_policy)
656
+ edited = mask(source.sequence, intervals, symbol=symbol)
657
+ annotations: Mapping[str, Iterable[int | float]] = (
658
+ {} if annotation_policy == "drop" else source.letter_annotations
659
+ )
660
+ action: LetterAnnotationAction = (
661
+ "dropped"
662
+ if annotation_policy == "drop" and source.letter_annotations
663
+ else "preserved"
664
+ if source.letter_annotations
665
+ else "absent"
666
+ )
667
+ return _result(
668
+ source,
669
+ edited.sequence,
670
+ "mask",
671
+ edited.edits,
672
+ policy,
673
+ annotations,
674
+ action,
675
+ {"symbol": symbol, "letter_annotation_policy": annotation_policy},
676
+ )
677
+
678
+
679
+ def trim_record(
680
+ record: DNARecord,
681
+ *,
682
+ left: int = 0,
683
+ right: int = 0,
684
+ feature_policy: FeatureOverlapPolicy = "preserve",
685
+ letter_annotation_policy: LetterAnnotationPolicy = "error",
686
+ ) -> RecordOperationResult:
687
+ """Trim record ends and synchronize features in original coordinates."""
688
+
689
+ source = _require_record(record)
690
+ policy = _feature_policy(feature_policy)
691
+ annotation_policy = _annotation_policy(letter_annotation_policy)
692
+ edited = trim(source.sequence, left=left, right=right)
693
+ if annotation_policy == "drop":
694
+ annotations: Mapping[str, Iterable[int | float]] = {}
695
+ action: LetterAnnotationAction = "dropped" if source.letter_annotations else "absent"
696
+ elif source.letter_annotations:
697
+ span = source.sequence.coordinate_span
698
+ assert span is not None
699
+ symbol_start = _symbol_boundary(source.sequence, left)
700
+ symbol_end = _symbol_boundary(source.sequence, span - right)
701
+ annotations = {
702
+ name: values[symbol_start:symbol_end]
703
+ for name, values in source.letter_annotations.items()
704
+ }
705
+ action = "trimmed" if left or right else "preserved"
706
+ else:
707
+ annotations = {}
708
+ action = "absent"
709
+ return _result(
710
+ source,
711
+ edited.sequence,
712
+ "trim",
713
+ edited.edits,
714
+ policy,
715
+ annotations,
716
+ action,
717
+ {
718
+ "left": left,
719
+ "right": right,
720
+ "letter_annotation_policy": annotation_policy,
721
+ "edit_coordinate_space": "original_record",
722
+ },
723
+ )
724
+
725
+
726
+ def _reverse_strand(strand: Strand) -> Strand:
727
+ if strand is Strand.FORWARD:
728
+ return Strand.REVERSE
729
+ if strand is Strand.REVERSE:
730
+ return Strand.FORWARD
731
+ return strand
732
+
733
+
734
+ def _copy_feature_with_strand(
735
+ feature: DNAFeature, location: Location, strand: Strand
736
+ ) -> DNAFeature:
737
+ return DNAFeature(
738
+ feature.type,
739
+ location,
740
+ id=feature.id,
741
+ strand=strand,
742
+ label=feature.label,
743
+ score=feature.score,
744
+ phase=feature.phase,
745
+ qualifiers=feature.qualifiers,
746
+ source=feature.source,
747
+ )
748
+
749
+
750
+ def reverse_complement_record(
751
+ record: DNARecord,
752
+ *,
753
+ feature_policy: FeatureOverlapPolicy = "preserve",
754
+ ) -> RecordOperationResult:
755
+ """Reverse-complement sequence, locations, strands, and letter arrays."""
756
+
757
+ source = _require_record(record)
758
+ policy = _feature_policy(feature_policy)
759
+ span = source.sequence.coordinate_span
760
+ output_features: list[DNAFeature] = []
761
+ changes: list[FeatureChange] = []
762
+ for index, feature in enumerate(source.features):
763
+ if span is None:
764
+ if policy == "delete":
765
+ changes.append(
766
+ FeatureChange(
767
+ index,
768
+ feature.id,
769
+ feature.type,
770
+ "deleted",
771
+ feature.location,
772
+ None,
773
+ policy,
774
+ reason="unknown sequence span prevents reverse coordinate mapping",
775
+ )
776
+ )
777
+ continue
778
+ if policy != "unresolved":
779
+ raise UnknownLengthError(
780
+ "Feature coordinates cannot be reverse-complemented across an unknown Gap.",
781
+ code="UNRESOLVED_REVERSE_FEATURE_COORDINATES",
782
+ context={"feature_index": index},
783
+ hint="Choose feature_policy='unresolved' or 'delete'.",
784
+ )
785
+ location: Location = UnresolvedLocation("reverse-complement coordinate span is unknown")
786
+ action: FeatureChangeAction = "unresolved"
787
+ else:
788
+ location = reverse_strand_location(feature.location, sequence_length=span)
789
+ action = "reverse_complemented"
790
+ mapped = _copy_feature_with_strand(feature, location, _reverse_strand(feature.strand))
791
+ output_features.append(mapped)
792
+ changes.append(
793
+ FeatureChange(
794
+ index,
795
+ feature.id,
796
+ feature.type,
797
+ action,
798
+ feature.location,
799
+ location,
800
+ policy,
801
+ )
802
+ )
803
+ transformed = DNARecord(
804
+ source.sequence.reverse_complement(),
805
+ source.id,
806
+ description=source.description,
807
+ features=output_features,
808
+ metadata=source.metadata,
809
+ letter_annotations={
810
+ name: tuple(reversed(values)) for name, values in source.letter_annotations.items()
811
+ },
812
+ )
813
+ return RecordOperationResult(
814
+ transformed,
815
+ "reverse_complement",
816
+ (),
817
+ policy,
818
+ tuple(changes),
819
+ "reversed" if source.letter_annotations else "absent",
820
+ FrozenDict(
821
+ {
822
+ "strand_mapping": "forward<->reverse; both/unknown unchanged",
823
+ "qualifiers": "preserved",
824
+ "feature_phase": "preserved_not_recomputed",
825
+ }
826
+ ),
827
+ )
828
+
829
+
830
+ def _rotate_interval(interval: Interval, offset: int, span: int) -> tuple[Interval, ...]:
831
+ length = len(interval)
832
+ start = (interval.start - offset) % span
833
+ if length == 0:
834
+ return (Interval(start, start),)
835
+ if length == span:
836
+ return (Interval(0, span),)
837
+ end = start + length
838
+ if end <= span:
839
+ return (Interval(start, end),)
840
+ return (Interval(start, span), Interval(0, end - span))
841
+
842
+
843
+ def _rotate_feature(
844
+ feature: DNAFeature,
845
+ offset: int,
846
+ span: int,
847
+ policy: FeatureOverlapPolicy,
848
+ ) -> tuple[DNAFeature | None, FeatureChangeAction, str | None]:
849
+ if isinstance(feature.location, UnresolvedLocation):
850
+ anchors = tuple(
851
+ mapped
852
+ for anchor in feature.location.anchors
853
+ for mapped in _rotate_interval(anchor, offset, span)
854
+ )
855
+ unresolved_location: Location = UnresolvedLocation(feature.location.reason, anchors)
856
+ return _copy_feature(feature, unresolved_location), "unresolved", None
857
+ source_parts = _location_parts(feature.location)
858
+ mapped_groups = tuple(_rotate_interval(part, offset, span) for part in source_parts)
859
+ mapped = tuple(transformed for group in mapped_groups for transformed in group)
860
+ wrapped = any(len(group) > 1 for group in mapped_groups)
861
+ if wrapped and policy == "delete":
862
+ return None, "deleted", "feature crosses the new circular origin"
863
+ if wrapped and policy == "unresolved":
864
+ reason = "feature crosses the new circular origin"
865
+ return _copy_feature(feature, UnresolvedLocation(reason, mapped)), "unresolved", reason
866
+ if wrapped and policy == "truncate":
867
+ selected = min(mapped, key=lambda part: (-len(part), part.start, part.end))
868
+ return _copy_feature(feature, selected), "truncated", "longest origin-side retained"
869
+ resolved_location = _resolved_location(mapped)
870
+ assert resolved_location is not None
871
+ action: FeatureChangeAction = (
872
+ "split" if wrapped and policy == "split" else "preserved" if wrapped else "rotated"
873
+ )
874
+ return (
875
+ _copy_feature(feature, resolved_location),
876
+ action,
877
+ "origin-spanning feature represented as CompoundLocation" if wrapped else None,
878
+ )
879
+
880
+
881
+ def _rotation_symbol_offset(sequence: DNASequence, coordinate: int) -> int:
882
+ return _symbol_boundary(sequence, coordinate)
883
+
884
+
885
+ def _rotate_record(
886
+ source: DNARecord,
887
+ *,
888
+ offset: int,
889
+ operation: Literal["rotate", "canonical_origin"],
890
+ requested_offset: int | None,
891
+ feature_policy: FeatureOverlapPolicy,
892
+ ) -> RecordOperationResult:
893
+ rotated = rotate(source.sequence, offset)
894
+ policy = _feature_policy(feature_policy)
895
+ features: list[DNAFeature] = []
896
+ changes: list[FeatureChange] = []
897
+ for index, feature in enumerate(source.features):
898
+ mapped, action, reason = _rotate_feature(
899
+ feature, rotated.effective_offset, rotated.sequence_span, policy
900
+ )
901
+ if mapped is not None:
902
+ features.append(mapped)
903
+ changes.append(
904
+ FeatureChange(
905
+ index,
906
+ feature.id,
907
+ feature.type,
908
+ action,
909
+ feature.location,
910
+ None if mapped is None else mapped.location,
911
+ policy,
912
+ reason=reason,
913
+ )
914
+ )
915
+ symbol_offset = _rotation_symbol_offset(source.sequence, rotated.effective_offset)
916
+ annotations = {
917
+ name: (*values[symbol_offset:], *values[:symbol_offset])
918
+ for name, values in source.letter_annotations.items()
919
+ }
920
+ transformed = DNARecord(
921
+ rotated.sequence,
922
+ source.id,
923
+ description=source.description,
924
+ features=features,
925
+ metadata=source.metadata,
926
+ letter_annotations=annotations,
927
+ )
928
+ origin_rule = (
929
+ "lexicographically_minimal_forward_rotation_then_smallest_offset"
930
+ if operation == "canonical_origin"
931
+ else rotated.rule
932
+ )
933
+ operation_parameters: dict[str, object] = {}
934
+ if operation == "canonical_origin":
935
+ operation_parameters = {
936
+ "comparison": "normalized_symbol_code_point_order",
937
+ "orientation": "forward_only",
938
+ "tie_break": "smallest_offset",
939
+ "algorithm": "booth",
940
+ }
941
+ return RecordOperationResult(
942
+ transformed,
943
+ operation,
944
+ (),
945
+ policy,
946
+ tuple(changes),
947
+ "rotated" if annotations else "absent",
948
+ FrozenDict(
949
+ {
950
+ "requested_offset": requested_offset,
951
+ "effective_offset": rotated.effective_offset,
952
+ "sequence_span": rotated.sequence_span,
953
+ "origin_rule": origin_rule,
954
+ **operation_parameters,
955
+ "qualifiers": "preserved",
956
+ "feature_phase": "preserved_not_recomputed",
957
+ }
958
+ ),
959
+ )
960
+
961
+
962
+ def rotate_record(
963
+ record: DNARecord,
964
+ offset: int,
965
+ *,
966
+ feature_policy: FeatureOverlapPolicy = "split",
967
+ ) -> RecordOperationResult:
968
+ """Rotate a circular record and map origin-spanning feature locations."""
969
+
970
+ return _rotate_record(
971
+ _require_record(record),
972
+ offset=offset,
973
+ operation="rotate",
974
+ requested_offset=offset,
975
+ feature_policy=feature_policy,
976
+ )
977
+
978
+
979
+ def canonical_origin_record(
980
+ record: DNARecord,
981
+ *,
982
+ feature_policy: FeatureOverlapPolicy = "split",
983
+ ) -> RecordOperationResult:
984
+ """Apply the canonical forward lexicographic origin to a record."""
985
+
986
+ source = _require_record(record)
987
+ canonical = canonical_origin(source.sequence)
988
+ return _rotate_record(
989
+ source,
990
+ offset=canonical.effective_offset,
991
+ operation="canonical_origin",
992
+ requested_offset=None,
993
+ feature_policy=feature_policy,
994
+ )
995
+
996
+
997
+ __all__ = [
998
+ "FeatureChange",
999
+ "FeatureChangeAction",
1000
+ "FeatureOverlapPolicy",
1001
+ "LetterAnnotationAction",
1002
+ "LetterAnnotationPolicy",
1003
+ "RecordOperationResult",
1004
+ "canonical_origin_record",
1005
+ "delete_record",
1006
+ "insert_record",
1007
+ "mask_record",
1008
+ "reverse_complement_record",
1009
+ "rotate_record",
1010
+ "substitute_record",
1011
+ "trim_record",
1012
+ ]