dnakit 0.1.0.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (210) hide show
  1. dnakit/__init__.py +108 -0
  2. dnakit/_version.py +5 -0
  3. dnakit/alignment/__init__.py +6 -0
  4. dnakit/alignment/pairwise.py +512 -0
  5. dnakit/alignment/results.py +188 -0
  6. dnakit/backends/__init__.py +80 -0
  7. dnakit/backends/external.py +517 -0
  8. dnakit/backends/registry.py +207 -0
  9. dnakit/batch.py +452 -0
  10. dnakit/cache/__init__.py +5 -0
  11. dnakit/cache/store.py +424 -0
  12. dnakit/chunking.py +920 -0
  13. dnakit/cli/__init__.py +5 -0
  14. dnakit/cli/__main__.py +6 -0
  15. dnakit/cli/app.py +814 -0
  16. dnakit/cli/workflow.py +94 -0
  17. dnakit/config/__init__.py +5 -0
  18. dnakit/config/loader.py +381 -0
  19. dnakit/core/__init__.py +80 -0
  20. dnakit/core/_json.py +206 -0
  21. dnakit/core/backend_info.py +70 -0
  22. dnakit/core/collection.py +163 -0
  23. dnakit/core/coordinates.py +295 -0
  24. dnakit/core/enums.py +95 -0
  25. dnakit/core/facade.py +719 -0
  26. dnakit/core/feature.py +90 -0
  27. dnakit/core/gap.py +82 -0
  28. dnakit/core/issues.py +62 -0
  29. dnakit/core/provenance.py +305 -0
  30. dnakit/core/record.py +161 -0
  31. dnakit/core/results.py +134 -0
  32. dnakit/core/sequence.py +327 -0
  33. dnakit/datasets/__init__.py +100 -0
  34. dnakit/datasets/_advanced_shared.py +198 -0
  35. dnakit/datasets/_metadata.py +33 -0
  36. dnakit/datasets/advanced_deduplicate.py +183 -0
  37. dnakit/datasets/advanced_split.py +302 -0
  38. dnakit/datasets/clustering.py +286 -0
  39. dnakit/datasets/config.py +531 -0
  40. dnakit/datasets/deduplicate.py +303 -0
  41. dnakit/datasets/evaluation.py +244 -0
  42. dnakit/datasets/filter.py +135 -0
  43. dnakit/datasets/neural_clustering.py +221 -0
  44. dnakit/datasets/results.py +520 -0
  45. dnakit/datasets/split.py +450 -0
  46. dnakit/descriptors/__init__.py +102 -0
  47. dnakit/descriptors/_dinucleotide.py +345 -0
  48. dnakit/descriptors/_shared.py +180 -0
  49. dnakit/descriptors/all.py +917 -0
  50. dnakit/descriptors/basic.py +193 -0
  51. dnakit/descriptors/codon.py +195 -0
  52. dnakit/descriptors/complexity.py +201 -0
  53. dnakit/descriptors/entropy.py +101 -0
  54. dnakit/descriptors/homopolymer.py +105 -0
  55. dnakit/descriptors/kmer.py +88 -0
  56. dnakit/descriptors/results.py +286 -0
  57. dnakit/descriptors/schema.py +414 -0
  58. dnakit/descriptors/window.py +203 -0
  59. dnakit/download/__init__.py +54 -0
  60. dnakit/download/catalogs.py +166 -0
  61. dnakit/download/ena.py +131 -0
  62. dnakit/download/encode.py +103 -0
  63. dnakit/download/files.py +469 -0
  64. dnakit/download/indexes.py +233 -0
  65. dnakit/download/metadata.py +164 -0
  66. dnakit/download/models.py +227 -0
  67. dnakit/download/ncbi.py +333 -0
  68. dnakit/download/sequence.py +133 -0
  69. dnakit/download/ucsc.py +99 -0
  70. dnakit/evaluation/__init__.py +81 -0
  71. dnakit/evaluation/_shared.py +288 -0
  72. dnakit/evaluation/collection.py +270 -0
  73. dnakit/evaluation/config.py +591 -0
  74. dnakit/evaluation/distribution.py +287 -0
  75. dnakit/evaluation/frechet.py +223 -0
  76. dnakit/evaluation/generative.py +381 -0
  77. dnakit/evaluation/reference.py +409 -0
  78. dnakit/evaluation/results.py +137 -0
  79. dnakit/evaluation/scorecard.py +211 -0
  80. dnakit/evaluation/sequence.py +403 -0
  81. dnakit/evaluation/synthesis.py +249 -0
  82. dnakit/exceptions.py +189 -0
  83. dnakit/fingerprints/__init__.py +109 -0
  84. dnakit/fingerprints/_shared.py +116 -0
  85. dnakit/fingerprints/advanced.py +829 -0
  86. dnakit/fingerprints/bit.py +229 -0
  87. dnakit/fingerprints/encoding.py +351 -0
  88. dnakit/fingerprints/kmer.py +170 -0
  89. dnakit/fingerprints/results.py +531 -0
  90. dnakit/fingerprints/sketch.py +246 -0
  91. dnakit/io/__init__.py +112 -0
  92. dnakit/io/_advanced_common.py +105 -0
  93. dnakit/io/_formats.py +994 -0
  94. dnakit/io/annotations.py +1179 -0
  95. dnakit/io/api.py +759 -0
  96. dnakit/io/config.py +315 -0
  97. dnakit/io/genbank.py +523 -0
  98. dnakit/io/indexed.py +1146 -0
  99. dnakit/io/metadata.py +452 -0
  100. dnakit/io/parquet.py +56 -0
  101. dnakit/io/results.py +131 -0
  102. dnakit/io/source.py +138 -0
  103. dnakit/io/tables.py +1237 -0
  104. dnakit/molbio/__init__.py +101 -0
  105. dnakit/molbio/_shared.py +266 -0
  106. dnakit/molbio/assembly.py +279 -0
  107. dnakit/molbio/crispr.py +181 -0
  108. dnakit/molbio/optimization.py +897 -0
  109. dnakit/molbio/primer3_design.py +667 -0
  110. dnakit/molbio/primers.py +899 -0
  111. dnakit/molbio/restriction.py +573 -0
  112. dnakit/molbio/results.py +583 -0
  113. dnakit/ops/__init__.py +588 -0
  114. dnakit/ops/_common.py +201 -0
  115. dnakit/ops/circular.py +193 -0
  116. dnakit/ops/concat.py +204 -0
  117. dnakit/ops/direction.py +27 -0
  118. dnakit/ops/edit.py +443 -0
  119. dnakit/ops/evolution.py +991 -0
  120. dnakit/ops/mutation.py +151 -0
  121. dnakit/ops/records.py +1012 -0
  122. dnakit/ops/translation.py +257 -0
  123. dnakit/patterns/__init__.py +81 -0
  124. dnakit/patterns/_shared.py +282 -0
  125. dnakit/patterns/coding.py +360 -0
  126. dnakit/patterns/crispr.py +337 -0
  127. dnakit/patterns/motif.py +721 -0
  128. dnakit/patterns/regions.py +341 -0
  129. dnakit/patterns/repeats.py +480 -0
  130. dnakit/patterns/restriction.py +281 -0
  131. dnakit/patterns/results.py +411 -0
  132. dnakit/py.typed +1 -0
  133. dnakit/references/__init__.py +27 -0
  134. dnakit/references/models.py +155 -0
  135. dnakit/references/ncbi.py +785 -0
  136. dnakit/representations/__init__.py +29 -0
  137. dnakit/representations/backends.py +928 -0
  138. dnakit/representations/checkpoints.py +356 -0
  139. dnakit/representations/extraction.py +220 -0
  140. dnakit/representations/models.py +395 -0
  141. dnakit/representations/results.py +79 -0
  142. dnakit/search/__init__.py +132 -0
  143. dnakit/search/_http.py +271 -0
  144. dnakit/search/_shared.py +108 -0
  145. dnakit/search/blast.py +516 -0
  146. dnakit/search/ena.py +245 -0
  147. dnakit/search/encode.py +102 -0
  148. dnakit/search/ensembl.py +874 -0
  149. dnakit/search/models.py +159 -0
  150. dnakit/search/ncbi.py +756 -0
  151. dnakit/search/ucsc.py +388 -0
  152. dnakit/secondary_structure/__init__.py +38 -0
  153. dnakit/secondary_structure/dotbracket.py +410 -0
  154. dnakit/secondary_structure/nupack.py +589 -0
  155. dnakit/secondary_structure/results.py +142 -0
  156. dnakit/similarity/__init__.py +125 -0
  157. dnakit/similarity/_shared.py +152 -0
  158. dnakit/similarity/approximate.py +258 -0
  159. dnakit/similarity/compare.py +162 -0
  160. dnakit/similarity/dashing.py +936 -0
  161. dnakit/similarity/distance.py +279 -0
  162. dnakit/similarity/index.py +365 -0
  163. dnakit/similarity/matrix.py +254 -0
  164. dnakit/similarity/results.py +676 -0
  165. dnakit/similarity/search.py +286 -0
  166. dnakit/similarity/sketch.py +96 -0
  167. dnakit/similarity/vector.py +662 -0
  168. dnakit/standardize/__init__.py +77 -0
  169. dnakit/standardize/_shared.py +100 -0
  170. dnakit/standardize/config.py +411 -0
  171. dnakit/standardize/gaps.py +1060 -0
  172. dnakit/standardize/normalize.py +565 -0
  173. dnakit/standardize/results.py +247 -0
  174. dnakit/standardize/validate.py +487 -0
  175. dnakit/structure3d/__init__.py +47 -0
  176. dnakit/structure3d/_shared.py +47 -0
  177. dnakit/structure3d/geometry.py +773 -0
  178. dnakit/structure3d/pdb.py +253 -0
  179. dnakit/structure3d/results.py +233 -0
  180. dnakit/structure3d/threedna.py +174 -0
  181. dnakit/thermodynamics/__init__.py +112 -0
  182. dnakit/thermodynamics/_shared.py +92 -0
  183. dnakit/thermodynamics/backends.py +653 -0
  184. dnakit/thermodynamics/calculations.py +837 -0
  185. dnakit/thermodynamics/config.py +175 -0
  186. dnakit/thermodynamics/equilibrium.py +340 -0
  187. dnakit/thermodynamics/optics.py +385 -0
  188. dnakit/thermodynamics/parameters.py +79 -0
  189. dnakit/thermodynamics/results.py +445 -0
  190. dnakit/visualization/__init__.py +43 -0
  191. dnakit/visualization/_svg.py +185 -0
  192. dnakit/visualization/advanced.py +314 -0
  193. dnakit/visualization/config.py +255 -0
  194. dnakit/visualization/export.py +349 -0
  195. dnakit/visualization/matrix.py +279 -0
  196. dnakit/visualization/report.py +431 -0
  197. dnakit/visualization/results.py +236 -0
  198. dnakit/visualization/sequence.py +459 -0
  199. dnakit/workflows/__init__.py +45 -0
  200. dnakit/workflows/manifest.py +365 -0
  201. dnakit/workflows/runner.py +1548 -0
  202. dnakit/workflows/schema.py +898 -0
  203. dnakit-0.1.0.dev0.dist-info/METADATA +126 -0
  204. dnakit-0.1.0.dev0.dist-info/RECORD +210 -0
  205. dnakit-0.1.0.dev0.dist-info/WHEEL +5 -0
  206. dnakit-0.1.0.dev0.dist-info/entry_points.txt +2 -0
  207. dnakit-0.1.0.dev0.dist-info/licenses/DISCLAIMER.md +26 -0
  208. dnakit-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
  209. dnakit-0.1.0.dev0.dist-info/licenses/THIRD_PARTY_NOTICES.md +73 -0
  210. dnakit-0.1.0.dev0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,77 @@
1
+ """Public DNA normalization and validation API."""
2
+
3
+ from .config import (
4
+ AmbiguityPolicy,
5
+ DatasetValidationConfig,
6
+ NormalizationConfig,
7
+ UPolicy,
8
+ ValidationConfig,
9
+ )
10
+ from .gaps import (
11
+ AGPAssemblyConfig,
12
+ AGPAssemblyResult,
13
+ AGPAssemblySegment,
14
+ AGPComponentValue,
15
+ CircularBoundaryPolicy,
16
+ GapNormalizable,
17
+ GapNormalizationChange,
18
+ GapNormalizationConfig,
19
+ GapNormalizationResult,
20
+ normalize_gaps,
21
+ sequence_from_agp,
22
+ )
23
+ from .normalize import RawSequenceInput, normalize
24
+ from .results import (
25
+ AmbiguityReport,
26
+ DatasetValidationReport,
27
+ DuplicateID,
28
+ InputPosition,
29
+ InvalidSymbol,
30
+ NormalizationChange,
31
+ NormalizationResult,
32
+ NormalizationStep,
33
+ ProbabilityResolution,
34
+ QualitySummary,
35
+ RawInputSnapshot,
36
+ SymbolCount,
37
+ SymbolOccurrence,
38
+ ValidationReport,
39
+ )
40
+ from .validate import validate, validate_set
41
+
42
+ __all__ = [
43
+ "AGPAssemblyConfig",
44
+ "AGPAssemblyResult",
45
+ "AGPAssemblySegment",
46
+ "AGPComponentValue",
47
+ "AmbiguityPolicy",
48
+ "AmbiguityReport",
49
+ "CircularBoundaryPolicy",
50
+ "DatasetValidationConfig",
51
+ "DatasetValidationReport",
52
+ "DuplicateID",
53
+ "GapNormalizable",
54
+ "GapNormalizationChange",
55
+ "GapNormalizationConfig",
56
+ "GapNormalizationResult",
57
+ "InputPosition",
58
+ "InvalidSymbol",
59
+ "NormalizationChange",
60
+ "NormalizationConfig",
61
+ "NormalizationResult",
62
+ "NormalizationStep",
63
+ "ProbabilityResolution",
64
+ "QualitySummary",
65
+ "RawInputSnapshot",
66
+ "RawSequenceInput",
67
+ "SymbolCount",
68
+ "SymbolOccurrence",
69
+ "UPolicy",
70
+ "ValidationConfig",
71
+ "ValidationReport",
72
+ "normalize",
73
+ "normalize_gaps",
74
+ "sequence_from_agp",
75
+ "validate",
76
+ "validate_set",
77
+ ]
@@ -0,0 +1,100 @@
1
+ """Private character-analysis kernels shared by normalization and validation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import defaultdict
6
+ from collections.abc import Iterable, Mapping
7
+
8
+ from dnakit.core.gap import Gap
9
+
10
+ from .results import (
11
+ AmbiguityReport,
12
+ ProbabilityResolution,
13
+ SymbolCount,
14
+ SymbolOccurrence,
15
+ )
16
+
17
+ CANONICAL_BASES = frozenset("ACGT")
18
+ IUPAC_AMBIGUITY = frozenset("RYSWKMBDHVN")
19
+ IUPAC_BASES = CANONICAL_BASES | IUPAC_AMBIGUITY
20
+ IUPAC_EXPANSION: Mapping[str, frozenset[str]] = {
21
+ "R": frozenset("AG"),
22
+ "Y": frozenset("CT"),
23
+ "S": frozenset("CG"),
24
+ "W": frozenset("AT"),
25
+ "K": frozenset("GT"),
26
+ "M": frozenset("AC"),
27
+ "B": frozenset("CGT"),
28
+ "D": frozenset("AGT"),
29
+ "H": frozenset("ACT"),
30
+ "V": frozenset("ACG"),
31
+ "N": frozenset("ACGT"),
32
+ }
33
+
34
+
35
+ def ambiguity_report(
36
+ parts: Iterable[str | Gap],
37
+ *,
38
+ denominator_includes_gap: bool = False,
39
+ base_priors: Mapping[str, float] | None = None,
40
+ ) -> AmbiguityReport:
41
+ """Describe every ambiguity symbol using normalized symbol coordinates."""
42
+ positions: dict[str, list[int]] = defaultdict(list)
43
+ occurrences: list[SymbolOccurrence] = []
44
+ resolutions: list[ProbabilityResolution] = []
45
+ symbol_offset = 0
46
+ gap_total = 0
47
+ denominator_known = True
48
+
49
+ for part_index, part in enumerate(parts):
50
+ if isinstance(part, Gap):
51
+ if denominator_includes_gap:
52
+ if part.length is None:
53
+ denominator_known = False
54
+ else:
55
+ gap_total += part.length
56
+ continue
57
+ for part_offset, symbol in enumerate(part):
58
+ if symbol in IUPAC_AMBIGUITY:
59
+ positions[symbol].append(symbol_offset)
60
+ occurrences.append(
61
+ SymbolOccurrence(
62
+ symbol=symbol,
63
+ symbol_offset=symbol_offset,
64
+ part_index=part_index,
65
+ part_offset=part_offset,
66
+ )
67
+ )
68
+ if base_priors is not None:
69
+ resolutions.append(
70
+ ProbabilityResolution(
71
+ symbol=symbol,
72
+ symbol_offset=symbol_offset,
73
+ probabilities=_probabilities(symbol, base_priors),
74
+ )
75
+ )
76
+ symbol_offset += 1
77
+
78
+ total = sum(len(item) for item in positions.values())
79
+ denominator = symbol_offset + gap_total if denominator_known else None
80
+ fraction = None if not denominator else total / denominator
81
+ return AmbiguityReport(
82
+ total_count=total,
83
+ denominator=denominator,
84
+ fraction=fraction,
85
+ by_symbol=tuple(
86
+ SymbolCount(symbol=symbol, count=len(items), positions=tuple(items))
87
+ for symbol, items in sorted(positions.items())
88
+ ),
89
+ occurrences=tuple(occurrences),
90
+ probability_resolutions=tuple(resolutions),
91
+ )
92
+
93
+
94
+ def _probabilities(symbol: str, priors: Mapping[str, float]) -> tuple[tuple[str, float], ...]:
95
+ allowed = IUPAC_EXPANSION[symbol]
96
+ total = sum(priors[base] for base in allowed)
97
+ if total == 0.0:
98
+ uniform = 1.0 / len(allowed)
99
+ return tuple((base, uniform) for base in sorted(allowed))
100
+ return tuple((base, priors[base] / total) for base in sorted(allowed))
@@ -0,0 +1,411 @@
1
+ """Configuration objects for deterministic sequence standardization and validation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from collections.abc import Iterable, Mapping
7
+ from dataclasses import dataclass, field
8
+ from enum import Enum
9
+ from itertools import islice
10
+ from types import MappingProxyType
11
+
12
+ from dnakit.core.enums import DNAAlphabet
13
+ from dnakit.exceptions import ConfigurationError
14
+
15
+ _MAX_REQUIRED_FIELDS = 10_000
16
+
17
+
18
+ class UPolicy(str, Enum):
19
+ """How :func:`normalize` handles uracil in a DNA input.
20
+
21
+ ``WARN`` and ``KEEP`` retain U for audit purposes, but U is not a valid
22
+ DNA alphabet symbol. Consequently those policies produce no
23
+ :class:`~dnakit.core.sequence.DNASequence`; ``WARN`` additionally emits a
24
+ warning. ``DELETE`` removes U and ``REPLACE`` converts U to valid DNA.
25
+ """
26
+
27
+ DELETE = "delete"
28
+ ERROR = "error"
29
+ WARN = "warn"
30
+ REPLACE = "replace"
31
+ KEEP = "keep"
32
+
33
+
34
+ class AmbiguityPolicy(str, Enum):
35
+ """How IUPAC ambiguity symbols are represented during normalization.
36
+
37
+ ``IGNORE`` means that the symbols are retained and excluded from any
38
+ canonical-base-only calculation. It does not delete sequence positions.
39
+ ``PROBABILITY`` also retains the symbols and records a deterministic base
40
+ probability distribution in the normalization result. ``DELETE`` removes
41
+ ambiguity symbols while retaining an audit change for every source position.
42
+ """
43
+
44
+ DELETE = "delete"
45
+ ERROR = "error"
46
+ IGNORE = "ignore"
47
+ MASK = "mask"
48
+ PROBABILITY = "probability"
49
+
50
+
51
+ @dataclass(frozen=True, slots=True)
52
+ class NormalizationConfig:
53
+ """Control a normalization run and retain an audit entry for every edit.
54
+
55
+ The three simple keep flags define the default character policy. Explicit
56
+ ``u_policy`` or ``ambiguity_policy`` values remain available for advanced
57
+ behavior and take precedence over their corresponding keep flag.
58
+ """
59
+
60
+ alphabet: DNAAlphabet | None = None
61
+ uppercase: bool = True
62
+ remove_whitespace: bool = True
63
+ remove_invisible: bool = True
64
+ removable_separators: tuple[str, ...] = ()
65
+ keep_ambiguous: bool = True
66
+ keep_u: bool = False
67
+ keep_other: bool = False
68
+ u_policy: UPolicy | None = None
69
+ ambiguity_policy: AmbiguityPolicy | None = None
70
+ ambiguity_mask: str = "N"
71
+ base_priors: Mapping[str, float] = field(
72
+ default_factory=lambda: MappingProxyType({"A": 0.25, "C": 0.25, "G": 0.25, "T": 0.25})
73
+ )
74
+ allow_gaps: bool = True
75
+ raise_on_error: bool = False
76
+ operator: str | None = None
77
+ run_id: str | None = None
78
+
79
+ def __post_init__(self) -> None:
80
+ for field_name in (
81
+ "uppercase",
82
+ "remove_whitespace",
83
+ "remove_invisible",
84
+ "keep_ambiguous",
85
+ "keep_u",
86
+ "keep_other",
87
+ "allow_gaps",
88
+ "raise_on_error",
89
+ ):
90
+ if not isinstance(getattr(self, field_name), bool):
91
+ raise ConfigurationError(
92
+ f"{field_name} must be a boolean.",
93
+ code="INVALID_NORMALIZATION_FLAG",
94
+ context={"field": field_name},
95
+ )
96
+ for field_name in ("operator", "run_id"):
97
+ value = getattr(self, field_name)
98
+ if value is not None and (not isinstance(value, str) or not value.strip()):
99
+ raise ConfigurationError(
100
+ f"{field_name} must be a non-empty string or None.",
101
+ code="INVALID_NORMALIZATION_AUDIT_FIELD",
102
+ context={"field": field_name},
103
+ )
104
+ raw_separators: object = self.removable_separators
105
+ if isinstance(raw_separators, (str, bytes)):
106
+ raise ConfigurationError(
107
+ "removable_separators must be an iterable of one-character strings.",
108
+ code="INVALID_NORMALIZATION_SEPARATOR",
109
+ )
110
+ separators = tuple(self.removable_separators)
111
+ if any(not isinstance(separator, str) or len(separator) != 1 for separator in separators):
112
+ raise ConfigurationError(
113
+ "Each removable separator must contain exactly one character.",
114
+ code="INVALID_NORMALIZATION_SEPARATOR",
115
+ )
116
+ if len(set(separators)) != len(separators):
117
+ raise ConfigurationError(
118
+ "removable_separators must not contain duplicates.",
119
+ code="DUPLICATE_NORMALIZATION_SEPARATOR",
120
+ )
121
+ if any(separator.isspace() for separator in separators):
122
+ raise ConfigurationError(
123
+ "Whitespace must be controlled with remove_whitespace.",
124
+ code="WHITESPACE_SEPARATOR_CONFLICT",
125
+ )
126
+
127
+ if not isinstance(self.ambiguity_mask, str):
128
+ raise ConfigurationError(
129
+ "ambiguity_mask must be a string.", code="INVALID_AMBIGUITY_MASK"
130
+ )
131
+ mask = self.ambiguity_mask.upper()
132
+ if len(mask) != 1 or mask not in "RYSWKMBDHVN":
133
+ raise ConfigurationError(
134
+ "ambiguity_mask must be one IUPAC ambiguity symbol.",
135
+ code="INVALID_AMBIGUITY_MASK",
136
+ )
137
+
138
+ try:
139
+ raw_priors = dict(self.base_priors.items())
140
+ except (AttributeError, TypeError, ValueError) as exc:
141
+ raise ConfigurationError(
142
+ "base_priors must map A, C, G and T to finite numbers.",
143
+ code="INVALID_BASE_PRIORS",
144
+ ) from exc
145
+ if any(
146
+ not isinstance(base, str) or base not in {"A", "C", "G", "T"} for base in raw_priors
147
+ ):
148
+ raise ConfigurationError(
149
+ "base_priors keys must be exactly uppercase A, C, G and T.",
150
+ code="INVALID_BASE_PRIORS",
151
+ )
152
+ if any(
153
+ isinstance(value, bool) or not isinstance(value, (int, float))
154
+ for value in raw_priors.values()
155
+ ):
156
+ raise ConfigurationError(
157
+ "base_priors must map A, C, G and T to finite numbers.",
158
+ code="INVALID_BASE_PRIORS",
159
+ )
160
+ priors = {base: float(value) for base, value in raw_priors.items()}
161
+ if set(priors) != {"A", "C", "G", "T"}:
162
+ raise ConfigurationError(
163
+ "base_priors must define exactly A, C, G and T.",
164
+ code="INVALID_BASE_PRIORS",
165
+ )
166
+ if (
167
+ any(not math.isfinite(value) or value < 0.0 for value in priors.values())
168
+ or sum(priors.values()) <= 0.0
169
+ ):
170
+ raise ConfigurationError(
171
+ "base_priors must be non-negative and have a positive sum.",
172
+ code="INVALID_BASE_PRIORS",
173
+ )
174
+
175
+ try:
176
+ alphabet = (
177
+ None
178
+ if self.alphabet is None
179
+ else self.alphabet
180
+ if isinstance(self.alphabet, DNAAlphabet)
181
+ else DNAAlphabet(self.alphabet)
182
+ )
183
+ u_policy = (
184
+ None
185
+ if self.u_policy is None
186
+ else self.u_policy
187
+ if isinstance(self.u_policy, UPolicy)
188
+ else UPolicy(self.u_policy)
189
+ )
190
+ ambiguity_policy = (
191
+ None
192
+ if self.ambiguity_policy is None
193
+ else self.ambiguity_policy
194
+ if isinstance(self.ambiguity_policy, AmbiguityPolicy)
195
+ else AmbiguityPolicy(self.ambiguity_policy)
196
+ )
197
+ except (TypeError, ValueError) as exc:
198
+ raise ConfigurationError(
199
+ "Unknown normalization policy or DNA alphabet.",
200
+ code="INVALID_NORMALIZATION_POLICY",
201
+ ) from exc
202
+
203
+ object.__setattr__(self, "alphabet", alphabet)
204
+ object.__setattr__(self, "u_policy", u_policy)
205
+ object.__setattr__(self, "ambiguity_policy", ambiguity_policy)
206
+ object.__setattr__(self, "removable_separators", separators)
207
+ object.__setattr__(self, "ambiguity_mask", mask)
208
+ object.__setattr__(self, "base_priors", MappingProxyType(priors))
209
+
210
+ @property
211
+ def effective_u_policy(self) -> UPolicy:
212
+ """Return the advanced U policy or the simple keep/delete policy."""
213
+
214
+ if self.u_policy is not None:
215
+ return self.u_policy
216
+ return UPolicy.KEEP if self.keep_u else UPolicy.DELETE
217
+
218
+ @property
219
+ def effective_ambiguity_policy(self) -> AmbiguityPolicy:
220
+ """Return the advanced ambiguity policy or the simple keep/delete policy."""
221
+
222
+ if self.ambiguity_policy is not None:
223
+ return self.ambiguity_policy
224
+ return AmbiguityPolicy.IGNORE if self.keep_ambiguous else AmbiguityPolicy.DELETE
225
+
226
+
227
+ @dataclass(frozen=True, slots=True)
228
+ class ValidationConfig:
229
+ """Thresholds and policies used by :func:`validate`.
230
+
231
+ ``sequence_length`` optionally requires an exact ``symbol_length``. It is
232
+ independent of the inclusive ``min_length`` and ``max_length`` bounds.
233
+ """
234
+
235
+ alphabet: DNAAlphabet | None = None
236
+ allow_empty: bool = False
237
+ allow_gaps: bool = True
238
+ allow_unknown_gap_length: bool = True
239
+ min_length: int | None = 1
240
+ max_length: int | None = None
241
+ max_ambiguity_fraction: float | None = 1.0
242
+ ambiguity_denominator_includes_gap: bool = False
243
+ required_metadata_fields: tuple[str, ...] = ()
244
+ required_letter_annotations: tuple[str, ...] = ()
245
+ check_phred_quality: bool = True
246
+ minimum_phred: float = 0.0
247
+ maximum_phred: float = 93.0
248
+ minimum_mean_phred: float | None = None
249
+ sequence_length: int | None = None
250
+
251
+ def __post_init__(self) -> None:
252
+ try:
253
+ alphabet = (
254
+ None
255
+ if self.alphabet is None
256
+ else self.alphabet
257
+ if isinstance(self.alphabet, DNAAlphabet)
258
+ else DNAAlphabet(self.alphabet)
259
+ )
260
+ except (TypeError, ValueError) as exc:
261
+ raise ConfigurationError(
262
+ "Unknown validation DNA alphabet.", code="INVALID_VALIDATION_ALPHABET"
263
+ ) from exc
264
+ for field_name in (
265
+ "allow_empty",
266
+ "allow_gaps",
267
+ "allow_unknown_gap_length",
268
+ "ambiguity_denominator_includes_gap",
269
+ "check_phred_quality",
270
+ ):
271
+ if not isinstance(getattr(self, field_name), bool):
272
+ raise ConfigurationError(
273
+ f"{field_name} must be a boolean.",
274
+ code="INVALID_VALIDATION_FLAG",
275
+ context={"field": field_name},
276
+ )
277
+ for field_name, threshold in (
278
+ ("min_length", self.min_length),
279
+ ("max_length", self.max_length),
280
+ ("sequence_length", self.sequence_length),
281
+ ):
282
+ if threshold is not None and (
283
+ isinstance(threshold, bool) or not isinstance(threshold, int) or threshold < 0
284
+ ):
285
+ raise ConfigurationError(
286
+ f"{field_name} must be a non-negative integer or None.",
287
+ code="INVALID_LENGTH_THRESHOLD",
288
+ context={"field": field_name},
289
+ )
290
+ if (
291
+ self.min_length is not None
292
+ and self.max_length is not None
293
+ and self.min_length > self.max_length
294
+ ):
295
+ raise ConfigurationError(
296
+ "min_length must not exceed max_length.", code="INVALID_LENGTH_THRESHOLD"
297
+ )
298
+ ambiguity_fraction = self.max_ambiguity_fraction
299
+ if ambiguity_fraction is not None and (
300
+ isinstance(ambiguity_fraction, bool)
301
+ or not isinstance(ambiguity_fraction, (int, float))
302
+ or not math.isfinite(ambiguity_fraction)
303
+ or not 0.0 <= ambiguity_fraction <= 1.0
304
+ ):
305
+ raise ConfigurationError(
306
+ "max_ambiguity_fraction must be a finite number between 0 and 1.",
307
+ code="INVALID_AMBIGUITY_THRESHOLD",
308
+ )
309
+ quality_values: dict[str, float | None] = {}
310
+ quality_thresholds: tuple[tuple[str, float | None, bool], ...] = (
311
+ ("minimum_phred", self.minimum_phred, False),
312
+ ("maximum_phred", self.maximum_phred, False),
313
+ ("minimum_mean_phred", self.minimum_mean_phred, True),
314
+ )
315
+ for field_name, quality_threshold, optional in quality_thresholds:
316
+ if quality_threshold is None and optional:
317
+ quality_values[field_name] = None
318
+ continue
319
+ if (
320
+ isinstance(quality_threshold, bool)
321
+ or not isinstance(quality_threshold, (int, float))
322
+ or not math.isfinite(quality_threshold)
323
+ ):
324
+ raise ConfigurationError(
325
+ f"{field_name} must be a finite number" + (" or None." if optional else "."),
326
+ code="INVALID_QUALITY_THRESHOLD",
327
+ context={"field": field_name},
328
+ )
329
+ quality_values[field_name] = float(quality_threshold)
330
+ minimum_phred = quality_values["minimum_phred"]
331
+ maximum_phred = quality_values["maximum_phred"]
332
+ assert minimum_phred is not None and maximum_phred is not None
333
+ if minimum_phred > maximum_phred:
334
+ raise ConfigurationError(
335
+ "minimum_phred must not exceed maximum_phred.",
336
+ code="INVALID_QUALITY_THRESHOLD",
337
+ )
338
+
339
+ resolved_required_fields: dict[str, tuple[str, ...]] = {}
340
+ for field_name, values in (
341
+ ("required_metadata_fields", self.required_metadata_fields),
342
+ ("required_letter_annotations", self.required_letter_annotations),
343
+ ):
344
+ raw_values: object = values
345
+ if isinstance(raw_values, (str, bytes)) or not isinstance(raw_values, Iterable):
346
+ raise ConfigurationError(
347
+ f"{field_name} must be an iterable of non-empty strings.",
348
+ code="INVALID_REQUIRED_FIELD",
349
+ context={"field": field_name},
350
+ )
351
+ resolved = tuple(islice(iter(values), _MAX_REQUIRED_FIELDS + 1))
352
+ if len(resolved) > _MAX_REQUIRED_FIELDS:
353
+ raise ConfigurationError(
354
+ f"{field_name} exceeds the supported field limit.",
355
+ code="REQUIRED_FIELD_LIMIT",
356
+ context={"field": field_name, "max_fields": _MAX_REQUIRED_FIELDS},
357
+ )
358
+ if any(not isinstance(value, str) or not value.strip() for value in resolved):
359
+ raise ConfigurationError(
360
+ f"{field_name} must contain only non-empty strings.",
361
+ code="INVALID_REQUIRED_FIELD",
362
+ context={"field": field_name},
363
+ )
364
+ if len(set(resolved)) != len(resolved):
365
+ raise ConfigurationError(
366
+ f"{field_name} must not contain duplicates.",
367
+ code="DUPLICATE_REQUIRED_FIELD",
368
+ context={"field": field_name},
369
+ )
370
+ resolved_required_fields[field_name] = resolved
371
+
372
+ object.__setattr__(self, "alphabet", alphabet)
373
+ object.__setattr__(
374
+ self,
375
+ "max_ambiguity_fraction",
376
+ None if ambiguity_fraction is None else float(ambiguity_fraction),
377
+ )
378
+ object.__setattr__(self, "minimum_phred", minimum_phred)
379
+ object.__setattr__(self, "maximum_phred", maximum_phred)
380
+ object.__setattr__(self, "minimum_mean_phred", quality_values["minimum_mean_phred"])
381
+ object.__setattr__(
382
+ self, "required_metadata_fields", resolved_required_fields["required_metadata_fields"]
383
+ )
384
+ object.__setattr__(
385
+ self,
386
+ "required_letter_annotations",
387
+ resolved_required_fields["required_letter_annotations"],
388
+ )
389
+
390
+
391
+ @dataclass(frozen=True, slots=True)
392
+ class DatasetValidationConfig:
393
+ """Collection-level validation rules."""
394
+
395
+ record: ValidationConfig = field(default_factory=ValidationConfig)
396
+ require_unique_ids: bool = True
397
+ collect_record_reports: bool = True
398
+
399
+ def __post_init__(self) -> None:
400
+ if not isinstance(self.record, ValidationConfig):
401
+ raise ConfigurationError(
402
+ "record must be a ValidationConfig object.",
403
+ code="INVALID_DATASET_VALIDATION_CONFIG",
404
+ )
405
+ for field_name in ("require_unique_ids", "collect_record_reports"):
406
+ if not isinstance(getattr(self, field_name), bool):
407
+ raise ConfigurationError(
408
+ f"{field_name} must be a boolean.",
409
+ code="INVALID_VALIDATION_FLAG",
410
+ context={"field": field_name},
411
+ )