promptfuse 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.
promptfuse/__init__.py ADDED
@@ -0,0 +1,14 @@
1
+ """Local prompt registry with a Langfuse-compatible read API."""
2
+
3
+ from promptfuse.client import Promptfuse
4
+ from promptfuse.errors import InvalidPromptRequest, PromptNotFound, PromptStoreError
5
+
6
+ __version__ = "0.1.0"
7
+
8
+ __all__ = [
9
+ "InvalidPromptRequest",
10
+ "PromptNotFound",
11
+ "PromptStoreError",
12
+ "Promptfuse",
13
+ "__version__",
14
+ ]
promptfuse/cache.py ADDED
@@ -0,0 +1,217 @@
1
+ """In-memory prompt cache with stale-while-revalidate refresh."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ import time
7
+ from dataclasses import dataclass
8
+ from typing import Any, Callable
9
+
10
+ from promptfuse.chain import load_prompt
11
+ from promptfuse.errors import InvalidPromptRequest, PromptNotFound, PromptStoreError
12
+ from promptfuse.models import PromptClient, clone_client, fallback_client
13
+ from promptfuse.registry import Registry
14
+ from promptfuse.snapshot import Snapshot
15
+
16
+ CacheKey = tuple[str, str, str | int]
17
+
18
+
19
+ @dataclass
20
+ class _Entry:
21
+ client: PromptClient
22
+ expires_at: float
23
+ ttl: float
24
+ prompt_type: str
25
+ version: int | None
26
+ label: str | None
27
+
28
+
29
+ class MemoryCache:
30
+ """Cache resolved prompt clients in front of a registry."""
31
+
32
+ def __init__(
33
+ self,
34
+ registry: Registry,
35
+ *,
36
+ ttl_seconds: float = 60,
37
+ clock: Callable[[], float] | None = None,
38
+ seed: Registry | None = None,
39
+ snapshot: Snapshot | None = None,
40
+ ) -> None:
41
+ self._registry = registry
42
+ self._seed = seed
43
+ self._snapshot = snapshot
44
+ self._ttl = ttl_seconds
45
+ self._clock = clock if clock is not None else time.monotonic
46
+ self._entries: dict[CacheKey, _Entry] = {}
47
+ self._inflight: set[CacheKey] = set()
48
+ self._lock = threading.Lock()
49
+
50
+ def get_prompt(
51
+ self,
52
+ name: str,
53
+ *,
54
+ version: int | None = None,
55
+ label: str | None = None,
56
+ type: str = "text",
57
+ cache_ttl_seconds: float | None = None,
58
+ fallback: str | list[Any] | None = None,
59
+ ) -> PromptClient:
60
+ if version is not None and label is not None:
61
+ raise InvalidPromptRequest("label and version are mutually exclusive")
62
+ ttl = self._ttl if cache_ttl_seconds is None else cache_ttl_seconds
63
+ if isinstance(ttl, bool) or not isinstance(ttl, (int, float)) or ttl < 0:
64
+ raise InvalidPromptRequest(f"invalid cache ttl: {ttl!r}")
65
+ key = _key(name, version, label)
66
+ if ttl == 0:
67
+ return self._load(key, name, version, label, type, fallback, remember=False, ttl=0)
68
+ now = self._clock()
69
+ with self._lock:
70
+ entry = self._entries.get(key)
71
+ if entry is not None and entry.expires_at > now and entry.prompt_type == type:
72
+ return clone_client(entry.client)
73
+ stale = entry if entry is not None and entry.prompt_type == type else None
74
+ if stale is not None:
75
+ self._schedule(key, stale)
76
+ return clone_client(stale.client)
77
+ return self._load(key, name, version, label, type, fallback, remember=True, ttl=ttl)
78
+
79
+ def invalidate_labels(self, name: str) -> None:
80
+ with self._lock:
81
+ for key in [key for key in self._entries if key[0] == name and key[1] == "label"]:
82
+ del self._entries[key]
83
+ if self._snapshot is not None:
84
+ self._snapshot.drop_labels(name)
85
+
86
+ def _load(
87
+ self,
88
+ key: CacheKey,
89
+ name: str,
90
+ version: int | None,
91
+ label: str | None,
92
+ prompt_type: str,
93
+ fallback: str | list[Any] | None,
94
+ *,
95
+ remember: bool,
96
+ ttl: float,
97
+ ) -> PromptClient:
98
+ try:
99
+ loaded = load_prompt(
100
+ self._registry,
101
+ name=name,
102
+ version=version,
103
+ label=label,
104
+ prompt_type=prompt_type,
105
+ snapshot=self._snapshot,
106
+ key=key,
107
+ seed=self._seed,
108
+ allow_snapshot=True,
109
+ )
110
+ except PromptNotFound:
111
+ if fallback is not None:
112
+ return fallback_client(name, fallback)
113
+ raise
114
+ except PromptStoreError:
115
+ if fallback is not None:
116
+ return fallback_client(name, fallback)
117
+ raise
118
+ if loaded.source == "snapshot":
119
+ self._remember(
120
+ key,
121
+ loaded.client,
122
+ expires_at=self._clock(),
123
+ ttl=self._ttl,
124
+ prompt_type=prompt_type,
125
+ version=version,
126
+ label=label,
127
+ )
128
+ return clone_client(loaded.client)
129
+ self._remember_success(key, loaded.client, prompt_type, version, label, remember, ttl)
130
+ return clone_client(loaded.client)
131
+
132
+ def _remember_success(
133
+ self,
134
+ key: CacheKey,
135
+ client: PromptClient,
136
+ prompt_type: str,
137
+ version: int | None,
138
+ label: str | None,
139
+ remember: bool,
140
+ ttl: float,
141
+ ) -> None:
142
+ if remember:
143
+ self._remember(
144
+ key,
145
+ client,
146
+ expires_at=self._clock() + ttl,
147
+ ttl=ttl,
148
+ prompt_type=prompt_type,
149
+ version=version,
150
+ label=label,
151
+ )
152
+ if self._snapshot is not None:
153
+ self._snapshot.put(key, client)
154
+
155
+ def _remember(
156
+ self,
157
+ key: CacheKey,
158
+ client: PromptClient,
159
+ *,
160
+ expires_at: float,
161
+ ttl: float,
162
+ prompt_type: str,
163
+ version: int | None,
164
+ label: str | None,
165
+ ) -> None:
166
+ with self._lock:
167
+ self._entries[key] = _Entry(
168
+ client=clone_client(client),
169
+ expires_at=expires_at,
170
+ ttl=ttl,
171
+ prompt_type=prompt_type,
172
+ version=version,
173
+ label=label,
174
+ )
175
+
176
+ def _schedule(self, key: CacheKey, entry: _Entry) -> None:
177
+ with self._lock:
178
+ if key in self._inflight:
179
+ return
180
+ self._inflight.add(key)
181
+ thread = threading.Thread(target=self._refresh, args=(key, entry), daemon=True)
182
+ thread.start()
183
+
184
+ def _refresh(self, key: CacheKey, entry: _Entry) -> None:
185
+ try:
186
+ try:
187
+ loaded = load_prompt(
188
+ self._registry,
189
+ name=key[0],
190
+ version=entry.version,
191
+ label=entry.label,
192
+ prompt_type=entry.prompt_type,
193
+ snapshot=self._snapshot,
194
+ key=key,
195
+ seed=self._seed,
196
+ allow_snapshot=False,
197
+ )
198
+ except (PromptNotFound, PromptStoreError):
199
+ return
200
+ self._remember_success(
201
+ key,
202
+ loaded.client,
203
+ entry.prompt_type,
204
+ entry.version,
205
+ entry.label,
206
+ True,
207
+ entry.ttl,
208
+ )
209
+ finally:
210
+ with self._lock:
211
+ self._inflight.discard(key)
212
+
213
+
214
+ def _key(name: str, version: int | None, label: str | None) -> CacheKey:
215
+ if version is not None:
216
+ return (name, "version", version)
217
+ return (name, "label", "production" if label is None else label)
promptfuse/chain.py ADDED
@@ -0,0 +1,58 @@
1
+ """Read a primary registry, then the disk snapshot, then a YAML seed."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Literal
7
+
8
+ from promptfuse.errors import PromptNotFound, PromptStoreError
9
+ from promptfuse.models import PromptClient
10
+ from promptfuse.registry import Registry
11
+ from promptfuse.snapshot import Snapshot
12
+
13
+ Source = Literal["primary", "snapshot", "seed"]
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class LoadedPrompt:
18
+ client: PromptClient
19
+ source: Source
20
+
21
+
22
+ def load_prompt(
23
+ registry: Registry,
24
+ *,
25
+ name: str,
26
+ version: int | None,
27
+ label: str | None,
28
+ prompt_type: str,
29
+ snapshot: Snapshot | None,
30
+ key: tuple[str, str, str | int],
31
+ seed: Registry | None,
32
+ allow_snapshot: bool,
33
+ ) -> LoadedPrompt:
34
+ """Resolve a prompt from the primary store.
35
+
36
+ ``PromptNotFound`` from a healthy primary store propagates. ``PromptStoreError``
37
+ tries the snapshot, then ``seed``, and then propagates.
38
+ """
39
+
40
+ try:
41
+ client = registry.get_prompt(name, version=version, label=label, type=prompt_type)
42
+ except PromptStoreError as exc:
43
+ if allow_snapshot and snapshot is not None:
44
+ try:
45
+ snapped = snapshot.get(key)
46
+ except PromptStoreError:
47
+ snapped = None
48
+ if snapped is not None and snapped.type == prompt_type:
49
+ return LoadedPrompt(snapped, "snapshot")
50
+ if seed is not None:
51
+ try:
52
+ seeded = seed.get_prompt(name, version=version, label=label, type=prompt_type)
53
+ except (PromptNotFound, PromptStoreError):
54
+ seeded = None
55
+ if seeded is not None:
56
+ return LoadedPrompt(seeded, "seed")
57
+ raise exc
58
+ return LoadedPrompt(client, "primary")
promptfuse/client.py ADDED
@@ -0,0 +1,108 @@
1
+ """Public promptfuse client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from promptfuse.cache import MemoryCache
9
+ from promptfuse.errors import InvalidPromptRequest
10
+ from promptfuse.models import PromptClient
11
+ from promptfuse.registry import Registry
12
+ from promptfuse.snapshot import Snapshot
13
+ from promptfuse.stores.sqlite import SqliteStore
14
+ from promptfuse.stores.yaml import YamlStore
15
+
16
+
17
+ class Promptfuse:
18
+ """Local prompt registry with a Langfuse-compatible prompt API."""
19
+
20
+ def __init__(
21
+ self,
22
+ *,
23
+ yaml_dir: Path | str | None = None,
24
+ sqlite_path: Path | str | None = None,
25
+ snapshot_path: Path | str | None = None,
26
+ cache_ttl_seconds: float = 60,
27
+ import_yaml: bool = False,
28
+ ) -> None:
29
+ if yaml_dir is None and sqlite_path is None:
30
+ raise InvalidPromptRequest("yaml_dir or sqlite_path is required")
31
+ self._yaml = YamlStore(yaml_dir) if yaml_dir is not None else None
32
+ self._sqlite = SqliteStore(sqlite_path) if sqlite_path is not None else None
33
+ if self._sqlite is not None:
34
+ primary = self._sqlite
35
+ seed = Registry(self._yaml) if self._yaml is not None else None
36
+ else:
37
+ primary = self._yaml
38
+ seed = None
39
+ if primary is None:
40
+ raise InvalidPromptRequest("yaml_dir or sqlite_path is required")
41
+ self._registry = Registry(primary)
42
+ snapshot = Snapshot(snapshot_path) if snapshot_path is not None else None
43
+ self._cache = MemoryCache(
44
+ self._registry,
45
+ ttl_seconds=cache_ttl_seconds,
46
+ seed=seed,
47
+ snapshot=snapshot,
48
+ )
49
+ if import_yaml:
50
+ self.import_yaml()
51
+
52
+ def get_prompt(
53
+ self,
54
+ name: str,
55
+ *,
56
+ version: int | None = None,
57
+ label: str | None = None,
58
+ type: str = "text",
59
+ cache_ttl_seconds: float | None = None,
60
+ fallback: str | list[Any] | None = None,
61
+ ) -> PromptClient:
62
+ return self._cache.get_prompt(
63
+ name,
64
+ version=version,
65
+ label=label,
66
+ type=type,
67
+ cache_ttl_seconds=cache_ttl_seconds,
68
+ fallback=fallback,
69
+ )
70
+
71
+ def create_prompt(
72
+ self,
73
+ name: str,
74
+ *,
75
+ type: str,
76
+ prompt: Any,
77
+ labels: list[str] | None = None,
78
+ config: dict[str, Any] | None = None,
79
+ tags: list[str] | None = None,
80
+ commit_message: str | None = None,
81
+ ) -> PromptClient:
82
+ client = self._registry.create_prompt(
83
+ name,
84
+ type=type,
85
+ prompt=prompt,
86
+ labels=labels,
87
+ config=config,
88
+ tags=tags,
89
+ commit_message=commit_message,
90
+ )
91
+ self._cache.invalidate_labels(name)
92
+ return client
93
+
94
+ def update_prompt(self, name: str, *, version: int, new_labels: list[str]) -> PromptClient:
95
+ client = self._registry.update_prompt(name, version=version, new_labels=new_labels)
96
+ self._cache.invalidate_labels(name)
97
+ return client
98
+
99
+ def import_yaml(self) -> None:
100
+ """Copy the YAML tree into the primary SQLite store."""
101
+
102
+ if self._yaml is None or self._sqlite is None:
103
+ raise InvalidPromptRequest("import_yaml requires yaml_dir and sqlite_path")
104
+ self._registry.import_records(self._yaml)
105
+ names = set(self._yaml.list_names())
106
+ names.update(name for name, _label, _version in self._yaml.label_assignments())
107
+ for name in names:
108
+ self._cache.invalidate_labels(name)
promptfuse/errors.py ADDED
@@ -0,0 +1,17 @@
1
+ """Errors raised by the prompt registry."""
2
+
3
+
4
+ class PromptfuseError(Exception):
5
+ """Base error for promptfuse."""
6
+
7
+
8
+ class PromptNotFound(PromptfuseError):
9
+ """The name, label, version, or prompt type does not resolve."""
10
+
11
+
12
+ class InvalidPromptRequest(PromptfuseError):
13
+ """The request or the stored prompt breaks the registry rules."""
14
+
15
+
16
+ class PromptStoreError(PromptfuseError):
17
+ """The store could not be read or written."""
promptfuse/models.py ADDED
@@ -0,0 +1,259 @@
1
+ """Prompt clients and Langfuse-style ``{{variable}}`` compilation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ from dataclasses import dataclass
7
+ from typing import Any, Mapping
8
+
9
+ from promptfuse.errors import InvalidPromptRequest
10
+
11
+ PromptType = str
12
+ ChatItem = dict[str, Any]
13
+
14
+
15
+ def find_variable_names(content: str) -> list[str]:
16
+ """Return ``{{name}}`` placeholders in ``content``, in order."""
17
+
18
+ names: list[str] = []
19
+ index = 0
20
+ while index < len(content):
21
+ found = _parse_variable(content, index)
22
+ if found is None:
23
+ break
24
+ names.append(found[0])
25
+ index = found[2]
26
+ return names
27
+
28
+
29
+ def compile_template(content: str, data: Mapping[str, Any] | None = None) -> str:
30
+ """Substitute ``{{variable}}`` values.
31
+
32
+ A missing name is left unchanged, including its braces. ``None`` becomes
33
+ an empty string. Surrounding whitespace inside the braces is ignored.
34
+ """
35
+
36
+ if data is None:
37
+ return content
38
+ pieces: list[str] = []
39
+ index = 0
40
+ while index < len(content):
41
+ found = _parse_variable(content, index)
42
+ if found is None:
43
+ pieces.append(content[index:])
44
+ break
45
+ name, start, end = found
46
+ pieces.append(content[index:start])
47
+ if name in data:
48
+ value = data[name]
49
+ pieces.append("" if value is None else str(value))
50
+ else:
51
+ pieces.append(content[start:end])
52
+ index = end
53
+ return "".join(pieces)
54
+
55
+
56
+ def _parse_variable(content: str, start_index: int) -> tuple[str, int, int] | None:
57
+ opening = content.find("{{", start_index)
58
+ if opening == -1:
59
+ return None
60
+ closing = content.find("}}", opening + 2)
61
+ if closing == -1:
62
+ return None
63
+ name = content[opening + 2 : closing].strip()
64
+ return name, opening, closing + 2
65
+
66
+
67
+ def normalize_prompt(prompt_type: str, prompt: Any) -> str | list[ChatItem]:
68
+ """Return the stored form of a text or chat prompt."""
69
+
70
+ if prompt_type == "text":
71
+ if not isinstance(prompt, str):
72
+ raise InvalidPromptRequest("a text prompt must be a string")
73
+ return prompt
74
+ if prompt_type == "chat":
75
+ if not isinstance(prompt, list):
76
+ raise InvalidPromptRequest("a chat prompt must be a list of messages")
77
+ return [_normalize_chat_item(item) for item in prompt]
78
+ raise InvalidPromptRequest(f"unknown prompt type: {prompt_type!r}")
79
+
80
+
81
+ def _normalize_chat_item(item: Any) -> ChatItem:
82
+ if not isinstance(item, dict):
83
+ raise InvalidPromptRequest("a chat item must be a mapping")
84
+ kind = item.get("type")
85
+ if kind == "placeholder" or (kind is None and "name" in item and "role" not in item):
86
+ if "role" in item or "content" in item:
87
+ raise InvalidPromptRequest("a placeholder must not include role or content")
88
+ name = item.get("name")
89
+ if not isinstance(name, str) or not name:
90
+ raise InvalidPromptRequest("a placeholder name must be a non-empty string")
91
+ return {"type": "placeholder", "name": name}
92
+ if kind not in (None, "message"):
93
+ raise InvalidPromptRequest(f"unknown chat item type: {kind!r}")
94
+ role = item.get("role")
95
+ content = item.get("content")
96
+ if not isinstance(role, str) or not isinstance(content, str):
97
+ raise InvalidPromptRequest("a chat message needs string role and content")
98
+ return {"type": "message", "role": role, "content": content}
99
+
100
+
101
+ @dataclass(frozen=True)
102
+ class TextPrompt:
103
+ """A text prompt client."""
104
+
105
+ name: str
106
+ version: int
107
+ prompt: str
108
+ config: dict[str, Any]
109
+ labels: tuple[str, ...]
110
+ tags: tuple[str, ...]
111
+ commit_message: str | None
112
+ is_fallback: bool = False
113
+
114
+ @property
115
+ def type(self) -> str:
116
+ return "text"
117
+
118
+ @property
119
+ def variables(self) -> list[str]:
120
+ return find_variable_names(self.prompt)
121
+
122
+ def compile(self, **kwargs: Any) -> str:
123
+ return compile_template(self.prompt, kwargs)
124
+
125
+
126
+ @dataclass(frozen=True)
127
+ class ChatPrompt:
128
+ """A chat prompt client."""
129
+
130
+ name: str
131
+ version: int
132
+ stored_messages: tuple[tuple[tuple[str, Any], ...], ...]
133
+ config: dict[str, Any]
134
+ labels: tuple[str, ...]
135
+ tags: tuple[str, ...]
136
+ commit_message: str | None
137
+ is_fallback: bool = False
138
+
139
+ @property
140
+ def type(self) -> str:
141
+ return "chat"
142
+
143
+ @property
144
+ def prompt(self) -> list[ChatItem]:
145
+ return [dict(item) for item in self.stored_messages]
146
+
147
+ @property
148
+ def variables(self) -> list[str]:
149
+ names: list[str] = []
150
+ for item in self.prompt:
151
+ if item.get("type") == "message":
152
+ names.extend(find_variable_names(item["content"]))
153
+ return names
154
+
155
+ def compile(self, **kwargs: Any) -> list[dict[str, Any]]:
156
+ compiled: list[dict[str, Any]] = []
157
+ for item in self.prompt:
158
+ if item["type"] == "message":
159
+ compiled.append(
160
+ {
161
+ "role": item["role"],
162
+ "content": compile_template(item["content"], kwargs),
163
+ }
164
+ )
165
+ continue
166
+ name = item["name"]
167
+ if name not in kwargs:
168
+ compiled.append({"type": "placeholder", "name": name})
169
+ continue
170
+ value = kwargs[name]
171
+ if isinstance(value, list):
172
+ for entry in value:
173
+ if isinstance(entry, dict):
174
+ copied = dict(entry)
175
+ content = copied.get("content", "")
176
+ if isinstance(content, str):
177
+ copied["content"] = compile_template(content, kwargs)
178
+ compiled.append(copied)
179
+ else:
180
+ compiled.append({"role": "NOT_GIVEN", "content": str(entry)})
181
+ else:
182
+ compiled.append({"role": "NOT_GIVEN", "content": str(value)})
183
+ return compiled
184
+
185
+
186
+ PromptClient = TextPrompt | ChatPrompt
187
+
188
+
189
+ def make_client(
190
+ *,
191
+ name: str,
192
+ version: int,
193
+ prompt_type: str,
194
+ prompt: str | list[ChatItem],
195
+ config: Mapping[str, Any] | None = None,
196
+ labels: list[str] | tuple[str, ...] = (),
197
+ tags: list[str] | tuple[str, ...] = (),
198
+ commit_message: str | None = None,
199
+ is_fallback: bool = False,
200
+ ) -> PromptClient:
201
+ """Build a prompt client from stored fields."""
202
+
203
+ stored_config = copy.deepcopy(dict(config or {}))
204
+ stored_labels = tuple(sorted(labels))
205
+ stored_tags = tuple(tags)
206
+ if prompt_type == "text":
207
+ if not isinstance(prompt, str):
208
+ raise InvalidPromptRequest("a text prompt must be a string")
209
+ return TextPrompt(
210
+ name=name,
211
+ version=version,
212
+ prompt=prompt,
213
+ config=stored_config,
214
+ labels=stored_labels,
215
+ tags=stored_tags,
216
+ commit_message=commit_message,
217
+ is_fallback=is_fallback,
218
+ )
219
+ if prompt_type == "chat":
220
+ normalized = normalize_prompt("chat", prompt)
221
+ assert isinstance(normalized, list)
222
+ frozen = tuple(tuple(sorted(item.items())) for item in normalized)
223
+ return ChatPrompt(
224
+ name=name,
225
+ version=version,
226
+ stored_messages=frozen,
227
+ config=stored_config,
228
+ labels=stored_labels,
229
+ tags=stored_tags,
230
+ commit_message=commit_message,
231
+ is_fallback=is_fallback,
232
+ )
233
+ raise InvalidPromptRequest(f"unknown prompt type: {prompt_type!r}")
234
+
235
+
236
+ def fallback_client(name: str, fallback: str | list[Any]) -> PromptClient:
237
+ """Build the client used when the cache and the stores cannot answer."""
238
+
239
+ if isinstance(fallback, str):
240
+ prompt_type: str = "text"
241
+ prompt: str | list[ChatItem] = fallback
242
+ elif isinstance(fallback, list):
243
+ prompt_type = "chat"
244
+ prompt = normalize_prompt("chat", fallback)
245
+ else:
246
+ raise InvalidPromptRequest("fallback must be a string or a list of messages")
247
+ return make_client(
248
+ name=name,
249
+ version=0,
250
+ prompt_type=prompt_type,
251
+ prompt=prompt,
252
+ is_fallback=True,
253
+ )
254
+
255
+
256
+ def clone_client(client: PromptClient) -> PromptClient:
257
+ """Return a detached copy of a cached client."""
258
+
259
+ return copy.deepcopy(client)