pymc-cache 0.1.0__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.
- pymc_cache/__init__.py +13 -0
- pymc_cache/_version.py +1 -0
- pymc_cache/api.py +127 -0
- pymc_cache/backends.py +262 -0
- pymc_cache/cli.py +50 -0
- pymc_cache/identity.py +246 -0
- pymc_cache/io.py +269 -0
- pymc_cache/naming.py +28 -0
- pymc_cache/policy.py +65 -0
- pymc_cache/py.typed +0 -0
- pymc_cache/session.py +146 -0
- pymc_cache-0.1.0.dist-info/METADATA +70 -0
- pymc_cache-0.1.0.dist-info/RECORD +16 -0
- pymc_cache-0.1.0.dist-info/WHEEL +4 -0
- pymc_cache-0.1.0.dist-info/entry_points.txt +2 -0
- pymc_cache-0.1.0.dist-info/licenses/LICENSE +21 -0
pymc_cache/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Model-aware result caching for standalone PyMC sampling."""
|
|
2
|
+
from pymc_cache._version import __version__
|
|
3
|
+
from pymc_cache.api import sample, sample_smc
|
|
4
|
+
from pymc_cache.backends import CacheBackend, CacheEntry, DiskCache, MemoryCache, cleanup_cache
|
|
5
|
+
from pymc_cache.identity import UncacheableError, cache_key, model_digest
|
|
6
|
+
from pymc_cache.policy import CachePolicy, CacheWarning
|
|
7
|
+
from pymc_cache.session import CacheLookup, CacheSession
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"sample", "sample_smc", "CacheBackend", "MemoryCache", "DiskCache", "CacheSession",
|
|
11
|
+
"CachePolicy", "CacheEntry", "CacheLookup", "UncacheableError", "CacheWarning",
|
|
12
|
+
"cleanup_cache", "model_digest", "cache_key", "__version__",
|
|
13
|
+
]
|
pymc_cache/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
pymc_cache/api.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Explicit (non-monkeypatching) PyMC sampling with reusable results."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import copy
|
|
5
|
+
import importlib.util
|
|
6
|
+
import os
|
|
7
|
+
import warnings
|
|
8
|
+
|
|
9
|
+
from pymc_cache.policy import CachePolicy, CacheWarning, report_from_idata
|
|
10
|
+
from pymc_cache.session import CacheSession
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _model(model):
|
|
14
|
+
import pymc as pm
|
|
15
|
+
|
|
16
|
+
return model if model is not None else pm.modelcontext(None)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _native_kwargs(model, kwargs: dict, *, operation: str) -> tuple[dict, dict, str]:
|
|
20
|
+
import pymc as pm
|
|
21
|
+
|
|
22
|
+
call = dict(kwargs)
|
|
23
|
+
key = dict(kwargs)
|
|
24
|
+
key.pop("progressbar", None)
|
|
25
|
+
if operation == "smc":
|
|
26
|
+
call.setdefault("draws", 2000)
|
|
27
|
+
call.setdefault("chains", max(2, min(4, os.cpu_count() or 1)))
|
|
28
|
+
key.update({"draws": call["draws"], "chains": call["chains"]})
|
|
29
|
+
kernel = call.get("kernel")
|
|
30
|
+
if kernel is not None and getattr(kernel, "__module__", "").startswith("pymc.smc"):
|
|
31
|
+
key["kernel"] = f"{kernel.__module__}.{kernel.__qualname__}"
|
|
32
|
+
return call, key, "smc"
|
|
33
|
+
call.setdefault("draws", 1000)
|
|
34
|
+
cores = call.get("cores") or min(4, os.cpu_count() or 1)
|
|
35
|
+
call.setdefault("cores", cores)
|
|
36
|
+
call.setdefault("chains", max(2, min(4, cores)))
|
|
37
|
+
sampler = call.get("nuts_sampler")
|
|
38
|
+
if sampler is None:
|
|
39
|
+
if int(pm.__version__.split(".")[0]) >= 6 and importlib.util.find_spec("nutpie"):
|
|
40
|
+
discrete = any(str(rv.dtype).startswith(("int", "uint", "bool"))
|
|
41
|
+
for rv in model.free_RVs)
|
|
42
|
+
sampler = "pymc" if discrete else "nutpie"
|
|
43
|
+
else:
|
|
44
|
+
sampler = "pymc"
|
|
45
|
+
call["nuts_sampler"] = sampler
|
|
46
|
+
call["tune"] = call.get("tune") if call.get("tune") is not None else (
|
|
47
|
+
400 if int(pm.__version__.split(".")[0]) >= 6 and sampler == "nutpie" else 1000
|
|
48
|
+
)
|
|
49
|
+
key.update({k: call[k] for k in ("draws", "tune", "chains", "cores", "nuts_sampler")})
|
|
50
|
+
return call, key, sampler
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _runner(model, call_kwargs, sample_fn, *, operation):
|
|
54
|
+
import pymc as pm
|
|
55
|
+
|
|
56
|
+
func = sample_fn if sample_fn is not None else (
|
|
57
|
+
pm.sample_smc if operation == "smc" else pm.sample
|
|
58
|
+
)
|
|
59
|
+
with model:
|
|
60
|
+
return func(model=model, **call_kwargs)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _call(*, model=None, cache=True, overwrite=False, policy=None, namespace="",
|
|
64
|
+
model_key=None, sampler_key=None, sample_fn=None, operation="sample", **sample_kwargs):
|
|
65
|
+
model = _model(model)
|
|
66
|
+
if policy is None:
|
|
67
|
+
policy = CachePolicy()
|
|
68
|
+
if not isinstance(policy, CachePolicy):
|
|
69
|
+
raise TypeError("policy must be CachePolicy")
|
|
70
|
+
call = dict(sample_kwargs)
|
|
71
|
+
key_kwargs = dict(call)
|
|
72
|
+
sampler_identity = operation
|
|
73
|
+
versions = None
|
|
74
|
+
hook = getattr(sample_fn, "__pymc_cache_normalize__", None)
|
|
75
|
+
if sample_fn is not None and hook is not None:
|
|
76
|
+
normalized = hook(model, copy.deepcopy(call))
|
|
77
|
+
if not isinstance(normalized, dict) or set(normalized) != {
|
|
78
|
+
"call_kwargs", "key_kwargs", "sampler_identity", "versions"
|
|
79
|
+
}:
|
|
80
|
+
raise TypeError("sample_fn normalization must return call_kwargs, key_kwargs, "
|
|
81
|
+
"sampler_identity, versions")
|
|
82
|
+
call = dict(normalized["call_kwargs"])
|
|
83
|
+
key_kwargs = dict(normalized["key_kwargs"])
|
|
84
|
+
sampler_identity = normalized["sampler_identity"]
|
|
85
|
+
versions = dict(normalized["versions"])
|
|
86
|
+
elif sample_fn is None:
|
|
87
|
+
call, key_kwargs, sampler_identity = _native_kwargs(model, call, operation=operation)
|
|
88
|
+
elif cache is not False and sampler_key is None:
|
|
89
|
+
warnings.warn("sampling result is not cacheable: sample_fn requires sampler_key or "
|
|
90
|
+
"__pymc_cache_normalize__", CacheWarning, stacklevel=2)
|
|
91
|
+
cache = False
|
|
92
|
+
if cache is not False and call.get("return_inferencedata") is False:
|
|
93
|
+
warnings.warn("sampling result is not cacheable: return_inferencedata=False",
|
|
94
|
+
CacheWarning, stacklevel=2)
|
|
95
|
+
cache = False
|
|
96
|
+
session = CacheSession(cache, model=model, policy=policy, namespace=namespace,
|
|
97
|
+
model_key=model_key, sampler_key=sampler_key)
|
|
98
|
+
lookup = session.lookup(key_kwargs, operation=operation, overwrite=overwrite,
|
|
99
|
+
versions=versions, sampler_identity=sampler_identity)
|
|
100
|
+
if lookup.hit:
|
|
101
|
+
return lookup.result
|
|
102
|
+
result = _runner(model, call, sample_fn, operation=operation)
|
|
103
|
+
report = report_from_idata(result)
|
|
104
|
+
status = report.get("status", "complete") if report else "complete"
|
|
105
|
+
session.store(lookup, result, status=status, effective_sampler=sampler_identity)
|
|
106
|
+
return result
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def sample(*, model=None, cache=True, overwrite=False, policy=None, namespace="",
|
|
110
|
+
model_key=None, sampler_key=None, sample_fn=None, **sample_kwargs):
|
|
111
|
+
"""Run ``pm.sample`` (or a trusted runner), reusing a matching result.
|
|
112
|
+
|
|
113
|
+
No global PyMC function is replaced. ``cache='disk'`` persists under
|
|
114
|
+
``.pymc-cache``; ``cache=False`` always samples anew. A user-supplied
|
|
115
|
+
sample_fn must opt into safe identity or supply a trusted sampler_key.
|
|
116
|
+
"""
|
|
117
|
+
return _call(model=model, cache=cache, overwrite=overwrite, policy=policy,
|
|
118
|
+
namespace=namespace, model_key=model_key, sampler_key=sampler_key,
|
|
119
|
+
sample_fn=sample_fn, operation="sample", **sample_kwargs)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def sample_smc(*, model=None, cache=True, overwrite=False, policy=None, namespace="",
|
|
123
|
+
model_key=None, sampler_key=None, **sample_kwargs):
|
|
124
|
+
"""Run and cache ``pm.sample_smc`` without conflating it with NUTS."""
|
|
125
|
+
return _call(model=model, cache=cache, overwrite=overwrite, policy=policy,
|
|
126
|
+
namespace=namespace, model_key=model_key, sampler_key=sampler_key,
|
|
127
|
+
operation="smc", **sample_kwargs)
|
pymc_cache/backends.py
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""Cache backends: bounded in-memory LRU and atomic, inspectable NetCDF disk storage."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import copy
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import threading
|
|
9
|
+
import uuid
|
|
10
|
+
import warnings
|
|
11
|
+
from collections import OrderedDict
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Protocol
|
|
15
|
+
|
|
16
|
+
from pymc_cache.io import get_group, load_inference_data, save_inference_data
|
|
17
|
+
from pymc_cache.naming import model_slug, params_label
|
|
18
|
+
from pymc_cache.policy import CacheWarning
|
|
19
|
+
|
|
20
|
+
_ENTRY_SCHEMA = 3
|
|
21
|
+
_ENTRY_META = "_pymc_cache_entry_v3"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class CacheBackend(Protocol):
|
|
25
|
+
"""Minimal backend interface; extra management operations are optional."""
|
|
26
|
+
|
|
27
|
+
def load(self, key: str, **kwargs): ...
|
|
28
|
+
|
|
29
|
+
def save(self, key: str, idata, **kwargs) -> None: ...
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class MemoryCache:
|
|
33
|
+
"""Thread-safe, copy-isolated LRU of posterior results."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, max_entries: int = 8):
|
|
36
|
+
if isinstance(max_entries, bool) or not isinstance(max_entries, int) or max_entries < 1:
|
|
37
|
+
raise ValueError("max_entries must be a positive integer")
|
|
38
|
+
self._max_entries = max_entries
|
|
39
|
+
self._store: OrderedDict[str, object] = OrderedDict()
|
|
40
|
+
self._lock = threading.RLock()
|
|
41
|
+
|
|
42
|
+
def load(self, key: str, **kwargs):
|
|
43
|
+
with self._lock:
|
|
44
|
+
if key not in self._store:
|
|
45
|
+
return None
|
|
46
|
+
self._store.move_to_end(key)
|
|
47
|
+
return copy.deepcopy(self._store[key])
|
|
48
|
+
|
|
49
|
+
def save(self, key: str, idata, **kwargs) -> None:
|
|
50
|
+
snapshot = copy.deepcopy(idata)
|
|
51
|
+
with self._lock:
|
|
52
|
+
self._store[key] = snapshot
|
|
53
|
+
self._store.move_to_end(key)
|
|
54
|
+
while len(self._store) > self._max_entries:
|
|
55
|
+
self._store.popitem(last=False)
|
|
56
|
+
|
|
57
|
+
def clear(self) -> int:
|
|
58
|
+
"""Discard stored entries and return the number removed."""
|
|
59
|
+
with self._lock:
|
|
60
|
+
removed = len(self._store)
|
|
61
|
+
self._store.clear()
|
|
62
|
+
return removed
|
|
63
|
+
|
|
64
|
+
def __len__(self):
|
|
65
|
+
with self._lock:
|
|
66
|
+
return len(self._store)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class CacheEntry:
|
|
71
|
+
"""Metadata for an owned, valid disk entry (without loading posterior arrays)."""
|
|
72
|
+
|
|
73
|
+
key: str
|
|
74
|
+
model_slug: str
|
|
75
|
+
operation: str
|
|
76
|
+
created_at: float
|
|
77
|
+
size_bytes: int
|
|
78
|
+
status: str
|
|
79
|
+
path: Path
|
|
80
|
+
software_versions: dict[str, str]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class DiskCache:
|
|
84
|
+
"""One-file, full-key-verified atomic NetCDF result cache.
|
|
85
|
+
|
|
86
|
+
Entries live at ``<root>/<model>/<parameters>-<full-sha256>.nc``. Contents
|
|
87
|
+
have an independently checked full key and schema; legacy files are never
|
|
88
|
+
reused. The directory is a trusted local cache, not an untrusted file reader.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
def __init__(self, base_dir: str | Path | None = None, model=None, *,
|
|
92
|
+
max_entries_per_model: int = 20):
|
|
93
|
+
if (isinstance(max_entries_per_model, bool) or
|
|
94
|
+
not isinstance(max_entries_per_model, int) or max_entries_per_model < 1):
|
|
95
|
+
raise ValueError("max_entries_per_model must be a positive integer")
|
|
96
|
+
self._base = Path(base_dir) if base_dir is not None else Path(".pymc-cache")
|
|
97
|
+
self._model_slug = model_slug(model)
|
|
98
|
+
self._keep = max_entries_per_model
|
|
99
|
+
|
|
100
|
+
def _path(self, key: str, sample_kwargs: dict | None = None) -> Path:
|
|
101
|
+
if not isinstance(key, str) or not re.fullmatch(r"[a-f0-9]{64}", key):
|
|
102
|
+
raise ValueError("cache key must be a full 64-character SHA-256 hexadecimal digest")
|
|
103
|
+
path = self._base / self._model_slug / f"{params_label(sample_kwargs)}-{key}.nc"
|
|
104
|
+
if not path.resolve().is_relative_to(self._base.resolve()):
|
|
105
|
+
raise ValueError("cache path escapes the cache root")
|
|
106
|
+
return path
|
|
107
|
+
|
|
108
|
+
@staticmethod
|
|
109
|
+
def _read_metadata(path: Path) -> dict:
|
|
110
|
+
import h5py
|
|
111
|
+
|
|
112
|
+
with h5py.File(path, "r") as f:
|
|
113
|
+
# Both ArviZ 0 and ArviZ 1 write group attrs to the HDF5 group.
|
|
114
|
+
raw = f["posterior"].attrs[_ENTRY_META]
|
|
115
|
+
if isinstance(raw, bytes):
|
|
116
|
+
raw = raw.decode("utf-8")
|
|
117
|
+
data = json.loads(raw)
|
|
118
|
+
if data.get("schema") != _ENTRY_SCHEMA:
|
|
119
|
+
raise ValueError("unknown cache schema")
|
|
120
|
+
return data
|
|
121
|
+
|
|
122
|
+
def load(self, key: str, sample_kwargs: dict | None = None):
|
|
123
|
+
path = self._path(key, sample_kwargs)
|
|
124
|
+
if self._base.is_symlink() or path.parent.is_symlink() or path.is_symlink() or not path.exists():
|
|
125
|
+
return None
|
|
126
|
+
try:
|
|
127
|
+
metadata = self._read_metadata(path)
|
|
128
|
+
if metadata.get("key") != key or metadata.get("status") not in {"complete", "met"}:
|
|
129
|
+
raise ValueError("cache entry identity or status does not match")
|
|
130
|
+
idata = load_inference_data(path)
|
|
131
|
+
post = get_group(idata, "posterior")
|
|
132
|
+
post.attrs.pop(_ENTRY_META, None)
|
|
133
|
+
return idata
|
|
134
|
+
except (OSError, ValueError, KeyError, TypeError, RuntimeError) as exc:
|
|
135
|
+
warnings.warn(f"pymc-cache: ignoring unreadable cache file {path} ({exc})",
|
|
136
|
+
CacheWarning, stacklevel=2)
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
def save(self, key: str, idata, sample_kwargs: dict | None = None, *,
|
|
140
|
+
operation="sample", status="complete", software_versions=None) -> None:
|
|
141
|
+
path = self._path(key, sample_kwargs)
|
|
142
|
+
if status not in ("complete", "met"):
|
|
143
|
+
raise ValueError("only complete or met results may be stored")
|
|
144
|
+
if self._base.is_symlink() or path.parent.is_symlink() or path.is_symlink():
|
|
145
|
+
raise ValueError("refusing to write through a cache symlink")
|
|
146
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
147
|
+
if self._base.is_symlink() or path.parent.is_symlink():
|
|
148
|
+
raise ValueError("refusing to write through a cache symlink")
|
|
149
|
+
if path.exists():
|
|
150
|
+
import h5py
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
with h5py.File(path, "r") as existing:
|
|
154
|
+
legacy = "posterior" in existing and any(
|
|
155
|
+
f"_pymc_cache_entry_v{version}" in existing["posterior"].attrs
|
|
156
|
+
for version in (1, 2)
|
|
157
|
+
)
|
|
158
|
+
except OSError:
|
|
159
|
+
legacy = False
|
|
160
|
+
if legacy:
|
|
161
|
+
raise ValueError("refusing to overwrite a schema-1/2 cache entry")
|
|
162
|
+
import time
|
|
163
|
+
|
|
164
|
+
metadata = {"key": key, "schema": _ENTRY_SCHEMA, "status": status,
|
|
165
|
+
"operation": operation, "created_at": time.time(),
|
|
166
|
+
"software_versions": software_versions or {}}
|
|
167
|
+
tmp = path.with_name(f"{path.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp")
|
|
168
|
+
try:
|
|
169
|
+
save_inference_data(idata, tmp, attrs={_ENTRY_META: json.dumps(metadata)})
|
|
170
|
+
os.replace(tmp, path)
|
|
171
|
+
finally:
|
|
172
|
+
tmp.unlink(missing_ok=True)
|
|
173
|
+
self.prune(self._keep)
|
|
174
|
+
|
|
175
|
+
def entries(self) -> list[CacheEntry]:
|
|
176
|
+
"""List this format's entries without materializing posterior data."""
|
|
177
|
+
results = []
|
|
178
|
+
if not self._base.exists() or self._base.is_symlink():
|
|
179
|
+
return results
|
|
180
|
+
for directory in self._base.iterdir():
|
|
181
|
+
if not directory.is_dir() or directory.is_symlink():
|
|
182
|
+
continue
|
|
183
|
+
for path in directory.glob("*.nc"):
|
|
184
|
+
if path.is_symlink() or not path.is_file():
|
|
185
|
+
continue
|
|
186
|
+
try:
|
|
187
|
+
metadata = self._read_metadata(path)
|
|
188
|
+
key = metadata["key"]
|
|
189
|
+
if not re.fullmatch(r"[0-9a-f]{64}", key) or not path.name.endswith(
|
|
190
|
+
f"-{key}.nc"
|
|
191
|
+
):
|
|
192
|
+
continue
|
|
193
|
+
results.append(CacheEntry(key, directory.name, metadata["operation"],
|
|
194
|
+
float(metadata["created_at"]), path.stat().st_size,
|
|
195
|
+
metadata["status"], path,
|
|
196
|
+
metadata.get("software_versions", {})))
|
|
197
|
+
except (OSError, ValueError, KeyError, TypeError):
|
|
198
|
+
continue
|
|
199
|
+
return sorted(results, key=lambda entry: entry.created_at, reverse=True)
|
|
200
|
+
|
|
201
|
+
def delete(self, key: str) -> bool:
|
|
202
|
+
"""Remove matching owned entries, never arbitrary files or symlinks."""
|
|
203
|
+
matches = [entry for entry in self.entries() if entry.key == key]
|
|
204
|
+
for entry in matches:
|
|
205
|
+
entry.path.unlink()
|
|
206
|
+
return bool(matches)
|
|
207
|
+
|
|
208
|
+
def prune(self, keep: int | None = None) -> int:
|
|
209
|
+
"""Keep the N newest entries per model; return count removed."""
|
|
210
|
+
if keep is None:
|
|
211
|
+
keep = self._keep
|
|
212
|
+
if isinstance(keep, bool) or not isinstance(keep, int) or keep < 0:
|
|
213
|
+
raise ValueError("keep must be a nonnegative integer")
|
|
214
|
+
counts: dict[str, int] = {}
|
|
215
|
+
removed = 0
|
|
216
|
+
for entry in self.entries():
|
|
217
|
+
current = counts.get(entry.model_slug, 0)
|
|
218
|
+
counts[entry.model_slug] = current + 1
|
|
219
|
+
if current >= keep:
|
|
220
|
+
entry.path.unlink()
|
|
221
|
+
removed += 1
|
|
222
|
+
return removed
|
|
223
|
+
|
|
224
|
+
def clear(self) -> int:
|
|
225
|
+
"""Delete only format-owned entries, preserving other files."""
|
|
226
|
+
entries = self.entries()
|
|
227
|
+
for entry in entries:
|
|
228
|
+
entry.path.unlink()
|
|
229
|
+
return len(entries)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
_default_memory_cache: MemoryCache | None = None
|
|
233
|
+
_default_lock = threading.Lock()
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def get_default_cache() -> MemoryCache:
|
|
237
|
+
"""Lazily construct the process-wide memory cache."""
|
|
238
|
+
global _default_memory_cache
|
|
239
|
+
with _default_lock:
|
|
240
|
+
if _default_memory_cache is None:
|
|
241
|
+
_default_memory_cache = MemoryCache()
|
|
242
|
+
return _default_memory_cache
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def resolve_cache(cache_arg, model=None, *, default_directory=".pymc-cache") -> CacheBackend | None:
|
|
246
|
+
"""Resolve bool, memory/disk alias, directory, or custom backend."""
|
|
247
|
+
if cache_arg is False:
|
|
248
|
+
return None
|
|
249
|
+
if cache_arg is True or cache_arg == "memory":
|
|
250
|
+
return get_default_cache()
|
|
251
|
+
if cache_arg == "disk":
|
|
252
|
+
return DiskCache(default_directory, model=model)
|
|
253
|
+
if isinstance(cache_arg, (str, Path)):
|
|
254
|
+
return DiskCache(cache_arg, model=model)
|
|
255
|
+
if callable(getattr(cache_arg, "load", None)) and callable(getattr(cache_arg, "save", None)):
|
|
256
|
+
return cache_arg
|
|
257
|
+
raise TypeError(f"cache must be bool | 'disk' | str | Path | CacheBackend; got {cache_arg!r}")
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def cleanup_cache(base_dir: str | Path | None = None) -> int:
|
|
261
|
+
"""Remove only new-format entries in a directory; do not delete the root."""
|
|
262
|
+
return DiskCache(base_dir).clear()
|
pymc_cache/cli.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Inspect and manage only entries owned by pymc-cache."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from pymc_cache.backends import DiskCache
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main(argv: list[str] | None = None) -> int:
|
|
12
|
+
parser = argparse.ArgumentParser(prog="pymc-cache", description=__doc__)
|
|
13
|
+
parser.add_argument("--directory", "--dir", dest="directory", default=".pymc-cache",
|
|
14
|
+
help="cache directory (default: .pymc-cache)")
|
|
15
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
16
|
+
listed = sub.add_parser("list", help="inspect owned cache entries")
|
|
17
|
+
listed.add_argument("--json", action="store_true", help="machine-readable metadata")
|
|
18
|
+
prune = sub.add_parser("prune", help="keep the newest entries per model")
|
|
19
|
+
prune.add_argument("--keep", type=int, default=20)
|
|
20
|
+
clear = sub.add_parser("clear", help="delete only entries owned by pymc-cache")
|
|
21
|
+
clear.add_argument("--yes", action="store_true", required=True,
|
|
22
|
+
help="confirm permanent removal of owned entries")
|
|
23
|
+
for command in (listed, prune, clear):
|
|
24
|
+
command.add_argument("--directory", "--dir", dest="directory",
|
|
25
|
+
default=argparse.SUPPRESS, help="cache directory")
|
|
26
|
+
args = parser.parse_args(argv)
|
|
27
|
+
cache = DiskCache(Path(args.directory))
|
|
28
|
+
if args.command == "list":
|
|
29
|
+
entries = cache.entries()
|
|
30
|
+
if args.json:
|
|
31
|
+
print(json.dumps([{"key": e.key, "model": e.model_slug,
|
|
32
|
+
"operation": e.operation, "status": e.status,
|
|
33
|
+
"path": str(e.path), "size_bytes": e.size_bytes,
|
|
34
|
+
"created_at": e.created_at,
|
|
35
|
+
"software_versions": e.software_versions}
|
|
36
|
+
for e in entries], indent=2))
|
|
37
|
+
else:
|
|
38
|
+
for entry in entries:
|
|
39
|
+
print(f"{entry.key} {entry.model_slug} {entry.status} "
|
|
40
|
+
f"{entry.size_bytes} B {entry.path}")
|
|
41
|
+
print(f"{len(entries)} entries")
|
|
42
|
+
elif args.command == "prune":
|
|
43
|
+
print(f"Removed {cache.prune(args.keep)} entries")
|
|
44
|
+
else:
|
|
45
|
+
print(f"Removed {cache.clear()} entries")
|
|
46
|
+
return 0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
if __name__ == "__main__":
|
|
50
|
+
raise SystemExit(main())
|