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,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"]
|