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
dnakit/core/record.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Immutable DNA record value object."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from collections.abc import Iterable, Mapping
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from itertools import islice
|
|
9
|
+
from typing import cast
|
|
10
|
+
|
|
11
|
+
from dnakit.core._json import FrozenDict, freeze_mapping
|
|
12
|
+
from dnakit.core.coordinates import CompoundLocation, UnresolvedLocation
|
|
13
|
+
from dnakit.core.feature import DNAFeature
|
|
14
|
+
from dnakit.core.gap import Gap
|
|
15
|
+
from dnakit.core.sequence import DNASequence
|
|
16
|
+
from dnakit.exceptions import SequenceError
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, init=False)
|
|
20
|
+
class DNARecord:
|
|
21
|
+
"""A DNA sequence plus stable identity, annotations, and metadata."""
|
|
22
|
+
|
|
23
|
+
sequence: DNASequence
|
|
24
|
+
id: str
|
|
25
|
+
description: str
|
|
26
|
+
features: tuple[DNAFeature, ...]
|
|
27
|
+
metadata: FrozenDict
|
|
28
|
+
letter_annotations: Mapping[str, tuple[int | float, ...]]
|
|
29
|
+
|
|
30
|
+
__hash__ = None # type: ignore[assignment]
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
sequence: DNASequence,
|
|
35
|
+
id: str,
|
|
36
|
+
*,
|
|
37
|
+
description: str = "",
|
|
38
|
+
features: Iterable[DNAFeature] = (),
|
|
39
|
+
metadata: Mapping[str, object] | None = None,
|
|
40
|
+
letter_annotations: Mapping[str, Iterable[int | float]] | None = None,
|
|
41
|
+
) -> None:
|
|
42
|
+
if not isinstance(sequence, DNASequence):
|
|
43
|
+
raise SequenceError(
|
|
44
|
+
"DNARecord sequence must be a DNASequence object.",
|
|
45
|
+
code="INVALID_RECORD_SEQUENCE",
|
|
46
|
+
context={"sequence_type": type(sequence).__name__},
|
|
47
|
+
)
|
|
48
|
+
if not isinstance(id, str) or not id.strip():
|
|
49
|
+
raise SequenceError(
|
|
50
|
+
"DNARecord id must be a non-empty string.",
|
|
51
|
+
code="INVALID_RECORD_ID",
|
|
52
|
+
)
|
|
53
|
+
if not isinstance(description, str):
|
|
54
|
+
raise SequenceError(
|
|
55
|
+
"DNARecord description must be a string.",
|
|
56
|
+
code="INVALID_RECORD_DESCRIPTION",
|
|
57
|
+
)
|
|
58
|
+
feature_tuple = tuple(features)
|
|
59
|
+
if any(not isinstance(feature, DNAFeature) for feature in feature_tuple):
|
|
60
|
+
raise SequenceError(
|
|
61
|
+
"DNARecord features must all be DNAFeature objects.",
|
|
62
|
+
code="INVALID_RECORD_FEATURE",
|
|
63
|
+
)
|
|
64
|
+
resolvable_span = 0
|
|
65
|
+
for part in sequence.parts:
|
|
66
|
+
if isinstance(part, str):
|
|
67
|
+
resolvable_span += len(part)
|
|
68
|
+
elif isinstance(part, Gap) and part.length is not None:
|
|
69
|
+
resolvable_span += part.length
|
|
70
|
+
else:
|
|
71
|
+
break
|
|
72
|
+
for index, feature in enumerate(feature_tuple):
|
|
73
|
+
location = feature.location
|
|
74
|
+
if isinstance(location, UnresolvedLocation):
|
|
75
|
+
intervals = location.anchors
|
|
76
|
+
else:
|
|
77
|
+
intervals = (
|
|
78
|
+
location.parts if isinstance(location, CompoundLocation) else (location,)
|
|
79
|
+
)
|
|
80
|
+
if any(interval.end > resolvable_span for interval in intervals):
|
|
81
|
+
unknown_span = sequence.coordinate_span is None
|
|
82
|
+
raise SequenceError(
|
|
83
|
+
(
|
|
84
|
+
"A resolved feature extends beyond the resolvable sequence prefix."
|
|
85
|
+
if unknown_span
|
|
86
|
+
else "A feature location extends beyond the sequence coordinate span."
|
|
87
|
+
),
|
|
88
|
+
code=(
|
|
89
|
+
"FEATURE_LOCATION_UNRESOLVED" if unknown_span else "FEATURE_OUT_OF_BOUNDS"
|
|
90
|
+
),
|
|
91
|
+
context={
|
|
92
|
+
"feature_index": index,
|
|
93
|
+
"feature_type": feature.type,
|
|
94
|
+
"location_kind": type(location).__name__,
|
|
95
|
+
"resolvable_span": resolvable_span,
|
|
96
|
+
"sequence_span": sequence.coordinate_span,
|
|
97
|
+
},
|
|
98
|
+
hint=(
|
|
99
|
+
"Use UnresolvedLocation for coordinates at or after an unknown-length Gap."
|
|
100
|
+
if unknown_span
|
|
101
|
+
else None
|
|
102
|
+
),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
annotations_input: dict[str, object] = {}
|
|
106
|
+
for name, values in (letter_annotations or {}).items():
|
|
107
|
+
if not isinstance(name, str) or not name.strip():
|
|
108
|
+
raise SequenceError(
|
|
109
|
+
"Letter annotation names must be non-empty strings.",
|
|
110
|
+
code="INVALID_LETTER_ANNOTATION_NAME",
|
|
111
|
+
)
|
|
112
|
+
if isinstance(values, (str, bytes)):
|
|
113
|
+
raise SequenceError(
|
|
114
|
+
"Letter annotation values must be numeric iterables.",
|
|
115
|
+
code="INVALID_LETTER_ANNOTATION",
|
|
116
|
+
context={"name": name},
|
|
117
|
+
)
|
|
118
|
+
try:
|
|
119
|
+
value_tuple = tuple(islice(iter(values), sequence.symbol_length + 1))
|
|
120
|
+
except TypeError as exc:
|
|
121
|
+
raise SequenceError(
|
|
122
|
+
"Letter annotation values must be numeric iterables.",
|
|
123
|
+
code="INVALID_LETTER_ANNOTATION",
|
|
124
|
+
context={"name": name},
|
|
125
|
+
) from exc
|
|
126
|
+
if len(value_tuple) != sequence.symbol_length:
|
|
127
|
+
raise SequenceError(
|
|
128
|
+
"Letter annotation length must equal sequence symbol_length.",
|
|
129
|
+
code="LETTER_ANNOTATION_LENGTH_MISMATCH",
|
|
130
|
+
context={
|
|
131
|
+
"name": name,
|
|
132
|
+
"annotation_length": len(value_tuple),
|
|
133
|
+
"symbol_length": sequence.symbol_length,
|
|
134
|
+
},
|
|
135
|
+
)
|
|
136
|
+
if any(
|
|
137
|
+
isinstance(value, bool)
|
|
138
|
+
or not isinstance(value, (int, float))
|
|
139
|
+
or not math.isfinite(value)
|
|
140
|
+
for value in value_tuple
|
|
141
|
+
):
|
|
142
|
+
raise SequenceError(
|
|
143
|
+
"Letter annotations must contain only finite numeric values.",
|
|
144
|
+
code="INVALID_LETTER_ANNOTATION",
|
|
145
|
+
context={"name": name},
|
|
146
|
+
)
|
|
147
|
+
annotations_input[name] = value_tuple
|
|
148
|
+
|
|
149
|
+
object.__setattr__(self, "sequence", sequence)
|
|
150
|
+
object.__setattr__(self, "id", id)
|
|
151
|
+
object.__setattr__(self, "description", description)
|
|
152
|
+
object.__setattr__(self, "features", feature_tuple)
|
|
153
|
+
object.__setattr__(self, "metadata", freeze_mapping(metadata))
|
|
154
|
+
frozen_annotations = cast(
|
|
155
|
+
Mapping[str, tuple[int | float, ...]],
|
|
156
|
+
freeze_mapping(annotations_input),
|
|
157
|
+
)
|
|
158
|
+
object.__setattr__(self, "letter_annotations", frozen_annotations)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
__all__ = ["DNARecord"]
|
dnakit/core/results.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Generic immutable result values shared by DNAKit domains."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from collections.abc import Iterable, Mapping
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any, Generic, TypeVar, cast
|
|
9
|
+
|
|
10
|
+
from dnakit.core._json import FrozenDict, JSONValue, freeze_json, freeze_mapping, to_json_compatible
|
|
11
|
+
from dnakit.core.issues import Issue
|
|
12
|
+
from dnakit.core.provenance import Provenance
|
|
13
|
+
from dnakit.exceptions import ConfigurationError
|
|
14
|
+
|
|
15
|
+
T = TypeVar("T")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, init=False)
|
|
19
|
+
class Uncertainty:
|
|
20
|
+
"""Optional uncertainty summary attached to a numerical result."""
|
|
21
|
+
|
|
22
|
+
confidence_interval: tuple[float, float] | None
|
|
23
|
+
standard_error: float | None
|
|
24
|
+
method: str | None
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
confidence_interval: tuple[float, float] | None = None,
|
|
29
|
+
standard_error: float | None = None,
|
|
30
|
+
method: str | None = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
resolved_interval: tuple[float, float] | None = None
|
|
33
|
+
if confidence_interval is not None:
|
|
34
|
+
copied_interval = tuple(confidence_interval)
|
|
35
|
+
if len(copied_interval) != 2:
|
|
36
|
+
raise ConfigurationError("confidence_interval must contain exactly two bounds.")
|
|
37
|
+
lower, upper = copied_interval
|
|
38
|
+
if (
|
|
39
|
+
isinstance(lower, bool)
|
|
40
|
+
or not isinstance(lower, (int, float))
|
|
41
|
+
or isinstance(upper, bool)
|
|
42
|
+
or not isinstance(upper, (int, float))
|
|
43
|
+
or not math.isfinite(lower)
|
|
44
|
+
or not math.isfinite(upper)
|
|
45
|
+
or lower > upper
|
|
46
|
+
):
|
|
47
|
+
raise ConfigurationError(
|
|
48
|
+
"confidence_interval bounds must be finite and ordered.",
|
|
49
|
+
context={"lower": lower, "upper": upper},
|
|
50
|
+
)
|
|
51
|
+
resolved_interval = (float(lower), float(upper))
|
|
52
|
+
if standard_error is not None and (
|
|
53
|
+
isinstance(standard_error, bool)
|
|
54
|
+
or not isinstance(standard_error, (int, float))
|
|
55
|
+
or not math.isfinite(standard_error)
|
|
56
|
+
or standard_error < 0
|
|
57
|
+
):
|
|
58
|
+
raise ConfigurationError("standard_error must be a finite non-negative number.")
|
|
59
|
+
if method is not None and (not isinstance(method, str) or not method.strip()):
|
|
60
|
+
raise ConfigurationError("Uncertainty method must be None or a non-empty string.")
|
|
61
|
+
|
|
62
|
+
object.__setattr__(self, "confidence_interval", resolved_interval)
|
|
63
|
+
object.__setattr__(
|
|
64
|
+
self,
|
|
65
|
+
"standard_error",
|
|
66
|
+
None if standard_error is None else float(standard_error),
|
|
67
|
+
)
|
|
68
|
+
object.__setattr__(self, "method", method)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True, init=False)
|
|
72
|
+
class MetricResult(Generic[T]):
|
|
73
|
+
"""A metric value plus method, conditions, provenance, and diagnostics."""
|
|
74
|
+
|
|
75
|
+
name: str
|
|
76
|
+
value: T
|
|
77
|
+
unit: str | None
|
|
78
|
+
method: str
|
|
79
|
+
algorithm_version: str
|
|
80
|
+
parameters: FrozenDict
|
|
81
|
+
conditions: FrozenDict
|
|
82
|
+
uncertainty: Uncertainty | None
|
|
83
|
+
provenance: Provenance
|
|
84
|
+
issues: tuple[Issue, ...]
|
|
85
|
+
|
|
86
|
+
def __init__(
|
|
87
|
+
self,
|
|
88
|
+
name: str,
|
|
89
|
+
value: T,
|
|
90
|
+
*,
|
|
91
|
+
method: str,
|
|
92
|
+
algorithm_version: str,
|
|
93
|
+
provenance: Provenance | None = None,
|
|
94
|
+
unit: str | None = None,
|
|
95
|
+
parameters: Mapping[str, object] | None = None,
|
|
96
|
+
conditions: Mapping[str, object] | None = None,
|
|
97
|
+
uncertainty: Uncertainty | None = None,
|
|
98
|
+
issues: Iterable[Issue] = (),
|
|
99
|
+
) -> None:
|
|
100
|
+
for field_name, field_value in (
|
|
101
|
+
("name", name),
|
|
102
|
+
("method", method),
|
|
103
|
+
("algorithm_version", algorithm_version),
|
|
104
|
+
):
|
|
105
|
+
if not isinstance(field_value, str) or not field_value.strip():
|
|
106
|
+
raise ConfigurationError(f"MetricResult {field_name} must be non-empty.")
|
|
107
|
+
if unit is not None and (not isinstance(unit, str) or not unit.strip()):
|
|
108
|
+
raise ConfigurationError("MetricResult unit must be None or a non-empty string.")
|
|
109
|
+
if uncertainty is not None and not isinstance(uncertainty, Uncertainty):
|
|
110
|
+
raise ConfigurationError("MetricResult uncertainty must be Uncertainty or None.")
|
|
111
|
+
resolved_provenance = provenance or Provenance()
|
|
112
|
+
if not isinstance(resolved_provenance, Provenance):
|
|
113
|
+
raise ConfigurationError("MetricResult provenance must be Provenance.")
|
|
114
|
+
issue_tuple = tuple(issues)
|
|
115
|
+
if any(not isinstance(issue, Issue) for issue in issue_tuple):
|
|
116
|
+
raise ConfigurationError("MetricResult issues must all be Issue objects.")
|
|
117
|
+
frozen_value: JSONValue = freeze_json(value)
|
|
118
|
+
|
|
119
|
+
object.__setattr__(self, "name", name)
|
|
120
|
+
object.__setattr__(self, "value", cast(T, frozen_value))
|
|
121
|
+
object.__setattr__(self, "unit", unit)
|
|
122
|
+
object.__setattr__(self, "method", method)
|
|
123
|
+
object.__setattr__(self, "algorithm_version", algorithm_version)
|
|
124
|
+
object.__setattr__(self, "parameters", freeze_mapping(parameters))
|
|
125
|
+
object.__setattr__(self, "conditions", freeze_mapping(conditions))
|
|
126
|
+
object.__setattr__(self, "uncertainty", uncertainty)
|
|
127
|
+
object.__setattr__(self, "provenance", resolved_provenance)
|
|
128
|
+
object.__setattr__(self, "issues", issue_tuple)
|
|
129
|
+
|
|
130
|
+
def to_dict(self) -> dict[str, Any]:
|
|
131
|
+
return cast(dict[str, Any], to_json_compatible(self))
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
__all__ = ["MetricResult", "Uncertainty"]
|
dnakit/core/sequence.py
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
"""Immutable DNA sequence value object."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable, Iterator, Sequence
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from dnakit.core.enums import DNAAlphabet, Strandedness, Topology
|
|
9
|
+
from dnakit.core.gap import Gap
|
|
10
|
+
from dnakit.exceptions import (
|
|
11
|
+
InvalidAlphabetError,
|
|
12
|
+
SequenceError,
|
|
13
|
+
UnknownLengthError,
|
|
14
|
+
UnsupportedGapOperationError,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
STRICT_SYMBOLS = frozenset("ACGT")
|
|
18
|
+
IUPAC_SYMBOLS = frozenset("ACGTRYSWKMBDHVN")
|
|
19
|
+
_COMPLEMENT = str.maketrans(
|
|
20
|
+
{
|
|
21
|
+
"A": "T",
|
|
22
|
+
"C": "G",
|
|
23
|
+
"G": "C",
|
|
24
|
+
"T": "A",
|
|
25
|
+
"R": "Y",
|
|
26
|
+
"Y": "R",
|
|
27
|
+
"S": "S",
|
|
28
|
+
"W": "W",
|
|
29
|
+
"K": "M",
|
|
30
|
+
"M": "K",
|
|
31
|
+
"B": "V",
|
|
32
|
+
"D": "H",
|
|
33
|
+
"H": "D",
|
|
34
|
+
"V": "B",
|
|
35
|
+
"N": "N",
|
|
36
|
+
}
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _coerce_alphabet(value: DNAAlphabet | str) -> DNAAlphabet:
|
|
41
|
+
try:
|
|
42
|
+
return value if isinstance(value, DNAAlphabet) else DNAAlphabet(value)
|
|
43
|
+
except (TypeError, ValueError) as exc:
|
|
44
|
+
raise SequenceError(
|
|
45
|
+
"Unknown DNA alphabet.",
|
|
46
|
+
code="INVALID_ALPHABET_DECLARATION",
|
|
47
|
+
context={"alphabet": value},
|
|
48
|
+
hint=f"Choose one of: {', '.join(item.value for item in DNAAlphabet)}.",
|
|
49
|
+
) from exc
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _coerce_topology(value: Topology | str) -> Topology:
|
|
53
|
+
try:
|
|
54
|
+
return value if isinstance(value, Topology) else Topology(value)
|
|
55
|
+
except (TypeError, ValueError) as exc:
|
|
56
|
+
raise SequenceError(
|
|
57
|
+
"Unknown sequence topology.",
|
|
58
|
+
code="INVALID_TOPOLOGY",
|
|
59
|
+
context={"topology": value},
|
|
60
|
+
hint=f"Choose one of: {', '.join(item.value for item in Topology)}.",
|
|
61
|
+
) from exc
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _coerce_strandedness(value: Strandedness | str) -> Strandedness:
|
|
65
|
+
try:
|
|
66
|
+
return value if isinstance(value, Strandedness) else Strandedness(value)
|
|
67
|
+
except (TypeError, ValueError) as exc:
|
|
68
|
+
raise SequenceError(
|
|
69
|
+
"Unknown sequence strandedness.",
|
|
70
|
+
code="INVALID_STRANDEDNESS",
|
|
71
|
+
context={"strandedness": value},
|
|
72
|
+
hint=f"Choose one of: {', '.join(item.value for item in Strandedness)}.",
|
|
73
|
+
) from exc
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _decode_ascii(value: bytes) -> str:
|
|
77
|
+
try:
|
|
78
|
+
return value.decode("ascii")
|
|
79
|
+
except UnicodeDecodeError as exc:
|
|
80
|
+
raise InvalidAlphabetError(
|
|
81
|
+
"DNA bytes must use ASCII encoding.",
|
|
82
|
+
context={"byte_offset": exc.start},
|
|
83
|
+
hint="Decode or clean the raw input with normalize() before construction.",
|
|
84
|
+
) from exc
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _normalize_parts(parts: str | bytes | Iterable[str | Gap]) -> tuple[str | Gap, ...]:
|
|
88
|
+
if isinstance(parts, bytes):
|
|
89
|
+
raw_parts: Iterable[str | Gap] = (_decode_ascii(parts),)
|
|
90
|
+
elif isinstance(parts, str):
|
|
91
|
+
raw_parts = (parts,)
|
|
92
|
+
else:
|
|
93
|
+
try:
|
|
94
|
+
raw_parts = iter(parts)
|
|
95
|
+
except TypeError as exc:
|
|
96
|
+
raise SequenceError(
|
|
97
|
+
"Sequence parts must be text, bytes, or an iterable of text and Gap objects.",
|
|
98
|
+
code="INVALID_SEQUENCE_PARTS",
|
|
99
|
+
context={"parts_type": type(parts).__name__},
|
|
100
|
+
) from exc
|
|
101
|
+
|
|
102
|
+
normalized: list[str | Gap] = []
|
|
103
|
+
text_buffer: list[str] = []
|
|
104
|
+
|
|
105
|
+
def flush_text() -> None:
|
|
106
|
+
if text_buffer:
|
|
107
|
+
normalized.append("".join(text_buffer))
|
|
108
|
+
text_buffer.clear()
|
|
109
|
+
|
|
110
|
+
for index, part in enumerate(raw_parts):
|
|
111
|
+
if isinstance(part, str):
|
|
112
|
+
if part:
|
|
113
|
+
text_buffer.append(part)
|
|
114
|
+
elif isinstance(part, Gap):
|
|
115
|
+
flush_text()
|
|
116
|
+
normalized.append(part)
|
|
117
|
+
else:
|
|
118
|
+
raise SequenceError(
|
|
119
|
+
"Each sequence part must be a string or Gap.",
|
|
120
|
+
code="INVALID_SEQUENCE_PART",
|
|
121
|
+
context={"part_index": index, "part_type": type(part).__name__},
|
|
122
|
+
)
|
|
123
|
+
flush_text()
|
|
124
|
+
return tuple(normalized)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass(frozen=True, init=False)
|
|
128
|
+
class DNASequence:
|
|
129
|
+
"""Canonical immutable DNA symbols and explicit gap parts.
|
|
130
|
+
|
|
131
|
+
The constructor validates already-normalized content. It deliberately does
|
|
132
|
+
not uppercase, strip whitespace, or replace uracil; callers should use the
|
|
133
|
+
normalization API for those auditable transformations.
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
parts: tuple[str | Gap, ...]
|
|
137
|
+
alphabet: DNAAlphabet
|
|
138
|
+
topology: Topology
|
|
139
|
+
strandedness: Strandedness
|
|
140
|
+
|
|
141
|
+
def __init__(
|
|
142
|
+
self,
|
|
143
|
+
parts: str | bytes | Iterable[str | Gap],
|
|
144
|
+
*,
|
|
145
|
+
alphabet: DNAAlphabet | str = DNAAlphabet.STRICT,
|
|
146
|
+
topology: Topology | str = Topology.LINEAR,
|
|
147
|
+
strandedness: Strandedness | str = Strandedness.SINGLE,
|
|
148
|
+
) -> None:
|
|
149
|
+
resolved_parts = _normalize_parts(parts)
|
|
150
|
+
resolved_alphabet = _coerce_alphabet(alphabet)
|
|
151
|
+
resolved_topology = _coerce_topology(topology)
|
|
152
|
+
resolved_strandedness = _coerce_strandedness(strandedness)
|
|
153
|
+
|
|
154
|
+
allowed = STRICT_SYMBOLS if resolved_alphabet is DNAAlphabet.STRICT else IUPAC_SYMBOLS
|
|
155
|
+
for part_index, part in enumerate(resolved_parts):
|
|
156
|
+
if isinstance(part, Gap):
|
|
157
|
+
continue
|
|
158
|
+
invalid = next(
|
|
159
|
+
((offset, symbol) for offset, symbol in enumerate(part) if symbol not in allowed),
|
|
160
|
+
None,
|
|
161
|
+
)
|
|
162
|
+
if invalid is not None:
|
|
163
|
+
offset, symbol = invalid
|
|
164
|
+
raise InvalidAlphabetError(
|
|
165
|
+
f"Symbol {symbol!r} is not valid for the {resolved_alphabet.value} alphabet.",
|
|
166
|
+
context={
|
|
167
|
+
"alphabet": resolved_alphabet.value,
|
|
168
|
+
"part_index": part_index,
|
|
169
|
+
"part_offset": offset,
|
|
170
|
+
"symbol": symbol,
|
|
171
|
+
},
|
|
172
|
+
hint="Pass raw or mixed-case input through normalize() first.",
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
symbol_length = sum(len(part) for part in resolved_parts if isinstance(part, str))
|
|
176
|
+
if resolved_topology is Topology.CIRCULAR and symbol_length == 0:
|
|
177
|
+
raise SequenceError(
|
|
178
|
+
"A circular DNA sequence must contain at least one nucleotide symbol.",
|
|
179
|
+
code="EMPTY_CIRCULAR_SEQUENCE",
|
|
180
|
+
hint="Use linear topology for an empty placeholder sequence.",
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
object.__setattr__(self, "parts", resolved_parts)
|
|
184
|
+
object.__setattr__(self, "alphabet", resolved_alphabet)
|
|
185
|
+
object.__setattr__(self, "topology", resolved_topology)
|
|
186
|
+
object.__setattr__(self, "strandedness", resolved_strandedness)
|
|
187
|
+
|
|
188
|
+
@classmethod
|
|
189
|
+
def from_fragments(
|
|
190
|
+
cls,
|
|
191
|
+
fragments: Iterable[str | bytes],
|
|
192
|
+
gaps: Iterable[Gap],
|
|
193
|
+
*,
|
|
194
|
+
alphabet: DNAAlphabet | str = DNAAlphabet.STRICT,
|
|
195
|
+
topology: Topology | str = Topology.LINEAR,
|
|
196
|
+
strandedness: Strandedness | str = Strandedness.SINGLE,
|
|
197
|
+
) -> DNASequence:
|
|
198
|
+
"""Interleave ``n`` gaps between exactly ``n + 1`` sequence fragments."""
|
|
199
|
+
|
|
200
|
+
fragment_tuple = tuple(
|
|
201
|
+
_decode_ascii(fragment) if isinstance(fragment, bytes) else fragment
|
|
202
|
+
for fragment in fragments
|
|
203
|
+
)
|
|
204
|
+
gap_tuple = tuple(gaps)
|
|
205
|
+
if len(fragment_tuple) != len(gap_tuple) + 1:
|
|
206
|
+
raise SequenceError(
|
|
207
|
+
"from_fragments() requires exactly one more fragment than gaps.",
|
|
208
|
+
code="FRAGMENT_GAP_COUNT_MISMATCH",
|
|
209
|
+
context={"fragment_count": len(fragment_tuple), "gap_count": len(gap_tuple)},
|
|
210
|
+
)
|
|
211
|
+
if any(not isinstance(fragment, str) for fragment in fragment_tuple):
|
|
212
|
+
raise SequenceError(
|
|
213
|
+
"Fragments must be strings or ASCII bytes.",
|
|
214
|
+
code="INVALID_SEQUENCE_FRAGMENT",
|
|
215
|
+
)
|
|
216
|
+
if any(not isinstance(gap, Gap) for gap in gap_tuple):
|
|
217
|
+
raise SequenceError("All gaps must be Gap objects.", code="INVALID_GAP")
|
|
218
|
+
|
|
219
|
+
parts: list[str | Gap] = []
|
|
220
|
+
for index, fragment in enumerate(fragment_tuple):
|
|
221
|
+
parts.append(fragment)
|
|
222
|
+
if index < len(gap_tuple):
|
|
223
|
+
parts.append(gap_tuple[index])
|
|
224
|
+
return cls(
|
|
225
|
+
parts,
|
|
226
|
+
alphabet=alphabet,
|
|
227
|
+
topology=topology,
|
|
228
|
+
strandedness=strandedness,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
@property
|
|
232
|
+
def symbol_length(self) -> int:
|
|
233
|
+
"""Number of nucleotide symbols, excluding explicit gap spans."""
|
|
234
|
+
|
|
235
|
+
return sum(len(part) for part in self.parts if isinstance(part, str))
|
|
236
|
+
|
|
237
|
+
@property
|
|
238
|
+
def canonical_base_count(self) -> int:
|
|
239
|
+
return sum(
|
|
240
|
+
sum(symbol in STRICT_SYMBOLS for symbol in part)
|
|
241
|
+
for part in self.parts
|
|
242
|
+
if isinstance(part, str)
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
@property
|
|
246
|
+
def ambiguity_count(self) -> int:
|
|
247
|
+
return self.symbol_length - self.canonical_base_count
|
|
248
|
+
|
|
249
|
+
@property
|
|
250
|
+
def coordinate_span(self) -> int | None:
|
|
251
|
+
span = self.symbol_length
|
|
252
|
+
for part in self.parts:
|
|
253
|
+
if isinstance(part, Gap):
|
|
254
|
+
if part.length is None:
|
|
255
|
+
return None
|
|
256
|
+
span += part.length
|
|
257
|
+
return span
|
|
258
|
+
|
|
259
|
+
@property
|
|
260
|
+
def length(self) -> int | None:
|
|
261
|
+
"""Explicit alias for :attr:`coordinate_span`."""
|
|
262
|
+
|
|
263
|
+
return self.coordinate_span
|
|
264
|
+
|
|
265
|
+
@property
|
|
266
|
+
def is_gapped(self) -> bool:
|
|
267
|
+
return any(isinstance(part, Gap) for part in self.parts)
|
|
268
|
+
|
|
269
|
+
@property
|
|
270
|
+
def has_unknown_length(self) -> bool:
|
|
271
|
+
return any(isinstance(part, Gap) and part.length is None for part in self.parts)
|
|
272
|
+
|
|
273
|
+
@property
|
|
274
|
+
def symbols(self) -> str:
|
|
275
|
+
"""Concatenate nucleotide symbols while explicitly omitting gaps."""
|
|
276
|
+
|
|
277
|
+
return "".join(part for part in self.parts if isinstance(part, str))
|
|
278
|
+
|
|
279
|
+
def to_string(self) -> str:
|
|
280
|
+
"""Return an ordinary string only when doing so cannot discard gaps."""
|
|
281
|
+
|
|
282
|
+
if self.is_gapped:
|
|
283
|
+
raise UnsupportedGapOperationError(
|
|
284
|
+
"A gapped DNASequence cannot be represented as a plain DNA string.",
|
|
285
|
+
hint="Inspect parts or use symbols explicitly if omitting gaps is intended.",
|
|
286
|
+
)
|
|
287
|
+
return self.symbols
|
|
288
|
+
|
|
289
|
+
def __len__(self) -> int:
|
|
290
|
+
span = self.coordinate_span
|
|
291
|
+
if span is None:
|
|
292
|
+
raise UnknownLengthError(
|
|
293
|
+
"Exact sequence length is unknown because at least one gap has unknown length.",
|
|
294
|
+
hint="Use symbol_length or resolve every Gap.length before requesting len().",
|
|
295
|
+
)
|
|
296
|
+
return span
|
|
297
|
+
|
|
298
|
+
def __iter__(self) -> Iterator[str | Gap]:
|
|
299
|
+
return iter(self.parts)
|
|
300
|
+
|
|
301
|
+
def reverse(self) -> DNASequence:
|
|
302
|
+
return self._transform(reverse=True, complement=False)
|
|
303
|
+
|
|
304
|
+
def complement(self) -> DNASequence:
|
|
305
|
+
return self._transform(reverse=False, complement=True)
|
|
306
|
+
|
|
307
|
+
def reverse_complement(self) -> DNASequence:
|
|
308
|
+
return self._transform(reverse=True, complement=True)
|
|
309
|
+
|
|
310
|
+
def _transform(self, *, reverse: bool, complement: bool) -> DNASequence:
|
|
311
|
+
source: Sequence[str | Gap] = self.parts[::-1] if reverse else self.parts
|
|
312
|
+
transformed: list[str | Gap] = []
|
|
313
|
+
for part in source:
|
|
314
|
+
if isinstance(part, Gap):
|
|
315
|
+
transformed.append(part)
|
|
316
|
+
continue
|
|
317
|
+
text = part[::-1] if reverse else part
|
|
318
|
+
transformed.append(text.translate(_COMPLEMENT) if complement else text)
|
|
319
|
+
return DNASequence(
|
|
320
|
+
transformed,
|
|
321
|
+
alphabet=self.alphabet,
|
|
322
|
+
topology=self.topology,
|
|
323
|
+
strandedness=self.strandedness,
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
__all__ = ["IUPAC_SYMBOLS", "STRICT_SYMBOLS", "DNASequence"]
|