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/_json.py ADDED
@@ -0,0 +1,206 @@
1
+ """Immutable JSON-compatible containers for core value objects."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ from collections.abc import Iterator, Mapping
8
+ from dataclasses import fields, is_dataclass
9
+ from enum import Enum
10
+ from types import MappingProxyType
11
+ from typing import TypeAlias, Union
12
+
13
+ from dnakit.exceptions import ConfigurationError
14
+
15
+ JSONScalar: TypeAlias = bool | int | float | str | None
16
+ JSONValue: TypeAlias = Union[JSONScalar, tuple["JSONValue", ...], "FrozenDict"]
17
+ _MAX_JSON_DEPTH = 64
18
+ _MAX_JSON_NODES = 1_000_000
19
+
20
+
21
+ class _TraversalBudget:
22
+ __slots__ = ("active", "nodes")
23
+
24
+ def __init__(self) -> None:
25
+ self.nodes = 0
26
+ self.active: set[int] = set()
27
+
28
+ def count(self, depth: int) -> None:
29
+ self.nodes += 1
30
+ if depth > _MAX_JSON_DEPTH or self.nodes > _MAX_JSON_NODES:
31
+ raise ConfigurationError(
32
+ "JSON-compatible value exceeds structural limits.",
33
+ code="JSON_STRUCTURE_LIMIT",
34
+ context={"max_depth": _MAX_JSON_DEPTH, "max_nodes": _MAX_JSON_NODES},
35
+ )
36
+
37
+ def enter(self, value: object) -> None:
38
+ identity = id(value)
39
+ if identity in self.active:
40
+ raise ConfigurationError(
41
+ "JSON-compatible value contains a recursive reference.",
42
+ code="JSON_RECURSIVE_REFERENCE",
43
+ )
44
+ self.active.add(identity)
45
+
46
+ def leave(self, value: object) -> None:
47
+ self.active.remove(id(value))
48
+
49
+
50
+ class FrozenDict(Mapping[str, JSONValue]):
51
+ """A recursively immutable and hashable mapping with string keys."""
52
+
53
+ __slots__ = ("_data", "_hash")
54
+
55
+ _data: Mapping[str, JSONValue]
56
+ _hash: int
57
+
58
+ def __init__(self, values: Mapping[str, object] | None = None) -> None:
59
+ source = {} if values is None else values
60
+ frozen = _freeze_json(source, _TraversalBudget(), 0)
61
+ if not isinstance(frozen, FrozenDict): # pragma: no cover - source is a Mapping.
62
+ raise ConfigurationError("FrozenDict source must be a mapping.")
63
+ object.__setattr__(self, "_data", frozen._data)
64
+ object.__setattr__(self, "_hash", frozen._hash)
65
+
66
+ def __getitem__(self, key: str) -> JSONValue:
67
+ return self._data[key]
68
+
69
+ def __iter__(self) -> Iterator[str]:
70
+ return iter(self._data)
71
+
72
+ def __len__(self) -> int:
73
+ return len(self._data)
74
+
75
+ def __hash__(self) -> int:
76
+ return self._hash
77
+
78
+ def __eq__(self, other: object) -> bool:
79
+ if not isinstance(other, Mapping):
80
+ return NotImplemented
81
+ return dict(self.items()) == dict(other.items())
82
+
83
+ def __repr__(self) -> str:
84
+ return f"FrozenDict({self._data!r})"
85
+
86
+ def __setattr__(self, name: str, value: object) -> None:
87
+ raise AttributeError("FrozenDict is immutable.")
88
+
89
+
90
+ def _freeze_json(value: object, budget: _TraversalBudget, depth: int) -> JSONValue:
91
+ budget.count(depth)
92
+ if isinstance(value, Enum):
93
+ return _freeze_json(value.value, budget, depth + 1)
94
+ if value is None or isinstance(value, (bool, int, str)):
95
+ return value
96
+ if isinstance(value, float):
97
+ if not math.isfinite(value):
98
+ raise ConfigurationError(
99
+ "JSON-compatible floating-point values must be finite.",
100
+ context={"value": repr(value)},
101
+ )
102
+ return value
103
+ if isinstance(value, Mapping):
104
+ budget.enter(value)
105
+ try:
106
+ data: dict[str, JSONValue] = {}
107
+ for key in value:
108
+ budget.count(depth + 1)
109
+ if not isinstance(key, str):
110
+ raise ConfigurationError(
111
+ "JSON-compatible mapping keys must be strings.",
112
+ context={"key_type": type(key).__name__},
113
+ )
114
+ data[key] = _freeze_json(value[key], budget, depth + 1)
115
+ frozen = object.__new__(FrozenDict)
116
+ object.__setattr__(frozen, "_data", MappingProxyType(data))
117
+ object.__setattr__(frozen, "_hash", hash(tuple(sorted(data.items()))))
118
+ return frozen
119
+ finally:
120
+ budget.leave(value)
121
+ if isinstance(value, (tuple, list)):
122
+ budget.enter(value)
123
+ try:
124
+ return tuple(_freeze_json(item, budget, depth + 1) for item in value)
125
+ finally:
126
+ budget.leave(value)
127
+ raise ConfigurationError(
128
+ "Value is not JSON-compatible.",
129
+ context={"value_type": type(value).__name__},
130
+ hint="Use None, booleans, finite numbers, strings, lists, or string-keyed mappings.",
131
+ )
132
+
133
+
134
+ def freeze_json(value: object) -> JSONValue:
135
+ """Validate and recursively freeze a bounded JSON-compatible value."""
136
+
137
+ return _freeze_json(value, _TraversalBudget(), 0)
138
+
139
+
140
+ def freeze_mapping(values: Mapping[str, object] | None) -> FrozenDict:
141
+ """Return an immutable defensive copy of a JSON-compatible mapping."""
142
+
143
+ return FrozenDict(values)
144
+
145
+
146
+ def to_json_compatible(value: object) -> object:
147
+ """Convert core values into plain objects accepted by ``json.dumps``."""
148
+
149
+ return _to_json_compatible(value, _TraversalBudget(), 0)
150
+
151
+
152
+ def _to_json_compatible(value: object, budget: _TraversalBudget, depth: int) -> object:
153
+ budget.count(depth)
154
+ if isinstance(value, Enum):
155
+ return _to_json_compatible(value.value, budget, depth + 1)
156
+ if isinstance(value, Mapping):
157
+ budget.enter(value)
158
+ try:
159
+ result: dict[str, object] = {}
160
+ for key in value:
161
+ budget.count(depth + 1)
162
+ result[str(key)] = _to_json_compatible(value[key], budget, depth + 1)
163
+ return result
164
+ finally:
165
+ budget.leave(value)
166
+ if isinstance(value, frozenset):
167
+ budget.enter(value)
168
+ try:
169
+ converted = [_to_json_compatible(item, budget, depth + 1) for item in value]
170
+ return sorted(
171
+ converted,
172
+ key=lambda item: json.dumps(
173
+ item,
174
+ ensure_ascii=False,
175
+ sort_keys=True,
176
+ separators=(",", ":"),
177
+ ),
178
+ )
179
+ finally:
180
+ budget.leave(value)
181
+ if isinstance(value, (tuple, list)):
182
+ budget.enter(value)
183
+ try:
184
+ return [_to_json_compatible(item, budget, depth + 1) for item in value]
185
+ finally:
186
+ budget.leave(value)
187
+ if is_dataclass(value) and not isinstance(value, type):
188
+ budget.enter(value)
189
+ try:
190
+ return {
191
+ field.name: _to_json_compatible(getattr(value, field.name), budget, depth + 1)
192
+ for field in fields(value)
193
+ }
194
+ finally:
195
+ budget.leave(value)
196
+ return value
197
+
198
+
199
+ __all__ = [
200
+ "FrozenDict",
201
+ "JSONScalar",
202
+ "JSONValue",
203
+ "freeze_json",
204
+ "freeze_mapping",
205
+ "to_json_compatible",
206
+ ]
@@ -0,0 +1,70 @@
1
+ """Passive metadata describing an optional computational backend."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable, Mapping
6
+ from dataclasses import dataclass
7
+ from typing import Any, cast
8
+
9
+ from dnakit.core._json import FrozenDict, freeze_mapping, to_json_compatible
10
+ from dnakit.exceptions import ConfigurationError
11
+
12
+
13
+ @dataclass(frozen=True, init=False)
14
+ class BackendInfo:
15
+ """Backend probe result without importing a domain protocol or adapter."""
16
+
17
+ name: str
18
+ version: str | None
19
+ executable_path: str | None
20
+ package_location: str | None
21
+ license_expression: str | None
22
+ capabilities: frozenset[str]
23
+ available: bool
24
+ metadata: FrozenDict
25
+
26
+ def __init__(
27
+ self,
28
+ name: str,
29
+ *,
30
+ version: str | None = None,
31
+ executable_path: str | None = None,
32
+ package_location: str | None = None,
33
+ license_expression: str | None = None,
34
+ capabilities: Iterable[str] = (),
35
+ available: bool = True,
36
+ metadata: Mapping[str, object] | None = None,
37
+ ) -> None:
38
+ if not isinstance(name, str) or not name.strip():
39
+ raise ConfigurationError("Backend name must be a non-empty string.")
40
+ for field_name, value in (
41
+ ("version", version),
42
+ ("executable_path", executable_path),
43
+ ("package_location", package_location),
44
+ ("license_expression", license_expression),
45
+ ):
46
+ if value is not None and (not isinstance(value, str) or not value.strip()):
47
+ raise ConfigurationError(
48
+ f"Backend {field_name} must be None or a non-empty string.",
49
+ context={field_name: value},
50
+ )
51
+ capability_set = frozenset(capabilities)
52
+ if any(not isinstance(item, str) or not item.strip() for item in capability_set):
53
+ raise ConfigurationError("Backend capabilities must be non-empty strings.")
54
+ if not isinstance(available, bool):
55
+ raise ConfigurationError("Backend available must be a boolean.")
56
+
57
+ object.__setattr__(self, "name", name)
58
+ object.__setattr__(self, "version", version)
59
+ object.__setattr__(self, "executable_path", executable_path)
60
+ object.__setattr__(self, "package_location", package_location)
61
+ object.__setattr__(self, "license_expression", license_expression)
62
+ object.__setattr__(self, "capabilities", capability_set)
63
+ object.__setattr__(self, "available", available)
64
+ object.__setattr__(self, "metadata", freeze_mapping(metadata))
65
+
66
+ def to_dict(self) -> dict[str, Any]:
67
+ return cast(dict[str, Any], to_json_compatible(self))
68
+
69
+
70
+ __all__ = ["BackendInfo"]
@@ -0,0 +1,163 @@
1
+ """In-memory immutable DNA record collection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Iterable, Iterator, Mapping
6
+ from dataclasses import dataclass
7
+ from typing import overload
8
+
9
+ from dnakit.core._json import FrozenDict, freeze_mapping
10
+ from dnakit.core.record import DNARecord
11
+ from dnakit.core.sequence import DNASequence
12
+ from dnakit.exceptions import DuplicateIDError, SequenceError
13
+
14
+ IDFactory = Callable[[int, DNASequence], str]
15
+
16
+
17
+ def _default_id_factory(index: int, sequence: DNASequence) -> str:
18
+ del sequence
19
+ return f"sequence_{index + 1}"
20
+
21
+
22
+ @dataclass(frozen=True, init=False)
23
+ class DNASet:
24
+ """A repeatable, materialized collection of :class:`DNARecord` objects."""
25
+
26
+ records: tuple[DNARecord, ...]
27
+ name: str | None
28
+ source: str | None
29
+ version: str | None
30
+ metadata: FrozenDict
31
+
32
+ __hash__ = None # type: ignore[assignment]
33
+
34
+ def __init__(
35
+ self,
36
+ records: Iterable[DNARecord],
37
+ *,
38
+ name: str | None = None,
39
+ source: str | None = None,
40
+ version: str | None = None,
41
+ metadata: Mapping[str, object] | None = None,
42
+ ) -> None:
43
+ record_tuple = tuple(records)
44
+ if any(not isinstance(record, DNARecord) for record in record_tuple):
45
+ raise SequenceError(
46
+ "DNASet records must all be DNARecord objects.",
47
+ code="INVALID_DNASET_RECORD",
48
+ )
49
+ for field_name, value in (("name", name), ("source", source), ("version", version)):
50
+ if value is not None and (not isinstance(value, str) or not value.strip()):
51
+ raise SequenceError(
52
+ f"DNASet {field_name} must be None or a non-empty string.",
53
+ code="INVALID_DNASET_METADATA",
54
+ context={field_name: value},
55
+ )
56
+ object.__setattr__(self, "records", record_tuple)
57
+ object.__setattr__(self, "name", name)
58
+ object.__setattr__(self, "source", source)
59
+ object.__setattr__(self, "version", version)
60
+ object.__setattr__(self, "metadata", freeze_mapping(metadata))
61
+
62
+ @classmethod
63
+ def from_records(
64
+ cls,
65
+ records: Iterable[DNARecord],
66
+ *,
67
+ name: str | None = None,
68
+ source: str | None = None,
69
+ version: str | None = None,
70
+ metadata: Mapping[str, object] | None = None,
71
+ ) -> DNASet:
72
+ return cls(
73
+ records,
74
+ name=name,
75
+ source=source,
76
+ version=version,
77
+ metadata=metadata,
78
+ )
79
+
80
+ @classmethod
81
+ def from_sequences(
82
+ cls,
83
+ sequences: Iterable[DNASequence],
84
+ *,
85
+ id_factory: IDFactory | None = None,
86
+ name: str | None = None,
87
+ source: str | None = None,
88
+ version: str | None = None,
89
+ metadata: Mapping[str, object] | None = None,
90
+ ) -> DNASet:
91
+ factory = _default_id_factory if id_factory is None else id_factory
92
+ records: list[DNARecord] = []
93
+ for index, sequence in enumerate(sequences):
94
+ if not isinstance(sequence, DNASequence):
95
+ raise SequenceError(
96
+ "DNASet.from_sequences() accepts only DNASequence objects.",
97
+ code="INVALID_DNASET_SEQUENCE",
98
+ context={"index": index, "value_type": type(sequence).__name__},
99
+ )
100
+ record_id = factory(index, sequence)
101
+ records.append(DNARecord(sequence, record_id))
102
+ return cls(
103
+ records,
104
+ name=name,
105
+ source=source,
106
+ version=version,
107
+ metadata=metadata,
108
+ )
109
+
110
+ def __len__(self) -> int:
111
+ return len(self.records)
112
+
113
+ def __iter__(self) -> Iterator[DNARecord]:
114
+ return iter(self.records)
115
+
116
+ @overload
117
+ def __getitem__(self, index: int) -> DNARecord: ...
118
+
119
+ @overload
120
+ def __getitem__(self, index: slice) -> DNASet: ...
121
+
122
+ def __getitem__(self, index: int | slice) -> DNARecord | DNASet:
123
+ if isinstance(index, slice):
124
+ return DNASet(
125
+ self.records[index],
126
+ name=self.name,
127
+ source=self.source,
128
+ version=self.version,
129
+ metadata=self.metadata,
130
+ )
131
+ return self.records[index]
132
+
133
+ @property
134
+ def ids(self) -> tuple[str, ...]:
135
+ return tuple(record.id for record in self.records)
136
+
137
+ def get(self, record_id: str) -> DNARecord:
138
+ """Return one record by ID, rejecting missing and duplicate matches."""
139
+
140
+ matches = tuple(record for record in self.records if record.id == record_id)
141
+ if not matches:
142
+ raise KeyError(record_id)
143
+ if len(matches) > 1:
144
+ raise DuplicateIDError(
145
+ "Record ID is not unique in this DNASet.",
146
+ context={"record_id": record_id, "count": len(matches)},
147
+ hint="Run validate() and resolve duplicate IDs before keyed lookup.",
148
+ )
149
+ return matches[0]
150
+
151
+ def select(self, indices: Iterable[int]) -> DNASet:
152
+ """Return records at the requested input-order indices."""
153
+
154
+ return DNASet(
155
+ (self.records[index] for index in indices),
156
+ name=self.name,
157
+ source=self.source,
158
+ version=self.version,
159
+ metadata=self.metadata,
160
+ )
161
+
162
+
163
+ __all__ = ["DNASet", "IDFactory"]