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.
- promptdock/__init__.py +29 -0
- promptdock/actor.py +11 -0
- promptdock/backends/__init__.py +5 -0
- promptdock/backends/base.py +75 -0
- promptdock/backends/db.py +117 -0
- promptdock/backends/http.py +244 -0
- promptdock/cache.py +111 -0
- promptdock/cli.py +633 -0
- promptdock/client.py +283 -0
- promptdock/config.py +41 -0
- promptdock/connect.py +27 -0
- promptdock/core/__init__.py +1 -0
- promptdock/core/compare.py +138 -0
- promptdock/core/gates.py +40 -0
- promptdock/core/service.py +562 -0
- promptdock/db/__init__.py +1 -0
- promptdock/db/engine.py +23 -0
- promptdock/db/migrate.py +49 -0
- promptdock/db/migrations/env.py +40 -0
- promptdock/db/migrations/script.py.mako +25 -0
- promptdock/db/migrations/versions/0001_initial.py +184 -0
- promptdock/db/migrations/versions/0002_drop_tag_is_protected.py +27 -0
- promptdock/db/migrations/versions/0003_readable_views.py +91 -0
- promptdock/db/migrations/versions/0004_drop_api_keys.py +36 -0
- promptdock/db/tables.py +150 -0
- promptdock/errors.py +87 -0
- promptdock/mcp/__init__.py +1 -0
- promptdock/mcp/server.py +116 -0
- promptdock/models.py +234 -0
- promptdock/prompt.py +74 -0
- promptdock/py.typed +0 -0
- promptdock/server/__init__.py +5 -0
- promptdock/server/access.py +57 -0
- promptdock/server/app.py +72 -0
- promptdock/server/errors.py +55 -0
- promptdock/server/routes.py +225 -0
- promptdock/sync.py +222 -0
- promptdock/templates.py +64 -0
- promptdock/ui/__init__.py +13 -0
- promptdock/ui/static/assets/index-B7bJCH_W.css +1 -0
- promptdock/ui/static/assets/index-BbnhHlWV.js +109 -0
- promptdock/ui/static/index.html +17 -0
- promptdock-0.1.0.dist-info/METADATA +226 -0
- promptdock-0.1.0.dist-info/RECORD +47 -0
- promptdock-0.1.0.dist-info/WHEEL +4 -0
- promptdock-0.1.0.dist-info/entry_points.txt +2 -0
- 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,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)
|