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