dnakit 0.1.0.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (210) hide show
  1. dnakit/__init__.py +108 -0
  2. dnakit/_version.py +5 -0
  3. dnakit/alignment/__init__.py +6 -0
  4. dnakit/alignment/pairwise.py +512 -0
  5. dnakit/alignment/results.py +188 -0
  6. dnakit/backends/__init__.py +80 -0
  7. dnakit/backends/external.py +517 -0
  8. dnakit/backends/registry.py +207 -0
  9. dnakit/batch.py +452 -0
  10. dnakit/cache/__init__.py +5 -0
  11. dnakit/cache/store.py +424 -0
  12. dnakit/chunking.py +920 -0
  13. dnakit/cli/__init__.py +5 -0
  14. dnakit/cli/__main__.py +6 -0
  15. dnakit/cli/app.py +814 -0
  16. dnakit/cli/workflow.py +94 -0
  17. dnakit/config/__init__.py +5 -0
  18. dnakit/config/loader.py +381 -0
  19. dnakit/core/__init__.py +80 -0
  20. dnakit/core/_json.py +206 -0
  21. dnakit/core/backend_info.py +70 -0
  22. dnakit/core/collection.py +163 -0
  23. dnakit/core/coordinates.py +295 -0
  24. dnakit/core/enums.py +95 -0
  25. dnakit/core/facade.py +719 -0
  26. dnakit/core/feature.py +90 -0
  27. dnakit/core/gap.py +82 -0
  28. dnakit/core/issues.py +62 -0
  29. dnakit/core/provenance.py +305 -0
  30. dnakit/core/record.py +161 -0
  31. dnakit/core/results.py +134 -0
  32. dnakit/core/sequence.py +327 -0
  33. dnakit/datasets/__init__.py +100 -0
  34. dnakit/datasets/_advanced_shared.py +198 -0
  35. dnakit/datasets/_metadata.py +33 -0
  36. dnakit/datasets/advanced_deduplicate.py +183 -0
  37. dnakit/datasets/advanced_split.py +302 -0
  38. dnakit/datasets/clustering.py +286 -0
  39. dnakit/datasets/config.py +531 -0
  40. dnakit/datasets/deduplicate.py +303 -0
  41. dnakit/datasets/evaluation.py +244 -0
  42. dnakit/datasets/filter.py +135 -0
  43. dnakit/datasets/neural_clustering.py +221 -0
  44. dnakit/datasets/results.py +520 -0
  45. dnakit/datasets/split.py +450 -0
  46. dnakit/descriptors/__init__.py +102 -0
  47. dnakit/descriptors/_dinucleotide.py +345 -0
  48. dnakit/descriptors/_shared.py +180 -0
  49. dnakit/descriptors/all.py +917 -0
  50. dnakit/descriptors/basic.py +193 -0
  51. dnakit/descriptors/codon.py +195 -0
  52. dnakit/descriptors/complexity.py +201 -0
  53. dnakit/descriptors/entropy.py +101 -0
  54. dnakit/descriptors/homopolymer.py +105 -0
  55. dnakit/descriptors/kmer.py +88 -0
  56. dnakit/descriptors/results.py +286 -0
  57. dnakit/descriptors/schema.py +414 -0
  58. dnakit/descriptors/window.py +203 -0
  59. dnakit/download/__init__.py +54 -0
  60. dnakit/download/catalogs.py +166 -0
  61. dnakit/download/ena.py +131 -0
  62. dnakit/download/encode.py +103 -0
  63. dnakit/download/files.py +469 -0
  64. dnakit/download/indexes.py +233 -0
  65. dnakit/download/metadata.py +164 -0
  66. dnakit/download/models.py +227 -0
  67. dnakit/download/ncbi.py +333 -0
  68. dnakit/download/sequence.py +133 -0
  69. dnakit/download/ucsc.py +99 -0
  70. dnakit/evaluation/__init__.py +81 -0
  71. dnakit/evaluation/_shared.py +288 -0
  72. dnakit/evaluation/collection.py +270 -0
  73. dnakit/evaluation/config.py +591 -0
  74. dnakit/evaluation/distribution.py +287 -0
  75. dnakit/evaluation/frechet.py +223 -0
  76. dnakit/evaluation/generative.py +381 -0
  77. dnakit/evaluation/reference.py +409 -0
  78. dnakit/evaluation/results.py +137 -0
  79. dnakit/evaluation/scorecard.py +211 -0
  80. dnakit/evaluation/sequence.py +403 -0
  81. dnakit/evaluation/synthesis.py +249 -0
  82. dnakit/exceptions.py +189 -0
  83. dnakit/fingerprints/__init__.py +109 -0
  84. dnakit/fingerprints/_shared.py +116 -0
  85. dnakit/fingerprints/advanced.py +829 -0
  86. dnakit/fingerprints/bit.py +229 -0
  87. dnakit/fingerprints/encoding.py +351 -0
  88. dnakit/fingerprints/kmer.py +170 -0
  89. dnakit/fingerprints/results.py +531 -0
  90. dnakit/fingerprints/sketch.py +246 -0
  91. dnakit/io/__init__.py +112 -0
  92. dnakit/io/_advanced_common.py +105 -0
  93. dnakit/io/_formats.py +994 -0
  94. dnakit/io/annotations.py +1179 -0
  95. dnakit/io/api.py +759 -0
  96. dnakit/io/config.py +315 -0
  97. dnakit/io/genbank.py +523 -0
  98. dnakit/io/indexed.py +1146 -0
  99. dnakit/io/metadata.py +452 -0
  100. dnakit/io/parquet.py +56 -0
  101. dnakit/io/results.py +131 -0
  102. dnakit/io/source.py +138 -0
  103. dnakit/io/tables.py +1237 -0
  104. dnakit/molbio/__init__.py +101 -0
  105. dnakit/molbio/_shared.py +266 -0
  106. dnakit/molbio/assembly.py +279 -0
  107. dnakit/molbio/crispr.py +181 -0
  108. dnakit/molbio/optimization.py +897 -0
  109. dnakit/molbio/primer3_design.py +667 -0
  110. dnakit/molbio/primers.py +899 -0
  111. dnakit/molbio/restriction.py +573 -0
  112. dnakit/molbio/results.py +583 -0
  113. dnakit/ops/__init__.py +588 -0
  114. dnakit/ops/_common.py +201 -0
  115. dnakit/ops/circular.py +193 -0
  116. dnakit/ops/concat.py +204 -0
  117. dnakit/ops/direction.py +27 -0
  118. dnakit/ops/edit.py +443 -0
  119. dnakit/ops/evolution.py +991 -0
  120. dnakit/ops/mutation.py +151 -0
  121. dnakit/ops/records.py +1012 -0
  122. dnakit/ops/translation.py +257 -0
  123. dnakit/patterns/__init__.py +81 -0
  124. dnakit/patterns/_shared.py +282 -0
  125. dnakit/patterns/coding.py +360 -0
  126. dnakit/patterns/crispr.py +337 -0
  127. dnakit/patterns/motif.py +721 -0
  128. dnakit/patterns/regions.py +341 -0
  129. dnakit/patterns/repeats.py +480 -0
  130. dnakit/patterns/restriction.py +281 -0
  131. dnakit/patterns/results.py +411 -0
  132. dnakit/py.typed +1 -0
  133. dnakit/references/__init__.py +27 -0
  134. dnakit/references/models.py +155 -0
  135. dnakit/references/ncbi.py +785 -0
  136. dnakit/representations/__init__.py +29 -0
  137. dnakit/representations/backends.py +928 -0
  138. dnakit/representations/checkpoints.py +356 -0
  139. dnakit/representations/extraction.py +220 -0
  140. dnakit/representations/models.py +395 -0
  141. dnakit/representations/results.py +79 -0
  142. dnakit/search/__init__.py +132 -0
  143. dnakit/search/_http.py +271 -0
  144. dnakit/search/_shared.py +108 -0
  145. dnakit/search/blast.py +516 -0
  146. dnakit/search/ena.py +245 -0
  147. dnakit/search/encode.py +102 -0
  148. dnakit/search/ensembl.py +874 -0
  149. dnakit/search/models.py +159 -0
  150. dnakit/search/ncbi.py +756 -0
  151. dnakit/search/ucsc.py +388 -0
  152. dnakit/secondary_structure/__init__.py +38 -0
  153. dnakit/secondary_structure/dotbracket.py +410 -0
  154. dnakit/secondary_structure/nupack.py +589 -0
  155. dnakit/secondary_structure/results.py +142 -0
  156. dnakit/similarity/__init__.py +125 -0
  157. dnakit/similarity/_shared.py +152 -0
  158. dnakit/similarity/approximate.py +258 -0
  159. dnakit/similarity/compare.py +162 -0
  160. dnakit/similarity/dashing.py +936 -0
  161. dnakit/similarity/distance.py +279 -0
  162. dnakit/similarity/index.py +365 -0
  163. dnakit/similarity/matrix.py +254 -0
  164. dnakit/similarity/results.py +676 -0
  165. dnakit/similarity/search.py +286 -0
  166. dnakit/similarity/sketch.py +96 -0
  167. dnakit/similarity/vector.py +662 -0
  168. dnakit/standardize/__init__.py +77 -0
  169. dnakit/standardize/_shared.py +100 -0
  170. dnakit/standardize/config.py +411 -0
  171. dnakit/standardize/gaps.py +1060 -0
  172. dnakit/standardize/normalize.py +565 -0
  173. dnakit/standardize/results.py +247 -0
  174. dnakit/standardize/validate.py +487 -0
  175. dnakit/structure3d/__init__.py +47 -0
  176. dnakit/structure3d/_shared.py +47 -0
  177. dnakit/structure3d/geometry.py +773 -0
  178. dnakit/structure3d/pdb.py +253 -0
  179. dnakit/structure3d/results.py +233 -0
  180. dnakit/structure3d/threedna.py +174 -0
  181. dnakit/thermodynamics/__init__.py +112 -0
  182. dnakit/thermodynamics/_shared.py +92 -0
  183. dnakit/thermodynamics/backends.py +653 -0
  184. dnakit/thermodynamics/calculations.py +837 -0
  185. dnakit/thermodynamics/config.py +175 -0
  186. dnakit/thermodynamics/equilibrium.py +340 -0
  187. dnakit/thermodynamics/optics.py +385 -0
  188. dnakit/thermodynamics/parameters.py +79 -0
  189. dnakit/thermodynamics/results.py +445 -0
  190. dnakit/visualization/__init__.py +43 -0
  191. dnakit/visualization/_svg.py +185 -0
  192. dnakit/visualization/advanced.py +314 -0
  193. dnakit/visualization/config.py +255 -0
  194. dnakit/visualization/export.py +349 -0
  195. dnakit/visualization/matrix.py +279 -0
  196. dnakit/visualization/report.py +431 -0
  197. dnakit/visualization/results.py +236 -0
  198. dnakit/visualization/sequence.py +459 -0
  199. dnakit/workflows/__init__.py +45 -0
  200. dnakit/workflows/manifest.py +365 -0
  201. dnakit/workflows/runner.py +1548 -0
  202. dnakit/workflows/schema.py +898 -0
  203. dnakit-0.1.0.dev0.dist-info/METADATA +126 -0
  204. dnakit-0.1.0.dev0.dist-info/RECORD +210 -0
  205. dnakit-0.1.0.dev0.dist-info/WHEEL +5 -0
  206. dnakit-0.1.0.dev0.dist-info/entry_points.txt +2 -0
  207. dnakit-0.1.0.dev0.dist-info/licenses/DISCLAIMER.md +26 -0
  208. dnakit-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
  209. dnakit-0.1.0.dev0.dist-info/licenses/THIRD_PARTY_NOTICES.md +73 -0
  210. dnakit-0.1.0.dev0.dist-info/top_level.txt +1 -0
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"]
@@ -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"]