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
@@ -0,0 +1,573 @@
1
+ """Bounded restriction digestion, end classification, and ligation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+ from dataclasses import dataclass
7
+ from itertools import pairwise
8
+
9
+ from dnakit.core import DNAAlphabet, DNASequence, Issue, IssueSeverity, Topology
10
+ from dnakit.exceptions import ConfigurationError
11
+ from dnakit.patterns import RestrictionEnzyme, scan_restriction_sites
12
+
13
+ from ._shared import (
14
+ circular_slice,
15
+ freeze_parameters,
16
+ materialize_bounded,
17
+ native_provenance,
18
+ require_sequence,
19
+ reverse_complement_text,
20
+ validate_positive_int,
21
+ validate_text,
22
+ )
23
+ from .results import (
24
+ DigestCut,
25
+ DigestFragment,
26
+ EndDescriptor,
27
+ EndPolarity,
28
+ EndSide,
29
+ EndTypeResult,
30
+ LigationCompatibilityResult,
31
+ LigationResult,
32
+ RestrictionDigestResult,
33
+ )
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class LigationFragment:
38
+ """A linear nucleotide fragment plus its two abstract duplex ends."""
39
+
40
+ id: str
41
+ sequence: DNASequence
42
+ left_end: EndDescriptor
43
+ right_end: EndDescriptor
44
+
45
+ def __post_init__(self) -> None:
46
+ validate_text(self.id, "fragment id")
47
+ require_sequence(
48
+ self.sequence,
49
+ operation="LigationFragment construction",
50
+ max_length=100_000_000,
51
+ allow_circular=False,
52
+ )
53
+ if self.left_end.side != "left" or self.right_end.side != "right":
54
+ raise ConfigurationError("Fragment ends must be assigned to their matching sides.")
55
+
56
+
57
+ def _cohesive_key(overhang: str) -> str:
58
+ reverse = reverse_complement_text(overhang)
59
+ return min(overhang, reverse)
60
+
61
+
62
+ def _end_descriptor(
63
+ sequence: DNASequence,
64
+ *,
65
+ top_cut: int,
66
+ bottom_cut: int,
67
+ side: EndSide,
68
+ five_prime_phosphorylated: bool,
69
+ cut_span: int | None,
70
+ source: str,
71
+ ) -> EndDescriptor:
72
+ symbols = require_sequence(
73
+ sequence,
74
+ operation="restriction-end classification",
75
+ max_length=100_000_000,
76
+ )
77
+ if not isinstance(five_prime_phosphorylated, bool):
78
+ raise ConfigurationError("five_prime_phosphorylated must be boolean.")
79
+ for value, name in ((top_cut, "top_cut"), (bottom_cut, "bottom_cut")):
80
+ if isinstance(value, bool) or not isinstance(value, int):
81
+ raise ConfigurationError(f"{name} must be an integer.")
82
+ upper = len(symbols) - 1 if sequence.topology is Topology.CIRCULAR else len(symbols)
83
+ if not 0 <= value <= upper:
84
+ raise ConfigurationError(f"{name} lies outside the template boundary.")
85
+ delta = bottom_cut - top_cut if cut_span is None else cut_span
86
+ if isinstance(delta, bool) or not isinstance(delta, int):
87
+ raise ConfigurationError("cut_span must be an integer or None.")
88
+ if abs(delta) > len(symbols):
89
+ raise ConfigurationError("The staggered cut span cannot exceed the sequence length.")
90
+ if delta == 0:
91
+ polarity: EndPolarity = "blunt"
92
+ overhang = ""
93
+ elif delta > 0:
94
+ polarity = "5prime"
95
+ overhang = (
96
+ circular_slice(symbols, top_cut, delta)
97
+ if sequence.topology is Topology.CIRCULAR
98
+ else symbols[top_cut:bottom_cut]
99
+ )
100
+ else:
101
+ polarity = "3prime"
102
+ overhang = (
103
+ circular_slice(symbols, bottom_cut, -delta)
104
+ if sequence.topology is Topology.CIRCULAR
105
+ else symbols[bottom_cut:top_cut]
106
+ )
107
+ if len(overhang) != abs(delta):
108
+ raise ConfigurationError(
109
+ "Cut coordinates and cut_span are inconsistent with the sequence boundary."
110
+ )
111
+ return EndDescriptor(
112
+ polarity=polarity,
113
+ overhang_sequence_5to3=overhang,
114
+ cohesive_key=_cohesive_key(overhang) if overhang else "",
115
+ side=side,
116
+ five_prime_phosphorylated=five_prime_phosphorylated,
117
+ top_cut=top_cut,
118
+ bottom_cut=bottom_cut,
119
+ source=source,
120
+ )
121
+
122
+
123
+ def classify_restriction_end(
124
+ sequence: DNASequence,
125
+ top_cut: int,
126
+ bottom_cut: int,
127
+ *,
128
+ side: EndSide = "right",
129
+ five_prime_phosphorylated: bool = True,
130
+ cut_span: int | None = None,
131
+ ) -> EndTypeResult:
132
+ """Classify one blunt, 5-prime, or 3-prime end.
133
+
134
+ For modular circular cut coordinates, ``cut_span`` must retain the signed
135
+ bottom-minus-top offset from the enzyme definition.
136
+ """
137
+
138
+ end = _end_descriptor(
139
+ sequence,
140
+ top_cut=top_cut,
141
+ bottom_cut=bottom_cut,
142
+ side=side,
143
+ five_prime_phosphorylated=five_prime_phosphorylated,
144
+ cut_span=cut_span,
145
+ source="explicit-cut-coordinates",
146
+ )
147
+ return EndTypeResult(
148
+ end=end,
149
+ method="signed_staggered_cut_classification",
150
+ algorithm_version="dnakit-end-type-v1",
151
+ parameters=freeze_parameters(
152
+ {
153
+ "top_cut": top_cut,
154
+ "bottom_cut": bottom_cut,
155
+ "cut_span": cut_span,
156
+ "side": side,
157
+ "five_prime_phosphorylated": five_prime_phosphorylated,
158
+ "coordinate_system": "0-based-interbase",
159
+ }
160
+ ),
161
+ provenance=native_provenance(),
162
+ issues=(),
163
+ )
164
+
165
+
166
+ def _natural_end(side: EndSide) -> EndDescriptor:
167
+ return EndDescriptor(
168
+ polarity="blunt",
169
+ overhang_sequence_5to3="",
170
+ cohesive_key="",
171
+ side=side,
172
+ five_prime_phosphorylated=False,
173
+ top_cut=None,
174
+ bottom_cut=None,
175
+ source="natural-linear-terminus",
176
+ )
177
+
178
+
179
+ def _uncut_circular_end(side: EndSide) -> EndDescriptor:
180
+ return EndDescriptor(
181
+ polarity="blunt",
182
+ overhang_sequence_5to3="",
183
+ cohesive_key="",
184
+ side=side,
185
+ five_prime_phosphorylated=False,
186
+ top_cut=None,
187
+ bottom_cut=None,
188
+ source="uncut-circular-no-physical-end",
189
+ )
190
+
191
+
192
+ def _slice_product(sequence: DNASequence, start: int, length: int) -> DNASequence:
193
+ symbols = (
194
+ circular_slice(sequence.symbols, start, length)
195
+ if sequence.topology is Topology.CIRCULAR
196
+ else sequence.symbols[start : start + length]
197
+ )
198
+ return DNASequence(
199
+ symbols,
200
+ alphabet=sequence.alphabet,
201
+ topology=Topology.LINEAR,
202
+ strandedness=sequence.strandedness,
203
+ )
204
+
205
+
206
+ def digest_restriction(
207
+ sequence: DNASequence,
208
+ enzymes: Iterable[str | RestrictionEnzyme],
209
+ *,
210
+ methylation_state: str = "unmethylated",
211
+ allow_ambiguous_template: bool = False,
212
+ five_prime_phosphorylated: bool = True,
213
+ max_fragments: int = 100_000,
214
+ max_enzymes: int = 10_000,
215
+ max_sequence_length: int = 10_000_000,
216
+ ) -> RestrictionDigestResult:
217
+ """Simulate complete simultaneous digestion by an audited enzyme panel."""
218
+
219
+ symbols = require_sequence(
220
+ sequence,
221
+ operation="restriction digestion",
222
+ max_length=max_sequence_length,
223
+ canonical=not allow_ambiguous_template,
224
+ )
225
+ if methylation_state != "unmethylated":
226
+ raise ConfigurationError(
227
+ "Native digestion models only an unmethylated template.",
228
+ code="METHYLATION_MODEL_UNAVAILABLE",
229
+ )
230
+ if not isinstance(allow_ambiguous_template, bool) or not isinstance(
231
+ five_prime_phosphorylated, bool
232
+ ):
233
+ raise ConfigurationError("Boolean digestion controls must be booleans.")
234
+ validate_positive_int(max_fragments, "max_fragments", maximum=1_000_000)
235
+ validate_positive_int(max_enzymes, "max_enzymes", maximum=100_000)
236
+ definitions = materialize_bounded(
237
+ enzymes,
238
+ max_items=max_enzymes,
239
+ name="restriction enzymes",
240
+ )
241
+ if not definitions:
242
+ raise ConfigurationError("At least one restriction enzyme is required.")
243
+ resolved_by_name: dict[str, RestrictionEnzyme] = {}
244
+ from dnakit.patterns import BUILTIN_RESTRICTION_ENZYMES
245
+
246
+ for definition in definitions:
247
+ if isinstance(definition, RestrictionEnzyme):
248
+ resolved = definition
249
+ elif isinstance(definition, str):
250
+ try:
251
+ resolved = BUILTIN_RESTRICTION_ENZYMES[definition]
252
+ except KeyError as exc:
253
+ raise ConfigurationError(
254
+ "Restriction enzyme is absent from the small built-in catalog.",
255
+ context={"enzyme": definition},
256
+ ) from exc
257
+ else:
258
+ raise ConfigurationError("Each enzyme must be a name or RestrictionEnzyme.")
259
+ if resolved.name in resolved_by_name:
260
+ raise ConfigurationError("Restriction enzyme names must be unique in one digest.")
261
+ resolved_by_name[resolved.name] = resolved
262
+ scan = scan_restriction_sites(
263
+ sequence,
264
+ tuple(resolved_by_name.values()),
265
+ max_matches=max_fragments,
266
+ max_scan_length=max_sequence_length,
267
+ max_enzymes=max_enzymes,
268
+ )
269
+ if scan.truncated:
270
+ raise ConfigurationError(
271
+ "Restriction site count reaches max_fragments; complete digestion is not safe.",
272
+ code="DIGEST_FRAGMENT_LIMIT_EXCEEDED",
273
+ )
274
+
275
+ grouped: dict[tuple[int, int], list[str]] = {}
276
+ cut_spans: dict[tuple[int, int], int] = {}
277
+ top_geometries: dict[int, tuple[int, int]] = {}
278
+ for hit in scan.hits:
279
+ if hit.top_cut is None or hit.bottom_cut is None:
280
+ raise ConfigurationError("Restriction digestion requires resolved cut coordinates.")
281
+ definition = resolved_by_name[hit.enzyme]
282
+ span = definition.bottom_cut_offset - definition.top_cut_offset
283
+ geometry = (hit.top_cut, hit.bottom_cut)
284
+ existing = top_geometries.get(hit.top_cut)
285
+ if existing is not None and existing != geometry:
286
+ raise ConfigurationError(
287
+ "Simultaneous enzymes create conflicting bottom cuts at one top-strand cut.",
288
+ code="CONFLICTING_DIGEST_GEOMETRY",
289
+ )
290
+ top_geometries[hit.top_cut] = geometry
291
+ grouped.setdefault(geometry, []).append(hit.enzyme)
292
+ cut_spans[geometry] = span
293
+
294
+ cuts: list[DigestCut] = []
295
+ left_ends: dict[int, EndDescriptor] = {}
296
+ right_ends: dict[int, EndDescriptor] = {}
297
+ for geometry in sorted(grouped):
298
+ top_cut, bottom_cut = geometry
299
+ span = cut_spans[geometry]
300
+ right = _end_descriptor(
301
+ sequence,
302
+ top_cut=top_cut,
303
+ bottom_cut=bottom_cut,
304
+ side="right",
305
+ five_prime_phosphorylated=five_prime_phosphorylated,
306
+ cut_span=span,
307
+ source="restriction-digest",
308
+ )
309
+ left = _end_descriptor(
310
+ sequence,
311
+ top_cut=top_cut,
312
+ bottom_cut=bottom_cut,
313
+ side="left",
314
+ five_prime_phosphorylated=five_prime_phosphorylated,
315
+ cut_span=span,
316
+ source="restriction-digest",
317
+ )
318
+ left_ends[top_cut] = left
319
+ right_ends[top_cut] = right
320
+ cuts.append(
321
+ DigestCut(
322
+ enzymes=tuple(sorted(set(grouped[geometry]))),
323
+ top_cut=top_cut,
324
+ bottom_cut=bottom_cut,
325
+ polarity=right.polarity,
326
+ overhang_sequence_5to3=right.overhang_sequence_5to3,
327
+ )
328
+ )
329
+
330
+ fragments: list[DigestFragment] = []
331
+ top_cuts = sorted(left_ends)
332
+ if sequence.topology is Topology.LINEAR:
333
+ boundaries = sorted({0, *top_cuts, len(symbols)})
334
+ for index, (start, end) in enumerate(pairwise(boundaries), 1):
335
+ fragments.append(
336
+ DigestFragment(
337
+ id=f"fragment_{index}",
338
+ sequence=_slice_product(sequence, start, end - start),
339
+ source_start=start,
340
+ source_end=end,
341
+ wraps_origin=False,
342
+ left_end=left_ends.get(start, _natural_end("left")),
343
+ right_end=(right_ends.get(end, _natural_end("right"))),
344
+ )
345
+ )
346
+ elif not top_cuts:
347
+ fragments.append(
348
+ DigestFragment(
349
+ id="fragment_1",
350
+ sequence=sequence,
351
+ source_start=0,
352
+ source_end=len(symbols),
353
+ wraps_origin=False,
354
+ left_end=_uncut_circular_end("left"),
355
+ right_end=_uncut_circular_end("right"),
356
+ )
357
+ )
358
+ else:
359
+ for index, start in enumerate(top_cuts, 1):
360
+ end = top_cuts[index % len(top_cuts)]
361
+ length = (end - start) % len(symbols)
362
+ if length == 0:
363
+ length = len(symbols)
364
+ fragments.append(
365
+ DigestFragment(
366
+ id=f"fragment_{index}",
367
+ sequence=_slice_product(sequence, start, length),
368
+ source_start=start,
369
+ source_end=end,
370
+ wraps_origin=end <= start,
371
+ left_end=left_ends[start],
372
+ right_end=right_ends[end],
373
+ )
374
+ )
375
+ if len(fragments) > max_fragments:
376
+ raise ConfigurationError("Digest product exceeds max_fragments.")
377
+ issues: list[Issue] = []
378
+ if allow_ambiguous_template and sequence.ambiguity_count:
379
+ issues.append(
380
+ Issue(
381
+ "AMBIGUOUS_DIGEST_MATCHES",
382
+ IssueSeverity.WARNING,
383
+ "Ambiguous template symbols were interpreted by IUPAC compatibility.",
384
+ )
385
+ )
386
+ if sequence.topology is Topology.CIRCULAR and not cuts:
387
+ issues.append(
388
+ Issue(
389
+ "UNCUT_CIRCULAR_MOLECULE",
390
+ IssueSeverity.INFO,
391
+ "No physical fragment ends exist because the circular template was not cut.",
392
+ )
393
+ )
394
+ return RestrictionDigestResult(
395
+ source_length=len(symbols),
396
+ source_topology=sequence.topology.value,
397
+ cuts=tuple(cuts),
398
+ fragments=tuple(fragments),
399
+ method="complete_simultaneous_restriction_digest",
400
+ algorithm_version="dnakit-restriction-digest-v1",
401
+ parameters=freeze_parameters(
402
+ {
403
+ "enzymes": sorted(resolved_by_name),
404
+ "methylation_state": methylation_state,
405
+ "allow_ambiguous_template": allow_ambiguous_template,
406
+ "five_prime_phosphorylated": five_prime_phosphorylated,
407
+ "partial_digest_modeled": False,
408
+ "star_activity_modeled": False,
409
+ "max_fragments": max_fragments,
410
+ "max_enzymes": max_enzymes,
411
+ "max_sequence_length": max_sequence_length,
412
+ }
413
+ ),
414
+ provenance=native_provenance(
415
+ reimplementation=True,
416
+ reference_name="DNAKit common restriction-enzyme definitions",
417
+ reference_version="common-enzymes-v1",
418
+ ),
419
+ issues=tuple(issues),
420
+ )
421
+
422
+
423
+ def as_ligation_fragment(fragment: DigestFragment | LigationFragment) -> LigationFragment:
424
+ if isinstance(fragment, LigationFragment):
425
+ return fragment
426
+ if isinstance(fragment, DigestFragment):
427
+ return LigationFragment(
428
+ fragment.id, fragment.sequence, fragment.left_end, fragment.right_end
429
+ )
430
+ raise ConfigurationError("Expected DigestFragment or LigationFragment.")
431
+
432
+
433
+ def check_end_compatibility(
434
+ left: EndDescriptor,
435
+ right: EndDescriptor,
436
+ *,
437
+ allow_blunt: bool = False,
438
+ require_phosphorylation: bool = True,
439
+ ) -> LigationCompatibilityResult:
440
+ """Check a right-facing end against a left-facing end."""
441
+
442
+ if not isinstance(left, EndDescriptor) or not isinstance(right, EndDescriptor):
443
+ raise ConfigurationError("Compatibility inputs must be EndDescriptor objects.")
444
+ if not isinstance(allow_blunt, bool) or not isinstance(require_phosphorylation, bool):
445
+ raise ConfigurationError("Compatibility controls must be booleans.")
446
+ if left.side != "right" or right.side != "left":
447
+ compatible, reason = False, "ends do not face an ordered right-to-left junction"
448
+ elif require_phosphorylation and not (
449
+ left.five_prime_phosphorylated and right.five_prime_phosphorylated
450
+ ):
451
+ compatible, reason = False, "the abstract model requires both 5-prime phosphates"
452
+ elif left.polarity != right.polarity:
453
+ compatible, reason = False, "overhang polarities differ"
454
+ elif left.polarity == "blunt":
455
+ compatible, reason = (
456
+ allow_blunt,
457
+ ("blunt ligation is enabled" if allow_blunt else "blunt ligation is disabled"),
458
+ )
459
+ elif left.cohesive_key != right.cohesive_key:
460
+ compatible, reason = False, "cohesive overhang sequences are not complementary"
461
+ else:
462
+ compatible, reason = True, "cohesive overhangs are complementary"
463
+ return LigationCompatibilityResult(
464
+ compatible=compatible,
465
+ reason=reason,
466
+ left=left,
467
+ right=right,
468
+ method="abstract_duplex_end_compatibility",
469
+ algorithm_version="dnakit-end-compatibility-v1",
470
+ parameters=freeze_parameters(
471
+ {
472
+ "allow_blunt": allow_blunt,
473
+ "require_phosphorylation": require_phosphorylation,
474
+ "kinetics_modeled": False,
475
+ }
476
+ ),
477
+ provenance=native_provenance(),
478
+ issues=(),
479
+ )
480
+
481
+
482
+ def ligate_fragments(
483
+ fragments: Iterable[DigestFragment | LigationFragment],
484
+ *,
485
+ circularize: bool = False,
486
+ allow_blunt: bool = False,
487
+ require_phosphorylation: bool = True,
488
+ max_fragments: int = 10_000,
489
+ max_product_length: int = 100_000_000,
490
+ ) -> LigationResult:
491
+ """Ligate an explicitly ordered list of pre-cut fragments."""
492
+
493
+ validate_positive_int(max_fragments, "max_fragments", maximum=1_000_000)
494
+ validate_positive_int(max_product_length, "max_product_length", maximum=100_000_000)
495
+ if not isinstance(circularize, bool):
496
+ raise ConfigurationError("circularize must be boolean.")
497
+ resolved: list[LigationFragment] = []
498
+ for fragment in fragments:
499
+ if len(resolved) >= max_fragments:
500
+ raise ConfigurationError("Ligation input exceeds max_fragments.")
501
+ resolved.append(as_ligation_fragment(fragment))
502
+ if len(resolved) < 2:
503
+ raise ConfigurationError("Ligation requires at least two fragments.")
504
+ junctions = list(pairwise(resolved))
505
+ if circularize:
506
+ junctions.append((resolved[-1], resolved[0]))
507
+ for left_fragment, right_fragment in junctions:
508
+ compatibility = check_end_compatibility(
509
+ left_fragment.right_end,
510
+ right_fragment.left_end,
511
+ allow_blunt=allow_blunt,
512
+ require_phosphorylation=require_phosphorylation,
513
+ )
514
+ if not compatibility.compatible:
515
+ raise ConfigurationError(
516
+ "Fragment ends are incompatible for the requested ligation.",
517
+ code="INCOMPATIBLE_LIGATION_ENDS",
518
+ context={
519
+ "left_fragment": left_fragment.id,
520
+ "right_fragment": right_fragment.id,
521
+ "reason": compatibility.reason,
522
+ },
523
+ )
524
+ product_length = sum(fragment.sequence.symbol_length for fragment in resolved)
525
+ if product_length > max_product_length:
526
+ raise ConfigurationError("Ligated product exceeds max_product_length.")
527
+ symbols = "".join(fragment.sequence.symbols for fragment in resolved)
528
+ alphabet = (
529
+ DNAAlphabet.IUPAC
530
+ if any(fragment.sequence.alphabet is DNAAlphabet.IUPAC for fragment in resolved)
531
+ else DNAAlphabet.STRICT
532
+ )
533
+ if any(
534
+ fragment.sequence.strandedness is not resolved[0].sequence.strandedness
535
+ for fragment in resolved[1:]
536
+ ):
537
+ raise ConfigurationError("All ligation fragments must use the same strandedness.")
538
+ product = DNASequence(
539
+ symbols,
540
+ alphabet=alphabet,
541
+ topology=Topology.CIRCULAR if circularize else Topology.LINEAR,
542
+ strandedness=resolved[0].sequence.strandedness,
543
+ )
544
+ return LigationResult(
545
+ product=product,
546
+ fragment_ids=tuple(fragment.id for fragment in resolved),
547
+ junction_count=len(junctions),
548
+ circularized=circularize,
549
+ method="ordered_abstract_duplex_ligation",
550
+ algorithm_version="dnakit-ligation-v1",
551
+ parameters=freeze_parameters(
552
+ {
553
+ "allow_blunt": allow_blunt,
554
+ "require_phosphorylation": require_phosphorylation,
555
+ "circularize": circularize,
556
+ "kinetics_modeled": False,
557
+ "max_fragments": max_fragments,
558
+ "max_product_length": max_product_length,
559
+ }
560
+ ),
561
+ provenance=native_provenance(),
562
+ issues=(),
563
+ )
564
+
565
+
566
+ __all__ = [
567
+ "LigationFragment",
568
+ "as_ligation_fragment",
569
+ "check_end_compatibility",
570
+ "classify_restriction_end",
571
+ "digest_restriction",
572
+ "ligate_fragments",
573
+ ]