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,565 @@
1
+ """Deterministic DNA input normalization with a complete in-memory audit."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import unicodedata
8
+ from collections import defaultdict
9
+ from collections.abc import Iterable
10
+ from typing import TypeAlias
11
+
12
+ from dnakit.core._json import to_json_compatible
13
+ from dnakit.core.enums import DNAAlphabet, IssueSeverity
14
+ from dnakit.core.gap import Gap
15
+ from dnakit.core.issues import Issue
16
+ from dnakit.core.provenance import Provenance
17
+ from dnakit.core.sequence import DNASequence
18
+ from dnakit.exceptions import ConfigurationError, InvalidAlphabetError
19
+
20
+ from ._shared import CANONICAL_BASES, IUPAC_AMBIGUITY, IUPAC_BASES, ambiguity_report
21
+ from .config import AmbiguityPolicy, NormalizationConfig, UPolicy
22
+ from .results import (
23
+ InputPosition,
24
+ InvalidSymbol,
25
+ NormalizationChange,
26
+ NormalizationResult,
27
+ NormalizationStep,
28
+ RawInputSnapshot,
29
+ )
30
+
31
+ RawSequenceInput: TypeAlias = str | bytes | Iterable[str | Gap] | DNASequence
32
+
33
+
34
+ def normalize(
35
+ raw: RawSequenceInput,
36
+ *,
37
+ keep_ambiguous: bool = True,
38
+ keep_u: bool = False,
39
+ keep_other: bool = False,
40
+ config: NormalizationConfig | None = None,
41
+ ) -> NormalizationResult:
42
+ """Normalize raw DNA while retaining an audit entry for every edit.
43
+
44
+ By default IUPAC ambiguity symbols are retained, while U and all other
45
+ non-DNA characters are deleted. Set the three ``keep_*`` arguments to
46
+ change those simple policies. Advanced callers can pass
47
+ :class:`NormalizationConfig`; non-default keep arguments must not be mixed
48
+ with an explicit config. Configuration errors and undecodable bytes raise
49
+ immediately.
50
+ """
51
+ simple_flags = {
52
+ "keep_ambiguous": keep_ambiguous,
53
+ "keep_u": keep_u,
54
+ "keep_other": keep_other,
55
+ }
56
+ for field_name, value in simple_flags.items():
57
+ if not isinstance(value, bool):
58
+ raise ConfigurationError(
59
+ f"{field_name} must be a boolean.",
60
+ code="INVALID_NORMALIZATION_FLAG",
61
+ context={"field": field_name},
62
+ )
63
+ if config is None:
64
+ resolved = NormalizationConfig(
65
+ keep_ambiguous=keep_ambiguous,
66
+ keep_u=keep_u,
67
+ keep_other=keep_other,
68
+ )
69
+ elif not isinstance(config, NormalizationConfig):
70
+ raise ConfigurationError(
71
+ "config must be NormalizationConfig or None.",
72
+ code="INVALID_NORMALIZATION_CONFIG",
73
+ )
74
+ else:
75
+ if (keep_ambiguous, keep_u, keep_other) != (True, False, False):
76
+ raise ConfigurationError(
77
+ "Non-default keep arguments cannot be combined with config.",
78
+ code="NORMALIZATION_ARGUMENT_CONFLICT",
79
+ hint="Set keep_ambiguous, keep_u and keep_other on NormalizationConfig.",
80
+ )
81
+ resolved = config
82
+
83
+ snapshot, source_parts = _snapshot_and_parts(raw)
84
+ source_sequence = raw if isinstance(raw, DNASequence) else None
85
+ u_policy = resolved.effective_u_policy
86
+ ambiguity_policy = resolved.effective_ambiguity_policy
87
+
88
+ output_parts: list[str | Gap] = []
89
+ output_positions: list[tuple[InputPosition, ...] | None] = []
90
+ changes: list[NormalizationChange] = []
91
+ issues: list[Issue] = []
92
+ u_positions: list[InputPosition] = []
93
+ gap_count = 0
94
+ normalized_symbol_offset = 0
95
+ absolute_offset = 0
96
+
97
+ for part_index, part in enumerate(source_parts):
98
+ if isinstance(part, Gap):
99
+ gap_count += 1
100
+ output_parts.append(part)
101
+ output_positions.append(None)
102
+ if not resolved.allow_gaps:
103
+ issues.append(
104
+ _issue(
105
+ "STD_GAP_NOT_ALLOWED",
106
+ IssueSeverity.ERROR,
107
+ "The input contains an explicit Gap but gaps are disabled.",
108
+ {"part_index": part_index},
109
+ )
110
+ )
111
+ continue
112
+
113
+ normalized_characters: list[str] = []
114
+ normalized_positions: list[InputPosition] = []
115
+ for offset, original in enumerate(part):
116
+ position = InputPosition(part_index, offset, absolute_offset + offset)
117
+ symbol = original
118
+
119
+ if symbol.isspace():
120
+ if resolved.remove_whitespace:
121
+ changes.append(
122
+ _change(
123
+ "remove_whitespace",
124
+ position,
125
+ symbol,
126
+ "",
127
+ normalized_symbol_offset + len(normalized_characters),
128
+ "Configured whitespace removal.",
129
+ )
130
+ )
131
+ continue
132
+ elif _is_invisible(symbol) and resolved.remove_invisible:
133
+ changes.append(
134
+ _change(
135
+ "remove_invisible",
136
+ position,
137
+ symbol,
138
+ "",
139
+ normalized_symbol_offset + len(normalized_characters),
140
+ f"Removed Unicode category {unicodedata.category(symbol)}.",
141
+ )
142
+ )
143
+ continue
144
+ elif symbol in resolved.removable_separators:
145
+ changes.append(
146
+ _change(
147
+ "remove_separator",
148
+ position,
149
+ symbol,
150
+ "",
151
+ normalized_symbol_offset + len(normalized_characters),
152
+ "Configured separator removal.",
153
+ )
154
+ )
155
+ continue
156
+
157
+ if resolved.uppercase and "a" <= symbol <= "z":
158
+ upper = symbol.upper()
159
+ changes.append(
160
+ _change(
161
+ "uppercase",
162
+ position,
163
+ symbol,
164
+ upper,
165
+ normalized_symbol_offset + len(normalized_characters),
166
+ "ASCII DNA characters use uppercase canonical form.",
167
+ )
168
+ )
169
+ symbol = upper
170
+
171
+ if symbol == "U":
172
+ u_positions.append(position)
173
+ if u_policy is UPolicy.DELETE:
174
+ changes.append(
175
+ _change(
176
+ "delete_u",
177
+ position,
178
+ symbol,
179
+ "",
180
+ normalized_symbol_offset + len(normalized_characters),
181
+ "Configured DNA uracil deletion.",
182
+ )
183
+ )
184
+ continue
185
+ if u_policy is UPolicy.REPLACE:
186
+ changes.append(
187
+ _change(
188
+ "replace_u",
189
+ position,
190
+ symbol,
191
+ "T",
192
+ normalized_symbol_offset + len(normalized_characters),
193
+ "Configured DNA uracil-to-thymine conversion.",
194
+ )
195
+ )
196
+ symbol = "T"
197
+
198
+ if symbol in IUPAC_AMBIGUITY:
199
+ if ambiguity_policy is AmbiguityPolicy.DELETE:
200
+ changes.append(
201
+ _change(
202
+ "delete_ambiguity",
203
+ position,
204
+ symbol,
205
+ "",
206
+ normalized_symbol_offset + len(normalized_characters),
207
+ "Configured IUPAC ambiguity deletion.",
208
+ )
209
+ )
210
+ continue
211
+ if ambiguity_policy is AmbiguityPolicy.MASK and symbol != resolved.ambiguity_mask:
212
+ changes.append(
213
+ _change(
214
+ "mask_ambiguity",
215
+ position,
216
+ symbol,
217
+ resolved.ambiguity_mask,
218
+ normalized_symbol_offset + len(normalized_characters),
219
+ "Configured ambiguity masking.",
220
+ )
221
+ )
222
+ symbol = resolved.ambiguity_mask
223
+
224
+ if symbol not in IUPAC_BASES and symbol != "U" and not resolved.keep_other:
225
+ changes.append(
226
+ _change(
227
+ "delete_other",
228
+ position,
229
+ symbol,
230
+ "",
231
+ normalized_symbol_offset + len(normalized_characters),
232
+ "Configured deletion of a non-DNA character.",
233
+ )
234
+ )
235
+ continue
236
+
237
+ normalized_characters.append(symbol)
238
+ normalized_positions.append(position)
239
+
240
+ output_parts.append("".join(normalized_characters))
241
+ output_positions.append(tuple(normalized_positions))
242
+ normalized_symbol_offset += len(normalized_characters)
243
+ absolute_offset += len(part)
244
+
245
+ normalized_parts = _coalesce_parts(tuple(output_parts))
246
+ alphabet = _resolve_output_alphabet(resolved.alphabet, source_sequence, output_parts)
247
+ invalid = _invalid_symbols(output_parts, output_positions, alphabet)
248
+
249
+ if u_positions and u_policy in {UPolicy.ERROR, UPolicy.WARN}:
250
+ severity = IssueSeverity.ERROR if u_policy is UPolicy.ERROR else IssueSeverity.WARNING
251
+ issues.append(
252
+ _issue(
253
+ "STD_U_PRESENT",
254
+ severity,
255
+ f"Found {len(u_positions)} uracil symbol(s); policy is {u_policy.value}.",
256
+ {"positions": [position.absolute_offset for position in u_positions]},
257
+ )
258
+ )
259
+
260
+ ambiguity = ambiguity_report(
261
+ normalized_parts,
262
+ base_priors=(
263
+ resolved.base_priors if ambiguity_policy is AmbiguityPolicy.PROBABILITY else None
264
+ ),
265
+ )
266
+ if ambiguity.total_count and ambiguity_policy is AmbiguityPolicy.ERROR:
267
+ issues.append(
268
+ _issue(
269
+ "STD_AMBIGUITY_NOT_ALLOWED",
270
+ IssueSeverity.ERROR,
271
+ f"Found {ambiguity.total_count} IUPAC ambiguity symbol(s).",
272
+ {"symbols": {item.symbol: item.count for item in ambiguity.by_symbol}},
273
+ )
274
+ )
275
+
276
+ for item in invalid:
277
+ issues.append(
278
+ _issue(
279
+ "STD_INVALID_SYMBOL",
280
+ IssueSeverity.ERROR,
281
+ f"Character {item.symbol!r} ({item.codepoint}) is not valid for "
282
+ f"the {alphabet.value} DNA alphabet.",
283
+ {
284
+ "symbol": item.symbol,
285
+ "codepoint": item.codepoint,
286
+ "positions": [position.absolute_offset for position in item.positions],
287
+ },
288
+ )
289
+ )
290
+
291
+ has_error = any(issue.severity is IssueSeverity.ERROR for issue in issues)
292
+ sequence: DNASequence | None = None
293
+ if not has_error:
294
+ if (
295
+ source_sequence is not None
296
+ and normalized_parts == source_sequence.parts
297
+ and alphabet is source_sequence.alphabet
298
+ ):
299
+ sequence = source_sequence
300
+ else:
301
+ if source_sequence is None:
302
+ sequence = DNASequence(normalized_parts, alphabet=alphabet)
303
+ else:
304
+ sequence = DNASequence(
305
+ normalized_parts,
306
+ alphabet=alphabet,
307
+ topology=source_sequence.topology,
308
+ strandedness=source_sequence.strandedness,
309
+ )
310
+
311
+ steps = _steps(
312
+ resolved,
313
+ alphabet,
314
+ u_policy,
315
+ ambiguity_policy,
316
+ changes,
317
+ invalid,
318
+ gap_count,
319
+ has_error,
320
+ )
321
+ result = NormalizationResult(
322
+ raw_input=snapshot,
323
+ sequence=sequence,
324
+ config=resolved,
325
+ algorithm_version="std-normalize-v2",
326
+ provenance=Provenance(),
327
+ normalized_parts=normalized_parts,
328
+ steps=steps,
329
+ changes=tuple(changes),
330
+ issues=tuple(issues),
331
+ ambiguity=ambiguity,
332
+ invalid_symbols=invalid,
333
+ u_positions=tuple(u_positions),
334
+ )
335
+ if has_error and resolved.raise_on_error:
336
+ raise InvalidAlphabetError(
337
+ "Sequence normalization failed alphabet validation.",
338
+ context={"issue_codes": [issue.code for issue in issues]},
339
+ hint="Inspect NormalizationConfig and the reported invalid input characters.",
340
+ )
341
+ return result
342
+
343
+
344
+ def _snapshot_and_parts(
345
+ raw: RawSequenceInput,
346
+ ) -> tuple[RawInputSnapshot, tuple[str | Gap, ...]]:
347
+ if isinstance(raw, DNASequence):
348
+ parts = raw.parts
349
+ payload = _parts_payload(parts, raw)
350
+ return (
351
+ RawInputSnapshot(
352
+ input_type="DNASequence",
353
+ content=raw,
354
+ sha256=hashlib.sha256(payload).hexdigest(),
355
+ character_count=sum(len(part) for part in parts if isinstance(part, str)),
356
+ ),
357
+ parts,
358
+ )
359
+ if isinstance(raw, str):
360
+ payload = b"str\0" + raw.encode("utf-8")
361
+ return (
362
+ RawInputSnapshot("str", raw, hashlib.sha256(payload).hexdigest(), len(raw)),
363
+ (raw,),
364
+ )
365
+ if isinstance(raw, bytes):
366
+ text = raw.decode("utf-8", errors="strict")
367
+ payload = b"bytes\0" + raw
368
+ return (
369
+ RawInputSnapshot("bytes", raw, hashlib.sha256(payload).hexdigest(), len(text)),
370
+ (text,),
371
+ )
372
+ if not isinstance(raw, Iterable):
373
+ raise TypeError("raw must be str, bytes, DNASequence or an iterable of str/Gap parts.")
374
+
375
+ materialized = tuple(raw)
376
+ for index, part in enumerate(materialized):
377
+ if not isinstance(part, (str, Gap)):
378
+ raise TypeError(
379
+ f"raw part {index} has type {type(part).__name__}; expected str or Gap."
380
+ )
381
+ parts = materialized
382
+ payload = _parts_payload(parts)
383
+ return (
384
+ RawInputSnapshot(
385
+ "parts",
386
+ parts,
387
+ hashlib.sha256(payload).hexdigest(),
388
+ sum(len(part) for part in parts if isinstance(part, str)),
389
+ ),
390
+ parts,
391
+ )
392
+
393
+
394
+ def _parts_payload(parts: tuple[str | Gap, ...], sequence: DNASequence | None = None) -> bytes:
395
+ encoded_parts: list[dict[str, object]] = []
396
+ for part in parts:
397
+ if isinstance(part, str):
398
+ encoded_parts.append({"sequence": part})
399
+ else:
400
+ encoded_parts.append(
401
+ {
402
+ "gap": {
403
+ "length": part.length,
404
+ "kind": getattr(part.kind, "value", part.kind),
405
+ "crossable": part.crossable,
406
+ "evidence": list(part.evidence),
407
+ "metadata": to_json_compatible(part.metadata),
408
+ }
409
+ }
410
+ )
411
+ payload: dict[str, object] = {"parts": encoded_parts}
412
+ if sequence is not None:
413
+ payload.update(
414
+ alphabet=sequence.alphabet.value,
415
+ topology=sequence.topology.value,
416
+ strandedness=sequence.strandedness.value,
417
+ )
418
+ return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
419
+
420
+
421
+ def _resolve_output_alphabet(
422
+ configured: DNAAlphabet | None,
423
+ source_sequence: DNASequence | None,
424
+ parts: list[str | Gap],
425
+ ) -> DNAAlphabet:
426
+ if configured is not None:
427
+ return configured
428
+ if source_sequence is not None:
429
+ return source_sequence.alphabet
430
+ if any(symbol in IUPAC_AMBIGUITY for part in parts if isinstance(part, str) for symbol in part):
431
+ return DNAAlphabet.IUPAC
432
+ return DNAAlphabet.STRICT
433
+
434
+
435
+ def _invalid_symbols(
436
+ parts: list[str | Gap],
437
+ positions: list[tuple[InputPosition, ...] | None],
438
+ alphabet: DNAAlphabet,
439
+ ) -> tuple[InvalidSymbol, ...]:
440
+ allowed = CANONICAL_BASES if alphabet is DNAAlphabet.STRICT else IUPAC_BASES
441
+ grouped: dict[str, list[InputPosition]] = defaultdict(list)
442
+ for part, part_positions in zip(parts, positions, strict=True):
443
+ if isinstance(part, Gap):
444
+ continue
445
+ assert part_positions is not None
446
+ for symbol, position in zip(part, part_positions, strict=True):
447
+ if symbol not in allowed:
448
+ grouped[symbol].append(position)
449
+ return tuple(
450
+ InvalidSymbol(
451
+ symbol=symbol,
452
+ codepoint=f"U+{ord(symbol):04X}",
453
+ positions=tuple(items),
454
+ )
455
+ for symbol, items in sorted(grouped.items(), key=lambda item: ord(item[0]))
456
+ )
457
+
458
+
459
+ def _coalesce_parts(parts: tuple[str | Gap, ...]) -> tuple[str | Gap, ...]:
460
+ result: list[str | Gap] = []
461
+ pending: list[str] = []
462
+ for part in parts:
463
+ if isinstance(part, str):
464
+ pending.append(part)
465
+ continue
466
+ if pending:
467
+ result.append("".join(pending))
468
+ pending.clear()
469
+ result.append(part)
470
+ if pending:
471
+ result.append("".join(pending))
472
+ return tuple(result)
473
+
474
+
475
+ def _steps(
476
+ config: NormalizationConfig,
477
+ alphabet: DNAAlphabet,
478
+ u_policy: UPolicy,
479
+ ambiguity_policy: AmbiguityPolicy,
480
+ changes: list[NormalizationChange],
481
+ invalid: tuple[InvalidSymbol, ...],
482
+ gap_count: int,
483
+ failed: bool,
484
+ ) -> tuple[NormalizationStep, ...]:
485
+ counts: dict[str, int] = defaultdict(int)
486
+ for change in changes:
487
+ counts[change.operation] += 1
488
+ return (
489
+ _step("whitespace", counts["remove_whitespace"], ("enabled", config.remove_whitespace)),
490
+ _step("invisible", counts["remove_invisible"], ("enabled", config.remove_invisible)),
491
+ _step(
492
+ "separators",
493
+ counts["remove_separator"],
494
+ ("count", len(config.removable_separators)),
495
+ ),
496
+ _step("case", counts["uppercase"], ("uppercase", config.uppercase)),
497
+ _step(
498
+ "uracil",
499
+ counts["replace_u"] + counts["delete_u"],
500
+ ("policy", u_policy.value),
501
+ ),
502
+ _step(
503
+ "ambiguity",
504
+ counts["mask_ambiguity"] + counts["delete_ambiguity"],
505
+ ("policy", ambiguity_policy.value),
506
+ ),
507
+ _step("other_characters", counts["delete_other"], ("keep", config.keep_other)),
508
+ NormalizationStep(
509
+ name="explicit_gap",
510
+ status="applied" if gap_count else "no-op",
511
+ change_count=0,
512
+ parameters=(("count", str(gap_count)), ("allowed", str(config.allow_gaps).lower())),
513
+ ),
514
+ NormalizationStep(
515
+ name="alphabet",
516
+ status="failed" if invalid else "no-op",
517
+ change_count=0,
518
+ parameters=(("mode", alphabet.value),),
519
+ ),
520
+ NormalizationStep(
521
+ name="construct",
522
+ status="failed" if failed else "applied",
523
+ change_count=0,
524
+ ),
525
+ )
526
+
527
+
528
+ def _step(name: str, count: int, parameter: tuple[str, object]) -> NormalizationStep:
529
+ return NormalizationStep(
530
+ name=name,
531
+ status="applied" if count else "no-op",
532
+ change_count=count,
533
+ parameters=((parameter[0], str(parameter[1]).lower()),),
534
+ )
535
+
536
+
537
+ def _is_invisible(symbol: str) -> bool:
538
+ return unicodedata.category(symbol) in {"Cc", "Cf"}
539
+
540
+
541
+ def _change(
542
+ operation: str,
543
+ position: InputPosition,
544
+ before: str,
545
+ after: str,
546
+ normalized_offset: int,
547
+ reason: str,
548
+ ) -> NormalizationChange:
549
+ return NormalizationChange(
550
+ operation=operation,
551
+ position=position,
552
+ before=before,
553
+ after=after,
554
+ normalized_offset=normalized_offset,
555
+ reason=reason,
556
+ )
557
+
558
+
559
+ def _issue(
560
+ code: str,
561
+ severity: IssueSeverity,
562
+ message: str,
563
+ details: dict[str, object],
564
+ ) -> Issue:
565
+ return Issue(code=code, severity=severity, message=message, details=details)