dnakit 0.1.0.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dnakit/__init__.py +108 -0
- dnakit/_version.py +5 -0
- dnakit/alignment/__init__.py +6 -0
- dnakit/alignment/pairwise.py +512 -0
- dnakit/alignment/results.py +188 -0
- dnakit/backends/__init__.py +80 -0
- dnakit/backends/external.py +517 -0
- dnakit/backends/registry.py +207 -0
- dnakit/batch.py +452 -0
- dnakit/cache/__init__.py +5 -0
- dnakit/cache/store.py +424 -0
- dnakit/chunking.py +920 -0
- dnakit/cli/__init__.py +5 -0
- dnakit/cli/__main__.py +6 -0
- dnakit/cli/app.py +814 -0
- dnakit/cli/workflow.py +94 -0
- dnakit/config/__init__.py +5 -0
- dnakit/config/loader.py +381 -0
- dnakit/core/__init__.py +80 -0
- dnakit/core/_json.py +206 -0
- dnakit/core/backend_info.py +70 -0
- dnakit/core/collection.py +163 -0
- dnakit/core/coordinates.py +295 -0
- dnakit/core/enums.py +95 -0
- dnakit/core/facade.py +719 -0
- dnakit/core/feature.py +90 -0
- dnakit/core/gap.py +82 -0
- dnakit/core/issues.py +62 -0
- dnakit/core/provenance.py +305 -0
- dnakit/core/record.py +161 -0
- dnakit/core/results.py +134 -0
- dnakit/core/sequence.py +327 -0
- dnakit/datasets/__init__.py +100 -0
- dnakit/datasets/_advanced_shared.py +198 -0
- dnakit/datasets/_metadata.py +33 -0
- dnakit/datasets/advanced_deduplicate.py +183 -0
- dnakit/datasets/advanced_split.py +302 -0
- dnakit/datasets/clustering.py +286 -0
- dnakit/datasets/config.py +531 -0
- dnakit/datasets/deduplicate.py +303 -0
- dnakit/datasets/evaluation.py +244 -0
- dnakit/datasets/filter.py +135 -0
- dnakit/datasets/neural_clustering.py +221 -0
- dnakit/datasets/results.py +520 -0
- dnakit/datasets/split.py +450 -0
- dnakit/descriptors/__init__.py +102 -0
- dnakit/descriptors/_dinucleotide.py +345 -0
- dnakit/descriptors/_shared.py +180 -0
- dnakit/descriptors/all.py +917 -0
- dnakit/descriptors/basic.py +193 -0
- dnakit/descriptors/codon.py +195 -0
- dnakit/descriptors/complexity.py +201 -0
- dnakit/descriptors/entropy.py +101 -0
- dnakit/descriptors/homopolymer.py +105 -0
- dnakit/descriptors/kmer.py +88 -0
- dnakit/descriptors/results.py +286 -0
- dnakit/descriptors/schema.py +414 -0
- dnakit/descriptors/window.py +203 -0
- dnakit/download/__init__.py +54 -0
- dnakit/download/catalogs.py +166 -0
- dnakit/download/ena.py +131 -0
- dnakit/download/encode.py +103 -0
- dnakit/download/files.py +469 -0
- dnakit/download/indexes.py +233 -0
- dnakit/download/metadata.py +164 -0
- dnakit/download/models.py +227 -0
- dnakit/download/ncbi.py +333 -0
- dnakit/download/sequence.py +133 -0
- dnakit/download/ucsc.py +99 -0
- dnakit/evaluation/__init__.py +81 -0
- dnakit/evaluation/_shared.py +288 -0
- dnakit/evaluation/collection.py +270 -0
- dnakit/evaluation/config.py +591 -0
- dnakit/evaluation/distribution.py +287 -0
- dnakit/evaluation/frechet.py +223 -0
- dnakit/evaluation/generative.py +381 -0
- dnakit/evaluation/reference.py +409 -0
- dnakit/evaluation/results.py +137 -0
- dnakit/evaluation/scorecard.py +211 -0
- dnakit/evaluation/sequence.py +403 -0
- dnakit/evaluation/synthesis.py +249 -0
- dnakit/exceptions.py +189 -0
- dnakit/fingerprints/__init__.py +109 -0
- dnakit/fingerprints/_shared.py +116 -0
- dnakit/fingerprints/advanced.py +829 -0
- dnakit/fingerprints/bit.py +229 -0
- dnakit/fingerprints/encoding.py +351 -0
- dnakit/fingerprints/kmer.py +170 -0
- dnakit/fingerprints/results.py +531 -0
- dnakit/fingerprints/sketch.py +246 -0
- dnakit/io/__init__.py +112 -0
- dnakit/io/_advanced_common.py +105 -0
- dnakit/io/_formats.py +994 -0
- dnakit/io/annotations.py +1179 -0
- dnakit/io/api.py +759 -0
- dnakit/io/config.py +315 -0
- dnakit/io/genbank.py +523 -0
- dnakit/io/indexed.py +1146 -0
- dnakit/io/metadata.py +452 -0
- dnakit/io/parquet.py +56 -0
- dnakit/io/results.py +131 -0
- dnakit/io/source.py +138 -0
- dnakit/io/tables.py +1237 -0
- dnakit/molbio/__init__.py +101 -0
- dnakit/molbio/_shared.py +266 -0
- dnakit/molbio/assembly.py +279 -0
- dnakit/molbio/crispr.py +181 -0
- dnakit/molbio/optimization.py +897 -0
- dnakit/molbio/primer3_design.py +667 -0
- dnakit/molbio/primers.py +899 -0
- dnakit/molbio/restriction.py +573 -0
- dnakit/molbio/results.py +583 -0
- dnakit/ops/__init__.py +588 -0
- dnakit/ops/_common.py +201 -0
- dnakit/ops/circular.py +193 -0
- dnakit/ops/concat.py +204 -0
- dnakit/ops/direction.py +27 -0
- dnakit/ops/edit.py +443 -0
- dnakit/ops/evolution.py +991 -0
- dnakit/ops/mutation.py +151 -0
- dnakit/ops/records.py +1012 -0
- dnakit/ops/translation.py +257 -0
- dnakit/patterns/__init__.py +81 -0
- dnakit/patterns/_shared.py +282 -0
- dnakit/patterns/coding.py +360 -0
- dnakit/patterns/crispr.py +337 -0
- dnakit/patterns/motif.py +721 -0
- dnakit/patterns/regions.py +341 -0
- dnakit/patterns/repeats.py +480 -0
- dnakit/patterns/restriction.py +281 -0
- dnakit/patterns/results.py +411 -0
- dnakit/py.typed +1 -0
- dnakit/references/__init__.py +27 -0
- dnakit/references/models.py +155 -0
- dnakit/references/ncbi.py +785 -0
- dnakit/representations/__init__.py +29 -0
- dnakit/representations/backends.py +928 -0
- dnakit/representations/checkpoints.py +356 -0
- dnakit/representations/extraction.py +220 -0
- dnakit/representations/models.py +395 -0
- dnakit/representations/results.py +79 -0
- dnakit/search/__init__.py +132 -0
- dnakit/search/_http.py +271 -0
- dnakit/search/_shared.py +108 -0
- dnakit/search/blast.py +516 -0
- dnakit/search/ena.py +245 -0
- dnakit/search/encode.py +102 -0
- dnakit/search/ensembl.py +874 -0
- dnakit/search/models.py +159 -0
- dnakit/search/ncbi.py +756 -0
- dnakit/search/ucsc.py +388 -0
- dnakit/secondary_structure/__init__.py +38 -0
- dnakit/secondary_structure/dotbracket.py +410 -0
- dnakit/secondary_structure/nupack.py +589 -0
- dnakit/secondary_structure/results.py +142 -0
- dnakit/similarity/__init__.py +125 -0
- dnakit/similarity/_shared.py +152 -0
- dnakit/similarity/approximate.py +258 -0
- dnakit/similarity/compare.py +162 -0
- dnakit/similarity/dashing.py +936 -0
- dnakit/similarity/distance.py +279 -0
- dnakit/similarity/index.py +365 -0
- dnakit/similarity/matrix.py +254 -0
- dnakit/similarity/results.py +676 -0
- dnakit/similarity/search.py +286 -0
- dnakit/similarity/sketch.py +96 -0
- dnakit/similarity/vector.py +662 -0
- dnakit/standardize/__init__.py +77 -0
- dnakit/standardize/_shared.py +100 -0
- dnakit/standardize/config.py +411 -0
- dnakit/standardize/gaps.py +1060 -0
- dnakit/standardize/normalize.py +565 -0
- dnakit/standardize/results.py +247 -0
- dnakit/standardize/validate.py +487 -0
- dnakit/structure3d/__init__.py +47 -0
- dnakit/structure3d/_shared.py +47 -0
- dnakit/structure3d/geometry.py +773 -0
- dnakit/structure3d/pdb.py +253 -0
- dnakit/structure3d/results.py +233 -0
- dnakit/structure3d/threedna.py +174 -0
- dnakit/thermodynamics/__init__.py +112 -0
- dnakit/thermodynamics/_shared.py +92 -0
- dnakit/thermodynamics/backends.py +653 -0
- dnakit/thermodynamics/calculations.py +837 -0
- dnakit/thermodynamics/config.py +175 -0
- dnakit/thermodynamics/equilibrium.py +340 -0
- dnakit/thermodynamics/optics.py +385 -0
- dnakit/thermodynamics/parameters.py +79 -0
- dnakit/thermodynamics/results.py +445 -0
- dnakit/visualization/__init__.py +43 -0
- dnakit/visualization/_svg.py +185 -0
- dnakit/visualization/advanced.py +314 -0
- dnakit/visualization/config.py +255 -0
- dnakit/visualization/export.py +349 -0
- dnakit/visualization/matrix.py +279 -0
- dnakit/visualization/report.py +431 -0
- dnakit/visualization/results.py +236 -0
- dnakit/visualization/sequence.py +459 -0
- dnakit/workflows/__init__.py +45 -0
- dnakit/workflows/manifest.py +365 -0
- dnakit/workflows/runner.py +1548 -0
- dnakit/workflows/schema.py +898 -0
- dnakit-0.1.0.dev0.dist-info/METADATA +126 -0
- dnakit-0.1.0.dev0.dist-info/RECORD +210 -0
- dnakit-0.1.0.dev0.dist-info/WHEEL +5 -0
- dnakit-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- dnakit-0.1.0.dev0.dist-info/licenses/DISCLAIMER.md +26 -0
- dnakit-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
- dnakit-0.1.0.dev0.dist-info/licenses/THIRD_PARTY_NOTICES.md +73 -0
- dnakit-0.1.0.dev0.dist-info/top_level.txt +1 -0
dnakit/core/_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"]
|