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/facade.py ADDED
@@ -0,0 +1,719 @@
1
+ """One user-facing entry point for one or many DNA records."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable, Iterator, Mapping
6
+ from dataclasses import dataclass, replace
7
+ from itertools import islice
8
+ from typing import TYPE_CHECKING, TypeAlias, cast
9
+
10
+ from dnakit.core._json import FrozenDict
11
+ from dnakit.core.collection import DNASet
12
+ from dnakit.core.coordinates import CompoundLocation, Interval, Location, UnresolvedLocation
13
+ from dnakit.core.enums import DNAAlphabet, Strandedness, Topology
14
+ from dnakit.core.feature import DNAFeature
15
+ from dnakit.core.gap import Gap
16
+ from dnakit.core.record import DNARecord
17
+ from dnakit.core.sequence import DNASequence
18
+ from dnakit.exceptions import (
19
+ ConfigurationError,
20
+ FeatureError,
21
+ InvalidAlphabetError,
22
+ SequenceError,
23
+ )
24
+
25
+ if TYPE_CHECKING:
26
+ from dnakit.standardize.config import NormalizationConfig
27
+ from dnakit.standardize.results import NormalizationResult
28
+
29
+
30
+ FeatureInput: TypeAlias = DNAFeature | Mapping[str, object]
31
+ _MAX_DEFAULT_RECORDS = 100_000
32
+ _RECORD_SPEC_KEYS = frozenset(
33
+ {
34
+ "sequence",
35
+ "parts",
36
+ "id",
37
+ "description",
38
+ "features",
39
+ "metadata",
40
+ "letter_annotations",
41
+ "alphabet",
42
+ "topology",
43
+ "strandedness",
44
+ "normalization_config",
45
+ }
46
+ )
47
+ _FEATURE_SPEC_KEYS = frozenset(
48
+ {
49
+ "type",
50
+ "location",
51
+ "start",
52
+ "end",
53
+ "id",
54
+ "strand",
55
+ "label",
56
+ "score",
57
+ "phase",
58
+ "qualifiers",
59
+ "source",
60
+ }
61
+ )
62
+
63
+
64
+ @dataclass(frozen=True, slots=True)
65
+ class _RecordOptions:
66
+ id: str | None = None
67
+ description: str | None = None
68
+ features: tuple[DNAFeature, ...] | None = None
69
+ metadata: Mapping[str, object] | None = None
70
+ letter_annotations: Mapping[str, tuple[int | float, ...]] | None = None
71
+ alphabet: DNAAlphabet | str | None = None
72
+ topology: Topology | str | None = None
73
+ strandedness: Strandedness | str | None = None
74
+ normalization_config: NormalizationConfig | None = None
75
+
76
+
77
+ @dataclass(frozen=True, init=False)
78
+ class DNA:
79
+ """A stable facade over one or many DNA records.
80
+
81
+ Raw text is normalized with a retained audit. A mapping can describe one
82
+ record, while an iterable can contain raw sequences, records, or record
83
+ mappings. Existing ``DNASequence``, ``DNARecord`` and ``DNASet`` values are
84
+ accepted so advanced and legacy code can enter the same public path.
85
+ """
86
+
87
+ _dataset: DNASet
88
+ normalizations: tuple[NormalizationResult | None, ...]
89
+
90
+ __hash__ = None # type: ignore[assignment]
91
+
92
+ def __init__(
93
+ self,
94
+ data: object,
95
+ *,
96
+ id: str | None = None,
97
+ description: str | None = None,
98
+ features: FeatureInput | Iterable[FeatureInput] | None = None,
99
+ metadata: Mapping[str, object] | None = None,
100
+ letter_annotations: Mapping[str, Iterable[int | float]] | None = None,
101
+ alphabet: DNAAlphabet | str | None = None,
102
+ topology: Topology | str | None = None,
103
+ strandedness: Strandedness | str | None = None,
104
+ normalization_config: NormalizationConfig | None = None,
105
+ name: str | None = None,
106
+ source: str | None = None,
107
+ version: str | None = None,
108
+ collection_metadata: Mapping[str, object] | None = None,
109
+ max_records: int = _MAX_DEFAULT_RECORDS,
110
+ ) -> None:
111
+ """Build one consistent object from one sequence or a bounded collection."""
112
+
113
+ _validate_max_records(max_records)
114
+ defaults = _RecordOptions(
115
+ id=id,
116
+ description=description,
117
+ features=None if features is None else _coerce_features(features),
118
+ metadata=metadata,
119
+ letter_annotations=_materialize_annotations(letter_annotations),
120
+ alphabet=alphabet,
121
+ topology=topology,
122
+ strandedness=strandedness,
123
+ normalization_config=normalization_config,
124
+ )
125
+ items, inherited_normalizations, inherited_dataset = _materialize_data(
126
+ data, max_records=max_records
127
+ )
128
+ if len(items) > 1 and defaults.id is not None:
129
+ raise ConfigurationError(
130
+ "A shared id cannot identify multiple DNA records.",
131
+ code="MULTIPLE_DNA_SHARED_ID",
132
+ hint="Put a distinct 'id' in each record mapping or omit it for generated IDs.",
133
+ )
134
+
135
+ records: list[DNARecord] = []
136
+ audits: list[NormalizationResult | None] = []
137
+ for index, item in enumerate(items):
138
+ inherited_audit = (
139
+ inherited_normalizations[index] if index < len(inherited_normalizations) else None
140
+ )
141
+ record, audit = _build_record(
142
+ item,
143
+ index=index,
144
+ defaults=defaults,
145
+ inherited_audit=inherited_audit,
146
+ )
147
+ records.append(record)
148
+ audits.append(audit)
149
+
150
+ resolved_name = name if name is not None else getattr(inherited_dataset, "name", None)
151
+ resolved_source = (
152
+ source if source is not None else getattr(inherited_dataset, "source", None)
153
+ )
154
+ resolved_version = (
155
+ version if version is not None else getattr(inherited_dataset, "version", None)
156
+ )
157
+ resolved_collection_metadata = (
158
+ collection_metadata
159
+ if collection_metadata is not None
160
+ else getattr(inherited_dataset, "metadata", None)
161
+ )
162
+ dataset = DNASet(
163
+ records,
164
+ name=resolved_name,
165
+ source=resolved_source,
166
+ version=resolved_version,
167
+ metadata=resolved_collection_metadata,
168
+ )
169
+ object.__setattr__(self, "_dataset", dataset)
170
+ object.__setattr__(self, "normalizations", tuple(audits))
171
+
172
+ def __len__(self) -> int:
173
+ """Return the number of contained DNA records."""
174
+
175
+ return len(self._dataset)
176
+
177
+ def __iter__(self) -> Iterator[DNARecord]:
178
+ """Iterate over compatible internal records for dataset algorithms."""
179
+
180
+ return iter(self._dataset)
181
+
182
+ def __getitem__(self, index: int | slice) -> DNA:
183
+ """Select one or more records while staying in the public DNA type."""
184
+
185
+ indices = range(len(self))[index]
186
+ if isinstance(indices, int):
187
+ return self._derive(((indices, self.records[indices]),))
188
+ return self._derive((position, self.records[position]) for position in indices)
189
+
190
+ @property
191
+ def records(self) -> tuple[DNARecord, ...]:
192
+ """The immutable compatible records used by advanced APIs."""
193
+
194
+ return self._dataset.records
195
+
196
+ @property
197
+ def record_count(self) -> int:
198
+ """Number of contained records."""
199
+
200
+ return len(self)
201
+
202
+ @property
203
+ def is_single(self) -> bool:
204
+ """Whether this object contains exactly one DNA record."""
205
+
206
+ return len(self) == 1
207
+
208
+ @property
209
+ def record(self) -> DNARecord:
210
+ """Return the only record, rejecting empty or multi-record data."""
211
+
212
+ if not self.is_single:
213
+ raise SequenceError(
214
+ "This operation requires exactly one DNA record.",
215
+ code="SINGLE_DNA_REQUIRED",
216
+ context={"record_count": len(self)},
217
+ hint="Select one record or use a dataset-level operation.",
218
+ )
219
+ return self.records[0]
220
+
221
+ @property
222
+ def sequence(self) -> DNASequence:
223
+ """Return the sequence of a single-record object."""
224
+
225
+ return self.record.sequence
226
+
227
+ @property
228
+ def id(self) -> str:
229
+ """Return the identifier of a single-record object."""
230
+
231
+ return self.record.id
232
+
233
+ @property
234
+ def ids(self) -> tuple[str, ...]:
235
+ """Return every record identifier in stable input order."""
236
+
237
+ return self._dataset.ids
238
+
239
+ @property
240
+ def description(self) -> str:
241
+ """Return the description of a single-record object."""
242
+
243
+ return self.record.description
244
+
245
+ @property
246
+ def features(self) -> tuple[DNAFeature, ...]:
247
+ """Return the features of a single-record object."""
248
+
249
+ return self.record.features
250
+
251
+ @property
252
+ def metadata(self) -> Mapping[str, object]:
253
+ """Return record metadata for one record, otherwise collection metadata."""
254
+
255
+ return self.record.metadata if self.is_single else self._dataset.metadata
256
+
257
+ @property
258
+ def collection_metadata(self) -> FrozenDict:
259
+ """Return metadata attached to the collection itself."""
260
+
261
+ return self._dataset.metadata
262
+
263
+ @property
264
+ def name(self) -> str | None:
265
+ return self._dataset.name
266
+
267
+ @property
268
+ def source(self) -> str | None:
269
+ return self._dataset.source
270
+
271
+ @property
272
+ def version(self) -> str | None:
273
+ return self._dataset.version
274
+
275
+ @property
276
+ def normalization(self) -> NormalizationResult | None:
277
+ """Return the retained normalization audit for one record."""
278
+
279
+ _ = self.record
280
+ return self.normalizations[0]
281
+
282
+ @property
283
+ def symbols(self) -> str:
284
+ """Convenience access to normalized symbols for one record."""
285
+
286
+ return self.sequence.symbols
287
+
288
+ @property
289
+ def symbol_length(self) -> int:
290
+ return self.sequence.symbol_length
291
+
292
+ @property
293
+ def parts(self) -> tuple[str | Gap, ...]:
294
+ return self.sequence.parts
295
+
296
+ @property
297
+ def is_gapped(self) -> bool:
298
+ return self.sequence.is_gapped
299
+
300
+ @property
301
+ def has_unknown_length(self) -> bool:
302
+ return self.sequence.has_unknown_length
303
+
304
+ @property
305
+ def canonical_base_count(self) -> int:
306
+ return self.sequence.canonical_base_count
307
+
308
+ @property
309
+ def ambiguity_count(self) -> int:
310
+ return self.sequence.ambiguity_count
311
+
312
+ @property
313
+ def coordinate_span(self) -> int | None:
314
+ return self.sequence.coordinate_span
315
+
316
+ @property
317
+ def alphabet(self) -> DNAAlphabet:
318
+ return self.sequence.alphabet
319
+
320
+ @property
321
+ def topology(self) -> Topology:
322
+ return self.sequence.topology
323
+
324
+ @property
325
+ def strandedness(self) -> Strandedness:
326
+ return self.sequence.strandedness
327
+
328
+ @property
329
+ def dataset(self) -> DNASet:
330
+ """Return the compatible immutable dataset for advanced APIs."""
331
+
332
+ return self._dataset
333
+
334
+ def _derive(self, indexed_records: Iterable[tuple[int, DNARecord]]) -> DNA:
335
+ """Build a facade result while retaining audits for unchanged source sequences."""
336
+
337
+ selected = tuple(indexed_records)
338
+ indices = tuple(index for index, _ in selected)
339
+ if any(
340
+ isinstance(index, bool) or not isinstance(index, int) or not 0 <= index < len(self)
341
+ for index in indices
342
+ ):
343
+ raise IndexError("A derived DNA record index is outside the source object.")
344
+ if len(indices) != len(set(indices)):
345
+ raise ValueError("A derived DNA object cannot select one source record twice.")
346
+ dataset = DNASet(
347
+ (record for _, record in selected),
348
+ name=self.name,
349
+ source=self.source,
350
+ version=self.version,
351
+ metadata=self.collection_metadata,
352
+ )
353
+ derived = object.__new__(DNA)
354
+ object.__setattr__(derived, "_dataset", dataset)
355
+ object.__setattr__(
356
+ derived, "normalizations", tuple(self.normalizations[i] for i in indices)
357
+ )
358
+ return derived
359
+
360
+
361
+ SingleDNAInput: TypeAlias = DNA | DNASequence | DNARecord
362
+
363
+
364
+ def resolve_single_dna(value: object) -> tuple[DNASequence, DNARecord | None]:
365
+ """Resolve one facade/record/sequence without exposing constructor choices."""
366
+
367
+ if isinstance(value, DNA):
368
+ record = value.record
369
+ return record.sequence, record
370
+ if isinstance(value, DNARecord):
371
+ return value.sequence, value
372
+ if isinstance(value, DNASequence):
373
+ return value, None
374
+ raise TypeError("value must be DNA, DNARecord, or DNASequence.")
375
+
376
+
377
+ def _validate_max_records(value: int) -> None:
378
+ if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
379
+ raise ConfigurationError(
380
+ "max_records must be a positive integer.",
381
+ code="INVALID_DNA_RECORD_LIMIT",
382
+ context={"max_records": value},
383
+ )
384
+
385
+
386
+ def _materialize_data(
387
+ data: object,
388
+ *,
389
+ max_records: int,
390
+ ) -> tuple[tuple[object, ...], tuple[NormalizationResult | None, ...], DNASet | None]:
391
+ if isinstance(data, DNA):
392
+ return data.records, data.normalizations, data.dataset
393
+ if isinstance(data, DNASet):
394
+ return data.records, (None,) * len(data), data
395
+ if isinstance(data, (str, bytes, DNASequence, DNARecord, Mapping)):
396
+ return (data,), (), None
397
+ if not isinstance(data, Iterable):
398
+ raise TypeError(
399
+ "DNA data must be sequence text, a record mapping, a DNA object, or an iterable."
400
+ )
401
+
402
+ items = tuple(islice(data, max_records + 1))
403
+ if len(items) > max_records:
404
+ raise ConfigurationError(
405
+ "DNA input exceeds max_records.",
406
+ code="DNA_RECORD_LIMIT_EXCEEDED",
407
+ context={"max_records": max_records},
408
+ )
409
+ if any(isinstance(item, Gap) for item in items):
410
+ if any(not isinstance(item, (str, Gap)) for item in items):
411
+ raise TypeError("A gapped sequence may contain only text fragments and Gap objects.")
412
+ return (items,), (), None
413
+ return cast(tuple[object, ...], items), (), None
414
+
415
+
416
+ def _build_record(
417
+ item: object,
418
+ *,
419
+ index: int,
420
+ defaults: _RecordOptions,
421
+ inherited_audit: NormalizationResult | None,
422
+ ) -> tuple[DNARecord, NormalizationResult | None]:
423
+ raw, options = _record_spec(item, defaults=defaults)
424
+ source_record = raw if isinstance(raw, DNARecord) else None
425
+ sequence_input = source_record.sequence if source_record is not None else raw
426
+
427
+ sequence_changed = any(
428
+ value is not None
429
+ for value in (
430
+ options.alphabet,
431
+ options.topology,
432
+ options.strandedness,
433
+ options.normalization_config,
434
+ )
435
+ )
436
+ if source_record is not None and not sequence_changed:
437
+ sequence = source_record.sequence
438
+ audit = inherited_audit
439
+ else:
440
+ sequence, audit = _normalize_sequence(
441
+ sequence_input,
442
+ alphabet=options.alphabet,
443
+ topology=options.topology,
444
+ strandedness=options.strandedness,
445
+ config=options.normalization_config,
446
+ )
447
+
448
+ record_id = options.id or (source_record.id if source_record is not None else None)
449
+ if record_id is None:
450
+ record_id = f"sequence_{index + 1}"
451
+ description = (
452
+ options.description
453
+ if options.description is not None
454
+ else source_record.description
455
+ if source_record is not None
456
+ else ""
457
+ )
458
+ features = (
459
+ options.features
460
+ if options.features is not None
461
+ else source_record.features
462
+ if source_record is not None
463
+ else ()
464
+ )
465
+ metadata = (
466
+ options.metadata
467
+ if options.metadata is not None
468
+ else source_record.metadata
469
+ if source_record is not None
470
+ else None
471
+ )
472
+ annotations = (
473
+ options.letter_annotations
474
+ if options.letter_annotations is not None
475
+ else source_record.letter_annotations
476
+ if source_record is not None
477
+ else None
478
+ )
479
+ return (
480
+ DNARecord(
481
+ sequence,
482
+ record_id,
483
+ description=description,
484
+ features=features,
485
+ metadata=metadata,
486
+ letter_annotations=annotations,
487
+ ),
488
+ audit,
489
+ )
490
+
491
+
492
+ def _record_spec(item: object, *, defaults: _RecordOptions) -> tuple[object, _RecordOptions]:
493
+ from dnakit.standardize.config import NormalizationConfig
494
+
495
+ if not isinstance(item, Mapping):
496
+ return item, defaults
497
+ unknown = set(item) - _RECORD_SPEC_KEYS
498
+ if unknown:
499
+ raise ConfigurationError(
500
+ "A DNA record mapping contains unknown fields.",
501
+ code="UNKNOWN_DNA_RECORD_FIELD",
502
+ context={"fields": sorted(str(key) for key in unknown)},
503
+ )
504
+ has_sequence = "sequence" in item
505
+ has_parts = "parts" in item
506
+ if has_sequence == has_parts:
507
+ raise ConfigurationError(
508
+ "A DNA record mapping must contain exactly one of 'sequence' or 'parts'.",
509
+ code="DNA_RECORD_SEQUENCE_REQUIRED",
510
+ )
511
+ raw = item["sequence"] if has_sequence else item["parts"]
512
+
513
+ local_features = (
514
+ _coerce_features(cast(object, item["features"]))
515
+ if "features" in item
516
+ else defaults.features
517
+ )
518
+ local_metadata = _optional_mapping(item.get("metadata"), name="metadata")
519
+ if "metadata" not in item:
520
+ local_metadata = defaults.metadata
521
+ elif defaults.metadata is not None and local_metadata is not None:
522
+ local_metadata = {**defaults.metadata, **local_metadata}
523
+ local_annotations = (
524
+ _materialize_annotations(
525
+ cast(Mapping[str, Iterable[int | float]] | None, item.get("letter_annotations"))
526
+ )
527
+ if "letter_annotations" in item
528
+ else defaults.letter_annotations
529
+ )
530
+ return raw, _RecordOptions(
531
+ id=cast(str | None, item.get("id", defaults.id)),
532
+ description=cast(str | None, item.get("description", defaults.description)),
533
+ features=local_features,
534
+ metadata=local_metadata,
535
+ letter_annotations=local_annotations,
536
+ alphabet=cast(DNAAlphabet | str | None, item.get("alphabet", defaults.alphabet)),
537
+ topology=cast(Topology | str | None, item.get("topology", defaults.topology)),
538
+ strandedness=cast(
539
+ Strandedness | str | None,
540
+ item.get("strandedness", defaults.strandedness),
541
+ ),
542
+ normalization_config=cast(
543
+ NormalizationConfig | None,
544
+ item.get("normalization_config", defaults.normalization_config),
545
+ ),
546
+ )
547
+
548
+
549
+ def _normalize_sequence(
550
+ raw: object,
551
+ *,
552
+ alphabet: DNAAlphabet | str | None,
553
+ topology: Topology | str | None,
554
+ strandedness: Strandedness | str | None,
555
+ config: NormalizationConfig | None,
556
+ ) -> tuple[DNASequence, NormalizationResult]:
557
+ from dnakit.standardize.config import NormalizationConfig
558
+ from dnakit.standardize.normalize import normalize
559
+
560
+ if not isinstance(raw, (str, bytes, DNASequence, Iterable)) or isinstance(raw, Mapping):
561
+ raise TypeError("Each DNA item must be text, DNASequence, DNARecord, or string/Gap parts.")
562
+ if config is not None and not isinstance(config, NormalizationConfig):
563
+ raise ConfigurationError(
564
+ "normalization_config must be NormalizationConfig or None.",
565
+ code="INVALID_DNA_NORMALIZATION_CONFIG",
566
+ )
567
+ resolved_alphabet = _coerce_alphabet(alphabet)
568
+ if config is not None and resolved_alphabet is not None:
569
+ if config.alphabet is not None and config.alphabet is not resolved_alphabet:
570
+ raise ConfigurationError(
571
+ "alphabet conflicts with normalization_config.alphabet.",
572
+ code="DNA_ALPHABET_CONFIG_CONFLICT",
573
+ )
574
+ config = replace(config, alphabet=resolved_alphabet)
575
+ elif config is None and resolved_alphabet is not None:
576
+ config = NormalizationConfig(alphabet=resolved_alphabet)
577
+
578
+ normalized = normalize(
579
+ cast(str | bytes | Iterable[str | Gap] | DNASequence, raw), config=config
580
+ )
581
+ if normalized.sequence is None:
582
+ raise InvalidAlphabetError(
583
+ "DNA input could not be converted into a valid sequence.",
584
+ code="DNA_NORMALIZATION_FAILED",
585
+ context={"issue_codes": [issue.code for issue in normalized.issues]},
586
+ hint="Inspect the retained normalization policy or correct the input symbols.",
587
+ )
588
+ source_sequence = raw if isinstance(raw, DNASequence) else None
589
+ resolved_topology = (
590
+ topology
591
+ if topology is not None
592
+ else source_sequence.topology
593
+ if source_sequence is not None
594
+ else Topology.LINEAR
595
+ )
596
+ resolved_strandedness = (
597
+ strandedness
598
+ if strandedness is not None
599
+ else source_sequence.strandedness
600
+ if source_sequence is not None
601
+ else Strandedness.SINGLE
602
+ )
603
+ sequence = DNASequence(
604
+ normalized.sequence.parts,
605
+ alphabet=normalized.sequence.alphabet,
606
+ topology=resolved_topology,
607
+ strandedness=resolved_strandedness,
608
+ )
609
+ return sequence, replace(normalized, sequence=sequence)
610
+
611
+
612
+ def _coerce_alphabet(value: DNAAlphabet | str | None) -> DNAAlphabet | None:
613
+ if value is None:
614
+ return None
615
+ try:
616
+ return value if isinstance(value, DNAAlphabet) else DNAAlphabet(value)
617
+ except (TypeError, ValueError) as exc:
618
+ raise ConfigurationError(
619
+ "Unknown DNA alphabet.",
620
+ code="INVALID_DNA_ALPHABET",
621
+ context={"alphabet": value},
622
+ hint="Choose 'strict' or 'iupac'.",
623
+ ) from exc
624
+
625
+
626
+ def _coerce_features(value: object) -> tuple[DNAFeature, ...]:
627
+ if value is None:
628
+ return ()
629
+ if isinstance(value, (DNAFeature, Mapping)):
630
+ items = (value,)
631
+ elif isinstance(value, Iterable) and not isinstance(value, (str, bytes)):
632
+ items = tuple(value)
633
+ else:
634
+ raise FeatureError(
635
+ "features must be a feature, a feature mapping, or an iterable of them.",
636
+ code="INVALID_DNA_FEATURES",
637
+ )
638
+ return tuple(_coerce_feature(item) for item in items)
639
+
640
+
641
+ def _coerce_feature(value: object) -> DNAFeature:
642
+ if isinstance(value, DNAFeature):
643
+ return value
644
+ if not isinstance(value, Mapping):
645
+ raise FeatureError(
646
+ "Every feature must be DNAFeature or a mapping.",
647
+ code="INVALID_DNA_FEATURE",
648
+ context={"type": type(value).__name__},
649
+ )
650
+ unknown = set(value) - _FEATURE_SPEC_KEYS
651
+ if unknown:
652
+ raise FeatureError(
653
+ "A feature mapping contains unknown fields.",
654
+ code="UNKNOWN_DNA_FEATURE_FIELD",
655
+ context={"fields": sorted(str(key) for key in unknown)},
656
+ )
657
+ if "type" not in value:
658
+ raise FeatureError("A feature mapping requires 'type'.", code="DNA_FEATURE_TYPE_REQUIRED")
659
+ has_location = "location" in value
660
+ has_interval = "start" in value or "end" in value
661
+ if has_location and has_interval:
662
+ raise FeatureError(
663
+ "Use either location or start/end for a feature, not both.",
664
+ code="DNA_FEATURE_LOCATION_CONFLICT",
665
+ )
666
+ if has_location:
667
+ location = value["location"]
668
+ if not isinstance(location, (Interval, CompoundLocation, UnresolvedLocation)):
669
+ raise FeatureError(
670
+ "feature location must be Interval, CompoundLocation, or UnresolvedLocation.",
671
+ code="INVALID_DNA_FEATURE_LOCATION",
672
+ )
673
+ else:
674
+ if "start" not in value or "end" not in value:
675
+ raise FeatureError(
676
+ "A simple feature mapping requires both start and end.",
677
+ code="DNA_FEATURE_INTERVAL_REQUIRED",
678
+ )
679
+ location = Interval(cast(int, value["start"]), cast(int, value["end"]))
680
+ qualifiers = _optional_mapping(value.get("qualifiers"), name="qualifiers")
681
+ return DNAFeature(
682
+ cast(str, value["type"]),
683
+ cast(Location, location),
684
+ id=cast(str | None, value.get("id")),
685
+ strand=cast(str, value.get("strand", "unknown")),
686
+ label=cast(str | None, value.get("label")),
687
+ score=cast(float | None, value.get("score")),
688
+ phase=cast(int | None, value.get("phase")),
689
+ qualifiers=qualifiers,
690
+ source=cast(str | None, value.get("source")),
691
+ )
692
+
693
+
694
+ def _optional_mapping(value: object, *, name: str) -> Mapping[str, object] | None:
695
+ if value is None:
696
+ return None
697
+ if not isinstance(value, Mapping):
698
+ raise ConfigurationError(
699
+ f"{name} must be a mapping or None.",
700
+ code="INVALID_DNA_MAPPING",
701
+ context={"field": name, "type": type(value).__name__},
702
+ )
703
+ return cast(Mapping[str, object], value)
704
+
705
+
706
+ def _materialize_annotations(
707
+ value: Mapping[str, Iterable[int | float]] | None,
708
+ ) -> Mapping[str, tuple[int | float, ...]] | None:
709
+ if value is None:
710
+ return None
711
+ if not isinstance(value, Mapping):
712
+ raise ConfigurationError(
713
+ "letter_annotations must be a mapping or None.",
714
+ code="INVALID_DNA_LETTER_ANNOTATIONS",
715
+ )
716
+ return {name: tuple(values) for name, values in value.items()}
717
+
718
+
719
+ __all__ = ["DNA", "FeatureInput", "SingleDNAInput", "resolve_single_dna"]