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/_common.py ADDED
@@ -0,0 +1,201 @@
1
+ """Internal helpers shared by deterministic sequence operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+
7
+ from dnakit.core import DNAAlphabet, DNASequence, Gap, Interval, Strandedness, Topology
8
+ from dnakit.exceptions import (
9
+ ConfigurationError,
10
+ CoordinateError,
11
+ SequenceError,
12
+ UnknownLengthError,
13
+ UnsupportedGapOperationError,
14
+ )
15
+
16
+
17
+ def require_sequence(sequence: DNASequence) -> DNASequence:
18
+ """Return a validated sequence argument with a domain-specific error."""
19
+
20
+ if not isinstance(sequence, DNASequence):
21
+ raise SequenceError(
22
+ "A DNASequence object is required.",
23
+ code="INVALID_SEQUENCE_ARGUMENT",
24
+ context={"type": type(sequence).__name__},
25
+ )
26
+ return sequence
27
+
28
+
29
+ def resolved_span(sequence: DNASequence, *, operation: str) -> int:
30
+ """Return a coordinate span, refusing to guess an unknown gap length."""
31
+
32
+ span = sequence.coordinate_span
33
+ if span is None:
34
+ raise UnknownLengthError(
35
+ f"{operation} requires every Gap to have a known length.",
36
+ code="UNRESOLVED_OPERATION_COORDINATES",
37
+ context={"operation": operation},
38
+ hint="Resolve unknown Gap.length values before using coordinate-based operations.",
39
+ )
40
+ return span
41
+
42
+
43
+ def validate_position(position: int, *, span: int, allow_end: bool = True) -> None:
44
+ """Validate one zero-based position or insertion boundary."""
45
+
46
+ if isinstance(position, bool) or not isinstance(position, int):
47
+ raise CoordinateError(
48
+ "Position must be a non-negative integer.",
49
+ context={"position": position, "sequence_span": span},
50
+ )
51
+ upper_ok = position <= span if allow_end else position < span
52
+ if position < 0 or not upper_ok:
53
+ relation = "at most" if allow_end else "smaller than"
54
+ raise CoordinateError(
55
+ f"Position must be a non-negative integer {relation} the sequence span.",
56
+ context={"position": position, "sequence_span": span},
57
+ )
58
+
59
+
60
+ def validate_interval(start: int, end: int, *, span: int) -> Interval:
61
+ """Build and bounds-check an internal zero-based, half-open interval."""
62
+
63
+ interval = Interval(start, end)
64
+ if interval.end > span:
65
+ raise CoordinateError(
66
+ "Interval exceeds the sequence coordinate span.",
67
+ context={"start": start, "end": end, "sequence_span": span},
68
+ )
69
+ return interval
70
+
71
+
72
+ def copy_gap(gap: Gap, *, length: int) -> Gap:
73
+ """Copy a gap while replacing only its known coordinate length."""
74
+
75
+ return Gap(
76
+ length,
77
+ kind=gap.kind,
78
+ crossable=gap.crossable,
79
+ evidence=gap.evidence,
80
+ metadata=gap.metadata,
81
+ )
82
+
83
+
84
+ def interval_overlaps_gap(sequence: DNASequence, interval: Interval) -> bool:
85
+ """Return whether a resolved interval contains any gap coordinate."""
86
+
87
+ cursor = 0
88
+ for part in sequence.parts:
89
+ part_length = len(part) if isinstance(part, str) else part.length
90
+ if part_length is None:
91
+ raise UnknownLengthError(
92
+ "Gap overlap cannot be resolved for an unknown-length Gap.",
93
+ code="UNRESOLVED_GAP_COORDINATES",
94
+ )
95
+ part_end = cursor + part_length
96
+ if isinstance(part, Gap) and interval.start < part_end and cursor < interval.end:
97
+ return True
98
+ cursor = part_end
99
+ return False
100
+
101
+
102
+ def position_inside_gap(sequence: DNASequence, position: int) -> bool:
103
+ """Return whether an insertion boundary lies strictly inside a gap."""
104
+
105
+ cursor = 0
106
+ for part in sequence.parts:
107
+ part_length = len(part) if isinstance(part, str) else part.length
108
+ if part_length is None:
109
+ raise UnknownLengthError(
110
+ "Insertion coordinates cannot be resolved for an unknown-length Gap.",
111
+ code="UNRESOLVED_GAP_COORDINATES",
112
+ )
113
+ part_end = cursor + part_length
114
+ if isinstance(part, Gap) and cursor < position < part_end:
115
+ return True
116
+ cursor = part_end
117
+ return False
118
+
119
+
120
+ def reject_gap_overlap(sequence: DNASequence, interval: Interval, *, operation: str) -> None:
121
+ """Reject edits that would silently consume assembly-gap coordinates."""
122
+
123
+ if interval_overlaps_gap(sequence, interval):
124
+ raise UnsupportedGapOperationError(
125
+ f"{operation} cannot overlap a Gap.",
126
+ code="EDIT_OVERLAPS_GAP",
127
+ context={"operation": operation, "start": interval.start, "end": interval.end},
128
+ hint="Edit nucleotide fragments separately or resolve the assembly gap first.",
129
+ )
130
+
131
+
132
+ def infer_alphabet(symbols: str, *, fallback: DNAAlphabet) -> DNAAlphabet:
133
+ """Infer the narrowest DNA alphabet for already-normalized symbols."""
134
+
135
+ if not symbols:
136
+ return fallback
137
+ return DNAAlphabet.STRICT if set(symbols) <= set("ACGT") else DNAAlphabet.IUPAC
138
+
139
+
140
+ def promote_alphabet(*alphabets: DNAAlphabet) -> DNAAlphabet:
141
+ """Return IUPAC if any contributing sequence requires it."""
142
+
143
+ return (
144
+ DNAAlphabet.IUPAC
145
+ if any(alphabet is DNAAlphabet.IUPAC for alphabet in alphabets)
146
+ else DNAAlphabet.STRICT
147
+ )
148
+
149
+
150
+ def coerce_fragment(
151
+ fragment: DNASequence | str,
152
+ *,
153
+ fallback_alphabet: DNAAlphabet,
154
+ strandedness: Strandedness,
155
+ ) -> DNASequence:
156
+ """Convert a normalized string or linear sequence to an embeddable fragment."""
157
+
158
+ if isinstance(fragment, str):
159
+ alphabet = infer_alphabet(fragment, fallback=fallback_alphabet)
160
+ return DNASequence(fragment, alphabet=alphabet, strandedness=strandedness)
161
+ if not isinstance(fragment, DNASequence):
162
+ raise SequenceError(
163
+ "A sequence fragment must be DNASequence or normalized DNA text.",
164
+ code="INVALID_SEQUENCE_FRAGMENT",
165
+ context={"type": type(fragment).__name__},
166
+ )
167
+ if fragment.topology is Topology.CIRCULAR:
168
+ raise ConfigurationError(
169
+ "A circular DNASequence cannot be embedded as a linear fragment.",
170
+ code="CIRCULAR_FRAGMENT_NOT_SUPPORTED",
171
+ )
172
+ if fragment.strandedness is not strandedness:
173
+ raise ConfigurationError(
174
+ "All sequence fragments must have matching strandedness.",
175
+ code="STRANDEDNESS_MISMATCH",
176
+ context={
177
+ "expected": strandedness.value,
178
+ "observed": fragment.strandedness.value,
179
+ },
180
+ )
181
+ return fragment
182
+
183
+
184
+ def combine_parts(
185
+ parts: Iterable[str | Gap],
186
+ *,
187
+ alphabet: DNAAlphabet,
188
+ topology: Topology,
189
+ strandedness: Strandedness,
190
+ ) -> DNASequence:
191
+ """Construct a fresh immutable sequence from operation output parts."""
192
+
193
+ return DNASequence(
194
+ parts,
195
+ alphabet=alphabet,
196
+ topology=topology,
197
+ strandedness=strandedness,
198
+ )
199
+
200
+
201
+ __all__: list[str] = []
dnakit/ops/circular.py ADDED
@@ -0,0 +1,193 @@
1
+ """Deterministic origin operations for circular DNA sequences."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Literal, TypeAlias
7
+
8
+ from dnakit.core import DNASequence, Topology
9
+ from dnakit.core._json import FrozenDict, to_json_compatible
10
+ from dnakit.exceptions import ConfigurationError, UnsupportedGapOperationError
11
+ from dnakit.ops._common import require_sequence, resolved_span
12
+ from dnakit.ops.edit import _split_at
13
+
14
+ CircularOperation: TypeAlias = Literal["rotate", "canonical_origin"]
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class CircularOperationResult:
19
+ """A circular sequence with an auditable origin-selection decision."""
20
+
21
+ sequence: DNASequence
22
+ operation: CircularOperation
23
+ requested_offset: int | None
24
+ effective_offset: int
25
+ sequence_span: int
26
+ rule: str
27
+ parameters: FrozenDict
28
+
29
+ def __post_init__(self) -> None:
30
+ if self.operation not in {"rotate", "canonical_origin"}:
31
+ raise ConfigurationError("Unknown circular operation result kind.")
32
+ if self.sequence.topology is not Topology.CIRCULAR:
33
+ raise ConfigurationError("A circular operation result must remain circular.")
34
+ if self.requested_offset is not None and (
35
+ isinstance(self.requested_offset, bool) or not isinstance(self.requested_offset, int)
36
+ ):
37
+ raise ConfigurationError("requested_offset must be an integer or None.")
38
+ if self.sequence_span <= 0:
39
+ raise ConfigurationError("A circular operation requires a positive sequence span.")
40
+ if not 0 <= self.effective_offset < self.sequence_span:
41
+ raise ConfigurationError("effective_offset must lie within the circular span.")
42
+ if self.sequence.coordinate_span != self.sequence_span:
43
+ raise ConfigurationError("sequence_span must match the transformed sequence.")
44
+ if not isinstance(self.rule, str) or not self.rule.strip():
45
+ raise ConfigurationError("A circular operation result rule must be non-empty.")
46
+ if not isinstance(self.parameters, FrozenDict):
47
+ raise ConfigurationError("Circular operation parameters must be FrozenDict.")
48
+
49
+ def to_dict(self) -> dict[str, object]:
50
+ """Return a JSON-compatible audit payload without flattening explicit gaps."""
51
+
52
+ payload = {
53
+ "operation": self.operation,
54
+ "requested_offset": self.requested_offset,
55
+ "effective_offset": self.effective_offset,
56
+ "sequence_span": self.sequence_span,
57
+ "rule": self.rule,
58
+ "parameters": self.parameters,
59
+ }
60
+ converted = to_json_compatible(payload)
61
+ assert isinstance(converted, dict)
62
+ return converted
63
+
64
+
65
+ def _require_circular(sequence: DNASequence, *, operation: str) -> DNASequence:
66
+ source = require_sequence(sequence)
67
+ if source.topology is not Topology.CIRCULAR:
68
+ raise ConfigurationError(
69
+ f"{operation} requires topology='circular'.",
70
+ code="CIRCULAR_TOPOLOGY_REQUIRED",
71
+ context={"operation": operation, "topology": source.topology.value},
72
+ )
73
+ return source
74
+
75
+
76
+ def rotate(sequence: DNASequence, offset: int) -> CircularOperationResult:
77
+ """Left-rotate a circular sequence so ``offset`` becomes coordinate zero.
78
+
79
+ Offsets are normalized modulo the resolved coordinate span. A rotation may
80
+ split a nucleotide fragment, but never an explicit Gap. Unknown-length Gaps
81
+ are rejected because the effective coordinate cannot be determined.
82
+ """
83
+
84
+ source = _require_circular(sequence, operation="rotate")
85
+ if isinstance(offset, bool) or not isinstance(offset, int):
86
+ raise ConfigurationError(
87
+ "Circular rotation offset must be an integer.",
88
+ code="INVALID_ROTATION_OFFSET",
89
+ context={"offset": offset},
90
+ )
91
+ span = resolved_span(source, operation="rotate")
92
+ effective = offset % span
93
+ try:
94
+ left, right = _split_at(source, effective)
95
+ except UnsupportedGapOperationError as exc:
96
+ raise UnsupportedGapOperationError(
97
+ "A circular origin cannot be placed inside a Gap.",
98
+ code="ROTATION_ORIGIN_INSIDE_GAP",
99
+ context={"requested_offset": offset, "effective_offset": effective},
100
+ hint="Choose a nucleotide or Gap boundary as the circular origin.",
101
+ ) from exc
102
+ rotated = DNASequence(
103
+ (*right, *left),
104
+ alphabet=source.alphabet,
105
+ topology=Topology.CIRCULAR,
106
+ strandedness=source.strandedness,
107
+ )
108
+ return CircularOperationResult(
109
+ rotated,
110
+ "rotate",
111
+ offset,
112
+ effective,
113
+ span,
114
+ "left_rotation_offset_modulo_coordinate_span",
115
+ FrozenDict(
116
+ {
117
+ "direction": "left",
118
+ "offset_normalization": "modulo_coordinate_span",
119
+ "gap_interior_policy": "error",
120
+ }
121
+ ),
122
+ )
123
+
124
+
125
+ def _minimal_rotation_offset(text: str) -> int:
126
+ """Return the smallest offset having the lexicographically minimal rotation.
127
+
128
+ This is Booth's linear-time algorithm with deterministic smallest-index
129
+ tie-breaking for periodic strings.
130
+ """
131
+
132
+ length = len(text)
133
+ if length < 2:
134
+ return 0
135
+ doubled = text + text
136
+ first, second, matched = 0, 1, 0
137
+ while first < length and second < length and matched < length:
138
+ left = doubled[first + matched]
139
+ right = doubled[second + matched]
140
+ if left == right:
141
+ matched += 1
142
+ continue
143
+ if left > right:
144
+ first = first + matched + 1
145
+ if first <= second:
146
+ first = second + 1
147
+ else:
148
+ second = second + matched + 1
149
+ if second <= first:
150
+ second = first + 1
151
+ matched = 0
152
+ return min(first, second)
153
+
154
+
155
+ def canonical_origin(sequence: DNASequence) -> CircularOperationResult:
156
+ """Choose the lexicographically minimal forward rotation as coordinate zero.
157
+
158
+ The rule compares normalized DNA symbols using their ordinary code-point
159
+ order and preserves strand orientation. Equal rotations select the smallest
160
+ offset. Explicit gaps are rejected because omitting or serializing them into
161
+ a comparison token would change the sequence model.
162
+ """
163
+
164
+ source = _require_circular(sequence, operation="canonical_origin")
165
+ if source.is_gapped:
166
+ raise UnsupportedGapOperationError(
167
+ "Canonical lexicographic origin selection does not accept explicit Gaps.",
168
+ code="CANONICAL_ORIGIN_GAPPED_SEQUENCE",
169
+ hint="Resolve the origin explicitly with rotate() at a Gap boundary.",
170
+ )
171
+ span = resolved_span(source, operation="canonical_origin")
172
+ offset = _minimal_rotation_offset(source.symbols)
173
+ rotated = rotate(source, offset).sequence
174
+ return CircularOperationResult(
175
+ rotated,
176
+ "canonical_origin",
177
+ None,
178
+ offset,
179
+ span,
180
+ "lexicographically_minimal_forward_rotation_then_smallest_offset",
181
+ FrozenDict(
182
+ {
183
+ "comparison": "normalized_symbol_code_point_order",
184
+ "orientation": "forward_only",
185
+ "tie_break": "smallest_offset",
186
+ "algorithm": "booth",
187
+ "algorithm_complexity": "O(n)",
188
+ }
189
+ ),
190
+ )
191
+
192
+
193
+ __all__ = ["CircularOperation", "CircularOperationResult", "canonical_origin", "rotate"]
dnakit/ops/concat.py ADDED
@@ -0,0 +1,204 @@
1
+ """Concatenation of immutable linear DNA sequences."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+
7
+ from dnakit.core import DNAAlphabet, DNASequence, Gap, Topology
8
+ from dnakit.exceptions import ConfigurationError, UnsupportedGapOperationError
9
+ from dnakit.ops._common import coerce_fragment, combine_parts, promote_alphabet
10
+
11
+
12
+ def concat(
13
+ sequences: Iterable[DNASequence | str],
14
+ *,
15
+ linker: DNASequence | str | None = None,
16
+ gap: Gap | None = None,
17
+ ) -> DNASequence:
18
+ """Concatenate at least two linear sequences with an optional separator.
19
+
20
+ ``linker`` and ``gap`` are mutually exclusive. No rotation, implicit
21
+ reverse-complement, or feature synchronization is performed.
22
+ """
23
+
24
+ raw = tuple(sequences)
25
+ if len(raw) < 2:
26
+ raise ConfigurationError(
27
+ "concat requires at least two sequences.",
28
+ code="INSUFFICIENT_CONCAT_INPUTS",
29
+ context={"count": len(raw)},
30
+ )
31
+ if linker is not None and gap is not None:
32
+ raise ConfigurationError(
33
+ "linker and gap are mutually exclusive.",
34
+ code="MULTIPLE_CONCAT_SEPARATORS",
35
+ )
36
+ if gap is not None and not isinstance(gap, Gap):
37
+ raise ConfigurationError(
38
+ "gap must be a Gap object.",
39
+ code="INVALID_CONCAT_GAP",
40
+ )
41
+
42
+ first_raw = raw[0]
43
+ if isinstance(first_raw, DNASequence):
44
+ first = first_raw
45
+ elif isinstance(first_raw, str):
46
+ first = DNASequence(first_raw, alphabet=_infer_text_alphabet(first_raw))
47
+ else:
48
+ raise ConfigurationError(
49
+ "concat inputs must be DNASequence or normalized DNA text.",
50
+ code="INVALID_CONCAT_INPUT",
51
+ context={"index": 0, "type": type(first_raw).__name__},
52
+ )
53
+ if first.topology is Topology.CIRCULAR:
54
+ raise ConfigurationError(
55
+ "Circular input concatenation is not supported in the MVP.",
56
+ code="CIRCULAR_CONCAT_NOT_SUPPORTED",
57
+ )
58
+ fragments = [
59
+ coerce_fragment(
60
+ item,
61
+ fallback_alphabet=first.alphabet,
62
+ strandedness=first.strandedness,
63
+ )
64
+ for item in raw
65
+ ]
66
+ separator: DNASequence | Gap | None
67
+ if linker is not None:
68
+ separator = coerce_fragment(
69
+ linker,
70
+ fallback_alphabet=first.alphabet,
71
+ strandedness=first.strandedness,
72
+ )
73
+ else:
74
+ separator = gap
75
+
76
+ alphabets = [fragment.alphabet for fragment in fragments]
77
+ if isinstance(separator, DNASequence):
78
+ alphabets.append(separator.alphabet)
79
+ alphabet = promote_alphabet(*alphabets)
80
+ parts: list[str | Gap] = []
81
+ for index, fragment in enumerate(fragments):
82
+ if index and separator is not None:
83
+ if isinstance(separator, DNASequence):
84
+ parts.extend(separator.parts)
85
+ else:
86
+ parts.append(separator)
87
+ parts.extend(fragment.parts)
88
+ return combine_parts(
89
+ parts,
90
+ alphabet=alphabet,
91
+ topology=Topology.LINEAR,
92
+ strandedness=first.strandedness,
93
+ )
94
+
95
+
96
+ def concat_overlap(
97
+ sequences: Iterable[DNASequence | str],
98
+ *,
99
+ min_overlap: int = 1,
100
+ max_overlap: int | None = None,
101
+ ) -> DNASequence:
102
+ """Concatenate two linear sequences while retaining an exact overlap once.
103
+
104
+ The longest exact suffix/prefix overlap is selected automatically. IUPAC
105
+ symbols are compared literally; ambiguity compatibility is not inferred.
106
+ """
107
+
108
+ _validate_overlap_bounds(min_overlap, max_overlap)
109
+ raw = tuple(sequences)
110
+ if len(raw) != 2:
111
+ raise ConfigurationError(
112
+ "concat_overlap requires exactly two sequences.",
113
+ code="INVALID_OVERLAP_CONCAT_INPUTS",
114
+ context={"count": len(raw)},
115
+ )
116
+
117
+ first_raw = raw[0]
118
+ if isinstance(first_raw, DNASequence):
119
+ first = first_raw
120
+ elif isinstance(first_raw, str):
121
+ first = DNASequence(first_raw, alphabet=_infer_text_alphabet(first_raw))
122
+ else:
123
+ raise ConfigurationError(
124
+ "concat_overlap inputs must be DNASequence or normalized DNA text.",
125
+ code="INVALID_OVERLAP_CONCAT_INPUT",
126
+ context={"index": 0, "type": type(first_raw).__name__},
127
+ )
128
+ if first.topology is Topology.CIRCULAR:
129
+ raise ConfigurationError(
130
+ "Circular input overlap concatenation is not supported.",
131
+ code="CIRCULAR_CONCAT_NOT_SUPPORTED",
132
+ )
133
+
134
+ fragments = tuple(
135
+ coerce_fragment(
136
+ item,
137
+ fallback_alphabet=first.alphabet,
138
+ strandedness=first.strandedness,
139
+ )
140
+ for item in raw
141
+ )
142
+ if any(fragment.is_gapped for fragment in fragments):
143
+ raise UnsupportedGapOperationError(
144
+ "concat_overlap cannot silently discard explicit Gap objects.",
145
+ code="OVERLAP_CONCAT_GAPPED_INPUT",
146
+ hint="Resolve the gaps first or use concat() when the Gap should be retained.",
147
+ )
148
+
149
+ overlap_length = _find_overlap_length(
150
+ fragments[0].symbols,
151
+ fragments[1].symbols,
152
+ min_overlap=min_overlap,
153
+ max_overlap=max_overlap,
154
+ )
155
+ alphabet = promote_alphabet(*(fragment.alphabet for fragment in fragments))
156
+ return combine_parts(
157
+ [fragments[0].symbols + fragments[1].symbols[overlap_length:]],
158
+ alphabet=alphabet,
159
+ topology=Topology.LINEAR,
160
+ strandedness=first.strandedness,
161
+ )
162
+
163
+
164
+ def _validate_overlap_bounds(min_overlap: int, max_overlap: int | None) -> None:
165
+ if isinstance(min_overlap, bool) or not isinstance(min_overlap, int) or min_overlap < 1:
166
+ raise ConfigurationError("min_overlap must be a positive integer.")
167
+ if max_overlap is not None and (
168
+ isinstance(max_overlap, bool) or not isinstance(max_overlap, int) or max_overlap < 1
169
+ ):
170
+ raise ConfigurationError("max_overlap must be a positive integer or None.")
171
+ if max_overlap is not None and min_overlap > max_overlap:
172
+ raise ConfigurationError("min_overlap cannot exceed max_overlap.")
173
+
174
+
175
+ def _find_overlap_length(
176
+ left: str,
177
+ right: str,
178
+ *,
179
+ min_overlap: int,
180
+ max_overlap: int | None,
181
+ ) -> int:
182
+ upper = min(len(left), len(right))
183
+ if max_overlap is not None:
184
+ upper = min(upper, max_overlap)
185
+ for length in range(upper, min_overlap - 1, -1):
186
+ if left[-length:] == right[:length]:
187
+ return length
188
+ raise ConfigurationError(
189
+ "No exact suffix/prefix overlap satisfies the configured bounds.",
190
+ code="OVERLAP_NOT_FOUND",
191
+ context={
192
+ "left_length": len(left),
193
+ "right_length": len(right),
194
+ "min_overlap": min_overlap,
195
+ "max_overlap": max_overlap,
196
+ },
197
+ )
198
+
199
+
200
+ def _infer_text_alphabet(text: str) -> DNAAlphabet:
201
+ return DNAAlphabet.STRICT if set(text) <= set("ACGT") else DNAAlphabet.IUPAC
202
+
203
+
204
+ __all__ = ["concat", "concat_overlap"]
@@ -0,0 +1,27 @@
1
+ """Direction-changing operations for immutable DNA sequences."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dnakit.core import DNASequence
6
+ from dnakit.ops._common import require_sequence
7
+
8
+
9
+ def reverse(sequence: DNASequence) -> DNASequence:
10
+ """Return a new sequence with part order and nucleotide order reversed."""
11
+
12
+ return require_sequence(sequence).reverse()
13
+
14
+
15
+ def complement(sequence: DNASequence) -> DNASequence:
16
+ """Return a new sequence using the complete DNA IUPAC complement table."""
17
+
18
+ return require_sequence(sequence).complement()
19
+
20
+
21
+ def reverse_complement(sequence: DNASequence) -> DNASequence:
22
+ """Return a new reverse-complemented sequence, preserving explicit gaps."""
23
+
24
+ return require_sequence(sequence).reverse_complement()
25
+
26
+
27
+ __all__ = ["complement", "reverse", "reverse_complement"]