promptdock 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.
Files changed (47) hide show
  1. promptdock/__init__.py +29 -0
  2. promptdock/actor.py +11 -0
  3. promptdock/backends/__init__.py +5 -0
  4. promptdock/backends/base.py +75 -0
  5. promptdock/backends/db.py +117 -0
  6. promptdock/backends/http.py +244 -0
  7. promptdock/cache.py +111 -0
  8. promptdock/cli.py +633 -0
  9. promptdock/client.py +283 -0
  10. promptdock/config.py +41 -0
  11. promptdock/connect.py +27 -0
  12. promptdock/core/__init__.py +1 -0
  13. promptdock/core/compare.py +138 -0
  14. promptdock/core/gates.py +40 -0
  15. promptdock/core/service.py +562 -0
  16. promptdock/db/__init__.py +1 -0
  17. promptdock/db/engine.py +23 -0
  18. promptdock/db/migrate.py +49 -0
  19. promptdock/db/migrations/env.py +40 -0
  20. promptdock/db/migrations/script.py.mako +25 -0
  21. promptdock/db/migrations/versions/0001_initial.py +184 -0
  22. promptdock/db/migrations/versions/0002_drop_tag_is_protected.py +27 -0
  23. promptdock/db/migrations/versions/0003_readable_views.py +91 -0
  24. promptdock/db/migrations/versions/0004_drop_api_keys.py +36 -0
  25. promptdock/db/tables.py +150 -0
  26. promptdock/errors.py +87 -0
  27. promptdock/mcp/__init__.py +1 -0
  28. promptdock/mcp/server.py +116 -0
  29. promptdock/models.py +234 -0
  30. promptdock/prompt.py +74 -0
  31. promptdock/py.typed +0 -0
  32. promptdock/server/__init__.py +5 -0
  33. promptdock/server/access.py +57 -0
  34. promptdock/server/app.py +72 -0
  35. promptdock/server/errors.py +55 -0
  36. promptdock/server/routes.py +225 -0
  37. promptdock/sync.py +222 -0
  38. promptdock/templates.py +64 -0
  39. promptdock/ui/__init__.py +13 -0
  40. promptdock/ui/static/assets/index-B7bJCH_W.css +1 -0
  41. promptdock/ui/static/assets/index-BbnhHlWV.js +109 -0
  42. promptdock/ui/static/index.html +17 -0
  43. promptdock-0.1.0.dist-info/METADATA +226 -0
  44. promptdock-0.1.0.dist-info/RECORD +47 -0
  45. promptdock-0.1.0.dist-info/WHEEL +4 -0
  46. promptdock-0.1.0.dist-info/entry_points.txt +2 -0
  47. promptdock-0.1.0.dist-info/licenses/LICENSE +21 -0
promptdock/__init__.py ADDED
@@ -0,0 +1,29 @@
1
+ """PromptDock: prompt versioning with immutable versions and movable tags."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ from promptdock.client import Client
6
+ from promptdock.errors import (
7
+ DatabaseUnavailableError,
8
+ GateFailedError,
9
+ MissingVariableError,
10
+ NotFoundError,
11
+ PromptDockError,
12
+ )
13
+ from promptdock.prompt import Prompt
14
+
15
+ try:
16
+ __version__ = version("promptdock")
17
+ except PackageNotFoundError: # pragma: no cover - running from a source tree
18
+ __version__ = "0.0.0"
19
+
20
+ __all__ = [
21
+ "Client",
22
+ "DatabaseUnavailableError",
23
+ "GateFailedError",
24
+ "MissingVariableError",
25
+ "NotFoundError",
26
+ "Prompt",
27
+ "PromptDockError",
28
+ "__version__",
29
+ ]
promptdock/actor.py ADDED
@@ -0,0 +1,11 @@
1
+ """Who made a change: a self-reported name, like a git author. Not verified."""
2
+
3
+ import getpass
4
+
5
+
6
+ def default_actor() -> str | None:
7
+ """The OS username, or None if it cannot be determined."""
8
+ try:
9
+ return getpass.getuser() or None
10
+ except Exception: # getuser raises if no user name is available (e.g. some containers)
11
+ return None
@@ -0,0 +1,5 @@
1
+ """Backends the ``Client`` talks to: the database directly, or a PromptDock server."""
2
+
3
+ from promptdock.backends.base import Backend
4
+
5
+ __all__ = ["Backend"]
@@ -0,0 +1,75 @@
1
+ """The interface every backend implements. Methods mirror ``promptdock.core.service``."""
2
+
3
+ from collections.abc import Sequence
4
+ from typing import Protocol
5
+
6
+ from promptdock.models import (
7
+ EvalResult,
8
+ EvalResultIn,
9
+ Gate,
10
+ Leaderboard,
11
+ PromptInfo,
12
+ PromptVersion,
13
+ PromptVersionCreate,
14
+ Tag,
15
+ TagEvent,
16
+ VersionDiff,
17
+ )
18
+
19
+
20
+ class Backend(Protocol):
21
+ """Raises ``DatabaseUnavailableError`` when the store cannot be reached."""
22
+
23
+ def create_prompt(self, project: str, name: str, description: str | None) -> PromptInfo: ...
24
+
25
+ def get_prompt_info(self, project: str, name: str) -> PromptInfo: ...
26
+
27
+ def list_prompts(self, project: str) -> list[PromptInfo]: ...
28
+
29
+ def create_version(
30
+ self, project: str, name: str, data: PromptVersionCreate, actor: str | None
31
+ ) -> PromptVersion: ...
32
+
33
+ def get_version(self, project: str, name: str, version: int | None) -> PromptVersion: ...
34
+
35
+ def get_by_tag(self, project: str, name: str, tag: str) -> PromptVersion: ...
36
+
37
+ def list_versions(self, project: str, name: str) -> list[PromptVersion]: ...
38
+
39
+ def list_tags(self, project: str, name: str) -> list[Tag]: ...
40
+
41
+ def set_gate(
42
+ self, project: str, name: str, tag: str, gate: Gate | None, actor: str | None
43
+ ) -> Tag: ...
44
+
45
+ def promote(
46
+ self,
47
+ project: str,
48
+ name: str,
49
+ tag: str,
50
+ version: int,
51
+ actor: str | None,
52
+ reason: str | None,
53
+ force: bool,
54
+ strict: bool,
55
+ ) -> Tag: ...
56
+
57
+ def rollback(
58
+ self, project: str, name: str, tag: str, actor: str | None, reason: str | None
59
+ ) -> Tag: ...
60
+
61
+ def list_tag_events(self, project: str, name: str, tag: str | None) -> list[TagEvent]: ...
62
+
63
+ def add_eval_results(self, results: Sequence[EvalResultIn]) -> list[EvalResult]: ...
64
+
65
+ def leaderboard(
66
+ self, project: str, name: str, sort_by: str, descending: bool
67
+ ) -> Leaderboard: ...
68
+
69
+ def diff(self, project: str, name: str, a: int, b: int) -> VersionDiff: ...
70
+
71
+ def get_eval_results(
72
+ self, project: str, name: str, version: int | None
73
+ ) -> list[EvalResult]: ...
74
+
75
+ def close(self) -> None: ...
@@ -0,0 +1,117 @@
1
+ """Library mode: talk to Postgres directly through ``promptdock.core.service``."""
2
+
3
+ from collections.abc import Callable, Iterator, Sequence
4
+ from contextlib import contextmanager
5
+ from typing import TypeVar
6
+
7
+ from sqlalchemy.exc import InterfaceError, OperationalError
8
+ from sqlalchemy.orm import Session
9
+
10
+ from promptdock.core import compare, service
11
+ from promptdock.db.engine import make_engine
12
+ from promptdock.errors import DatabaseUnavailableError
13
+ from promptdock.models import (
14
+ EvalResult,
15
+ EvalResultIn,
16
+ Gate,
17
+ Leaderboard,
18
+ PromptInfo,
19
+ PromptVersion,
20
+ PromptVersionCreate,
21
+ Tag,
22
+ TagEvent,
23
+ VersionDiff,
24
+ )
25
+
26
+ T = TypeVar("T")
27
+
28
+
29
+ class DbBackend:
30
+ """Each call runs in its own transaction and commits on success."""
31
+
32
+ def __init__(self, database_url: str | None = None, connect_timeout: int = 10) -> None:
33
+ self._engine = make_engine(database_url, connect_timeout)
34
+
35
+ def run(self, fn: Callable[[Session], T]) -> T:
36
+ """Run ``fn`` in its own transaction, committing on success."""
37
+ with self._session() as session:
38
+ return fn(session)
39
+
40
+ @contextmanager
41
+ def _session(self) -> Iterator[Session]:
42
+ try:
43
+ with Session(self._engine) as session, session.begin():
44
+ yield session
45
+ except (OperationalError, InterfaceError) as exc:
46
+ raise DatabaseUnavailableError(f"Database unavailable: {exc.orig}") from exc
47
+
48
+ def create_prompt(self, project: str, name: str, description: str | None) -> PromptInfo:
49
+ return self.run(lambda s: service.create_prompt(s, name, description, project))
50
+
51
+ def get_prompt_info(self, project: str, name: str) -> PromptInfo:
52
+ return self.run(lambda s: service.get_prompt(s, name, project))
53
+
54
+ def list_prompts(self, project: str) -> list[PromptInfo]:
55
+ return self.run(lambda s: service.list_prompts(s, project))
56
+
57
+ def create_version(
58
+ self, project: str, name: str, data: PromptVersionCreate, actor: str | None
59
+ ) -> PromptVersion:
60
+ return self.run(lambda s: service.create_version(s, name, data, project, actor))
61
+
62
+ def get_version(self, project: str, name: str, version: int | None) -> PromptVersion:
63
+ return self.run(lambda s: service.get_version(s, name, version, project))
64
+
65
+ def get_by_tag(self, project: str, name: str, tag: str) -> PromptVersion:
66
+ return self.run(lambda s: service.get_by_tag(s, name, tag, project))
67
+
68
+ def list_versions(self, project: str, name: str) -> list[PromptVersion]:
69
+ return self.run(lambda s: service.list_versions(s, name, project))
70
+
71
+ def list_tags(self, project: str, name: str) -> list[Tag]:
72
+ return self.run(lambda s: service.list_tags(s, name, project))
73
+
74
+ def set_gate(
75
+ self, project: str, name: str, tag: str, gate: Gate | None, actor: str | None
76
+ ) -> Tag:
77
+ return self.run(lambda s: service.set_gate(s, name, tag, gate, project, actor))
78
+
79
+ def promote(
80
+ self,
81
+ project: str,
82
+ name: str,
83
+ tag: str,
84
+ version: int,
85
+ actor: str | None,
86
+ reason: str | None,
87
+ force: bool,
88
+ strict: bool,
89
+ ) -> Tag:
90
+ return self.run(
91
+ lambda s: service.promote(
92
+ s, name, tag, version, actor, reason, force, project, strict=strict
93
+ )
94
+ )
95
+
96
+ def rollback(
97
+ self, project: str, name: str, tag: str, actor: str | None, reason: str | None
98
+ ) -> Tag:
99
+ return self.run(lambda s: service.rollback(s, name, tag, actor, reason, project))
100
+
101
+ def list_tag_events(self, project: str, name: str, tag: str | None) -> list[TagEvent]:
102
+ return self.run(lambda s: service.list_tag_events(s, name, tag, project))
103
+
104
+ def add_eval_results(self, results: Sequence[EvalResultIn]) -> list[EvalResult]:
105
+ return self.run(lambda s: service.add_eval_results(s, results))
106
+
107
+ def leaderboard(self, project: str, name: str, sort_by: str, descending: bool) -> Leaderboard:
108
+ return self.run(lambda s: compare.leaderboard(s, name, sort_by, descending, project))
109
+
110
+ def diff(self, project: str, name: str, a: int, b: int) -> VersionDiff:
111
+ return self.run(lambda s: compare.diff(s, name, a, b, project))
112
+
113
+ def get_eval_results(self, project: str, name: str, version: int | None) -> list[EvalResult]:
114
+ return self.run(lambda s: service.get_eval_results(s, name, version, project))
115
+
116
+ def close(self) -> None:
117
+ self._engine.dispose()
@@ -0,0 +1,244 @@
1
+ """Server mode: talk to a ``promptdock serve`` instance over HTTP."""
2
+
3
+ from collections.abc import Sequence
4
+ from typing import Any, TypeVar
5
+ from urllib.parse import quote
6
+
7
+ import httpx
8
+ from pydantic import BaseModel, TypeAdapter
9
+
10
+ from promptdock import errors
11
+ from promptdock.models import (
12
+ CreatePromptRequest,
13
+ EvalResult,
14
+ EvalResultIn,
15
+ Gate,
16
+ Leaderboard,
17
+ PromoteRequest,
18
+ PromptInfo,
19
+ PromptVersion,
20
+ PromptVersionCreate,
21
+ RollbackRequest,
22
+ SetGateRequest,
23
+ Tag,
24
+ TagEvent,
25
+ VersionDiff,
26
+ )
27
+
28
+ M = TypeVar("M", bound=BaseModel)
29
+
30
+ _ERROR_CLASSES: dict[str, type[errors.PromptDockError]] = {
31
+ name: cls
32
+ for name, cls in vars(errors).items()
33
+ if isinstance(cls, type) and issubclass(cls, errors.PromptDockError)
34
+ }
35
+
36
+
37
+ class HttpBackend:
38
+ """Implements the same methods as ``DbBackend`` by calling the REST API.
39
+
40
+ Server errors are raised as the same exception classes library mode raises.
41
+ """
42
+
43
+ def __init__(
44
+ self,
45
+ base_url: str,
46
+ token: str | None,
47
+ timeout: float = 5.0,
48
+ http_client: httpx.Client | None = None,
49
+ ) -> None:
50
+ headers = {"Authorization": f"Bearer {token}"} if token else {}
51
+ if http_client is None:
52
+ self._http = httpx.Client(base_url=base_url, headers=headers, timeout=timeout)
53
+ else:
54
+ http_client.headers.update(headers)
55
+ self._http = http_client
56
+ # Last response per prompt URL, for conditional requests (ETag / 304).
57
+ self._etags: dict[str, tuple[str, PromptVersion]] = {}
58
+
59
+ # --- prompts -------------------------------------------------------------------------
60
+
61
+ def create_prompt(self, project: str, name: str, description: str | None) -> PromptInfo:
62
+ body = CreatePromptRequest(name=name, description=description)
63
+ return self._call(PromptInfo, "POST", "/prompts", project, json=_dump(body))
64
+
65
+ def get_prompt_info(self, project: str, name: str) -> PromptInfo:
66
+ return self._call(PromptInfo, "GET", f"/prompts/{_q(name)}/info", project)
67
+
68
+ def list_prompts(self, project: str) -> list[PromptInfo]:
69
+ return self._call_list(PromptInfo, "GET", "/prompts", project)
70
+
71
+ # --- versions ------------------------------------------------------------------------
72
+
73
+ def create_version(
74
+ self, project: str, name: str, data: PromptVersionCreate, actor: str | None
75
+ ) -> PromptVersion:
76
+ path = f"/prompts/{_q(name)}/versions"
77
+ return self._call(
78
+ PromptVersion, "POST", path, project, json=_dump(data), headers=_actor(actor)
79
+ )
80
+
81
+ def get_version(self, project: str, name: str, version: int | None) -> PromptVersion:
82
+ if version is None:
83
+ return self._get_conditional(project, name, {})
84
+ path = f"/prompts/{_q(name)}/versions/{version}"
85
+ return self._call(PromptVersion, "GET", path, project)
86
+
87
+ def get_by_tag(self, project: str, name: str, tag: str) -> PromptVersion:
88
+ return self._get_conditional(project, name, {"tag": tag})
89
+
90
+ def list_versions(self, project: str, name: str) -> list[PromptVersion]:
91
+ return self._call_list(PromptVersion, "GET", f"/prompts/{_q(name)}/versions", project)
92
+
93
+ # --- tags ----------------------------------------------------------------------------
94
+
95
+ def list_tags(self, project: str, name: str) -> list[Tag]:
96
+ return self._call_list(Tag, "GET", f"/prompts/{_q(name)}/tags", project)
97
+
98
+ def set_gate(
99
+ self, project: str, name: str, tag: str, gate: Gate | None, actor: str | None
100
+ ) -> Tag:
101
+ path = f"/prompts/{_q(name)}/tags/{_q(tag)}/gate"
102
+ body = _dump(SetGateRequest(gate=gate))
103
+ return self._call(Tag, "PUT", path, project, json=body, headers=_actor(actor))
104
+
105
+ def promote(
106
+ self,
107
+ project: str,
108
+ name: str,
109
+ tag: str,
110
+ version: int,
111
+ actor: str | None,
112
+ reason: str | None,
113
+ force: bool,
114
+ strict: bool,
115
+ ) -> Tag:
116
+ body = PromoteRequest(version=version, reason=reason, force=force, strict=strict)
117
+ path = f"/prompts/{_q(name)}/tags/{_q(tag)}"
118
+ return self._call(Tag, "PUT", path, project, json=_dump(body), headers=_actor(actor))
119
+
120
+ def rollback(
121
+ self, project: str, name: str, tag: str, actor: str | None, reason: str | None
122
+ ) -> Tag:
123
+ body = RollbackRequest(reason=reason)
124
+ path = f"/prompts/{_q(name)}/tags/{_q(tag)}/rollback"
125
+ return self._call(Tag, "POST", path, project, json=_dump(body), headers=_actor(actor))
126
+
127
+ def list_tag_events(self, project: str, name: str, tag: str | None) -> list[TagEvent]:
128
+ if tag is None:
129
+ path = f"/prompts/{_q(name)}/events"
130
+ else:
131
+ path = f"/prompts/{_q(name)}/tags/{_q(tag)}/events"
132
+ return self._call_list(TagEvent, "GET", path, project)
133
+
134
+ # --- evals ---------------------------------------------------------------------------
135
+
136
+ def add_eval_results(self, results: Sequence[EvalResultIn]) -> list[EvalResult]:
137
+ body = [_dump(r) for r in results]
138
+ return self._call_list(EvalResult, "POST", "/evals", None, json=body)
139
+
140
+ def leaderboard(self, project: str, name: str, sort_by: str, descending: bool) -> Leaderboard:
141
+ params = {"sort": sort_by, "order": "desc" if descending else "asc"}
142
+ path = f"/prompts/{_q(name)}/leaderboard"
143
+ return self._call(Leaderboard, "GET", path, project, params=params)
144
+
145
+ def diff(self, project: str, name: str, a: int, b: int) -> VersionDiff:
146
+ path = f"/prompts/{_q(name)}/diff"
147
+ return self._call(VersionDiff, "GET", path, project, params={"a": a, "b": b})
148
+
149
+ def get_eval_results(self, project: str, name: str, version: int | None) -> list[EvalResult]:
150
+ params = {"version": version} if version is not None else {}
151
+ path = f"/prompts/{_q(name)}/evals"
152
+ return self._call_list(EvalResult, "GET", path, project, params=params)
153
+
154
+ def close(self) -> None:
155
+ self._http.close()
156
+
157
+ # --- plumbing ------------------------------------------------------------------------
158
+
159
+ def _get_conditional(self, project: str, name: str, params: dict[str, Any]) -> PromptVersion:
160
+ """GET /prompts/{name}, sending the last ETag so an unchanged prompt costs a 304."""
161
+ path = f"/prompts/{_q(name)}"
162
+ cache_key = f"{project}|{path}|{sorted(params.items())}"
163
+ headers = {}
164
+ previous = self._etags.get(cache_key)
165
+ if previous is not None:
166
+ headers["If-None-Match"] = previous[0]
167
+ response = self._send("GET", path, project, params=params, headers=headers)
168
+ if response.status_code == 304 and previous is not None:
169
+ return previous[1]
170
+ result = PromptVersion.model_validate(response.json())
171
+ if etag := response.headers.get("ETag"):
172
+ self._etags[cache_key] = (etag, result)
173
+ return result
174
+
175
+ def _call(self, model: type[M], method: str, path: str, project: str | None, **kw: Any) -> M:
176
+ return model.model_validate(self._send(method, path, project, **kw).json())
177
+
178
+ def _call_list(
179
+ self, model: type[M], method: str, path: str, project: str | None, **kw: Any
180
+ ) -> list[M]:
181
+ return TypeAdapter(list[model]).validate_python( # type: ignore[valid-type]
182
+ self._send(method, path, project, **kw).json()
183
+ )
184
+
185
+ def _send(
186
+ self,
187
+ method: str,
188
+ path: str,
189
+ project: str | None,
190
+ *,
191
+ params: dict[str, Any] | None = None,
192
+ **kw: Any,
193
+ ) -> httpx.Response:
194
+ params = dict(params or {})
195
+ if project is not None:
196
+ params["project"] = project
197
+ try:
198
+ response = self._http.request(method, path, params=params, **kw)
199
+ except httpx.TransportError as exc:
200
+ raise errors.ServerUnavailableError(f"PromptDock server unreachable: {exc}") from exc
201
+ if response.status_code >= 400:
202
+ raise _to_error(response)
203
+ return response
204
+
205
+
206
+ def _to_error(response: httpx.Response) -> errors.PromptDockError:
207
+ try:
208
+ payload = response.json()
209
+ except ValueError:
210
+ payload = {}
211
+ error = payload.get("error") if isinstance(payload, dict) else None
212
+
213
+ if isinstance(error, dict):
214
+ cls = _ERROR_CLASSES.get(str(error.get("type")), errors.PromptDockError)
215
+ message = str(error.get("message", ""))
216
+ if cls is errors.GateFailedError:
217
+ return errors.GateFailedError(error["prompt"], error["tag"], list(error["failures"]))
218
+ if cls is errors.MissingVariableError:
219
+ return errors.InvalidInputError(message)
220
+ if cls is errors.DatabaseUnavailableError:
221
+ # The server is up but its database is not: from here, the server is unavailable.
222
+ return errors.ServerUnavailableError(message)
223
+ return cls(message)
224
+
225
+ if response.status_code == 422:
226
+ return errors.InvalidInputError(f"Invalid request: {payload.get('detail', payload)}")
227
+ if response.status_code == 401:
228
+ return errors.AuthenticationError("The server requires a token")
229
+ if response.status_code >= 500:
230
+ return errors.ServerUnavailableError(f"PromptDock server error {response.status_code}")
231
+ return errors.PromptDockError(f"Unexpected response {response.status_code}: {response.text}")
232
+
233
+
234
+ def _actor(actor: str | None) -> dict[str, str]:
235
+ """X-Actor header; URL-encoded so names with any characters fit in a header."""
236
+ return {"X-Actor": quote(actor, safe="")} if actor else {}
237
+
238
+
239
+ def _dump(model: BaseModel) -> Any:
240
+ return model.model_dump(mode="json", by_alias=True)
241
+
242
+
243
+ def _q(segment: str) -> str:
244
+ return quote(segment, safe="")
promptdock/cache.py ADDED
@@ -0,0 +1,111 @@
1
+ """In-memory TTL cache with a last-known-good fallback and an optional snapshot file."""
2
+
3
+ import json
4
+ import logging
5
+ import os
6
+ import threading
7
+ import time
8
+ from collections.abc import Callable
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+
12
+ from promptdock.models import PromptVersion
13
+
14
+ logger = logging.getLogger("promptdock")
15
+
16
+
17
+ @dataclass
18
+ class _Entry:
19
+ value: PromptVersion
20
+ fetched_at: float
21
+ immutable: bool
22
+ stale: bool
23
+
24
+
25
+ class PromptCache:
26
+ """Caches prompt versions by lookup key.
27
+
28
+ Entries for a tag (or "latest") are fresh for ``ttl`` seconds. Entries fetched by
29
+ version number never expire, because versions never change. Expired entries are kept
30
+ as the last known good copy for when the database is unreachable.
31
+
32
+ If ``snapshot_path`` is set, every fetched version is also written to that JSON file
33
+ so a freshly started process can fall back to it before its first successful read.
34
+ """
35
+
36
+ def __init__(
37
+ self,
38
+ ttl: float,
39
+ snapshot_path: str | Path | None = None,
40
+ clock: Callable[[], float] = time.monotonic,
41
+ ) -> None:
42
+ self.ttl = ttl
43
+ self.snapshot_path = Path(snapshot_path) if snapshot_path is not None else None
44
+ self._clock = clock
45
+ self._entries: dict[str, _Entry] = {}
46
+ self._lock = threading.Lock()
47
+
48
+ def get_fresh(self, key: str) -> tuple[PromptVersion, bool] | None:
49
+ """``(value, is_stale)`` if the entry has not expired, else None."""
50
+ with self._lock:
51
+ entry = self._entries.get(key)
52
+ if entry is None:
53
+ return None
54
+ if entry.immutable or self._clock() - entry.fetched_at < self.ttl:
55
+ return entry.value, entry.stale
56
+ return None
57
+
58
+ def get_stale(self, key: str) -> PromptVersion | None:
59
+ """Last known good value from memory, else from the snapshot file."""
60
+ with self._lock:
61
+ entry = self._entries.get(key)
62
+ if entry is not None:
63
+ return entry.value
64
+ return self._read_snapshot().get(key)
65
+
66
+ def put(
67
+ self, key: str, value: PromptVersion, *, immutable: bool = False, stale: bool = False
68
+ ) -> None:
69
+ """Store a value. ``stale=True`` re-stores a last-known-good copy while the database
70
+ is down, so it is served (and the database retried) once per TTL, not every call."""
71
+ with self._lock:
72
+ previous = self._entries.get(key)
73
+ self._entries[key] = _Entry(value, self._clock(), immutable, stale)
74
+ if not stale and (previous is None or previous.value.id != value.id):
75
+ self._write_snapshot(key, value)
76
+
77
+ def invalidate(self, prefix: str = "") -> None:
78
+ """Expire entries whose key starts with ``prefix`` (all entries by default).
79
+
80
+ They stay available as last-known-good copies.
81
+ """
82
+ with self._lock:
83
+ for key, entry in self._entries.items():
84
+ if key.startswith(prefix) and not entry.immutable:
85
+ entry.fetched_at = float("-inf")
86
+
87
+ def _read_snapshot(self) -> dict[str, PromptVersion]:
88
+ if self.snapshot_path is None or not self.snapshot_path.exists():
89
+ return {}
90
+ try:
91
+ raw = json.loads(self.snapshot_path.read_text(encoding="utf-8"))
92
+ return {key: PromptVersion.model_validate(value) for key, value in raw.items()}
93
+ except (OSError, ValueError) as exc:
94
+ logger.warning("Ignoring unreadable prompt snapshot %s: %s", self.snapshot_path, exc)
95
+ return {}
96
+
97
+ def _write_snapshot(self, key: str, value: PromptVersion) -> None:
98
+ if self.snapshot_path is None:
99
+ return
100
+ with self._lock:
101
+ try:
102
+ data: dict[str, object] = {}
103
+ if self.snapshot_path.exists():
104
+ data = json.loads(self.snapshot_path.read_text(encoding="utf-8"))
105
+ data[key] = value.model_dump(mode="json", by_alias=True)
106
+ self.snapshot_path.parent.mkdir(parents=True, exist_ok=True)
107
+ tmp = self.snapshot_path.with_suffix(self.snapshot_path.suffix + ".tmp")
108
+ tmp.write_text(json.dumps(data, indent=2), encoding="utf-8")
109
+ os.replace(tmp, self.snapshot_path)
110
+ except (OSError, ValueError) as exc:
111
+ logger.warning("Could not write prompt snapshot %s: %s", self.snapshot_path, exc)