dnakit 0.1.0.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (210) hide show
  1. dnakit/__init__.py +108 -0
  2. dnakit/_version.py +5 -0
  3. dnakit/alignment/__init__.py +6 -0
  4. dnakit/alignment/pairwise.py +512 -0
  5. dnakit/alignment/results.py +188 -0
  6. dnakit/backends/__init__.py +80 -0
  7. dnakit/backends/external.py +517 -0
  8. dnakit/backends/registry.py +207 -0
  9. dnakit/batch.py +452 -0
  10. dnakit/cache/__init__.py +5 -0
  11. dnakit/cache/store.py +424 -0
  12. dnakit/chunking.py +920 -0
  13. dnakit/cli/__init__.py +5 -0
  14. dnakit/cli/__main__.py +6 -0
  15. dnakit/cli/app.py +814 -0
  16. dnakit/cli/workflow.py +94 -0
  17. dnakit/config/__init__.py +5 -0
  18. dnakit/config/loader.py +381 -0
  19. dnakit/core/__init__.py +80 -0
  20. dnakit/core/_json.py +206 -0
  21. dnakit/core/backend_info.py +70 -0
  22. dnakit/core/collection.py +163 -0
  23. dnakit/core/coordinates.py +295 -0
  24. dnakit/core/enums.py +95 -0
  25. dnakit/core/facade.py +719 -0
  26. dnakit/core/feature.py +90 -0
  27. dnakit/core/gap.py +82 -0
  28. dnakit/core/issues.py +62 -0
  29. dnakit/core/provenance.py +305 -0
  30. dnakit/core/record.py +161 -0
  31. dnakit/core/results.py +134 -0
  32. dnakit/core/sequence.py +327 -0
  33. dnakit/datasets/__init__.py +100 -0
  34. dnakit/datasets/_advanced_shared.py +198 -0
  35. dnakit/datasets/_metadata.py +33 -0
  36. dnakit/datasets/advanced_deduplicate.py +183 -0
  37. dnakit/datasets/advanced_split.py +302 -0
  38. dnakit/datasets/clustering.py +286 -0
  39. dnakit/datasets/config.py +531 -0
  40. dnakit/datasets/deduplicate.py +303 -0
  41. dnakit/datasets/evaluation.py +244 -0
  42. dnakit/datasets/filter.py +135 -0
  43. dnakit/datasets/neural_clustering.py +221 -0
  44. dnakit/datasets/results.py +520 -0
  45. dnakit/datasets/split.py +450 -0
  46. dnakit/descriptors/__init__.py +102 -0
  47. dnakit/descriptors/_dinucleotide.py +345 -0
  48. dnakit/descriptors/_shared.py +180 -0
  49. dnakit/descriptors/all.py +917 -0
  50. dnakit/descriptors/basic.py +193 -0
  51. dnakit/descriptors/codon.py +195 -0
  52. dnakit/descriptors/complexity.py +201 -0
  53. dnakit/descriptors/entropy.py +101 -0
  54. dnakit/descriptors/homopolymer.py +105 -0
  55. dnakit/descriptors/kmer.py +88 -0
  56. dnakit/descriptors/results.py +286 -0
  57. dnakit/descriptors/schema.py +414 -0
  58. dnakit/descriptors/window.py +203 -0
  59. dnakit/download/__init__.py +54 -0
  60. dnakit/download/catalogs.py +166 -0
  61. dnakit/download/ena.py +131 -0
  62. dnakit/download/encode.py +103 -0
  63. dnakit/download/files.py +469 -0
  64. dnakit/download/indexes.py +233 -0
  65. dnakit/download/metadata.py +164 -0
  66. dnakit/download/models.py +227 -0
  67. dnakit/download/ncbi.py +333 -0
  68. dnakit/download/sequence.py +133 -0
  69. dnakit/download/ucsc.py +99 -0
  70. dnakit/evaluation/__init__.py +81 -0
  71. dnakit/evaluation/_shared.py +288 -0
  72. dnakit/evaluation/collection.py +270 -0
  73. dnakit/evaluation/config.py +591 -0
  74. dnakit/evaluation/distribution.py +287 -0
  75. dnakit/evaluation/frechet.py +223 -0
  76. dnakit/evaluation/generative.py +381 -0
  77. dnakit/evaluation/reference.py +409 -0
  78. dnakit/evaluation/results.py +137 -0
  79. dnakit/evaluation/scorecard.py +211 -0
  80. dnakit/evaluation/sequence.py +403 -0
  81. dnakit/evaluation/synthesis.py +249 -0
  82. dnakit/exceptions.py +189 -0
  83. dnakit/fingerprints/__init__.py +109 -0
  84. dnakit/fingerprints/_shared.py +116 -0
  85. dnakit/fingerprints/advanced.py +829 -0
  86. dnakit/fingerprints/bit.py +229 -0
  87. dnakit/fingerprints/encoding.py +351 -0
  88. dnakit/fingerprints/kmer.py +170 -0
  89. dnakit/fingerprints/results.py +531 -0
  90. dnakit/fingerprints/sketch.py +246 -0
  91. dnakit/io/__init__.py +112 -0
  92. dnakit/io/_advanced_common.py +105 -0
  93. dnakit/io/_formats.py +994 -0
  94. dnakit/io/annotations.py +1179 -0
  95. dnakit/io/api.py +759 -0
  96. dnakit/io/config.py +315 -0
  97. dnakit/io/genbank.py +523 -0
  98. dnakit/io/indexed.py +1146 -0
  99. dnakit/io/metadata.py +452 -0
  100. dnakit/io/parquet.py +56 -0
  101. dnakit/io/results.py +131 -0
  102. dnakit/io/source.py +138 -0
  103. dnakit/io/tables.py +1237 -0
  104. dnakit/molbio/__init__.py +101 -0
  105. dnakit/molbio/_shared.py +266 -0
  106. dnakit/molbio/assembly.py +279 -0
  107. dnakit/molbio/crispr.py +181 -0
  108. dnakit/molbio/optimization.py +897 -0
  109. dnakit/molbio/primer3_design.py +667 -0
  110. dnakit/molbio/primers.py +899 -0
  111. dnakit/molbio/restriction.py +573 -0
  112. dnakit/molbio/results.py +583 -0
  113. dnakit/ops/__init__.py +588 -0
  114. dnakit/ops/_common.py +201 -0
  115. dnakit/ops/circular.py +193 -0
  116. dnakit/ops/concat.py +204 -0
  117. dnakit/ops/direction.py +27 -0
  118. dnakit/ops/edit.py +443 -0
  119. dnakit/ops/evolution.py +991 -0
  120. dnakit/ops/mutation.py +151 -0
  121. dnakit/ops/records.py +1012 -0
  122. dnakit/ops/translation.py +257 -0
  123. dnakit/patterns/__init__.py +81 -0
  124. dnakit/patterns/_shared.py +282 -0
  125. dnakit/patterns/coding.py +360 -0
  126. dnakit/patterns/crispr.py +337 -0
  127. dnakit/patterns/motif.py +721 -0
  128. dnakit/patterns/regions.py +341 -0
  129. dnakit/patterns/repeats.py +480 -0
  130. dnakit/patterns/restriction.py +281 -0
  131. dnakit/patterns/results.py +411 -0
  132. dnakit/py.typed +1 -0
  133. dnakit/references/__init__.py +27 -0
  134. dnakit/references/models.py +155 -0
  135. dnakit/references/ncbi.py +785 -0
  136. dnakit/representations/__init__.py +29 -0
  137. dnakit/representations/backends.py +928 -0
  138. dnakit/representations/checkpoints.py +356 -0
  139. dnakit/representations/extraction.py +220 -0
  140. dnakit/representations/models.py +395 -0
  141. dnakit/representations/results.py +79 -0
  142. dnakit/search/__init__.py +132 -0
  143. dnakit/search/_http.py +271 -0
  144. dnakit/search/_shared.py +108 -0
  145. dnakit/search/blast.py +516 -0
  146. dnakit/search/ena.py +245 -0
  147. dnakit/search/encode.py +102 -0
  148. dnakit/search/ensembl.py +874 -0
  149. dnakit/search/models.py +159 -0
  150. dnakit/search/ncbi.py +756 -0
  151. dnakit/search/ucsc.py +388 -0
  152. dnakit/secondary_structure/__init__.py +38 -0
  153. dnakit/secondary_structure/dotbracket.py +410 -0
  154. dnakit/secondary_structure/nupack.py +589 -0
  155. dnakit/secondary_structure/results.py +142 -0
  156. dnakit/similarity/__init__.py +125 -0
  157. dnakit/similarity/_shared.py +152 -0
  158. dnakit/similarity/approximate.py +258 -0
  159. dnakit/similarity/compare.py +162 -0
  160. dnakit/similarity/dashing.py +936 -0
  161. dnakit/similarity/distance.py +279 -0
  162. dnakit/similarity/index.py +365 -0
  163. dnakit/similarity/matrix.py +254 -0
  164. dnakit/similarity/results.py +676 -0
  165. dnakit/similarity/search.py +286 -0
  166. dnakit/similarity/sketch.py +96 -0
  167. dnakit/similarity/vector.py +662 -0
  168. dnakit/standardize/__init__.py +77 -0
  169. dnakit/standardize/_shared.py +100 -0
  170. dnakit/standardize/config.py +411 -0
  171. dnakit/standardize/gaps.py +1060 -0
  172. dnakit/standardize/normalize.py +565 -0
  173. dnakit/standardize/results.py +247 -0
  174. dnakit/standardize/validate.py +487 -0
  175. dnakit/structure3d/__init__.py +47 -0
  176. dnakit/structure3d/_shared.py +47 -0
  177. dnakit/structure3d/geometry.py +773 -0
  178. dnakit/structure3d/pdb.py +253 -0
  179. dnakit/structure3d/results.py +233 -0
  180. dnakit/structure3d/threedna.py +174 -0
  181. dnakit/thermodynamics/__init__.py +112 -0
  182. dnakit/thermodynamics/_shared.py +92 -0
  183. dnakit/thermodynamics/backends.py +653 -0
  184. dnakit/thermodynamics/calculations.py +837 -0
  185. dnakit/thermodynamics/config.py +175 -0
  186. dnakit/thermodynamics/equilibrium.py +340 -0
  187. dnakit/thermodynamics/optics.py +385 -0
  188. dnakit/thermodynamics/parameters.py +79 -0
  189. dnakit/thermodynamics/results.py +445 -0
  190. dnakit/visualization/__init__.py +43 -0
  191. dnakit/visualization/_svg.py +185 -0
  192. dnakit/visualization/advanced.py +314 -0
  193. dnakit/visualization/config.py +255 -0
  194. dnakit/visualization/export.py +349 -0
  195. dnakit/visualization/matrix.py +279 -0
  196. dnakit/visualization/report.py +431 -0
  197. dnakit/visualization/results.py +236 -0
  198. dnakit/visualization/sequence.py +459 -0
  199. dnakit/workflows/__init__.py +45 -0
  200. dnakit/workflows/manifest.py +365 -0
  201. dnakit/workflows/runner.py +1548 -0
  202. dnakit/workflows/schema.py +898 -0
  203. dnakit-0.1.0.dev0.dist-info/METADATA +126 -0
  204. dnakit-0.1.0.dev0.dist-info/RECORD +210 -0
  205. dnakit-0.1.0.dev0.dist-info/WHEEL +5 -0
  206. dnakit-0.1.0.dev0.dist-info/entry_points.txt +2 -0
  207. dnakit-0.1.0.dev0.dist-info/licenses/DISCLAIMER.md +26 -0
  208. dnakit-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
  209. dnakit-0.1.0.dev0.dist-info/licenses/THIRD_PARTY_NOTICES.md +73 -0
  210. dnakit-0.1.0.dev0.dist-info/top_level.txt +1 -0
dnakit/cache/store.py ADDED
@@ -0,0 +1,424 @@
1
+ """Small safe cache for JSON-compatible DNAKit results."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import re
9
+ import tempfile
10
+ from collections.abc import Mapping
11
+ from contextlib import suppress
12
+ from dataclasses import dataclass
13
+ from datetime import datetime, timezone
14
+ from importlib.metadata import PackageNotFoundError, version
15
+ from pathlib import Path
16
+ from typing import Any, cast
17
+
18
+ from dnakit.core import ArtifactRef
19
+ from dnakit.core._json import freeze_json, to_json_compatible
20
+ from dnakit.exceptions import CacheError, ConfigurationError
21
+
22
+ _NAMESPACE = re.compile(r"^[a-zA-Z0-9_.-]+$")
23
+ _DIGEST = re.compile(r"^[0-9a-f]{64}$")
24
+ _ROOT_MARKER = ".dnakit-cache-v1"
25
+ _NAMESPACE_MARKER = ".dnakit-cache-namespace-v1"
26
+ DEFAULT_MAX_ENTRY_BYTES = 20_000_000
27
+ _MAX_CACHE_DEPTH = 64
28
+ _MAX_CACHE_NODES = 1_000_000
29
+
30
+
31
+ def _dnakit_version() -> str:
32
+ try:
33
+ return version("dnakit")
34
+ except PackageNotFoundError:
35
+ return "unknown"
36
+
37
+
38
+ def _validate_json_shape(value: object) -> None:
39
+ nodes = [0]
40
+ active: set[int] = set()
41
+
42
+ def visit(item: object, depth: int) -> None:
43
+ nodes[0] += 1
44
+ if depth > _MAX_CACHE_DEPTH or nodes[0] > _MAX_CACHE_NODES:
45
+ raise ConfigurationError(
46
+ "Cache value exceeds structural limits.", code="CACHE_STRUCTURE_LIMIT"
47
+ )
48
+ if isinstance(item, (Mapping, tuple, list)):
49
+ identity = id(item)
50
+ if identity in active:
51
+ raise ConfigurationError(
52
+ "Cache value contains a recursive object.", code="CACHE_STRUCTURE_LIMIT"
53
+ )
54
+ active.add(identity)
55
+ try:
56
+ if isinstance(item, Mapping):
57
+ for key in item:
58
+ visit(key, depth + 1)
59
+ visit(item[key], depth + 1)
60
+ else:
61
+ for child in item:
62
+ visit(child, depth + 1)
63
+ finally:
64
+ active.remove(identity)
65
+
66
+ visit(value, 0)
67
+
68
+
69
+ def _unique_json_object(pairs: list[tuple[str, object]]) -> dict[str, object]:
70
+ result: dict[str, object] = {}
71
+ for key, value in pairs:
72
+ if key in result:
73
+ raise CacheError(
74
+ "Cache entry contains a duplicate JSON key.",
75
+ code="CACHE_DECODE_ERROR",
76
+ context={"key": key},
77
+ )
78
+ result[key] = value
79
+ return result
80
+
81
+
82
+ def _canonical_bytes(value: object) -> bytes:
83
+ try:
84
+ _validate_json_shape(value)
85
+ compatible = to_json_compatible(freeze_json(value))
86
+ return json.dumps(
87
+ compatible,
88
+ ensure_ascii=False,
89
+ sort_keys=True,
90
+ separators=(",", ":"),
91
+ allow_nan=False,
92
+ ).encode("utf-8")
93
+ except RecursionError as exc:
94
+ raise ConfigurationError(
95
+ "Cache values exceed structural limits.", code="CACHE_STRUCTURE_LIMIT"
96
+ ) from exc
97
+ except (TypeError, ValueError, ConfigurationError) as exc:
98
+ raise ConfigurationError(
99
+ "Cache values must be finite JSON-compatible data.", code="INVALID_CACHE_VALUE"
100
+ ) from exc
101
+
102
+
103
+ @dataclass(frozen=True, slots=True)
104
+ class CacheKey:
105
+ """Versioned content hash for all computation-defining inputs."""
106
+
107
+ namespace: str
108
+ digest: str
109
+ schema_version: str = "dnakit-cache-key-v1"
110
+
111
+ def __post_init__(self) -> None:
112
+ if not isinstance(self.namespace, str) or not _NAMESPACE.fullmatch(self.namespace):
113
+ raise ConfigurationError(
114
+ "Cache namespace must be a safe non-empty identifier.",
115
+ code="INVALID_CACHE_NAMESPACE",
116
+ )
117
+ if not isinstance(self.digest, str) or not _DIGEST.fullmatch(self.digest):
118
+ raise ConfigurationError(
119
+ "Cache digest must contain 64 lowercase hexadecimal digits.",
120
+ code="INVALID_CACHE_DIGEST",
121
+ )
122
+ if not isinstance(self.schema_version, str) or not self.schema_version.strip():
123
+ raise ConfigurationError("Cache key schema_version must be non-empty.")
124
+
125
+ @classmethod
126
+ def from_components(
127
+ cls,
128
+ namespace: str,
129
+ components: Mapping[str, object],
130
+ *,
131
+ schema_version: str = "dnakit-cache-key-v1",
132
+ ) -> CacheKey:
133
+ if not isinstance(namespace, str) or not _NAMESPACE.fullmatch(namespace):
134
+ raise ConfigurationError(
135
+ "Cache namespace must be a safe non-empty identifier.",
136
+ code="INVALID_CACHE_NAMESPACE",
137
+ )
138
+ if not isinstance(components, Mapping):
139
+ raise ConfigurationError(
140
+ "Cache components must be a mapping.", code="INVALID_CACHE_COMPONENTS"
141
+ )
142
+ if not isinstance(schema_version, str) or not schema_version.strip():
143
+ raise ConfigurationError("Cache key schema_version must be non-empty.")
144
+ payload = _canonical_bytes(
145
+ {
146
+ "schema_version": schema_version,
147
+ "dnakit_version": _dnakit_version(),
148
+ "namespace": namespace,
149
+ "components": components,
150
+ }
151
+ )
152
+ return cls(namespace, hashlib.sha256(payload).hexdigest(), schema_version)
153
+
154
+
155
+ @dataclass(frozen=True, slots=True)
156
+ class CacheClearReport:
157
+ """Audited count and bytes removed from one cache namespace or all namespaces."""
158
+
159
+ namespace: str | None
160
+ removed_count: int
161
+ removed_bytes: int
162
+
163
+ def __post_init__(self) -> None:
164
+ if self.namespace is not None and (
165
+ not isinstance(self.namespace, str) or not _NAMESPACE.fullmatch(self.namespace)
166
+ ):
167
+ raise ConfigurationError("Cache report namespace is invalid.")
168
+ for name in ("removed_count", "removed_bytes"):
169
+ value = getattr(self, name)
170
+ if isinstance(value, bool) or not isinstance(value, int) or value < 0:
171
+ raise ConfigurationError(f"Cache report {name} must be non-negative.")
172
+
173
+
174
+ class JSONCache:
175
+ """Filesystem cache using canonical JSON and atomic same-directory replacement."""
176
+
177
+ def __init__(
178
+ self,
179
+ root: str | os.PathLike[str],
180
+ *,
181
+ max_entry_bytes: int = DEFAULT_MAX_ENTRY_BYTES,
182
+ ) -> None:
183
+ if (
184
+ isinstance(max_entry_bytes, bool)
185
+ or not isinstance(max_entry_bytes, int)
186
+ or not 1 <= max_entry_bytes <= 1_000_000_000
187
+ ):
188
+ raise ConfigurationError(
189
+ "max_entry_bytes must be an integer in [1, 1000000000].",
190
+ code="INVALID_CACHE_ENTRY_LIMIT",
191
+ )
192
+ self.max_entry_bytes = max_entry_bytes
193
+ try:
194
+ self.root = Path(root).resolve()
195
+ except TypeError as exc:
196
+ raise ConfigurationError(
197
+ "Cache root must be a filesystem path.", code="INVALID_CACHE_ROOT"
198
+ ) from exc
199
+ if self.root in {Path("/"), Path.home().resolve(), Path.cwd().resolve()}:
200
+ raise CacheError(
201
+ "Cache root must be a dedicated subdirectory, not a broad filesystem root.",
202
+ code="UNSAFE_CACHE_ROOT",
203
+ context={"root": str(self.root)},
204
+ )
205
+ self.root.mkdir(parents=True, exist_ok=True)
206
+ if not self.root.is_dir():
207
+ raise CacheError("Cache root is not a directory.", code="INVALID_CACHE_ROOT")
208
+ marker = self.root / _ROOT_MARKER
209
+ nonempty = next(self.root.iterdir(), None) is not None
210
+ if nonempty and not marker.is_file():
211
+ raise CacheError(
212
+ "Existing cache root has no DNAKit cache marker.",
213
+ code="UNSAFE_CACHE_ROOT",
214
+ context={"root": str(self.root)},
215
+ hint="Choose a new empty directory dedicated to DNAKit cache data.",
216
+ )
217
+ marker.touch(exist_ok=True)
218
+
219
+ def _namespace_dir(self, namespace: str, *, create: bool) -> Path:
220
+ directory = self.root / namespace
221
+ if directory.is_symlink():
222
+ raise CacheError(
223
+ "Cache namespace must not be a symbolic link.",
224
+ code="UNSAFE_CACHE_NAMESPACE",
225
+ context={"namespace": namespace},
226
+ )
227
+ marker = directory / _NAMESPACE_MARKER
228
+ if create:
229
+ directory.mkdir(parents=False, exist_ok=True)
230
+ if any(directory.iterdir()) and not marker.is_file():
231
+ raise CacheError(
232
+ "Existing namespace has no DNAKit cache marker.",
233
+ code="UNSAFE_CACHE_NAMESPACE",
234
+ context={"namespace": namespace},
235
+ )
236
+ marker.touch(exist_ok=True)
237
+ elif directory.exists() and not marker.is_file():
238
+ raise CacheError(
239
+ "Cache namespace marker is missing.",
240
+ code="UNSAFE_CACHE_NAMESPACE",
241
+ context={"namespace": namespace},
242
+ )
243
+ return directory
244
+
245
+ def _path(self, key: CacheKey) -> Path:
246
+ if not isinstance(key, CacheKey):
247
+ raise ConfigurationError("key must be CacheKey.", code="INVALID_CACHE_KEY")
248
+ return self._namespace_dir(key.namespace, create=False) / f"{key.digest}.json"
249
+
250
+ def get(self, key: CacheKey) -> object | None:
251
+ path = self._path(key)
252
+ if path.is_symlink():
253
+ raise CacheError(
254
+ "Cache entry must not be a symbolic link.",
255
+ code="UNSAFE_CACHE_ENTRY",
256
+ context={"path": str(path)},
257
+ )
258
+ if not path.exists():
259
+ return None
260
+ try:
261
+ byte_size = path.stat().st_size
262
+ if byte_size > self.max_entry_bytes:
263
+ raise CacheError(
264
+ "Cache entry exceeds max_entry_bytes.",
265
+ code="CACHE_ENTRY_SIZE_LIMIT",
266
+ context={
267
+ "path": str(path),
268
+ "byte_size": byte_size,
269
+ "max_entry_bytes": self.max_entry_bytes,
270
+ },
271
+ )
272
+ with path.open("rb") as handle:
273
+ raw = handle.read(self.max_entry_bytes + 1)
274
+ if len(raw) > self.max_entry_bytes:
275
+ raise CacheError(
276
+ "Cache entry grew beyond max_entry_bytes while being read.",
277
+ code="CACHE_ENTRY_SIZE_LIMIT",
278
+ context={"path": str(path), "max_entry_bytes": self.max_entry_bytes},
279
+ )
280
+ envelope = json.loads(raw, object_pairs_hook=_unique_json_object)
281
+ _validate_json_shape(envelope)
282
+ if not isinstance(envelope, dict):
283
+ raise ValueError("not an object")
284
+ if (
285
+ envelope.get("cache_schema") != "dnakit-json-cache-v1"
286
+ or envelope.get("key_schema") != key.schema_version
287
+ or envelope.get("namespace") != key.namespace
288
+ or envelope.get("digest") != key.digest
289
+ ):
290
+ raise CacheError(
291
+ "Cached envelope does not match the requested key.",
292
+ code="CACHE_KEY_MISMATCH",
293
+ context={"path": str(path)},
294
+ )
295
+ payload = envelope["payload"]
296
+ expected = envelope["payload_sha256"]
297
+ actual = hashlib.sha256(_canonical_bytes(payload)).hexdigest()
298
+ if actual != expected:
299
+ raise CacheError(
300
+ "Cached payload checksum does not match.",
301
+ code="CACHE_INTEGRITY_ERROR",
302
+ context={"path": str(path), "expected": expected, "actual": actual},
303
+ )
304
+ return to_json_compatible(freeze_json(payload))
305
+ except CacheError:
306
+ raise
307
+ except (
308
+ OSError,
309
+ KeyError,
310
+ TypeError,
311
+ ValueError,
312
+ ConfigurationError,
313
+ json.JSONDecodeError,
314
+ RecursionError,
315
+ ) as exc:
316
+ raise CacheError(
317
+ "Cache entry cannot be decoded.",
318
+ code="CACHE_DECODE_ERROR",
319
+ context={"path": str(path)},
320
+ ) from exc
321
+
322
+ def put(self, key: CacheKey, value: object) -> ArtifactRef:
323
+ path = self._path(key)
324
+ path = self._namespace_dir(key.namespace, create=True) / path.name
325
+ _validate_json_shape(value)
326
+ try:
327
+ payload = cast(Any, to_json_compatible(freeze_json(value)))
328
+ except RecursionError as exc:
329
+ raise ConfigurationError(
330
+ "Cache value exceeds structural limits.", code="CACHE_STRUCTURE_LIMIT"
331
+ ) from exc
332
+ payload_sha256 = hashlib.sha256(_canonical_bytes(payload)).hexdigest()
333
+ envelope = {
334
+ "cache_schema": "dnakit-json-cache-v1",
335
+ "key_schema": key.schema_version,
336
+ "namespace": key.namespace,
337
+ "digest": key.digest,
338
+ "payload_sha256": payload_sha256,
339
+ "payload": payload,
340
+ }
341
+ encoded = _canonical_bytes(envelope)
342
+ if len(encoded) > self.max_entry_bytes:
343
+ raise CacheError(
344
+ "Encoded cache entry exceeds max_entry_bytes.",
345
+ code="CACHE_ENTRY_SIZE_LIMIT",
346
+ context={
347
+ "byte_size": len(encoded),
348
+ "max_entry_bytes": self.max_entry_bytes,
349
+ },
350
+ )
351
+ descriptor, temporary_name = tempfile.mkstemp(
352
+ prefix=f".{key.digest}.", suffix=".tmp", dir=path.parent
353
+ )
354
+ temporary = Path(temporary_name)
355
+ try:
356
+ with os.fdopen(descriptor, "wb") as handle:
357
+ handle.write(encoded)
358
+ handle.flush()
359
+ os.fsync(handle.fileno())
360
+ os.replace(temporary, path)
361
+ except BaseException:
362
+ temporary.unlink(missing_ok=True)
363
+ raise
364
+ stat = path.stat()
365
+ return ArtifactRef(
366
+ relative_path=os.path.relpath(path, Path.cwd()),
367
+ media_type="application/vnd.dnakit.cache+json",
368
+ schema_version="dnakit-json-cache-v1",
369
+ sha256=hashlib.sha256(encoded).hexdigest(),
370
+ byte_size=stat.st_size,
371
+ created_at=datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
372
+ )
373
+
374
+ def invalidate(self, key: CacheKey) -> bool:
375
+ path = self._path(key)
376
+ if not path.exists():
377
+ return False
378
+ try:
379
+ path.unlink()
380
+ except OSError as exc:
381
+ raise CacheError(
382
+ "Cache entry could not be removed.",
383
+ code="CACHE_REMOVE_ERROR",
384
+ context={"path": str(path)},
385
+ ) from exc
386
+ with suppress(OSError):
387
+ path.parent.rmdir()
388
+ return True
389
+
390
+ def clear(self, namespace: str | None = None) -> CacheClearReport:
391
+ if namespace is not None and (
392
+ not isinstance(namespace, str) or not _NAMESPACE.fullmatch(namespace)
393
+ ):
394
+ raise ConfigurationError(
395
+ "Cache namespace must be a safe identifier.", code="INVALID_CACHE_NAMESPACE"
396
+ )
397
+ roots = (
398
+ [self._namespace_dir(namespace, create=False)]
399
+ if namespace is not None
400
+ else sorted(self.root.iterdir())
401
+ )
402
+ count = 0
403
+ byte_count = 0
404
+ for directory in roots:
405
+ if (
406
+ not directory.is_dir()
407
+ or directory.is_symlink()
408
+ or directory.parent != self.root
409
+ or not (directory / _NAMESPACE_MARKER).is_file()
410
+ ):
411
+ continue
412
+ for path in sorted(directory.glob("*.json")):
413
+ if path.parent != directory:
414
+ continue
415
+ byte_count += path.stat().st_size
416
+ path.unlink()
417
+ count += 1
418
+ (directory / _NAMESPACE_MARKER).unlink(missing_ok=True)
419
+ with suppress(OSError):
420
+ directory.rmdir()
421
+ return CacheClearReport(namespace, count, byte_count)
422
+
423
+
424
+ __all__ = ["DEFAULT_MAX_ENTRY_BYTES", "CacheClearReport", "CacheKey", "JSONCache"]