dnakit 0.1.0.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (210) hide show
  1. dnakit/__init__.py +108 -0
  2. dnakit/_version.py +5 -0
  3. dnakit/alignment/__init__.py +6 -0
  4. dnakit/alignment/pairwise.py +512 -0
  5. dnakit/alignment/results.py +188 -0
  6. dnakit/backends/__init__.py +80 -0
  7. dnakit/backends/external.py +517 -0
  8. dnakit/backends/registry.py +207 -0
  9. dnakit/batch.py +452 -0
  10. dnakit/cache/__init__.py +5 -0
  11. dnakit/cache/store.py +424 -0
  12. dnakit/chunking.py +920 -0
  13. dnakit/cli/__init__.py +5 -0
  14. dnakit/cli/__main__.py +6 -0
  15. dnakit/cli/app.py +814 -0
  16. dnakit/cli/workflow.py +94 -0
  17. dnakit/config/__init__.py +5 -0
  18. dnakit/config/loader.py +381 -0
  19. dnakit/core/__init__.py +80 -0
  20. dnakit/core/_json.py +206 -0
  21. dnakit/core/backend_info.py +70 -0
  22. dnakit/core/collection.py +163 -0
  23. dnakit/core/coordinates.py +295 -0
  24. dnakit/core/enums.py +95 -0
  25. dnakit/core/facade.py +719 -0
  26. dnakit/core/feature.py +90 -0
  27. dnakit/core/gap.py +82 -0
  28. dnakit/core/issues.py +62 -0
  29. dnakit/core/provenance.py +305 -0
  30. dnakit/core/record.py +161 -0
  31. dnakit/core/results.py +134 -0
  32. dnakit/core/sequence.py +327 -0
  33. dnakit/datasets/__init__.py +100 -0
  34. dnakit/datasets/_advanced_shared.py +198 -0
  35. dnakit/datasets/_metadata.py +33 -0
  36. dnakit/datasets/advanced_deduplicate.py +183 -0
  37. dnakit/datasets/advanced_split.py +302 -0
  38. dnakit/datasets/clustering.py +286 -0
  39. dnakit/datasets/config.py +531 -0
  40. dnakit/datasets/deduplicate.py +303 -0
  41. dnakit/datasets/evaluation.py +244 -0
  42. dnakit/datasets/filter.py +135 -0
  43. dnakit/datasets/neural_clustering.py +221 -0
  44. dnakit/datasets/results.py +520 -0
  45. dnakit/datasets/split.py +450 -0
  46. dnakit/descriptors/__init__.py +102 -0
  47. dnakit/descriptors/_dinucleotide.py +345 -0
  48. dnakit/descriptors/_shared.py +180 -0
  49. dnakit/descriptors/all.py +917 -0
  50. dnakit/descriptors/basic.py +193 -0
  51. dnakit/descriptors/codon.py +195 -0
  52. dnakit/descriptors/complexity.py +201 -0
  53. dnakit/descriptors/entropy.py +101 -0
  54. dnakit/descriptors/homopolymer.py +105 -0
  55. dnakit/descriptors/kmer.py +88 -0
  56. dnakit/descriptors/results.py +286 -0
  57. dnakit/descriptors/schema.py +414 -0
  58. dnakit/descriptors/window.py +203 -0
  59. dnakit/download/__init__.py +54 -0
  60. dnakit/download/catalogs.py +166 -0
  61. dnakit/download/ena.py +131 -0
  62. dnakit/download/encode.py +103 -0
  63. dnakit/download/files.py +469 -0
  64. dnakit/download/indexes.py +233 -0
  65. dnakit/download/metadata.py +164 -0
  66. dnakit/download/models.py +227 -0
  67. dnakit/download/ncbi.py +333 -0
  68. dnakit/download/sequence.py +133 -0
  69. dnakit/download/ucsc.py +99 -0
  70. dnakit/evaluation/__init__.py +81 -0
  71. dnakit/evaluation/_shared.py +288 -0
  72. dnakit/evaluation/collection.py +270 -0
  73. dnakit/evaluation/config.py +591 -0
  74. dnakit/evaluation/distribution.py +287 -0
  75. dnakit/evaluation/frechet.py +223 -0
  76. dnakit/evaluation/generative.py +381 -0
  77. dnakit/evaluation/reference.py +409 -0
  78. dnakit/evaluation/results.py +137 -0
  79. dnakit/evaluation/scorecard.py +211 -0
  80. dnakit/evaluation/sequence.py +403 -0
  81. dnakit/evaluation/synthesis.py +249 -0
  82. dnakit/exceptions.py +189 -0
  83. dnakit/fingerprints/__init__.py +109 -0
  84. dnakit/fingerprints/_shared.py +116 -0
  85. dnakit/fingerprints/advanced.py +829 -0
  86. dnakit/fingerprints/bit.py +229 -0
  87. dnakit/fingerprints/encoding.py +351 -0
  88. dnakit/fingerprints/kmer.py +170 -0
  89. dnakit/fingerprints/results.py +531 -0
  90. dnakit/fingerprints/sketch.py +246 -0
  91. dnakit/io/__init__.py +112 -0
  92. dnakit/io/_advanced_common.py +105 -0
  93. dnakit/io/_formats.py +994 -0
  94. dnakit/io/annotations.py +1179 -0
  95. dnakit/io/api.py +759 -0
  96. dnakit/io/config.py +315 -0
  97. dnakit/io/genbank.py +523 -0
  98. dnakit/io/indexed.py +1146 -0
  99. dnakit/io/metadata.py +452 -0
  100. dnakit/io/parquet.py +56 -0
  101. dnakit/io/results.py +131 -0
  102. dnakit/io/source.py +138 -0
  103. dnakit/io/tables.py +1237 -0
  104. dnakit/molbio/__init__.py +101 -0
  105. dnakit/molbio/_shared.py +266 -0
  106. dnakit/molbio/assembly.py +279 -0
  107. dnakit/molbio/crispr.py +181 -0
  108. dnakit/molbio/optimization.py +897 -0
  109. dnakit/molbio/primer3_design.py +667 -0
  110. dnakit/molbio/primers.py +899 -0
  111. dnakit/molbio/restriction.py +573 -0
  112. dnakit/molbio/results.py +583 -0
  113. dnakit/ops/__init__.py +588 -0
  114. dnakit/ops/_common.py +201 -0
  115. dnakit/ops/circular.py +193 -0
  116. dnakit/ops/concat.py +204 -0
  117. dnakit/ops/direction.py +27 -0
  118. dnakit/ops/edit.py +443 -0
  119. dnakit/ops/evolution.py +991 -0
  120. dnakit/ops/mutation.py +151 -0
  121. dnakit/ops/records.py +1012 -0
  122. dnakit/ops/translation.py +257 -0
  123. dnakit/patterns/__init__.py +81 -0
  124. dnakit/patterns/_shared.py +282 -0
  125. dnakit/patterns/coding.py +360 -0
  126. dnakit/patterns/crispr.py +337 -0
  127. dnakit/patterns/motif.py +721 -0
  128. dnakit/patterns/regions.py +341 -0
  129. dnakit/patterns/repeats.py +480 -0
  130. dnakit/patterns/restriction.py +281 -0
  131. dnakit/patterns/results.py +411 -0
  132. dnakit/py.typed +1 -0
  133. dnakit/references/__init__.py +27 -0
  134. dnakit/references/models.py +155 -0
  135. dnakit/references/ncbi.py +785 -0
  136. dnakit/representations/__init__.py +29 -0
  137. dnakit/representations/backends.py +928 -0
  138. dnakit/representations/checkpoints.py +356 -0
  139. dnakit/representations/extraction.py +220 -0
  140. dnakit/representations/models.py +395 -0
  141. dnakit/representations/results.py +79 -0
  142. dnakit/search/__init__.py +132 -0
  143. dnakit/search/_http.py +271 -0
  144. dnakit/search/_shared.py +108 -0
  145. dnakit/search/blast.py +516 -0
  146. dnakit/search/ena.py +245 -0
  147. dnakit/search/encode.py +102 -0
  148. dnakit/search/ensembl.py +874 -0
  149. dnakit/search/models.py +159 -0
  150. dnakit/search/ncbi.py +756 -0
  151. dnakit/search/ucsc.py +388 -0
  152. dnakit/secondary_structure/__init__.py +38 -0
  153. dnakit/secondary_structure/dotbracket.py +410 -0
  154. dnakit/secondary_structure/nupack.py +589 -0
  155. dnakit/secondary_structure/results.py +142 -0
  156. dnakit/similarity/__init__.py +125 -0
  157. dnakit/similarity/_shared.py +152 -0
  158. dnakit/similarity/approximate.py +258 -0
  159. dnakit/similarity/compare.py +162 -0
  160. dnakit/similarity/dashing.py +936 -0
  161. dnakit/similarity/distance.py +279 -0
  162. dnakit/similarity/index.py +365 -0
  163. dnakit/similarity/matrix.py +254 -0
  164. dnakit/similarity/results.py +676 -0
  165. dnakit/similarity/search.py +286 -0
  166. dnakit/similarity/sketch.py +96 -0
  167. dnakit/similarity/vector.py +662 -0
  168. dnakit/standardize/__init__.py +77 -0
  169. dnakit/standardize/_shared.py +100 -0
  170. dnakit/standardize/config.py +411 -0
  171. dnakit/standardize/gaps.py +1060 -0
  172. dnakit/standardize/normalize.py +565 -0
  173. dnakit/standardize/results.py +247 -0
  174. dnakit/standardize/validate.py +487 -0
  175. dnakit/structure3d/__init__.py +47 -0
  176. dnakit/structure3d/_shared.py +47 -0
  177. dnakit/structure3d/geometry.py +773 -0
  178. dnakit/structure3d/pdb.py +253 -0
  179. dnakit/structure3d/results.py +233 -0
  180. dnakit/structure3d/threedna.py +174 -0
  181. dnakit/thermodynamics/__init__.py +112 -0
  182. dnakit/thermodynamics/_shared.py +92 -0
  183. dnakit/thermodynamics/backends.py +653 -0
  184. dnakit/thermodynamics/calculations.py +837 -0
  185. dnakit/thermodynamics/config.py +175 -0
  186. dnakit/thermodynamics/equilibrium.py +340 -0
  187. dnakit/thermodynamics/optics.py +385 -0
  188. dnakit/thermodynamics/parameters.py +79 -0
  189. dnakit/thermodynamics/results.py +445 -0
  190. dnakit/visualization/__init__.py +43 -0
  191. dnakit/visualization/_svg.py +185 -0
  192. dnakit/visualization/advanced.py +314 -0
  193. dnakit/visualization/config.py +255 -0
  194. dnakit/visualization/export.py +349 -0
  195. dnakit/visualization/matrix.py +279 -0
  196. dnakit/visualization/report.py +431 -0
  197. dnakit/visualization/results.py +236 -0
  198. dnakit/visualization/sequence.py +459 -0
  199. dnakit/workflows/__init__.py +45 -0
  200. dnakit/workflows/manifest.py +365 -0
  201. dnakit/workflows/runner.py +1548 -0
  202. dnakit/workflows/schema.py +898 -0
  203. dnakit-0.1.0.dev0.dist-info/METADATA +126 -0
  204. dnakit-0.1.0.dev0.dist-info/RECORD +210 -0
  205. dnakit-0.1.0.dev0.dist-info/WHEEL +5 -0
  206. dnakit-0.1.0.dev0.dist-info/entry_points.txt +2 -0
  207. dnakit-0.1.0.dev0.dist-info/licenses/DISCLAIMER.md +26 -0
  208. dnakit-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
  209. dnakit-0.1.0.dev0.dist-info/licenses/THIRD_PARTY_NOTICES.md +73 -0
  210. dnakit-0.1.0.dev0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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
+ ]
@@ -0,0 +1,5 @@
1
+ """Content-addressed JSON cache with integrity verification."""
2
+
3
+ from dnakit.cache.store import DEFAULT_MAX_ENTRY_BYTES, CacheClearReport, CacheKey, JSONCache
4
+
5
+ __all__ = ["DEFAULT_MAX_ENTRY_BYTES", "CacheClearReport", "CacheKey", "JSONCache"]