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,257 @@
1
+ """Deterministic transcription and genetic-code translation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Literal, TypeAlias
6
+
7
+ from dnakit.core import DNASequence
8
+ from dnakit.exceptions import ConfigurationError, InvalidAlphabetError, UnsupportedGapOperationError
9
+ from dnakit.ops._common import require_sequence
10
+
11
+ StrandPolicy: TypeAlias = Literal["forward", "reverse"]
12
+ StopPolicy: TypeAlias = Literal["include", "truncate", "error"]
13
+ UnknownCodonPolicy: TypeAlias = Literal["x", "error"]
14
+ IncompleteCodonPolicy: TypeAlias = Literal["ignore", "error"]
15
+
16
+ _IUPAC_DNA = frozenset("ACGTRYSWKMBDHVN")
17
+ _IUPAC_RNA = frozenset("ACGURYSWKMBDHVN")
18
+ _DNA_COMPLEMENT = str.maketrans(
19
+ {
20
+ "A": "T",
21
+ "C": "G",
22
+ "G": "C",
23
+ "T": "A",
24
+ "R": "Y",
25
+ "Y": "R",
26
+ "S": "S",
27
+ "W": "W",
28
+ "K": "M",
29
+ "M": "K",
30
+ "B": "V",
31
+ "D": "H",
32
+ "H": "D",
33
+ "V": "B",
34
+ "N": "N",
35
+ }
36
+ )
37
+
38
+ # NCBI genetic code table 1, represented with DNA codons.
39
+ _TABLE_1 = {
40
+ "TTT": "F",
41
+ "TTC": "F",
42
+ "TTA": "L",
43
+ "TTG": "L",
44
+ "TCT": "S",
45
+ "TCC": "S",
46
+ "TCA": "S",
47
+ "TCG": "S",
48
+ "TAT": "Y",
49
+ "TAC": "Y",
50
+ "TAA": "*",
51
+ "TAG": "*",
52
+ "TGT": "C",
53
+ "TGC": "C",
54
+ "TGA": "*",
55
+ "TGG": "W",
56
+ "CTT": "L",
57
+ "CTC": "L",
58
+ "CTA": "L",
59
+ "CTG": "L",
60
+ "CCT": "P",
61
+ "CCC": "P",
62
+ "CCA": "P",
63
+ "CCG": "P",
64
+ "CAT": "H",
65
+ "CAC": "H",
66
+ "CAA": "Q",
67
+ "CAG": "Q",
68
+ "CGT": "R",
69
+ "CGC": "R",
70
+ "CGA": "R",
71
+ "CGG": "R",
72
+ "ATT": "I",
73
+ "ATC": "I",
74
+ "ATA": "I",
75
+ "ATG": "M",
76
+ "ACT": "T",
77
+ "ACC": "T",
78
+ "ACA": "T",
79
+ "ACG": "T",
80
+ "AAT": "N",
81
+ "AAC": "N",
82
+ "AAA": "K",
83
+ "AAG": "K",
84
+ "AGT": "S",
85
+ "AGC": "S",
86
+ "AGA": "R",
87
+ "AGG": "R",
88
+ "GTT": "V",
89
+ "GTC": "V",
90
+ "GTA": "V",
91
+ "GTG": "V",
92
+ "GCT": "A",
93
+ "GCC": "A",
94
+ "GCA": "A",
95
+ "GCG": "A",
96
+ "GAT": "D",
97
+ "GAC": "D",
98
+ "GAA": "E",
99
+ "GAG": "E",
100
+ "GGT": "G",
101
+ "GGC": "G",
102
+ "GGA": "G",
103
+ "GGG": "G",
104
+ }
105
+
106
+
107
+ def _coerce_strand(strand: StrandPolicy) -> StrandPolicy:
108
+ if strand not in ("forward", "reverse"):
109
+ raise ConfigurationError(
110
+ "strand must be 'forward' or 'reverse'.",
111
+ code="INVALID_STRAND_POLICY",
112
+ context={"strand": strand},
113
+ )
114
+ return strand
115
+
116
+
117
+ def _ungapped_symbols(sequence: DNASequence, *, operation: str) -> str:
118
+ resolved = require_sequence(sequence)
119
+ if resolved.is_gapped:
120
+ raise UnsupportedGapOperationError(
121
+ f"{operation} does not silently omit Gap objects.",
122
+ code="UNSUPPORTED_GAPPED_OPERATION",
123
+ context={"operation": operation},
124
+ hint="Translate or transcribe each nucleotide fragment separately.",
125
+ )
126
+ return resolved.symbols
127
+
128
+
129
+ def transcribe(sequence: DNASequence, *, strand: StrandPolicy = "forward") -> str:
130
+ """Transcribe DNA to RNA on the selected strand.
131
+
132
+ The return value is uppercase RNA text. Explicit gaps are rejected rather
133
+ than silently removed. ``strand='reverse'`` transcribes the reverse
134
+ complement of the supplied DNA representation.
135
+ """
136
+
137
+ symbols = _ungapped_symbols(sequence, operation="transcribe")
138
+ if _coerce_strand(strand) == "reverse":
139
+ symbols = symbols.translate(_DNA_COMPLEMENT)[::-1]
140
+ return symbols.replace("T", "U")
141
+
142
+
143
+ def _coerce_nucleic_text(sequence: DNASequence | str) -> str:
144
+ if isinstance(sequence, DNASequence):
145
+ return _ungapped_symbols(sequence, operation="translate")
146
+ if not isinstance(sequence, str):
147
+ raise InvalidAlphabetError(
148
+ "Translation input must be DNASequence or uppercase DNA/RNA text.",
149
+ context={"type": type(sequence).__name__},
150
+ )
151
+ symbols = set(sequence)
152
+ if "T" in symbols and "U" in symbols:
153
+ raise InvalidAlphabetError(
154
+ "Translation input cannot mix thymine and uracil.",
155
+ code="MIXED_NUCLEIC_ACID_ALPHABET",
156
+ )
157
+ allowed = _IUPAC_RNA if "U" in symbols else _IUPAC_DNA
158
+ invalid = sorted(symbols - allowed)
159
+ if invalid:
160
+ raise InvalidAlphabetError(
161
+ "Translation input contains invalid or non-normalized symbols.",
162
+ context={"invalid_symbols": invalid},
163
+ hint="Uppercase and clean raw input before translation.",
164
+ )
165
+ return sequence.replace("U", "T")
166
+
167
+
168
+ def translate(
169
+ sequence: DNASequence | str,
170
+ *,
171
+ frame: int = 0,
172
+ table: int = 1,
173
+ strand: StrandPolicy = "forward",
174
+ stop_policy: StopPolicy = "include",
175
+ unknown_policy: UnknownCodonPolicy = "x",
176
+ incomplete_policy: IncompleteCodonPolicy = "ignore",
177
+ ) -> str:
178
+ """Translate DNA or RNA using NCBI genetic code table 1.
179
+
180
+ Coordinates use a zero-based frame (0, 1, or 2). Ambiguous codons become
181
+ ``X`` or raise according to ``unknown_policy``. Stop codons are included as
182
+ ``*``, truncate translation, or raise according to ``stop_policy``. A
183
+ trailing partial codon is ignored or rejected according to
184
+ ``incomplete_policy``.
185
+ """
186
+
187
+ if isinstance(frame, bool) or not isinstance(frame, int) or frame not in (0, 1, 2):
188
+ raise ConfigurationError(
189
+ "frame must be one of 0, 1, or 2.",
190
+ code="INVALID_READING_FRAME",
191
+ context={"frame": frame},
192
+ )
193
+ if isinstance(table, bool) or not isinstance(table, int) or table != 1:
194
+ raise ConfigurationError(
195
+ "Only NCBI genetic code table 1 is available in the MVP.",
196
+ code="UNSUPPORTED_GENETIC_CODE",
197
+ context={"table": table},
198
+ )
199
+ _coerce_strand(strand)
200
+ if stop_policy not in ("include", "truncate", "error"):
201
+ raise ConfigurationError(
202
+ "stop_policy must be 'include', 'truncate', or 'error'.",
203
+ code="INVALID_STOP_POLICY",
204
+ context={"stop_policy": stop_policy},
205
+ )
206
+ if unknown_policy not in ("x", "error"):
207
+ raise ConfigurationError(
208
+ "unknown_policy must be 'x' or 'error'.",
209
+ code="INVALID_UNKNOWN_CODON_POLICY",
210
+ context={"unknown_policy": unknown_policy},
211
+ )
212
+ if incomplete_policy not in ("ignore", "error"):
213
+ raise ConfigurationError(
214
+ "incomplete_policy must be 'ignore' or 'error'.",
215
+ code="INVALID_INCOMPLETE_CODON_POLICY",
216
+ context={"incomplete_policy": incomplete_policy},
217
+ )
218
+
219
+ dna = _coerce_nucleic_text(sequence)
220
+ if strand == "reverse":
221
+ dna = dna.translate(_DNA_COMPLEMENT)[::-1]
222
+ coding = dna[frame:]
223
+ remainder = len(coding) % 3
224
+ if remainder and incomplete_policy == "error":
225
+ raise InvalidAlphabetError(
226
+ "Translation input has a trailing incomplete codon.",
227
+ code="INCOMPLETE_CODON",
228
+ context={"frame": frame, "trailing_symbols": remainder},
229
+ )
230
+
231
+ protein: list[str] = []
232
+ coding_end = len(coding) - remainder
233
+ for offset in range(0, coding_end, 3):
234
+ codon = coding[offset : offset + 3]
235
+ amino_acid = _TABLE_1.get(codon)
236
+ if amino_acid is None:
237
+ if unknown_policy == "error":
238
+ raise InvalidAlphabetError(
239
+ "Ambiguous codon cannot be translated under unknown_policy='error'.",
240
+ code="AMBIGUOUS_CODON",
241
+ context={"codon": codon, "codon_index": offset // 3},
242
+ )
243
+ amino_acid = "X"
244
+ if amino_acid == "*":
245
+ if stop_policy == "truncate":
246
+ break
247
+ if stop_policy == "error":
248
+ raise InvalidAlphabetError(
249
+ "Stop codon encountered under stop_policy='error'.",
250
+ code="STOP_CODON",
251
+ context={"codon": codon, "codon_index": offset // 3},
252
+ )
253
+ protein.append(amino_acid)
254
+ return "".join(protein)
255
+
256
+
257
+ __all__ = ["transcribe", "translate"]
@@ -0,0 +1,81 @@
1
+ """Deterministic sequence-pattern annotation APIs."""
2
+
3
+ from dnakit.patterns.coding import scan_codon_sites, scan_orfs
4
+ from dnakit.patterns.crispr import (
5
+ BUILTIN_PAM_RULES,
6
+ PAM_CATALOG_VERSION,
7
+ PAMRule,
8
+ scan_pam_candidates,
9
+ )
10
+ from dnakit.patterns.motif import (
11
+ PROMOTER_CATALOG_VERSION,
12
+ PROMOTER_MOTIFS,
13
+ PWM,
14
+ scan_motif,
15
+ scan_promoter_motifs,
16
+ scan_pwm,
17
+ scan_tf_pwm,
18
+ )
19
+ from dnakit.patterns.regions import find_cpg_islands, find_low_complexity_regions
20
+ from dnakit.patterns.repeats import (
21
+ find_inverted_repeats,
22
+ find_microsatellites,
23
+ find_reverse_complement_palindromes,
24
+ find_tandem_repeats,
25
+ )
26
+ from dnakit.patterns.restriction import (
27
+ BUILTIN_RESTRICTION_ENZYMES,
28
+ RESTRICTION_CATALOG_VERSION,
29
+ RestrictionEnzyme,
30
+ scan_restriction_sites,
31
+ )
32
+ from dnakit.patterns.results import (
33
+ CodonSite,
34
+ GuideCandidate,
35
+ InvertedRepeatHit,
36
+ LowComplexityResult,
37
+ MotifHit,
38
+ ORFHit,
39
+ PalindromeHit,
40
+ PatternResult,
41
+ RegionHit,
42
+ RestrictionSiteHit,
43
+ TandemRepeatHit,
44
+ )
45
+
46
+ __all__ = [
47
+ "BUILTIN_PAM_RULES",
48
+ "BUILTIN_RESTRICTION_ENZYMES",
49
+ "PAM_CATALOG_VERSION",
50
+ "PROMOTER_CATALOG_VERSION",
51
+ "PROMOTER_MOTIFS",
52
+ "PWM",
53
+ "RESTRICTION_CATALOG_VERSION",
54
+ "CodonSite",
55
+ "GuideCandidate",
56
+ "InvertedRepeatHit",
57
+ "LowComplexityResult",
58
+ "MotifHit",
59
+ "ORFHit",
60
+ "PAMRule",
61
+ "PalindromeHit",
62
+ "PatternResult",
63
+ "RegionHit",
64
+ "RestrictionEnzyme",
65
+ "RestrictionSiteHit",
66
+ "TandemRepeatHit",
67
+ "find_cpg_islands",
68
+ "find_inverted_repeats",
69
+ "find_low_complexity_regions",
70
+ "find_microsatellites",
71
+ "find_reverse_complement_palindromes",
72
+ "find_tandem_repeats",
73
+ "scan_codon_sites",
74
+ "scan_motif",
75
+ "scan_orfs",
76
+ "scan_pam_candidates",
77
+ "scan_promoter_motifs",
78
+ "scan_pwm",
79
+ "scan_restriction_sites",
80
+ "scan_tf_pwm",
81
+ ]
@@ -0,0 +1,282 @@
1
+ """Shared bounded traversal and coordinate helpers for pattern analyses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable, Mapping
6
+ from dataclasses import dataclass
7
+ from typing import TypeAlias
8
+
9
+ from dnakit.core import (
10
+ DNA,
11
+ CompoundLocation,
12
+ DNARecord,
13
+ DNASequence,
14
+ ExecutionMode,
15
+ ImplementationInfo,
16
+ ImplementationLabel,
17
+ Interval,
18
+ Issue,
19
+ IssueSeverity,
20
+ Location,
21
+ OriginClass,
22
+ Provenance,
23
+ ReferenceInfo,
24
+ Strand,
25
+ UnresolvedLocation,
26
+ )
27
+ from dnakit.core._json import FrozenDict, freeze_mapping
28
+ from dnakit.core.facade import resolve_single_dna
29
+ from dnakit.core.gap import Gap
30
+ from dnakit.exceptions import ConfigurationError, SequenceError
31
+ from dnakit.patterns.results import PatternResult
32
+
33
+ SequenceInput: TypeAlias = DNA | DNASequence | DNARecord
34
+
35
+ IUPAC_BASES: Mapping[str, frozenset[str]] = {
36
+ "A": frozenset("A"),
37
+ "C": frozenset("C"),
38
+ "G": frozenset("G"),
39
+ "T": frozenset("T"),
40
+ "R": frozenset("AG"),
41
+ "Y": frozenset("CT"),
42
+ "S": frozenset("CG"),
43
+ "W": frozenset("AT"),
44
+ "K": frozenset("GT"),
45
+ "M": frozenset("AC"),
46
+ "B": frozenset("CGT"),
47
+ "D": frozenset("AGT"),
48
+ "H": frozenset("ACT"),
49
+ "V": frozenset("ACG"),
50
+ "N": frozenset("ACGT"),
51
+ }
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class Segment:
56
+ text: str
57
+ symbol_start: int
58
+ coordinate_start: int | None
59
+
60
+
61
+ def resolve_sequence(value: SequenceInput) -> tuple[DNASequence, str | None]:
62
+ if isinstance(value, (DNA, DNARecord, DNASequence)):
63
+ sequence, record = resolve_single_dna(value)
64
+ return sequence, None if record is None else record.id
65
+ raise ConfigurationError(
66
+ "Pattern input must be a DNASequence or DNARecord.",
67
+ context={"input_type": type(value).__name__},
68
+ )
69
+
70
+
71
+ def validate_positive_int(value: int, name: str) -> None:
72
+ if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
73
+ raise ConfigurationError(f"{name} must be a positive integer.", context={name: value})
74
+
75
+
76
+ def validate_non_negative_int(value: int, name: str) -> None:
77
+ if isinstance(value, bool) or not isinstance(value, int) or value < 0:
78
+ raise ConfigurationError(f"{name} must be a non-negative integer.", context={name: value})
79
+
80
+
81
+ def validate_bool(value: bool, name: str) -> None:
82
+ if not isinstance(value, bool):
83
+ raise ConfigurationError(f"{name} must be a boolean.", context={name: value})
84
+
85
+
86
+ def segments(sequence: DNASequence) -> tuple[Segment, ...]:
87
+ """Split at every explicit Gap and retain all resolvable coordinates."""
88
+
89
+ resolved: list[Segment] = []
90
+ symbol_start = 0
91
+ coordinate: int | None = 0
92
+ for part in sequence.parts:
93
+ if isinstance(part, str):
94
+ if part:
95
+ resolved.append(Segment(part, symbol_start, coordinate))
96
+ symbol_start += len(part)
97
+ if coordinate is not None:
98
+ coordinate += len(part)
99
+ else:
100
+ if part.length is None:
101
+ coordinate = None
102
+ elif coordinate is not None:
103
+ coordinate += part.length
104
+ return tuple(resolved)
105
+
106
+
107
+ def _split_location(start: int, length: int, total: int) -> Location:
108
+ if length < 0 or length > total:
109
+ raise ConfigurationError(
110
+ "A circular match length must be between zero and sequence length."
111
+ )
112
+ end = start + length
113
+ if end <= total:
114
+ return Interval(start, end)
115
+ return CompoundLocation((Interval(start, total), Interval(0, end - total)))
116
+
117
+
118
+ def segment_location(
119
+ segment: Segment,
120
+ local_start: int,
121
+ local_end: int,
122
+ ) -> tuple[Location, Location]:
123
+ """Map one non-wrapping segment-local interval to symbol and genomic coordinates."""
124
+
125
+ symbol_location = Interval(
126
+ segment.symbol_start + local_start,
127
+ segment.symbol_start + local_end,
128
+ )
129
+ if segment.coordinate_start is None:
130
+ coordinate_location: Location = UnresolvedLocation(
131
+ "unknown-length gap precedes this pattern hit",
132
+ (symbol_location,),
133
+ )
134
+ else:
135
+ coordinate_location = Interval(
136
+ segment.coordinate_start + local_start,
137
+ segment.coordinate_start + local_end,
138
+ )
139
+ return symbol_location, coordinate_location
140
+
141
+
142
+ def circular_location(start: int, length: int, total: int) -> tuple[Location, Location, bool]:
143
+ start %= total
144
+ location = _split_location(start, length, total)
145
+ return location, location, isinstance(location, CompoundLocation)
146
+
147
+
148
+ def reverse_complement_text(text: str) -> str:
149
+ return DNASequence(text, alphabet="iupac").reverse_complement().symbols
150
+
151
+
152
+ def iupac_compatible(left: str, right: str) -> bool:
153
+ return bool(IUPAC_BASES[left] & IUPAC_BASES[right])
154
+
155
+
156
+ def validate_iupac_text(value: str, name: str, *, allow_empty: bool = False) -> str:
157
+ if not isinstance(value, str):
158
+ raise ConfigurationError(f"{name} must be a string.")
159
+ resolved = value.upper()
160
+ if not resolved and not allow_empty:
161
+ raise ConfigurationError(f"{name} must be non-empty.")
162
+ invalid = sorted(set(resolved) - set(IUPAC_BASES))
163
+ if invalid:
164
+ raise ConfigurationError(
165
+ f"{name} contains symbols outside the DNA IUPAC alphabet.",
166
+ context={"invalid_symbols": invalid},
167
+ )
168
+ return resolved
169
+
170
+
171
+ def require_ungapped_circular(sequence: DNASequence, operation: str) -> None:
172
+ if sequence.topology.value == "circular" and sequence.is_gapped:
173
+ raise SequenceError(
174
+ f"{operation} cannot resolve a circular origin across explicit gaps.",
175
+ code="GAPPED_CIRCULAR_PATTERN_UNSUPPORTED",
176
+ )
177
+
178
+
179
+ def require_linear(sequence: DNASequence, operation: str) -> None:
180
+ if sequence.topology.value == "circular":
181
+ raise SequenceError(
182
+ f"{operation} currently requires linear topology.",
183
+ code="CIRCULAR_PATTERN_UNSUPPORTED",
184
+ hint="Linearize at a documented origin before running this analysis.",
185
+ )
186
+
187
+
188
+ def pattern_provenance(
189
+ *,
190
+ reimplementation: bool,
191
+ reference_name: str | None = None,
192
+ reference_version: str | None = None,
193
+ ) -> Provenance:
194
+ return Provenance(
195
+ implementation=ImplementationInfo(
196
+ label=(
197
+ ImplementationLabel.REIMPLEMENTATION
198
+ if reimplementation
199
+ else ImplementationLabel.NATIVE
200
+ ),
201
+ execution_mode=ExecutionMode.INTERNAL,
202
+ origin_class=(
203
+ OriginClass.PUBLISHED_ALGORITHM if reimplementation else OriginClass.DNAKIT
204
+ ),
205
+ ),
206
+ reference=(
207
+ None
208
+ if reference_name is None
209
+ else ReferenceInfo(reference_name, version=reference_version)
210
+ ),
211
+ )
212
+
213
+
214
+ def build_result(
215
+ sequence: DNASequence,
216
+ sequence_id: str | None,
217
+ *,
218
+ name: str,
219
+ method: str,
220
+ algorithm_version: str,
221
+ parameters: Mapping[str, object],
222
+ hits: Iterable[object],
223
+ max_matches: int,
224
+ truncated: bool,
225
+ provenance: Provenance,
226
+ issues: Iterable[Issue] = (),
227
+ ) -> PatternResult[object]:
228
+ gap_count = sum(isinstance(part, Gap) for part in sequence.parts)
229
+ unknown_gap_count = sum(
230
+ isinstance(part, Gap) and part.length is None for part in sequence.parts
231
+ )
232
+ resolved_issues = list(issues)
233
+ if truncated:
234
+ resolved_issues.append(
235
+ Issue(
236
+ "PATTERN_MATCH_LIMIT_REACHED",
237
+ IssueSeverity.WARNING,
238
+ "Pattern hits were truncated at max_matches.",
239
+ details={"max_matches": max_matches},
240
+ )
241
+ )
242
+ return PatternResult(
243
+ name=name,
244
+ method=method,
245
+ algorithm_version=algorithm_version,
246
+ sequence_id=sequence_id,
247
+ parameters=freeze_mapping(parameters),
248
+ hits=tuple(hits),
249
+ inspected_symbol_count=sequence.symbol_length,
250
+ gap_count=gap_count,
251
+ unknown_gap_count=unknown_gap_count,
252
+ max_matches=max_matches,
253
+ truncated=truncated,
254
+ coordinate_system="0-based-half-open",
255
+ gap_policy="split-no-crossing",
256
+ topology=sequence.topology.value,
257
+ provenance=provenance,
258
+ issues=tuple(resolved_issues),
259
+ )
260
+
261
+
262
+ def frozen(values: Mapping[str, object]) -> FrozenDict:
263
+ return freeze_mapping(values)
264
+
265
+
266
+ def coerce_strands(value: Strand | str) -> tuple[Strand, ...]:
267
+ try:
268
+ strand = value if isinstance(value, Strand) else Strand(value)
269
+ except (TypeError, ValueError) as exc:
270
+ raise ConfigurationError(
271
+ "Unknown strand selection.",
272
+ context={"strand": value},
273
+ hint="Choose 'forward', 'reverse', or 'both'.",
274
+ ) from exc
275
+ if strand is Strand.BOTH:
276
+ return (Strand.FORWARD, Strand.REVERSE)
277
+ if strand not in (Strand.FORWARD, Strand.REVERSE):
278
+ raise ConfigurationError("Pattern strand must be forward, reverse, or both.")
279
+ return (strand,)
280
+
281
+
282
+ __all__ = ["IUPAC_BASES", "SequenceInput"]