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,345 @@
1
+ """Validated user-supplied dinucleotide-property tables.
2
+
3
+ DNAKit deliberately ships property names and units only. Numerical
4
+ coefficients must be supplied by the caller in an explicitly loaded JSON file.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import json
11
+ import math
12
+ from collections.abc import Mapping
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from dnakit.core._json import FrozenDict
18
+ from dnakit.exceptions import ConfigurationError
19
+
20
+ DINUCLEOTIDES = (
21
+ "AA",
22
+ "AC",
23
+ "AG",
24
+ "AT",
25
+ "CA",
26
+ "CC",
27
+ "CG",
28
+ "CT",
29
+ "GA",
30
+ "GC",
31
+ "GG",
32
+ "GT",
33
+ "TA",
34
+ "TC",
35
+ "TG",
36
+ "TT",
37
+ )
38
+ DINUCLEOTIDE_TABLE_SCHEMA_VERSION = "dnakit.dinucleotide_property_table.v1"
39
+ DEFAULT_MAX_DINUCLEOTIDE_TABLE_BYTES = 1_000_000
40
+ _MAX_METADATA_LENGTH = 1_000
41
+ _MAX_ABSOLUTE_VALUE = 1.0e12
42
+
43
+
44
+ @dataclass(frozen=True, slots=True)
45
+ class DinucleotidePropertySpec:
46
+ """Stable output identity for one optional dinucleotide-property scale."""
47
+
48
+ key: str
49
+ display_name: str
50
+ unit: str
51
+
52
+
53
+ DINUCLEOTIDE_PROPERTY_SPECS = (
54
+ DinucleotidePropertySpec("twist", "Twist", "degree"),
55
+ DinucleotidePropertySpec("tilt", "Tilt", "degree"),
56
+ DinucleotidePropertySpec("roll", "Roll", "degree"),
57
+ DinucleotidePropertySpec("shift", "Shift", "angstrom"),
58
+ DinucleotidePropertySpec("slide", "Slide", "angstrom"),
59
+ DinucleotidePropertySpec("rise", "Rise", "angstrom"),
60
+ DinucleotidePropertySpec("bend", "Bend", "degree"),
61
+ DinucleotidePropertySpec("inclination", "Inclination", "degree"),
62
+ DinucleotidePropertySpec("direction", "Direction", "degree"),
63
+ DinucleotidePropertySpec("propeller_twist", "Propeller twist", "degree"),
64
+ DinucleotidePropertySpec("major_groove_width", "Major groove width", "angstrom"),
65
+ DinucleotidePropertySpec("minor_groove_width", "Minor groove width", "angstrom"),
66
+ DinucleotidePropertySpec("persistence_length", "Persistence length", "nanometer"),
67
+ DinucleotidePropertySpec("stacking_energy", "Stacking energy", "kcal/mol"),
68
+ DinucleotidePropertySpec("free_energy", "Free energy", "kcal/mol"),
69
+ )
70
+
71
+
72
+ @dataclass(frozen=True, slots=True)
73
+ class DinucleotideProperty:
74
+ """One fully validated 16-value property supplied by the caller."""
75
+
76
+ key: str
77
+ display_name: str
78
+ unit: str
79
+ values: FrozenDict
80
+
81
+ def __post_init__(self) -> None:
82
+ spec = next((item for item in DINUCLEOTIDE_PROPERTY_SPECS if item.key == self.key), None)
83
+ if spec is None or self.display_name != spec.display_name or self.unit != spec.unit:
84
+ raise ConfigurationError(
85
+ "Dinucleotide property identity does not match the fixed schema.",
86
+ code="INVALID_DINUCLEOTIDE_TABLE_PROPERTY",
87
+ context={"property": self.key},
88
+ )
89
+ object.__setattr__(self, "values", _validated_coefficients(self.values, spec))
90
+
91
+
92
+ @dataclass(frozen=True, slots=True)
93
+ class DinucleotidePropertyTable:
94
+ """An immutable, checksummed table loaded from a user-owned JSON file."""
95
+
96
+ name: str
97
+ version: str
98
+ source: str
99
+ sha256: str
100
+ properties: tuple[DinucleotideProperty, ...]
101
+
102
+ def __post_init__(self) -> None:
103
+ for field_name in ("name", "version", "source"):
104
+ value = getattr(self, field_name)
105
+ if not isinstance(value, str) or not value.strip() or len(value) > _MAX_METADATA_LENGTH:
106
+ raise ConfigurationError(
107
+ f"Dinucleotide table {field_name} must be non-empty bounded text.",
108
+ code="INVALID_DINUCLEOTIDE_TABLE_METADATA",
109
+ )
110
+ if (
111
+ not isinstance(self.sha256, str)
112
+ or len(self.sha256) != 64
113
+ or any(character not in "0123456789abcdef" for character in self.sha256)
114
+ ):
115
+ raise ConfigurationError(
116
+ "Dinucleotide table sha256 must contain 64 lowercase hexadecimal digits.",
117
+ code="INVALID_DINUCLEOTIDE_TABLE_CHECKSUM",
118
+ )
119
+ if not isinstance(self.properties, tuple) or not all(
120
+ isinstance(item, DinucleotideProperty) for item in self.properties
121
+ ):
122
+ raise ConfigurationError(
123
+ "Dinucleotide table properties must be a tuple of validated properties.",
124
+ code="INVALID_DINUCLEOTIDE_TABLE_PROPERTIES",
125
+ )
126
+ expected = tuple(spec.key for spec in DINUCLEOTIDE_PROPERTY_SPECS)
127
+ observed = tuple(item.key for item in self.properties)
128
+ if observed != expected:
129
+ raise ConfigurationError(
130
+ "Dinucleotide table properties must follow the required 15-property order.",
131
+ code="INVALID_DINUCLEOTIDE_TABLE_PROPERTIES",
132
+ context={"expected": expected, "observed": observed},
133
+ )
134
+
135
+ def property(self, key: str) -> DinucleotideProperty:
136
+ """Return one required property by its stable key."""
137
+
138
+ for item in self.properties:
139
+ if item.key == key:
140
+ return item
141
+ raise KeyError(key) # pragma: no cover - construction enforces all keys.
142
+
143
+
144
+ def _object_without_duplicates(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
145
+ result: dict[str, Any] = {}
146
+ for key, value in pairs:
147
+ if key in result:
148
+ raise ConfigurationError(
149
+ "Dinucleotide table JSON contains a duplicate object key.",
150
+ code="DUPLICATE_DINUCLEOTIDE_TABLE_KEY",
151
+ context={"key": key},
152
+ )
153
+ result[key] = value
154
+ return result
155
+
156
+
157
+ def _bounded_metadata(value: object, name: str) -> str:
158
+ if not isinstance(value, str) or not value.strip() or len(value) > _MAX_METADATA_LENGTH:
159
+ raise ConfigurationError(
160
+ f"Dinucleotide table {name} must be non-empty bounded text.",
161
+ code="INVALID_DINUCLEOTIDE_TABLE_METADATA",
162
+ context={"field": name},
163
+ )
164
+ return value.strip()
165
+
166
+
167
+ def _validated_coefficients(
168
+ raw_values: object,
169
+ spec: DinucleotidePropertySpec,
170
+ ) -> FrozenDict:
171
+ if not isinstance(raw_values, Mapping) or set(raw_values) != set(DINUCLEOTIDES):
172
+ raise ConfigurationError(
173
+ "Each property must define exactly the 16 canonical dinucleotides.",
174
+ code="INCOMPLETE_DINUCLEOTIDE_TABLE_PROPERTY",
175
+ context={"property": spec.key, "required_dinucleotides": DINUCLEOTIDES},
176
+ )
177
+ resolved: dict[str, float] = {}
178
+ for word in DINUCLEOTIDES:
179
+ item = raw_values[word]
180
+ if (
181
+ isinstance(item, bool)
182
+ or not isinstance(item, (int, float))
183
+ or not math.isfinite(item)
184
+ or abs(float(item)) > _MAX_ABSOLUTE_VALUE
185
+ ):
186
+ raise ConfigurationError(
187
+ "Dinucleotide coefficients must be finite bounded numbers.",
188
+ code="INVALID_DINUCLEOTIDE_TABLE_VALUE",
189
+ context={"property": spec.key, "dinucleotide": word},
190
+ )
191
+ resolved[word] = float(item)
192
+ return FrozenDict(resolved)
193
+
194
+
195
+ def _property_values(value: object, spec: DinucleotidePropertySpec) -> FrozenDict:
196
+ if not isinstance(value, Mapping):
197
+ raise ConfigurationError(
198
+ "Each dinucleotide property must be a JSON object.",
199
+ code="INVALID_DINUCLEOTIDE_TABLE_PROPERTY",
200
+ context={"property": spec.key},
201
+ )
202
+ if set(value) != {"unit", "values"}:
203
+ raise ConfigurationError(
204
+ "Each dinucleotide property must contain exactly 'unit' and 'values'.",
205
+ code="INVALID_DINUCLEOTIDE_TABLE_PROPERTY",
206
+ context={"property": spec.key},
207
+ )
208
+ if value["unit"] != spec.unit:
209
+ raise ConfigurationError(
210
+ "Dinucleotide property unit does not match the fixed descriptor schema.",
211
+ code="DINUCLEOTIDE_TABLE_UNIT_MISMATCH",
212
+ context={
213
+ "property": spec.key,
214
+ "expected_unit": spec.unit,
215
+ "observed_unit": value["unit"],
216
+ },
217
+ )
218
+ return _validated_coefficients(value["values"], spec)
219
+
220
+
221
+ def load_dinucleotide_property_table(
222
+ path: str | Path,
223
+ *,
224
+ max_bytes: int = DEFAULT_MAX_DINUCLEOTIDE_TABLE_BYTES,
225
+ ) -> DinucleotidePropertyTable:
226
+ """Load a bounded JSON table supplied and licensed by the caller.
227
+
228
+ Loading is explicit and local. DNAKit does not fetch, generate, cache, or
229
+ redistribute the table and records a SHA-256 digest for every calculation.
230
+ """
231
+
232
+ if (
233
+ isinstance(max_bytes, bool)
234
+ or not isinstance(max_bytes, int)
235
+ or not 1 <= max_bytes <= 10_000_000
236
+ ):
237
+ raise ConfigurationError("max_bytes must be an integer in [1, 10000000].")
238
+ try:
239
+ resolved_path = Path(path).expanduser().resolve(strict=True)
240
+ size = resolved_path.stat().st_size
241
+ except (OSError, RuntimeError, TypeError, ValueError) as exc:
242
+ raise ConfigurationError(
243
+ "Dinucleotide property table is unavailable.",
244
+ code="DINUCLEOTIDE_TABLE_UNAVAILABLE",
245
+ context={"path": str(path)},
246
+ ) from exc
247
+ if not resolved_path.is_file():
248
+ raise ConfigurationError(
249
+ "Dinucleotide property table must be a regular file.",
250
+ code="DINUCLEOTIDE_TABLE_UNAVAILABLE",
251
+ context={"path": str(resolved_path)},
252
+ )
253
+ if size > max_bytes:
254
+ raise ConfigurationError(
255
+ "Dinucleotide property table exceeds max_bytes.",
256
+ code="DINUCLEOTIDE_TABLE_SIZE_LIMIT",
257
+ context={"size": size, "max_bytes": max_bytes},
258
+ )
259
+ try:
260
+ with resolved_path.open("rb") as handle:
261
+ payload = handle.read(max_bytes + 1)
262
+ except OSError as exc:
263
+ raise ConfigurationError(
264
+ "Dinucleotide property table could not be read.",
265
+ code="DINUCLEOTIDE_TABLE_UNAVAILABLE",
266
+ context={"path": str(resolved_path)},
267
+ ) from exc
268
+ if len(payload) > max_bytes:
269
+ raise ConfigurationError(
270
+ "Dinucleotide property table exceeds max_bytes.",
271
+ code="DINUCLEOTIDE_TABLE_SIZE_LIMIT",
272
+ context={"size_at_least": len(payload), "max_bytes": max_bytes},
273
+ )
274
+ try:
275
+ decoded = payload.decode("utf-8")
276
+ raw = json.loads(decoded, object_pairs_hook=_object_without_duplicates)
277
+ except ConfigurationError:
278
+ raise
279
+ except (UnicodeDecodeError, json.JSONDecodeError, RecursionError) as exc:
280
+ raise ConfigurationError(
281
+ "Dinucleotide property table must be valid UTF-8 JSON.",
282
+ code="INVALID_DINUCLEOTIDE_TABLE_JSON",
283
+ context={"path": str(resolved_path), "error_type": type(exc).__name__},
284
+ ) from exc
285
+ if not isinstance(raw, Mapping) or set(raw) != {
286
+ "schema_version",
287
+ "name",
288
+ "version",
289
+ "source",
290
+ "properties",
291
+ }:
292
+ raise ConfigurationError(
293
+ "Dinucleotide table root fields do not match the required schema.",
294
+ code="INVALID_DINUCLEOTIDE_TABLE_SCHEMA",
295
+ )
296
+ if raw["schema_version"] != DINUCLEOTIDE_TABLE_SCHEMA_VERSION:
297
+ raise ConfigurationError(
298
+ "Unsupported dinucleotide table schema_version.",
299
+ code="UNSUPPORTED_DINUCLEOTIDE_TABLE_SCHEMA",
300
+ context={"schema_version": raw["schema_version"]},
301
+ )
302
+ raw_properties = raw["properties"]
303
+ if not isinstance(raw_properties, Mapping):
304
+ raise ConfigurationError(
305
+ "Dinucleotide table properties must be a JSON object.",
306
+ code="INVALID_DINUCLEOTIDE_TABLE_PROPERTIES",
307
+ )
308
+ expected_keys = {spec.key for spec in DINUCLEOTIDE_PROPERTY_SPECS}
309
+ if set(raw_properties) != expected_keys:
310
+ raise ConfigurationError(
311
+ "Dinucleotide table must contain exactly the 15 required properties.",
312
+ code="INVALID_DINUCLEOTIDE_TABLE_PROPERTIES",
313
+ context={
314
+ "missing": tuple(sorted(expected_keys - set(raw_properties))),
315
+ "extra": tuple(sorted(set(raw_properties) - expected_keys)),
316
+ },
317
+ )
318
+ properties = tuple(
319
+ DinucleotideProperty(
320
+ key=spec.key,
321
+ display_name=spec.display_name,
322
+ unit=spec.unit,
323
+ values=_property_values(raw_properties[spec.key], spec),
324
+ )
325
+ for spec in DINUCLEOTIDE_PROPERTY_SPECS
326
+ )
327
+ return DinucleotidePropertyTable(
328
+ name=_bounded_metadata(raw["name"], "name"),
329
+ version=_bounded_metadata(raw["version"], "version"),
330
+ source=_bounded_metadata(raw["source"], "source"),
331
+ sha256=hashlib.sha256(payload).hexdigest(),
332
+ properties=properties,
333
+ )
334
+
335
+
336
+ __all__ = [
337
+ "DEFAULT_MAX_DINUCLEOTIDE_TABLE_BYTES",
338
+ "DINUCLEOTIDES",
339
+ "DINUCLEOTIDE_PROPERTY_SPECS",
340
+ "DINUCLEOTIDE_TABLE_SCHEMA_VERSION",
341
+ "DinucleotideProperty",
342
+ "DinucleotidePropertySpec",
343
+ "DinucleotidePropertyTable",
344
+ "load_dinucleotide_property_table",
345
+ ]
@@ -0,0 +1,180 @@
1
+ """Shared validation and sequence traversal for native descriptors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterator
6
+ from dataclasses import dataclass
7
+ from enum import Enum
8
+ from typing import TypeAlias
9
+
10
+ from dnakit.core.facade import DNA, resolve_single_dna
11
+ from dnakit.core.record import DNARecord
12
+ from dnakit.core.sequence import DNASequence
13
+ from dnakit.exceptions import ConfigurationError, InvalidAlphabetError
14
+
15
+ SequenceInput: TypeAlias = DNA | DNASequence | DNARecord
16
+
17
+
18
+ class DescriptorAmbiguityPolicy(str, Enum):
19
+ """Treatment of IUPAC symbols that are not one of A, C, G, and T."""
20
+
21
+ ERROR = "error"
22
+ IGNORE = "ignore"
23
+
24
+
25
+ def sequence_and_id(value: SequenceInput) -> tuple[DNASequence, str | None]:
26
+ """Resolve the sequence and optional record identifier from public input."""
27
+
28
+ if isinstance(value, (DNA, DNARecord, DNASequence)):
29
+ sequence, record = resolve_single_dna(value)
30
+ return sequence, None if record is None else record.id
31
+ raise ConfigurationError(
32
+ "Descriptor input must be a DNASequence or DNARecord.",
33
+ context={"input_type": type(value).__name__},
34
+ )
35
+
36
+
37
+ def coerce_ambiguity_policy(
38
+ value: DescriptorAmbiguityPolicy | str,
39
+ ) -> DescriptorAmbiguityPolicy:
40
+ """Coerce a public ambiguity policy and report stable configuration errors."""
41
+
42
+ try:
43
+ return (
44
+ value
45
+ if isinstance(value, DescriptorAmbiguityPolicy)
46
+ else DescriptorAmbiguityPolicy(value)
47
+ )
48
+ except (TypeError, ValueError) as exc:
49
+ raise ConfigurationError(
50
+ "Unknown descriptor ambiguity policy.",
51
+ context={"ambiguity_policy": value},
52
+ hint="Choose 'error' or 'ignore'.",
53
+ ) from exc
54
+
55
+
56
+ def validate_bool(value: bool, name: str) -> None:
57
+ if not isinstance(value, bool):
58
+ raise ConfigurationError(f"{name} must be a boolean.", context={name: value})
59
+
60
+
61
+ def validate_positive_int(value: int, name: str) -> None:
62
+ if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
63
+ raise ConfigurationError(
64
+ f"{name} must be a positive integer.",
65
+ context={name: value},
66
+ )
67
+
68
+
69
+ def reject_ambiguity(sequence: DNASequence, policy: DescriptorAmbiguityPolicy) -> None:
70
+ """Reject ambiguity before any partial descriptor calculation."""
71
+
72
+ if policy is DescriptorAmbiguityPolicy.ERROR and sequence.ambiguity_count:
73
+ raise InvalidAlphabetError(
74
+ "This descriptor does not accept ambiguous IUPAC symbols under the error policy.",
75
+ code="DESCRIPTOR_AMBIGUITY_NOT_ALLOWED",
76
+ context={"ambiguity_count": sequence.ambiguity_count},
77
+ hint="Use ambiguity_policy='ignore' to omit affected symbols or windows.",
78
+ )
79
+
80
+
81
+ def fragments(sequence: DNASequence, *, cross_gaps: bool) -> tuple[str, ...]:
82
+ """Return symbol fragments without crossing a gap that explicitly forbids it."""
83
+
84
+ validate_bool(cross_gaps, "cross_gaps")
85
+ if not cross_gaps:
86
+ return tuple(part for part in sequence.parts if isinstance(part, str))
87
+ resolved: list[str] = []
88
+ current: list[str] = []
89
+ for part in sequence.parts:
90
+ if isinstance(part, str):
91
+ current.append(part)
92
+ elif part.crossable is False and current:
93
+ resolved.append("".join(current))
94
+ current = []
95
+ if current:
96
+ resolved.append("".join(current))
97
+ return tuple(resolved)
98
+
99
+
100
+ def canonical_runs(text: str) -> Iterator[str]:
101
+ """Yield uninterrupted A/C/G/T runs, splitting at ignored IUPAC symbols."""
102
+
103
+ start = 0
104
+ for index, symbol in enumerate(text):
105
+ if symbol not in "ACGT":
106
+ if start < index:
107
+ yield text[start:index]
108
+ start = index + 1
109
+ if start < len(text):
110
+ yield text[start:]
111
+
112
+
113
+ def iter_kmers(
114
+ sequence: DNASequence,
115
+ *,
116
+ k: int,
117
+ overlapping: bool,
118
+ cross_gaps: bool,
119
+ ) -> Iterator[str]:
120
+ """Yield valid canonical k-mers without joining ignored ambiguity."""
121
+
122
+ validate_positive_int(k, "k")
123
+ validate_bool(overlapping, "overlapping")
124
+ step = 1 if overlapping else k
125
+ for fragment in fragments(sequence, cross_gaps=cross_gaps):
126
+ for run in canonical_runs(fragment):
127
+ for start in range(0, len(run) - k + 1, step):
128
+ yield run[start : start + k]
129
+
130
+
131
+ @dataclass(frozen=True)
132
+ class SymbolView:
133
+ """Flattened symbols with coordinate and gap-segment information."""
134
+
135
+ text: str
136
+ coordinate_positions: tuple[int | None, ...]
137
+ segment_ids: tuple[int, ...]
138
+ crossable_segment_ids: tuple[int, ...]
139
+ unknown_gap_boundaries: frozenset[int]
140
+
141
+
142
+ def symbol_view(sequence: DNASequence) -> SymbolView:
143
+ """Build a symbol-indexed view without silently erasing gap boundaries."""
144
+
145
+ symbols: list[str] = []
146
+ coordinate_positions: list[int | None] = []
147
+ segment_ids: list[int] = []
148
+ crossable_segment_ids: list[int] = []
149
+ unknown_gap_boundaries: set[int] = set()
150
+ coordinate: int | None = 0
151
+ segment = 0
152
+ crossable_segment = 0
153
+ for part in sequence.parts:
154
+ if isinstance(part, str):
155
+ for symbol in part:
156
+ symbols.append(symbol)
157
+ coordinate_positions.append(coordinate)
158
+ segment_ids.append(segment)
159
+ crossable_segment_ids.append(crossable_segment)
160
+ if coordinate is not None:
161
+ coordinate += 1
162
+ else:
163
+ segment += 1
164
+ if part.crossable is False:
165
+ crossable_segment += 1
166
+ if part.length is None:
167
+ unknown_gap_boundaries.add(segment)
168
+ coordinate = None
169
+ elif coordinate is not None:
170
+ coordinate += part.length
171
+ return SymbolView(
172
+ text="".join(symbols),
173
+ coordinate_positions=tuple(coordinate_positions),
174
+ segment_ids=tuple(segment_ids),
175
+ crossable_segment_ids=tuple(crossable_segment_ids),
176
+ unknown_gap_boundaries=frozenset(unknown_gap_boundaries),
177
+ )
178
+
179
+
180
+ __all__ = ["DescriptorAmbiguityPolicy", "SequenceInput"]