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
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Thread-safe lazy registry for optional computational backends."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Callable, Iterator
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from importlib.metadata import EntryPoint, entry_points
|
|
9
|
+
from threading import RLock
|
|
10
|
+
|
|
11
|
+
from dnakit.core import BackendInfo
|
|
12
|
+
from dnakit.exceptions import (
|
|
13
|
+
BackendExecutionError,
|
|
14
|
+
BackendUnavailableError,
|
|
15
|
+
ConfigurationError,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
BackendProbe = Callable[[], BackendInfo]
|
|
19
|
+
BackendLoader = Callable[[], object]
|
|
20
|
+
|
|
21
|
+
_BACKEND_ID = re.compile(r"^[a-z][a-z0-9_.-]*$")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _validate_backend_id(backend_id: object) -> str:
|
|
25
|
+
if not isinstance(backend_id, str) or not _BACKEND_ID.fullmatch(backend_id):
|
|
26
|
+
raise ConfigurationError(
|
|
27
|
+
"backend_id must be a lowercase dotted, dashed, or underscored identifier.",
|
|
28
|
+
code="INVALID_BACKEND_ID",
|
|
29
|
+
context={"backend_id": backend_id},
|
|
30
|
+
)
|
|
31
|
+
return backend_id
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class BackendRegistration:
|
|
36
|
+
"""Lazy probe and loader associated with one stable backend identifier."""
|
|
37
|
+
|
|
38
|
+
backend_id: str
|
|
39
|
+
probe: BackendProbe
|
|
40
|
+
loader: BackendLoader | None = None
|
|
41
|
+
source: str = "manual"
|
|
42
|
+
|
|
43
|
+
def __post_init__(self) -> None:
|
|
44
|
+
_validate_backend_id(self.backend_id)
|
|
45
|
+
if not callable(self.probe):
|
|
46
|
+
raise ConfigurationError("probe must be callable.", code="INVALID_BACKEND_PROBE")
|
|
47
|
+
if self.loader is not None and not callable(self.loader):
|
|
48
|
+
raise ConfigurationError(
|
|
49
|
+
"loader must be callable or None.", code="INVALID_BACKEND_LOADER"
|
|
50
|
+
)
|
|
51
|
+
if not isinstance(self.source, str) or not self.source.strip():
|
|
52
|
+
raise ConfigurationError(
|
|
53
|
+
"source must be a non-empty string.", code="INVALID_BACKEND_SOURCE"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _entry_point_registration(point: EntryPoint) -> BackendRegistration:
|
|
58
|
+
def load() -> object:
|
|
59
|
+
candidate = point.load()
|
|
60
|
+
return candidate() if isinstance(candidate, type) else candidate
|
|
61
|
+
|
|
62
|
+
def probe() -> BackendInfo:
|
|
63
|
+
candidate = load()
|
|
64
|
+
probe_method = getattr(candidate, "probe", None)
|
|
65
|
+
if not callable(probe_method):
|
|
66
|
+
raise BackendExecutionError(
|
|
67
|
+
"Backend entry point does not expose a callable probe().",
|
|
68
|
+
code="BACKEND_PROBE_MISSING",
|
|
69
|
+
context={"backend_id": point.name, "entry_point": point.value},
|
|
70
|
+
)
|
|
71
|
+
info = probe_method()
|
|
72
|
+
if not isinstance(info, BackendInfo):
|
|
73
|
+
raise BackendExecutionError(
|
|
74
|
+
"Backend probe did not return BackendInfo.",
|
|
75
|
+
code="INVALID_BACKEND_PROBE_RESULT",
|
|
76
|
+
context={"backend_id": point.name},
|
|
77
|
+
)
|
|
78
|
+
return info
|
|
79
|
+
|
|
80
|
+
return BackendRegistration(point.name, probe, load, source=f"entry-point:{point.value}")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class BackendRegistry:
|
|
84
|
+
"""Register and resolve backend adapters without importing them eagerly."""
|
|
85
|
+
|
|
86
|
+
def __init__(self) -> None:
|
|
87
|
+
self._registrations: dict[str, BackendRegistration] = {}
|
|
88
|
+
self._lock = RLock()
|
|
89
|
+
|
|
90
|
+
def register(self, registration: BackendRegistration, *, replace: bool = False) -> None:
|
|
91
|
+
if not isinstance(registration, BackendRegistration):
|
|
92
|
+
raise ConfigurationError(
|
|
93
|
+
"registration must be BackendRegistration.", code="INVALID_BACKEND_REGISTRATION"
|
|
94
|
+
)
|
|
95
|
+
if not isinstance(replace, bool):
|
|
96
|
+
raise ConfigurationError("replace must be boolean.", code="INVALID_BACKEND_REPLACE")
|
|
97
|
+
with self._lock:
|
|
98
|
+
if registration.backend_id in self._registrations and not replace:
|
|
99
|
+
raise ConfigurationError(
|
|
100
|
+
"Backend identifier is already registered.",
|
|
101
|
+
code="DUPLICATE_BACKEND",
|
|
102
|
+
context={"backend_id": registration.backend_id},
|
|
103
|
+
)
|
|
104
|
+
self._registrations[registration.backend_id] = registration
|
|
105
|
+
|
|
106
|
+
def unregister(self, backend_id: str) -> bool:
|
|
107
|
+
resolved_id = _validate_backend_id(backend_id)
|
|
108
|
+
with self._lock:
|
|
109
|
+
return self._registrations.pop(resolved_id, None) is not None
|
|
110
|
+
|
|
111
|
+
def registrations(self) -> tuple[BackendRegistration, ...]:
|
|
112
|
+
with self._lock:
|
|
113
|
+
return tuple(self._registrations[key] for key in sorted(self._registrations))
|
|
114
|
+
|
|
115
|
+
def __iter__(self) -> Iterator[str]:
|
|
116
|
+
return iter(tuple(item.backend_id for item in self.registrations()))
|
|
117
|
+
|
|
118
|
+
def __len__(self) -> int:
|
|
119
|
+
with self._lock:
|
|
120
|
+
return len(self._registrations)
|
|
121
|
+
|
|
122
|
+
def _get(self, backend_id: str) -> BackendRegistration:
|
|
123
|
+
resolved_id = _validate_backend_id(backend_id)
|
|
124
|
+
with self._lock:
|
|
125
|
+
registration = self._registrations.get(resolved_id)
|
|
126
|
+
if registration is None:
|
|
127
|
+
raise BackendUnavailableError(
|
|
128
|
+
"Requested backend is not registered.",
|
|
129
|
+
code="BACKEND_NOT_REGISTERED",
|
|
130
|
+
context={"backend_id": backend_id},
|
|
131
|
+
hint="Inspect dnakit backends and install or register the required adapter.",
|
|
132
|
+
)
|
|
133
|
+
return registration
|
|
134
|
+
|
|
135
|
+
def probe(self, backend_id: str) -> BackendInfo:
|
|
136
|
+
registration = self._get(backend_id)
|
|
137
|
+
try:
|
|
138
|
+
result = registration.probe()
|
|
139
|
+
except BackendUnavailableError:
|
|
140
|
+
raise
|
|
141
|
+
except Exception as exc:
|
|
142
|
+
raise BackendExecutionError(
|
|
143
|
+
"Backend probe failed.",
|
|
144
|
+
code="BACKEND_PROBE_FAILED",
|
|
145
|
+
context={"backend_id": backend_id, "error_type": type(exc).__name__},
|
|
146
|
+
) from exc
|
|
147
|
+
if not isinstance(result, BackendInfo):
|
|
148
|
+
raise BackendExecutionError(
|
|
149
|
+
"Backend probe did not return BackendInfo.",
|
|
150
|
+
code="INVALID_BACKEND_PROBE_RESULT",
|
|
151
|
+
context={"backend_id": backend_id},
|
|
152
|
+
)
|
|
153
|
+
return result
|
|
154
|
+
|
|
155
|
+
def probe_all(self) -> tuple[BackendInfo, ...]:
|
|
156
|
+
return tuple(self.probe(backend_id) for backend_id in self)
|
|
157
|
+
|
|
158
|
+
def load(self, backend_id: str, *, capability: str | None = None) -> object:
|
|
159
|
+
if capability is not None and (not isinstance(capability, str) or not capability.strip()):
|
|
160
|
+
raise ConfigurationError(
|
|
161
|
+
"capability must be a non-empty string or None.",
|
|
162
|
+
code="INVALID_BACKEND_CAPABILITY",
|
|
163
|
+
)
|
|
164
|
+
registration = self._get(backend_id)
|
|
165
|
+
info = self.probe(backend_id)
|
|
166
|
+
if not info.available:
|
|
167
|
+
raise BackendUnavailableError(
|
|
168
|
+
"Registered backend is not available in this environment.",
|
|
169
|
+
context={"backend_id": backend_id},
|
|
170
|
+
)
|
|
171
|
+
if capability is not None and capability not in info.capabilities:
|
|
172
|
+
raise BackendUnavailableError(
|
|
173
|
+
"Backend does not advertise the requested capability.",
|
|
174
|
+
code="BACKEND_CAPABILITY_UNAVAILABLE",
|
|
175
|
+
context={"backend_id": backend_id, "capability": capability},
|
|
176
|
+
)
|
|
177
|
+
if registration.loader is None:
|
|
178
|
+
raise BackendUnavailableError(
|
|
179
|
+
"Backend registration has no adapter loader.",
|
|
180
|
+
code="BACKEND_LOADER_UNAVAILABLE",
|
|
181
|
+
context={"backend_id": backend_id},
|
|
182
|
+
)
|
|
183
|
+
try:
|
|
184
|
+
return registration.loader()
|
|
185
|
+
except Exception as exc:
|
|
186
|
+
raise BackendExecutionError(
|
|
187
|
+
"Backend adapter could not be loaded.",
|
|
188
|
+
code="BACKEND_LOAD_FAILED",
|
|
189
|
+
context={"backend_id": backend_id, "error_type": type(exc).__name__},
|
|
190
|
+
) from exc
|
|
191
|
+
|
|
192
|
+
def discover(self, *, group: str = "dnakit.backends", replace: bool = False) -> tuple[str, ...]:
|
|
193
|
+
if not isinstance(group, str) or not group.strip():
|
|
194
|
+
raise ConfigurationError("group must be non-empty.", code="INVALID_ENTRY_POINT_GROUP")
|
|
195
|
+
if not isinstance(replace, bool):
|
|
196
|
+
raise ConfigurationError("replace must be boolean.", code="INVALID_BACKEND_REPLACE")
|
|
197
|
+
discovered: list[str] = []
|
|
198
|
+
selected = entry_points().select(group=group)
|
|
199
|
+
for point in sorted(selected, key=lambda item: item.name):
|
|
200
|
+
self.register(_entry_point_registration(point), replace=replace)
|
|
201
|
+
discovered.append(point.name)
|
|
202
|
+
return tuple(discovered)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
backend_registry = BackendRegistry()
|
|
206
|
+
|
|
207
|
+
__all__ = ["BackendRegistration", "BackendRegistry", "backend_registry"]
|
dnakit/batch.py
ADDED
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
"""Ordered serial batch execution for DNA records."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import random
|
|
6
|
+
from collections.abc import Callable, Iterable, Iterator
|
|
7
|
+
from concurrent.futures import Future, ThreadPoolExecutor, as_completed
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Any, Generic, Literal, TypeAlias, TypeVar, cast
|
|
10
|
+
|
|
11
|
+
from dnakit.core import DNARecord
|
|
12
|
+
from dnakit.core._json import to_json_compatible
|
|
13
|
+
from dnakit.core.provenance import Provenance
|
|
14
|
+
from dnakit.exceptions import BatchExecutionError, ConfigurationError, DNAKitError
|
|
15
|
+
|
|
16
|
+
T = TypeVar("T")
|
|
17
|
+
ErrorPolicy: TypeAlias = Literal["raise", "collect"]
|
|
18
|
+
ExecutionMode: TypeAlias = Literal["serial", "thread"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True, slots=True)
|
|
22
|
+
class BatchConfig:
|
|
23
|
+
"""Configure bounded deterministic serial or thread-based batch execution."""
|
|
24
|
+
|
|
25
|
+
error_policy: ErrorPolicy = "raise"
|
|
26
|
+
seed: int | None = None
|
|
27
|
+
jobs: int = 1
|
|
28
|
+
max_records: int | None = None
|
|
29
|
+
execution_mode: ExecutionMode = "serial"
|
|
30
|
+
max_in_flight: int | None = None
|
|
31
|
+
resume_completed_ids: frozenset[str] = frozenset()
|
|
32
|
+
|
|
33
|
+
def __post_init__(self) -> None:
|
|
34
|
+
if self.error_policy not in {"raise", "collect"}:
|
|
35
|
+
raise ConfigurationError(
|
|
36
|
+
"error_policy must be 'raise' or 'collect'.",
|
|
37
|
+
code="INVALID_BATCH_ERROR_POLICY",
|
|
38
|
+
)
|
|
39
|
+
if self.seed is not None and (
|
|
40
|
+
isinstance(self.seed, bool) or not isinstance(self.seed, int)
|
|
41
|
+
):
|
|
42
|
+
raise ConfigurationError(
|
|
43
|
+
"seed must be an integer or None.",
|
|
44
|
+
code="INVALID_BATCH_SEED",
|
|
45
|
+
)
|
|
46
|
+
if isinstance(self.jobs, bool) or not isinstance(self.jobs, int) or self.jobs <= 0:
|
|
47
|
+
raise ConfigurationError(
|
|
48
|
+
"jobs must be a positive integer.",
|
|
49
|
+
code="INVALID_BATCH_JOBS",
|
|
50
|
+
)
|
|
51
|
+
if self.execution_mode not in ("serial", "thread"):
|
|
52
|
+
raise ConfigurationError(
|
|
53
|
+
"execution_mode must be 'serial' or 'thread'.",
|
|
54
|
+
code="INVALID_BATCH_EXECUTION_MODE",
|
|
55
|
+
)
|
|
56
|
+
if self.execution_mode == "serial" and self.jobs != 1:
|
|
57
|
+
raise ConfigurationError(
|
|
58
|
+
"Serial execution requires jobs=1.",
|
|
59
|
+
code="INVALID_BATCH_JOBS",
|
|
60
|
+
hint="Use execution_mode='thread' for concurrent I/O or external work.",
|
|
61
|
+
)
|
|
62
|
+
if self.max_records is not None and (
|
|
63
|
+
isinstance(self.max_records, bool)
|
|
64
|
+
or not isinstance(self.max_records, int)
|
|
65
|
+
or self.max_records <= 0
|
|
66
|
+
):
|
|
67
|
+
raise ConfigurationError(
|
|
68
|
+
"max_records must be a positive integer or None.",
|
|
69
|
+
code="INVALID_BATCH_LIMIT",
|
|
70
|
+
)
|
|
71
|
+
if self.max_in_flight is not None and (
|
|
72
|
+
isinstance(self.max_in_flight, bool)
|
|
73
|
+
or not isinstance(self.max_in_flight, int)
|
|
74
|
+
or self.max_in_flight <= 0
|
|
75
|
+
):
|
|
76
|
+
raise ConfigurationError(
|
|
77
|
+
"max_in_flight must be a positive integer or None.",
|
|
78
|
+
code="INVALID_BATCH_IN_FLIGHT_LIMIT",
|
|
79
|
+
)
|
|
80
|
+
if (
|
|
81
|
+
self.execution_mode == "thread"
|
|
82
|
+
and self.max_in_flight is not None
|
|
83
|
+
and self.max_in_flight < self.jobs
|
|
84
|
+
):
|
|
85
|
+
raise ConfigurationError(
|
|
86
|
+
"max_in_flight must be at least jobs.",
|
|
87
|
+
code="INVALID_BATCH_IN_FLIGHT_LIMIT",
|
|
88
|
+
)
|
|
89
|
+
if not isinstance(self.resume_completed_ids, frozenset) or any(
|
|
90
|
+
not isinstance(item, str) or not item.strip() for item in self.resume_completed_ids
|
|
91
|
+
):
|
|
92
|
+
raise ConfigurationError(
|
|
93
|
+
"resume_completed_ids must be a frozenset of non-empty record IDs.",
|
|
94
|
+
code="INVALID_BATCH_RESUME_IDS",
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass(frozen=True, slots=True)
|
|
99
|
+
class BatchContext:
|
|
100
|
+
"""Stable per-record execution context supplied to a batch operation."""
|
|
101
|
+
|
|
102
|
+
input_index: int
|
|
103
|
+
record_id: str
|
|
104
|
+
seed: int | None
|
|
105
|
+
|
|
106
|
+
def __post_init__(self) -> None:
|
|
107
|
+
if (
|
|
108
|
+
isinstance(self.input_index, bool)
|
|
109
|
+
or not isinstance(self.input_index, int)
|
|
110
|
+
or self.input_index < 0
|
|
111
|
+
):
|
|
112
|
+
raise ConfigurationError("BatchContext input_index must be non-negative.")
|
|
113
|
+
if not isinstance(self.record_id, str) or not self.record_id.strip():
|
|
114
|
+
raise ConfigurationError("BatchContext record_id must be non-empty.")
|
|
115
|
+
if self.seed is not None and (
|
|
116
|
+
isinstance(self.seed, bool) or not isinstance(self.seed, int)
|
|
117
|
+
):
|
|
118
|
+
raise ConfigurationError("BatchContext seed must be an integer or None.")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass(frozen=True, slots=True)
|
|
122
|
+
class BatchFailure:
|
|
123
|
+
"""Serializable details for one collected operation error."""
|
|
124
|
+
|
|
125
|
+
error_type: str
|
|
126
|
+
code: str | None
|
|
127
|
+
message: str
|
|
128
|
+
hint: str | None
|
|
129
|
+
|
|
130
|
+
def __post_init__(self) -> None:
|
|
131
|
+
for name in ("error_type", "message"):
|
|
132
|
+
value = getattr(self, name)
|
|
133
|
+
if not isinstance(value, str) or not value.strip():
|
|
134
|
+
raise ConfigurationError(f"BatchFailure {name} must be non-empty.")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass(frozen=True, slots=True)
|
|
138
|
+
class BatchItemResult(Generic[T]):
|
|
139
|
+
"""One ordered success or collected failure."""
|
|
140
|
+
|
|
141
|
+
context: BatchContext
|
|
142
|
+
value: T | None = None
|
|
143
|
+
failure: BatchFailure | None = None
|
|
144
|
+
|
|
145
|
+
def __post_init__(self) -> None:
|
|
146
|
+
if self.value is not None and self.failure is not None:
|
|
147
|
+
raise ConfigurationError(
|
|
148
|
+
"A failed batch item cannot also contain a value.",
|
|
149
|
+
code="INVALID_BATCH_ITEM_RESULT",
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def succeeded(self) -> bool:
|
|
154
|
+
return self.failure is None
|
|
155
|
+
|
|
156
|
+
def to_dict(self) -> dict[str, Any]:
|
|
157
|
+
"""Return a JSON-compatible payload when the operation value supports it."""
|
|
158
|
+
|
|
159
|
+
value: object = self.value
|
|
160
|
+
value_to_dict = getattr(value, "to_dict", None)
|
|
161
|
+
if callable(value_to_dict):
|
|
162
|
+
value = value_to_dict()
|
|
163
|
+
return cast(
|
|
164
|
+
dict[str, Any],
|
|
165
|
+
to_json_compatible({"context": self.context, "value": value, "failure": self.failure}),
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@dataclass(frozen=True, slots=True)
|
|
170
|
+
class BatchProgress:
|
|
171
|
+
"""Progress event emitted only through an explicit caller callback."""
|
|
172
|
+
|
|
173
|
+
processed_count: int
|
|
174
|
+
success_count: int
|
|
175
|
+
failure_count: int
|
|
176
|
+
last_record_id: str
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@dataclass(frozen=True, slots=True)
|
|
180
|
+
class _ThreadOutcome(Generic[T]):
|
|
181
|
+
item: BatchItemResult[T]
|
|
182
|
+
error: Exception | None = None
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@dataclass(frozen=True, slots=True)
|
|
186
|
+
class _PreparedRecord:
|
|
187
|
+
"""Validated input retaining its original position and derived seed."""
|
|
188
|
+
|
|
189
|
+
record: DNARecord
|
|
190
|
+
context: BatchContext
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@dataclass(frozen=True, slots=True)
|
|
194
|
+
class BatchResult(Generic[T]):
|
|
195
|
+
"""Materialized ordered batch result with resolved execution settings."""
|
|
196
|
+
|
|
197
|
+
operation: str
|
|
198
|
+
items: tuple[BatchItemResult[T], ...]
|
|
199
|
+
config: BatchConfig
|
|
200
|
+
provenance: Provenance = field(default_factory=Provenance)
|
|
201
|
+
|
|
202
|
+
def __post_init__(self) -> None:
|
|
203
|
+
if not isinstance(self.operation, str) or not self.operation.strip():
|
|
204
|
+
raise ConfigurationError(
|
|
205
|
+
"operation must be a non-empty string.",
|
|
206
|
+
code="INVALID_BATCH_OPERATION",
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
@property
|
|
210
|
+
def success_count(self) -> int:
|
|
211
|
+
return sum(item.succeeded for item in self.items)
|
|
212
|
+
|
|
213
|
+
@property
|
|
214
|
+
def failure_count(self) -> int:
|
|
215
|
+
return len(self.items) - self.success_count
|
|
216
|
+
|
|
217
|
+
def to_dict(self) -> dict[str, Any]:
|
|
218
|
+
"""Return a JSON-compatible audit payload."""
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
"operation": self.operation,
|
|
222
|
+
"items": [item.to_dict() for item in self.items],
|
|
223
|
+
"config": cast(dict[str, Any], to_json_compatible(self.config)),
|
|
224
|
+
"success_count": self.success_count,
|
|
225
|
+
"failure_count": self.failure_count,
|
|
226
|
+
"provenance": self.provenance.to_dict(),
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
BatchOperation: TypeAlias = Callable[[DNARecord, BatchContext], T]
|
|
231
|
+
ProgressCallback: TypeAlias = Callable[[BatchProgress], None]
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _failure(error: Exception) -> BatchFailure:
|
|
235
|
+
return BatchFailure(
|
|
236
|
+
error_type=type(error).__name__,
|
|
237
|
+
code=error.code if isinstance(error, DNAKitError) else None,
|
|
238
|
+
message=str(error),
|
|
239
|
+
hint=error.hint if isinstance(error, DNAKitError) else None,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _prepare_records(
|
|
244
|
+
iterator: Iterator[object],
|
|
245
|
+
config: BatchConfig,
|
|
246
|
+
) -> Iterator[_PreparedRecord]:
|
|
247
|
+
"""Validate and seed every raw input before applying the resume filter.
|
|
248
|
+
|
|
249
|
+
Deriving seeds from the unfiltered input stream guarantees that resuming a
|
|
250
|
+
run produces the same context for every remaining record as a fresh run.
|
|
251
|
+
``max_records`` also bounds the raw stream, so an infinite stream containing
|
|
252
|
+
only completed IDs cannot bypass the configured limit.
|
|
253
|
+
"""
|
|
254
|
+
|
|
255
|
+
seed_generator = random.Random(config.seed) if config.seed is not None else None
|
|
256
|
+
for input_index, record in enumerate(iterator):
|
|
257
|
+
if config.max_records is not None and input_index >= config.max_records:
|
|
258
|
+
raise BatchExecutionError(
|
|
259
|
+
"Batch input exceeds max_records.",
|
|
260
|
+
code="BATCH_RECORD_LIMIT",
|
|
261
|
+
context={
|
|
262
|
+
"max_records": config.max_records,
|
|
263
|
+
"next_input_index": input_index,
|
|
264
|
+
},
|
|
265
|
+
)
|
|
266
|
+
if not isinstance(record, DNARecord):
|
|
267
|
+
raise BatchExecutionError(
|
|
268
|
+
"Every batch input must be a DNARecord.",
|
|
269
|
+
code="INVALID_BATCH_RECORD",
|
|
270
|
+
context={"input_index": input_index, "type": type(record).__name__},
|
|
271
|
+
)
|
|
272
|
+
context = BatchContext(
|
|
273
|
+
input_index=input_index,
|
|
274
|
+
record_id=record.id,
|
|
275
|
+
seed=(seed_generator.getrandbits(64) if seed_generator is not None else None),
|
|
276
|
+
)
|
|
277
|
+
if record.id not in config.resume_completed_ids:
|
|
278
|
+
yield _PreparedRecord(record, context)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def iter_batch(
|
|
282
|
+
records: Iterable[DNARecord],
|
|
283
|
+
operation: BatchOperation[T],
|
|
284
|
+
*,
|
|
285
|
+
config: BatchConfig | None = None,
|
|
286
|
+
progress: ProgressCallback | None = None,
|
|
287
|
+
) -> Iterator[BatchItemResult[T]]:
|
|
288
|
+
"""Lazily execute ``operation`` once per record in stable input order."""
|
|
289
|
+
|
|
290
|
+
resolved = BatchConfig() if config is None else config
|
|
291
|
+
if not isinstance(resolved, BatchConfig):
|
|
292
|
+
raise ConfigurationError("config must be BatchConfig or None.", code="INVALID_BATCH_CONFIG")
|
|
293
|
+
if not callable(operation):
|
|
294
|
+
raise ConfigurationError("operation must be callable.", code="INVALID_BATCH_OPERATION")
|
|
295
|
+
if progress is not None and not callable(progress):
|
|
296
|
+
raise ConfigurationError(
|
|
297
|
+
"progress must be callable or None.", code="INVALID_BATCH_PROGRESS"
|
|
298
|
+
)
|
|
299
|
+
try:
|
|
300
|
+
iterator = iter(records)
|
|
301
|
+
except TypeError as exc:
|
|
302
|
+
raise ConfigurationError(
|
|
303
|
+
"records must be an iterable of DNARecord objects.",
|
|
304
|
+
code="INVALID_BATCH_RECORDS",
|
|
305
|
+
) from exc
|
|
306
|
+
prepared = _prepare_records(cast(Iterator[object], iterator), resolved)
|
|
307
|
+
if resolved.execution_mode == "thread":
|
|
308
|
+
yield from _iter_batch_threaded(
|
|
309
|
+
prepared,
|
|
310
|
+
operation,
|
|
311
|
+
config=resolved,
|
|
312
|
+
progress=progress,
|
|
313
|
+
)
|
|
314
|
+
return
|
|
315
|
+
successes = 0
|
|
316
|
+
failures = 0
|
|
317
|
+
for processed, prepared_record in enumerate(prepared, start=1):
|
|
318
|
+
record = prepared_record.record
|
|
319
|
+
context = prepared_record.context
|
|
320
|
+
item: BatchItemResult[T]
|
|
321
|
+
try:
|
|
322
|
+
item = BatchItemResult(context=context, value=operation(record, context))
|
|
323
|
+
successes += 1
|
|
324
|
+
except Exception as exc:
|
|
325
|
+
if resolved.error_policy == "raise":
|
|
326
|
+
raise BatchExecutionError(
|
|
327
|
+
"Batch operation failed.",
|
|
328
|
+
code="BATCH_ITEM_FAILED",
|
|
329
|
+
context={"input_index": context.input_index, "record_id": record.id},
|
|
330
|
+
hint="Use error_policy='collect' to retain per-record failures.",
|
|
331
|
+
) from exc
|
|
332
|
+
item = BatchItemResult(context=context, failure=_failure(exc))
|
|
333
|
+
failures += 1
|
|
334
|
+
if progress is not None:
|
|
335
|
+
progress(BatchProgress(processed, successes, failures, record.id))
|
|
336
|
+
yield item
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _thread_item(
|
|
340
|
+
record: DNARecord,
|
|
341
|
+
context: BatchContext,
|
|
342
|
+
operation: BatchOperation[T],
|
|
343
|
+
) -> _ThreadOutcome[T]:
|
|
344
|
+
try:
|
|
345
|
+
return _ThreadOutcome(BatchItemResult(context=context, value=operation(record, context)))
|
|
346
|
+
except Exception as exc:
|
|
347
|
+
return _ThreadOutcome(
|
|
348
|
+
BatchItemResult(context=context, failure=_failure(exc)),
|
|
349
|
+
error=exc,
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _iter_batch_threaded(
|
|
354
|
+
iterator: Iterator[_PreparedRecord],
|
|
355
|
+
operation: BatchOperation[T],
|
|
356
|
+
*,
|
|
357
|
+
config: BatchConfig,
|
|
358
|
+
progress: ProgressCallback | None,
|
|
359
|
+
) -> Iterator[BatchItemResult[T]]:
|
|
360
|
+
"""Execute bounded concurrent work while yielding stable input order."""
|
|
361
|
+
|
|
362
|
+
limit = config.max_in_flight or max(config.jobs, config.jobs * 2)
|
|
363
|
+
next_submit = 0
|
|
364
|
+
next_yield = 0
|
|
365
|
+
successes = 0
|
|
366
|
+
failures = 0
|
|
367
|
+
exhausted = False
|
|
368
|
+
pending: dict[Future[_ThreadOutcome[T]], int] = {}
|
|
369
|
+
completed: dict[int, _ThreadOutcome[T]] = {}
|
|
370
|
+
with ThreadPoolExecutor(max_workers=config.jobs, thread_name_prefix="dnakit") as executor:
|
|
371
|
+
while not exhausted or pending or completed:
|
|
372
|
+
while not exhausted and len(pending) + len(completed) < limit:
|
|
373
|
+
try:
|
|
374
|
+
prepared_record = next(iterator)
|
|
375
|
+
except StopIteration:
|
|
376
|
+
exhausted = True
|
|
377
|
+
break
|
|
378
|
+
future = executor.submit(
|
|
379
|
+
_thread_item,
|
|
380
|
+
prepared_record.record,
|
|
381
|
+
prepared_record.context,
|
|
382
|
+
operation,
|
|
383
|
+
)
|
|
384
|
+
pending[future] = next_submit
|
|
385
|
+
next_submit += 1
|
|
386
|
+
if pending:
|
|
387
|
+
done = next(as_completed(tuple(pending)))
|
|
388
|
+
completed[pending.pop(done)] = done.result()
|
|
389
|
+
while next_yield in completed:
|
|
390
|
+
outcome = completed.pop(next_yield)
|
|
391
|
+
item = outcome.item
|
|
392
|
+
if item.failure is not None and config.error_policy == "raise":
|
|
393
|
+
for future in pending:
|
|
394
|
+
future.cancel()
|
|
395
|
+
batch_error = BatchExecutionError(
|
|
396
|
+
"Batch operation failed.",
|
|
397
|
+
code="BATCH_ITEM_FAILED",
|
|
398
|
+
context={
|
|
399
|
+
"input_index": item.context.input_index,
|
|
400
|
+
"record_id": item.context.record_id,
|
|
401
|
+
"error_type": item.failure.error_type,
|
|
402
|
+
},
|
|
403
|
+
hint="Use error_policy='collect' to retain per-record failures.",
|
|
404
|
+
)
|
|
405
|
+
if outcome.error is None:
|
|
406
|
+
raise batch_error
|
|
407
|
+
raise batch_error from outcome.error
|
|
408
|
+
if item.failure is None:
|
|
409
|
+
successes += 1
|
|
410
|
+
else:
|
|
411
|
+
failures += 1
|
|
412
|
+
if progress is not None:
|
|
413
|
+
progress(
|
|
414
|
+
BatchProgress(
|
|
415
|
+
next_yield + 1,
|
|
416
|
+
successes,
|
|
417
|
+
failures,
|
|
418
|
+
item.context.record_id,
|
|
419
|
+
)
|
|
420
|
+
)
|
|
421
|
+
yield item
|
|
422
|
+
next_yield += 1
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def run_batch(
|
|
426
|
+
records: Iterable[DNARecord],
|
|
427
|
+
operation: BatchOperation[T],
|
|
428
|
+
*,
|
|
429
|
+
name: str,
|
|
430
|
+
config: BatchConfig | None = None,
|
|
431
|
+
progress: ProgressCallback | None = None,
|
|
432
|
+
) -> BatchResult[T]:
|
|
433
|
+
"""Materialize an ordered serial batch execution."""
|
|
434
|
+
|
|
435
|
+
resolved = BatchConfig() if config is None else config
|
|
436
|
+
items = tuple(iter_batch(records, operation, config=resolved, progress=progress))
|
|
437
|
+
return BatchResult(name, items, resolved)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
__all__ = [
|
|
441
|
+
"BatchConfig",
|
|
442
|
+
"BatchContext",
|
|
443
|
+
"BatchFailure",
|
|
444
|
+
"BatchItemResult",
|
|
445
|
+
"BatchOperation",
|
|
446
|
+
"BatchProgress",
|
|
447
|
+
"BatchResult",
|
|
448
|
+
"ErrorPolicy",
|
|
449
|
+
"ProgressCallback",
|
|
450
|
+
"iter_batch",
|
|
451
|
+
"run_batch",
|
|
452
|
+
]
|
dnakit/cache/__init__.py
ADDED