promptfuse 0.1.0__tar.gz

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.
@@ -0,0 +1,20 @@
1
+ name: ci
2
+
3
+ on:
4
+ push:
5
+ pull_request:
6
+
7
+ jobs:
8
+ test:
9
+ runs-on: ubuntu-latest
10
+ strategy:
11
+ fail-fast: false
12
+ matrix:
13
+ python-version: ["3.10", "3.13"]
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ - uses: astral-sh/setup-uv@v5
17
+ with:
18
+ python-version: ${{ matrix.python-version }}
19
+ - run: uv sync --extra dev --frozen
20
+ - run: uv run pytest
@@ -0,0 +1,22 @@
1
+ name: publish
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ jobs:
9
+ pypi:
10
+ name: upload release to PyPI
11
+ runs-on: ubuntu-latest
12
+ environment: pypi
13
+ permissions:
14
+ id-token: write
15
+ contents: read
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+ - uses: astral-sh/setup-uv@v5
19
+ with:
20
+ python-version: "3.13"
21
+ - run: uv build
22
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,11 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ .pytest_cache/
5
+ .mypy_cache/
6
+ .ruff_cache/
7
+ dist/
8
+ build/
9
+ *.egg-info/
10
+ .env
11
+ .env.*
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Henry Fong
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,356 @@
1
+ Metadata-Version: 2.5
2
+ Name: promptfuse
3
+ Version: 0.1.0
4
+ Summary: Local prompt registry with a Langfuse-compatible read API.
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.10
8
+ Requires-Dist: pyyaml>=6
9
+ Provides-Extra: dev
10
+ Requires-Dist: pytest>=8; extra == 'dev'
11
+ Description-Content-Type: text/markdown
12
+
13
+ # promptfuse
14
+
15
+ [![ci](https://github.com/foongsy/promptfuse/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/foongsy/promptfuse/actions/workflows/ci.yml)
16
+
17
+ Local prompt registry with a Langfuse-compatible prompt API. Prompts are stored in a YAML tree, in SQLite, or in SQLite with the YAML tree as the seed. `promptfuse` does not call Langfuse Cloud and does not send traces.
18
+
19
+ This document is the contract for the public API.
20
+
21
+ The prompt record and the fetch, label, cache, compile, and fallback rules match [Langfuse prompt management](https://langfuse.com/docs/prompt-management/data-model). Where Langfuse leaves a local-store choice open, this document defines it.
22
+
23
+ ## Install
24
+
25
+ Requires Python 3.10+.
26
+
27
+ ```bash
28
+ pip install promptfuse
29
+ ```
30
+
31
+ ## Client
32
+
33
+ ```python
34
+ from promptfuse import Promptfuse
35
+
36
+ client = Promptfuse(
37
+ yaml_dir="prompts", # optional YAML store
38
+ sqlite_path="prompts.db", # optional SQLite store
39
+ snapshot_path=None, # optional disk cache of successful reads
40
+ cache_ttl_seconds=60, # default; 0 disables the memory cache
41
+ )
42
+ ```
43
+
44
+ At least one of `yaml_dir` and `sqlite_path` is required. See [Stores](#stores) for which one is read and written.
45
+
46
+ ```python
47
+ prompt = client.get_prompt("deep-agent/system")
48
+ text = prompt.compile(persona="analyst")
49
+
50
+ client.create_prompt(
51
+ name="deep-agent/system",
52
+ type="text",
53
+ prompt="You are a {{persona}} research assistant.",
54
+ labels=["production"],
55
+ config={"model": "gpt-4.1", "temperature": 0.2},
56
+ tags=["deep-agent"],
57
+ commit_message="initial system prompt",
58
+ )
59
+
60
+ client.update_prompt(
61
+ name="deep-agent/system",
62
+ version=1,
63
+ new_labels=["staging"],
64
+ )
65
+ ```
66
+
67
+ `get_prompt` follows [`GET /api/public/v2/prompts/{name}`](https://langfuse.com/docs/prompt-management/features/prompt-version-control). `create_prompt` and `update_prompt` follow the Langfuse Python SDK methods of the same names.
68
+
69
+ ## Prompt client
70
+
71
+ `get_prompt`, `create_prompt`, and a fallback all return a prompt client.
72
+
73
+ | Attribute | Meaning |
74
+ |---|---|
75
+ | `name` | Prompt name. Slashes are folders (`deep-agent/system`). |
76
+ | `version` | Integer. Store versions start at 1. |
77
+ | `type` | `"text"` or `"chat"`. Fixed for a name after the first version. |
78
+ | `prompt` | Text prompt: `str`. Chat prompt: list of messages. |
79
+ | `config` | JSON object versioned with this prompt. `{}` when omitted. |
80
+ | `labels` | Labels currently pointing at this version. |
81
+ | `tags` | String list. `[]` when omitted. |
82
+ | `commit_message` | Optional string. |
83
+ | `is_fallback` | `True` only for a client built from the `fallback` argument. |
84
+ | `variables` | `{{name}}` placeholders found in the prompt, in order. |
85
+
86
+ A text prompt's `prompt` is a string. A chat prompt's `prompt` is a list of either of:
87
+
88
+ ```python
89
+ {"type": "message", "role": "system", "content": "You are a {{persona}}."}
90
+ {"type": "placeholder", "name": "chat_history"}
91
+ ```
92
+
93
+ `role` is any string. `promptfuse` does not restrict it to `system`, `user`, or `assistant`.
94
+
95
+ `config` is an arbitrary JSON object. Typical keys are `model`, `temperature`, `max_tokens`, `response_format`, `tools`, and `tool_choice`. `promptfuse` stores and returns `config`. It does not call a model.
96
+
97
+ ## Fetching
98
+
99
+ From [How label resolution works](https://langfuse.com/docs/prompt-management/features/prompt-version-control#label-resolution):
100
+
101
+ | Call | Result |
102
+ |---|---|
103
+ | `get_prompt(name)` | Version labeled `production`. |
104
+ | `get_prompt(name, label="staging")` | Version labeled `staging`. |
105
+ | `get_prompt(name, version=3)` | Version 3, whatever its labels are. |
106
+ | `get_prompt(name, label=..., version=...)` | `InvalidPromptRequest`. `label` and `version` are mutually exclusive. |
107
+
108
+ A missing name, a missing label, or a missing version raises `PromptNotFound`. There is no silent fallback from `staging` to `production` or `latest`.
109
+
110
+ `label` defaults to `"production"` only when `version` is omitted. `type` defaults to `"text"`. Passing `type="chat"` for a text prompt, or the reverse, raises `PromptNotFound`. The default `type` does not coerce a chat prompt into text.
111
+
112
+ `latest` is a real label. It always points at the highest version of that name. Callers fetch it with `label="latest"`. They cannot assign it.
113
+
114
+ `cache_ttl_seconds` overrides the client default for that call. `fallback` is used only as described in [Fallback](#fallback).
115
+
116
+ ## Creating a version
117
+
118
+ `create_prompt` appends a version. It does not edit an existing version.
119
+
120
+ - The first version of a name is `1`. The next is `max(version) + 1`.
121
+ - `type` must match every existing version of that name. A change raises `InvalidPromptRequest`.
122
+ - `labels` moves each listed label onto the new version and off any older version. Omitted labels stay where they are.
123
+ - `latest` always moves to the new version. An entry of `"latest"` in `labels` raises `InvalidPromptRequest`.
124
+ - `config`, `tags`, and `commit_message` belong to this version.
125
+
126
+ ```python
127
+ client.create_prompt(
128
+ name="movie-critic",
129
+ type="text",
130
+ prompt="As a {{criticLevel}} movie critic, do you like {{movie}}?",
131
+ labels=["production"],
132
+ )
133
+ ```
134
+
135
+ The same call with `type="chat"` takes a message list:
136
+
137
+ ```python
138
+ client.create_prompt(
139
+ name="movie-critic-chat",
140
+ type="chat",
141
+ prompt=[
142
+ {"role": "system", "content": "You are a {{criticLevel}} movie critic."},
143
+ {"type": "placeholder", "name": "chat_history"},
144
+ {"role": "user", "content": "What should I watch next?"},
145
+ ],
146
+ labels=["production"],
147
+ )
148
+ ```
149
+
150
+ A chat item with `role` and `content` is stored as `{"type": "message", "role": ..., "content": ...}`. `type` may be omitted on input. A placeholder must be `{"type": "placeholder", "name": "..."}` and must not include `role` or `content`.
151
+
152
+ ## Moving labels
153
+
154
+ `update_prompt(name, version, new_labels)` replaces the labels on that version. Prompt text, `config`, `tags`, and `commit_message` stay as stored.
155
+
156
+ - Each name in `new_labels` is removed from every other version of this prompt, then pointed at `version`.
157
+ - Labels previously on `version` and absent from `new_labels` are removed.
158
+ - `"latest"` in `new_labels` raises `InvalidPromptRequest`. `latest` stays on the highest version.
159
+ - A version that does not exist raises `PromptNotFound`.
160
+
161
+ Rollback is this call. Repoint `production` at the earlier version:
162
+
163
+ ```python
164
+ client.update_prompt(name="movie-critic", version=2, new_labels=["production"])
165
+ ```
166
+
167
+ ## Compile
168
+
169
+ `compile` substitutes `{{variable}}` the way the Langfuse Python SDK's `TemplateParser` does.
170
+
171
+ - The name is the text between `{{` and `}}`, with surrounding whitespace removed. `{{ persona }}` and `{{persona}}` are the same variable.
172
+ - A provided value is inserted with `str(value)`. `None` is inserted as an empty string.
173
+ - A variable with no matching keyword argument is left unchanged, including its original braces.
174
+ - `compile` does not require every variable to be present, and it does not reject extra keyword arguments. There is no `throw_on_incomplete_variables` switch.
175
+
176
+ ```python
177
+ prompt.compile(criticLevel="expert", movie="Dune 2")
178
+ # "As an expert movie critic, do you like Dune 2?"
179
+ ```
180
+
181
+ Text `compile(**kwargs)` returns a `str`.
182
+
183
+ Chat `compile(**kwargs)` returns a list of messages:
184
+
185
+ - A message gets `{{variable}}` substitution in `content`. The result is `{"role": ..., "content": ...}` with no `type` key.
186
+ - A placeholder whose name is a keyword argument must be a list of messages. Each dict in that list is copied, and its `content` is compiled when it is a string. A non-dict item, or a value that is not a list, is appended as `{"role": "NOT_GIVEN", "content": str(value)}`.
187
+ - A placeholder with no keyword argument stays `{"type": "placeholder", "name": ...}` in the result.
188
+
189
+ Prompt references are stored and returned as literal text. `promptfuse` does not expand `@@@langfusePrompt:name=...|version=1@@@` or `@@@langfusePrompt:name=...|label=production@@@`.
190
+
191
+ `get_langchain_prompt` is not part of this package.
192
+
193
+ ## Cache
194
+
195
+ The memory cache matches the [Langfuse client cache](https://langfuse.com/docs/prompt-management/features/caching).
196
+
197
+ - The key is `(name, "label", label)` or `(name, "version", version)`. A label and a version pin do not share an entry.
198
+ - Default TTL is 60 seconds.
199
+ - A fresh entry is returned without reading the store.
200
+ - After the TTL, the stale client is returned immediately and the store is read in the background. A failed refresh keeps the stale client.
201
+ - `cache_ttl_seconds=0` skips the memory cache and reads the store on every call.
202
+
203
+ The cache stores the resolved client, so a label move is visible on the next read after the TTL, not at the moment the label changes. Use `cache_ttl_seconds=0` when a call must see the store immediately. `label="latest"` with a TTL of 0 is the usual development setting.
204
+
205
+ ## Fallback
206
+
207
+ `fallback` matches [guaranteed availability](https://langfuse.com/docs/prompt-management/features/guaranteed-availability).
208
+
209
+ `get_prompt` raises if the memory cache has neither a fresh nor a stale client and every store read fails, and `fallback` was not passed.
210
+
211
+ `fallback` is used only in that case. A successful store read ignores `fallback`. A stale cache hit ignores `fallback` and does not build a fallback client.
212
+
213
+ A string fallback returns a text client. A list fallback returns a chat client. The client uses the requested `name`, `version=0`, `labels=[]`, `config={}`, `tags=[]`, `commit_message=None`, and `is_fallback=True`. `compile` still runs on the fallback body.
214
+
215
+ ```python
216
+ prompt = client.get_prompt("movie-critic", fallback="Do you like {{movie}}?")
217
+ prompt.is_fallback # True only when the stores and the cache could not answer
218
+ ```
219
+
220
+ ## Stores
221
+
222
+ YAML and SQLite store the same prompt record. The memory cache sits in front of both.
223
+
224
+ | Constructor | Source of truth | Writes |
225
+ |---|---|---|
226
+ | `yaml_dir` only | YAML | YAML |
227
+ | `sqlite_path` only | SQLite | SQLite |
228
+ | both | SQLite, when it can be opened | SQLite |
229
+
230
+ A healthy SQLite database that does not contain the prompt raises `PromptNotFound`. It does not continue into YAML. YAML is consulted when SQLite cannot be opened or a query fails, and when SQLite was not configured.
231
+
232
+ ### YAML
233
+
234
+ ```text
235
+ prompts/
236
+ ├── index.yaml
237
+ └── deep-agent/
238
+ └── system/
239
+ ├── v1.yaml
240
+ └── v2.yaml
241
+ ```
242
+
243
+ The directory under `yaml_dir` is the prompt name. `deep-agent/system/v2.yaml` is version 2 of `deep-agent/system`. The file stem must be `v` plus the integer version. Names must not contain `..` or escape `yaml_dir`.
244
+
245
+ Version files are immutable. A label change edits `index.yaml` only.
246
+
247
+ ```yaml
248
+ # prompts/deep-agent/system/v1.yaml
249
+ name: deep-agent/system
250
+ version: 1
251
+ type: text
252
+ prompt: |
253
+ You are a {{persona}} research assistant.
254
+ config:
255
+ model: gpt-4.1
256
+ temperature: 0.2
257
+ tags: ["deep-agent", "system"]
258
+ commit_message: initial system prompt
259
+ created_at: "2026-09-28T00:00:00Z"
260
+ updated_at: "2026-09-28T00:00:00Z"
261
+ ```
262
+
263
+ `name` and `version` in the file must match the path. `type` is `text` or `chat`. For `chat`, `prompt` is the message list. `config`, `tags`, `commit_message`, `created_at`, and `updated_at` may be omitted.
264
+
265
+ ```yaml
266
+ # prompts/index.yaml
267
+ prompts:
268
+ deep-agent/system:
269
+ development: 1
270
+ staging: 1
271
+ production: 1
272
+ ```
273
+
274
+ Index values are integers. A label points at one version. `latest` must not appear in the index; the highest `vN.yaml` for that name is `latest`. A version file does not list its labels.
275
+
276
+ `create_prompt` writes `vN.yaml` and updates the index. `update_prompt` updates the index only. Neither rewrites an existing `vN.yaml`.
277
+
278
+ ### SQLite
279
+
280
+ Two tables:
281
+
282
+ ```sql
283
+ CREATE TABLE prompt_version (
284
+ name TEXT NOT NULL,
285
+ version INTEGER NOT NULL,
286
+ type TEXT NOT NULL,
287
+ prompt TEXT NOT NULL, -- JSON: a string or a message list
288
+ config TEXT NOT NULL, -- JSON object
289
+ tags TEXT NOT NULL, -- JSON array of strings
290
+ commit_message TEXT,
291
+ created_at TEXT NOT NULL,
292
+ updated_at TEXT NOT NULL,
293
+ PRIMARY KEY (name, version)
294
+ );
295
+
296
+ CREATE TABLE prompt_label (
297
+ name TEXT NOT NULL,
298
+ label TEXT NOT NULL,
299
+ version INTEGER NOT NULL,
300
+ PRIMARY KEY (name, label),
301
+ FOREIGN KEY (name, version) REFERENCES prompt_version (name, version)
302
+ );
303
+ ```
304
+
305
+ `prompt_version` rows are inserted, never updated. `prompt_label` is the only mutable data. `latest` is a row in `prompt_label`, moved when a version is inserted.
306
+
307
+ ### Using both
308
+
309
+ `import_yaml()` copies the YAML tree into SQLite.
310
+
311
+ - A YAML version that is not in SQLite is inserted.
312
+ - A YAML version that is already in SQLite with the same body, `type`, `config`, `tags`, and `commit_message` is left as stored.
313
+ - A YAML version whose stored body differs raises `InvalidPromptRequest`. Versions stay immutable.
314
+ - Labels in `index.yaml` replace the SQLite label rows for names present in the index. SQLite versions that exist only in SQLite are kept.
315
+ - `latest` is recomputed as the highest version after the import.
316
+
317
+ `Promptfuse(..., import_yaml=True)` runs `import_yaml()` once at construction. The default is `False`. Import overwrites SQLite label pointers for the imported names, including labels that were moved at runtime.
318
+
319
+ ### Disk snapshot
320
+
321
+ `snapshot_path` is a JSON file of prompt clients from successful store reads. It is a cache. It is not edited by hand and it is not a third source of truth.
322
+
323
+ On a store outage, `get_prompt` uses the snapshot entry for that cache key when the memory cache is empty. A missing snapshot entry then uses YAML if SQLite failed and `yaml_dir` is set. A snapshot hit sets `is_fallback` to `False`.
324
+
325
+ The snapshot is updated after a successful SQLite or YAML read. A fallback client is not written to the snapshot.
326
+
327
+ ## Errors
328
+
329
+ | Exception | When |
330
+ |---|---|
331
+ | `PromptNotFound` | Unknown name, label, or version. `type` does not match the stored prompt. SQLite is healthy and the row is absent. |
332
+ | `InvalidPromptRequest` | `label` and `version` both set. `type` changes on `create_prompt`. `"latest"` is assigned by the caller or written in `index.yaml`. A version file disagrees with its path. `import_yaml()` finds a body that differs from SQLite. The YAML path escapes `yaml_dir`. |
333
+ | `PromptStoreError` | SQLite cannot be opened or a query fails, and snapshot, YAML, and `fallback` do not produce a client. |
334
+
335
+ `PromptNotFound` and `InvalidPromptRequest` are not retried through the seed files when SQLite is healthy.
336
+
337
+ ## Out of scope
338
+
339
+ - Langfuse Cloud, API keys, tracing, and linking a prompt to a generation
340
+ - Expanding `@@@langfusePrompt:...@@@` references
341
+ - `get_langchain_prompt`
342
+ - Protected labels
343
+ - Postgres and other database engines
344
+ - A network server
345
+
346
+ ## References
347
+
348
+ - [Prompt data model](https://langfuse.com/docs/prompt-management/data-model)
349
+ - [Version control and label resolution](https://langfuse.com/docs/prompt-management/features/prompt-version-control)
350
+ - [Variables](https://langfuse.com/docs/prompt-management/features/variables)
351
+ - [Message placeholders](https://langfuse.com/docs/prompt-management/features/message-placeholders)
352
+ - [Prompt config](https://langfuse.com/docs/prompt-management/features/config)
353
+ - [Client cache](https://langfuse.com/docs/prompt-management/features/caching)
354
+ - [Guaranteed availability](https://langfuse.com/docs/prompt-management/features/guaranteed-availability)
355
+ - [Folders](https://langfuse.com/docs/prompt-management/features/folders)
356
+ - Compile rules follow `TemplateParser` in the Langfuse Python SDK (`langfuse/model.py`): brace text is stripped, missing variables stay literal, and `None` becomes an empty string.