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.
- dnakit/__init__.py +108 -0
- dnakit/_version.py +5 -0
- dnakit/alignment/__init__.py +6 -0
- dnakit/alignment/pairwise.py +512 -0
- dnakit/alignment/results.py +188 -0
- dnakit/backends/__init__.py +80 -0
- dnakit/backends/external.py +517 -0
- dnakit/backends/registry.py +207 -0
- dnakit/batch.py +452 -0
- dnakit/cache/__init__.py +5 -0
- dnakit/cache/store.py +424 -0
- dnakit/chunking.py +920 -0
- dnakit/cli/__init__.py +5 -0
- dnakit/cli/__main__.py +6 -0
- dnakit/cli/app.py +814 -0
- dnakit/cli/workflow.py +94 -0
- dnakit/config/__init__.py +5 -0
- dnakit/config/loader.py +381 -0
- dnakit/core/__init__.py +80 -0
- dnakit/core/_json.py +206 -0
- dnakit/core/backend_info.py +70 -0
- dnakit/core/collection.py +163 -0
- dnakit/core/coordinates.py +295 -0
- dnakit/core/enums.py +95 -0
- dnakit/core/facade.py +719 -0
- dnakit/core/feature.py +90 -0
- dnakit/core/gap.py +82 -0
- dnakit/core/issues.py +62 -0
- dnakit/core/provenance.py +305 -0
- dnakit/core/record.py +161 -0
- dnakit/core/results.py +134 -0
- dnakit/core/sequence.py +327 -0
- dnakit/datasets/__init__.py +100 -0
- dnakit/datasets/_advanced_shared.py +198 -0
- dnakit/datasets/_metadata.py +33 -0
- dnakit/datasets/advanced_deduplicate.py +183 -0
- dnakit/datasets/advanced_split.py +302 -0
- dnakit/datasets/clustering.py +286 -0
- dnakit/datasets/config.py +531 -0
- dnakit/datasets/deduplicate.py +303 -0
- dnakit/datasets/evaluation.py +244 -0
- dnakit/datasets/filter.py +135 -0
- dnakit/datasets/neural_clustering.py +221 -0
- dnakit/datasets/results.py +520 -0
- dnakit/datasets/split.py +450 -0
- dnakit/descriptors/__init__.py +102 -0
- dnakit/descriptors/_dinucleotide.py +345 -0
- dnakit/descriptors/_shared.py +180 -0
- dnakit/descriptors/all.py +917 -0
- dnakit/descriptors/basic.py +193 -0
- dnakit/descriptors/codon.py +195 -0
- dnakit/descriptors/complexity.py +201 -0
- dnakit/descriptors/entropy.py +101 -0
- dnakit/descriptors/homopolymer.py +105 -0
- dnakit/descriptors/kmer.py +88 -0
- dnakit/descriptors/results.py +286 -0
- dnakit/descriptors/schema.py +414 -0
- dnakit/descriptors/window.py +203 -0
- dnakit/download/__init__.py +54 -0
- dnakit/download/catalogs.py +166 -0
- dnakit/download/ena.py +131 -0
- dnakit/download/encode.py +103 -0
- dnakit/download/files.py +469 -0
- dnakit/download/indexes.py +233 -0
- dnakit/download/metadata.py +164 -0
- dnakit/download/models.py +227 -0
- dnakit/download/ncbi.py +333 -0
- dnakit/download/sequence.py +133 -0
- dnakit/download/ucsc.py +99 -0
- dnakit/evaluation/__init__.py +81 -0
- dnakit/evaluation/_shared.py +288 -0
- dnakit/evaluation/collection.py +270 -0
- dnakit/evaluation/config.py +591 -0
- dnakit/evaluation/distribution.py +287 -0
- dnakit/evaluation/frechet.py +223 -0
- dnakit/evaluation/generative.py +381 -0
- dnakit/evaluation/reference.py +409 -0
- dnakit/evaluation/results.py +137 -0
- dnakit/evaluation/scorecard.py +211 -0
- dnakit/evaluation/sequence.py +403 -0
- dnakit/evaluation/synthesis.py +249 -0
- dnakit/exceptions.py +189 -0
- dnakit/fingerprints/__init__.py +109 -0
- dnakit/fingerprints/_shared.py +116 -0
- dnakit/fingerprints/advanced.py +829 -0
- dnakit/fingerprints/bit.py +229 -0
- dnakit/fingerprints/encoding.py +351 -0
- dnakit/fingerprints/kmer.py +170 -0
- dnakit/fingerprints/results.py +531 -0
- dnakit/fingerprints/sketch.py +246 -0
- dnakit/io/__init__.py +112 -0
- dnakit/io/_advanced_common.py +105 -0
- dnakit/io/_formats.py +994 -0
- dnakit/io/annotations.py +1179 -0
- dnakit/io/api.py +759 -0
- dnakit/io/config.py +315 -0
- dnakit/io/genbank.py +523 -0
- dnakit/io/indexed.py +1146 -0
- dnakit/io/metadata.py +452 -0
- dnakit/io/parquet.py +56 -0
- dnakit/io/results.py +131 -0
- dnakit/io/source.py +138 -0
- dnakit/io/tables.py +1237 -0
- dnakit/molbio/__init__.py +101 -0
- dnakit/molbio/_shared.py +266 -0
- dnakit/molbio/assembly.py +279 -0
- dnakit/molbio/crispr.py +181 -0
- dnakit/molbio/optimization.py +897 -0
- dnakit/molbio/primer3_design.py +667 -0
- dnakit/molbio/primers.py +899 -0
- dnakit/molbio/restriction.py +573 -0
- dnakit/molbio/results.py +583 -0
- dnakit/ops/__init__.py +588 -0
- dnakit/ops/_common.py +201 -0
- dnakit/ops/circular.py +193 -0
- dnakit/ops/concat.py +204 -0
- dnakit/ops/direction.py +27 -0
- dnakit/ops/edit.py +443 -0
- dnakit/ops/evolution.py +991 -0
- dnakit/ops/mutation.py +151 -0
- dnakit/ops/records.py +1012 -0
- dnakit/ops/translation.py +257 -0
- dnakit/patterns/__init__.py +81 -0
- dnakit/patterns/_shared.py +282 -0
- dnakit/patterns/coding.py +360 -0
- dnakit/patterns/crispr.py +337 -0
- dnakit/patterns/motif.py +721 -0
- dnakit/patterns/regions.py +341 -0
- dnakit/patterns/repeats.py +480 -0
- dnakit/patterns/restriction.py +281 -0
- dnakit/patterns/results.py +411 -0
- dnakit/py.typed +1 -0
- dnakit/references/__init__.py +27 -0
- dnakit/references/models.py +155 -0
- dnakit/references/ncbi.py +785 -0
- dnakit/representations/__init__.py +29 -0
- dnakit/representations/backends.py +928 -0
- dnakit/representations/checkpoints.py +356 -0
- dnakit/representations/extraction.py +220 -0
- dnakit/representations/models.py +395 -0
- dnakit/representations/results.py +79 -0
- dnakit/search/__init__.py +132 -0
- dnakit/search/_http.py +271 -0
- dnakit/search/_shared.py +108 -0
- dnakit/search/blast.py +516 -0
- dnakit/search/ena.py +245 -0
- dnakit/search/encode.py +102 -0
- dnakit/search/ensembl.py +874 -0
- dnakit/search/models.py +159 -0
- dnakit/search/ncbi.py +756 -0
- dnakit/search/ucsc.py +388 -0
- dnakit/secondary_structure/__init__.py +38 -0
- dnakit/secondary_structure/dotbracket.py +410 -0
- dnakit/secondary_structure/nupack.py +589 -0
- dnakit/secondary_structure/results.py +142 -0
- dnakit/similarity/__init__.py +125 -0
- dnakit/similarity/_shared.py +152 -0
- dnakit/similarity/approximate.py +258 -0
- dnakit/similarity/compare.py +162 -0
- dnakit/similarity/dashing.py +936 -0
- dnakit/similarity/distance.py +279 -0
- dnakit/similarity/index.py +365 -0
- dnakit/similarity/matrix.py +254 -0
- dnakit/similarity/results.py +676 -0
- dnakit/similarity/search.py +286 -0
- dnakit/similarity/sketch.py +96 -0
- dnakit/similarity/vector.py +662 -0
- dnakit/standardize/__init__.py +77 -0
- dnakit/standardize/_shared.py +100 -0
- dnakit/standardize/config.py +411 -0
- dnakit/standardize/gaps.py +1060 -0
- dnakit/standardize/normalize.py +565 -0
- dnakit/standardize/results.py +247 -0
- dnakit/standardize/validate.py +487 -0
- dnakit/structure3d/__init__.py +47 -0
- dnakit/structure3d/_shared.py +47 -0
- dnakit/structure3d/geometry.py +773 -0
- dnakit/structure3d/pdb.py +253 -0
- dnakit/structure3d/results.py +233 -0
- dnakit/structure3d/threedna.py +174 -0
- dnakit/thermodynamics/__init__.py +112 -0
- dnakit/thermodynamics/_shared.py +92 -0
- dnakit/thermodynamics/backends.py +653 -0
- dnakit/thermodynamics/calculations.py +837 -0
- dnakit/thermodynamics/config.py +175 -0
- dnakit/thermodynamics/equilibrium.py +340 -0
- dnakit/thermodynamics/optics.py +385 -0
- dnakit/thermodynamics/parameters.py +79 -0
- dnakit/thermodynamics/results.py +445 -0
- dnakit/visualization/__init__.py +43 -0
- dnakit/visualization/_svg.py +185 -0
- dnakit/visualization/advanced.py +314 -0
- dnakit/visualization/config.py +255 -0
- dnakit/visualization/export.py +349 -0
- dnakit/visualization/matrix.py +279 -0
- dnakit/visualization/report.py +431 -0
- dnakit/visualization/results.py +236 -0
- dnakit/visualization/sequence.py +459 -0
- dnakit/workflows/__init__.py +45 -0
- dnakit/workflows/manifest.py +365 -0
- dnakit/workflows/runner.py +1548 -0
- dnakit/workflows/schema.py +898 -0
- dnakit-0.1.0.dev0.dist-info/METADATA +126 -0
- dnakit-0.1.0.dev0.dist-info/RECORD +210 -0
- dnakit-0.1.0.dev0.dist-info/WHEEL +5 -0
- dnakit-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- dnakit-0.1.0.dev0.dist-info/licenses/DISCLAIMER.md +26 -0
- dnakit-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
- dnakit-0.1.0.dev0.dist-info/licenses/THIRD_PARTY_NOTICES.md +73 -0
- 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)
|