pixie-prompts 0.1.1__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.
@@ -0,0 +1,399 @@
1
+ import json
2
+ import logging
3
+ import os
4
+ from dataclasses import dataclass
5
+ from types import NoneType
6
+ from typing import Any, Dict, NotRequired, Protocol, Self, TypedDict
7
+
8
+ from jsonsubschema import isSubschema
9
+
10
+ from .prompt import (
11
+ BasePrompt,
12
+ BaseUntypedPrompt,
13
+ Prompt,
14
+ TPromptVar,
15
+ variables_definition_to_schema,
16
+ )
17
+
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ VERSION_FILE_EXTENSION = ".jinja"
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class _PromptLoadFailure:
27
+ prompt_id: str | None
28
+ path: str
29
+ error: Exception
30
+
31
+
32
+ class PromptLoadError(Exception):
33
+
34
+ def __init__(self, failures: list[_PromptLoadFailure]):
35
+ self.failures = failures
36
+ message_lines = [
37
+ f"- {failure.prompt_id or '<unknown>'} ({failure.path}): {failure.error}"
38
+ for failure in failures
39
+ ]
40
+ message = "Failed to load prompts:\n" + "\n".join(message_lines)
41
+ super().__init__(message)
42
+
43
+
44
+ class BaseUntypedPromptWithCreationTime(BaseUntypedPrompt):
45
+
46
+ def __init__(
47
+ self,
48
+ *,
49
+ id: str,
50
+ versions: dict[str, str],
51
+ default_version_id: str,
52
+ variables_schema: dict[str, Any] | None = None,
53
+ version_creation_times: dict[str, float],
54
+ ) -> None:
55
+ super().__init__(
56
+ id=id,
57
+ versions=versions,
58
+ default_version_id=default_version_id,
59
+ variables_schema=variables_schema,
60
+ )
61
+ self._version_creation_times = version_creation_times
62
+
63
+ def get_version_creation_time(self, version_id: str) -> float:
64
+ return self._version_creation_times[version_id]
65
+
66
+
67
+ class PromptStorage(Protocol):
68
+
69
+ def load(self, *, raise_on_error: bool = True) -> list[_PromptLoadFailure]: ...
70
+
71
+ def exists(self, prompt_id: str) -> bool: ...
72
+
73
+ def save(self, prompt: BaseUntypedPrompt) -> bool: ...
74
+
75
+ def get(self, prompt_id: str) -> BaseUntypedPromptWithCreationTime: ...
76
+
77
+
78
+ class _BasePromptMetadata(TypedDict):
79
+ defaultVersionId: str
80
+ variablesSchema: NotRequired[Dict[str, Any]]
81
+
82
+
83
+ class _FilePromptStorage(PromptStorage):
84
+
85
+ def __init__(self, directory: str, *, raise_on_error: bool = True) -> None:
86
+ self._directory = directory
87
+ self._prompts: Dict[str, BaseUntypedPromptWithCreationTime] = {}
88
+ self._load_failures: list[_PromptLoadFailure] = []
89
+ self.load(raise_on_error=raise_on_error)
90
+
91
+ def load(self, *, raise_on_error: bool = True) -> list[_PromptLoadFailure]:
92
+ """Load prompts from storage with error isolation.
93
+
94
+ Continues loading valid prompts even if some fail and aggregates failures.
95
+ """
96
+ logger.info("Loading prompts from directory %s", self._directory)
97
+ self._prompts.clear()
98
+ self._load_failures = []
99
+ if not os.path.exists(self._directory):
100
+ os.makedirs(self._directory)
101
+ for entry in os.listdir(self._directory):
102
+ prompt_path = os.path.join(self._directory, entry)
103
+ if not os.path.isdir(prompt_path):
104
+ logger.debug("Skipping non-directory entry at %s", prompt_path)
105
+ continue
106
+ try:
107
+ metadata_path = os.path.join(prompt_path, "metadata.json")
108
+ metadata: _BasePromptMetadata | None = None
109
+ if os.path.isfile(metadata_path):
110
+ with open(metadata_path, "r") as f:
111
+ metadata = json.load(f)
112
+
113
+ versions: dict[str, str] = {}
114
+ version_creation_times: dict[str, float] = {}
115
+ for filename in os.listdir(prompt_path):
116
+ if not filename.endswith(VERSION_FILE_EXTENSION):
117
+ continue
118
+ version_id, _ = os.path.splitext(filename)
119
+ version_path = os.path.join(prompt_path, filename)
120
+ with open(version_path, "r") as vf:
121
+ versions[version_id] = vf.read()
122
+ version_creation_times[version_id] = os.path.getctime(version_path)
123
+
124
+ if not versions:
125
+ raise KeyError("No versions provided for the prompt.")
126
+
127
+ if metadata is not None:
128
+ default_version_id = metadata["defaultVersionId"]
129
+ variables_schema = metadata.get("variablesSchema", None)
130
+ else:
131
+ default_version_id, _ = max(
132
+ version_creation_times.items(),
133
+ key=lambda item: (item[1], item[0]),
134
+ )
135
+ variables_schema = None
136
+
137
+ if default_version_id not in versions:
138
+ raise KeyError(
139
+ f"Default version '{default_version_id}' not found for prompt '{entry}'."
140
+ )
141
+
142
+ prompt = BaseUntypedPromptWithCreationTime(
143
+ id=entry,
144
+ versions=versions,
145
+ default_version_id=default_version_id,
146
+ variables_schema=variables_schema,
147
+ version_creation_times=version_creation_times,
148
+ )
149
+ self._prompts[entry] = prompt
150
+ logger.debug(
151
+ "Loaded prompt '%s' with %d version(s)", entry, len(versions)
152
+ )
153
+ except Exception as exc: # noqa: BLE001
154
+ logger.exception("Failed to load prompt '%s' at %s", entry, prompt_path)
155
+ self._load_failures.append(
156
+ _PromptLoadFailure(prompt_id=entry, path=prompt_path, error=exc)
157
+ )
158
+ if self._load_failures:
159
+ logger.warning(
160
+ "Completed loading prompts with %d failure(s)", len(self._load_failures)
161
+ )
162
+ if raise_on_error:
163
+ raise PromptLoadError(self._load_failures)
164
+ else:
165
+ logger.info("Loaded %d prompt(s) successfully", len(self._prompts))
166
+ return list(self._load_failures)
167
+
168
+ @property
169
+ def load_failures(self) -> list[_PromptLoadFailure]:
170
+ return list(self._load_failures)
171
+
172
+ def exists(self, prompt_id: str) -> bool:
173
+ return prompt_id in self._prompts
174
+
175
+ def save(self, prompt: BaseUntypedPrompt) -> bool:
176
+ prompt_id = prompt.id
177
+ original = self._prompts.get(prompt_id)
178
+ new_schema = prompt.get_variables_schema()
179
+ if original:
180
+ original_schema = original.get_variables_schema()
181
+ if not isSubschema(original_schema, new_schema):
182
+ raise TypeError(
183
+ "Original schema must be a subschema of the new schema."
184
+ )
185
+ prompt_dir = os.path.join(self._directory, prompt_id)
186
+ os.makedirs(prompt_dir, exist_ok=True)
187
+
188
+ versions = prompt.get_versions()
189
+ version_ids = set(versions.keys())
190
+ existing_versions = {
191
+ os.path.splitext(filename)[0]
192
+ for filename in os.listdir(prompt_dir)
193
+ if filename.endswith(VERSION_FILE_EXTENSION)
194
+ }
195
+
196
+ # Validate that we are not overwriting existing content with new data
197
+ for version_id in version_ids & existing_versions:
198
+ version_path = os.path.join(
199
+ prompt_dir, f"{version_id}{VERSION_FILE_EXTENSION}"
200
+ )
201
+ with open(version_path, "r") as vf:
202
+ existing_content = vf.read()
203
+ if existing_content != versions[version_id]:
204
+ raise ValueError(
205
+ f"Version '{version_id}' already exists with different content."
206
+ )
207
+
208
+ metadata: _BasePromptMetadata = {
209
+ "defaultVersionId": prompt.get_default_version_id(),
210
+ "variablesSchema": prompt.get_variables_schema(),
211
+ }
212
+ metadata_path = os.path.join(prompt_dir, "metadata.json")
213
+ with open(metadata_path, "w") as f:
214
+ json.dump(metadata, f, indent=2)
215
+
216
+ # Write only new versions; existing identical versions are left untouched
217
+ for version_id, content in versions.items():
218
+ version_path = os.path.join(
219
+ prompt_dir, f"{version_id}{VERSION_FILE_EXTENSION}"
220
+ )
221
+ if os.path.exists(version_path):
222
+ continue
223
+ with open(version_path, "w") as vf:
224
+ vf.write(content)
225
+
226
+ for stale_version in existing_versions - set(versions.keys()):
227
+ stale_path = os.path.join(
228
+ prompt_dir, f"{stale_version}{VERSION_FILE_EXTENSION}"
229
+ )
230
+ os.remove(stale_path)
231
+
232
+ version_creation_times = {
233
+ version_id: os.path.getctime(
234
+ os.path.join(prompt_dir, f"{version_id}{VERSION_FILE_EXTENSION}")
235
+ )
236
+ for version_id in versions.keys()
237
+ }
238
+
239
+ stored_prompt = BaseUntypedPromptWithCreationTime(
240
+ id=prompt_id,
241
+ versions=versions,
242
+ default_version_id=prompt.get_default_version_id(),
243
+ variables_schema=prompt.get_variables_schema(),
244
+ version_creation_times=version_creation_times,
245
+ )
246
+
247
+ try:
248
+ BasePrompt.update_prompt_registry(stored_prompt)
249
+ except KeyError:
250
+ # Prompt not in type prompt registry yet, meaning there's no usage in code
251
+ # thus this untyped prompt would just be stored but not used in code
252
+ pass
253
+ self._prompts[prompt_id] = stored_prompt
254
+ return original is None
255
+
256
+ def get(self, prompt_id: str) -> BaseUntypedPromptWithCreationTime:
257
+ return self._prompts[prompt_id]
258
+
259
+
260
+ _storage_instance: PromptStorage | None = None
261
+
262
+
263
+ # TODO allow other storage types later
264
+ def initialize_prompt_storage(directory: str) -> None:
265
+ global _storage_instance
266
+ if _storage_instance is not None:
267
+ raise RuntimeError("Prompt storage has already been initialized.")
268
+ storage = _FilePromptStorage(directory, raise_on_error=False)
269
+ _storage_instance = storage
270
+ logger.info("Initialized prompt storage at directory: %s", directory)
271
+ if storage.load_failures:
272
+ raise PromptLoadError(storage.load_failures)
273
+
274
+
275
+ class StorageBackedPrompt(Prompt[TPromptVar]):
276
+
277
+ def __init__(
278
+ self,
279
+ id: str,
280
+ *,
281
+ variables_definition: type[TPromptVar] = NoneType,
282
+ ) -> None:
283
+ self._id = id
284
+ self._variables_definition = variables_definition
285
+ self._prompt: BasePrompt[TPromptVar] | None = None
286
+
287
+ @property
288
+ def id(self) -> str:
289
+ return self._id
290
+
291
+ @property
292
+ def variables_definition(self) -> type[TPromptVar]:
293
+ return self._variables_definition
294
+
295
+ def get_variables_schema(self) -> dict[str, Any]:
296
+ return variables_definition_to_schema(self._variables_definition)
297
+
298
+ def _get_prompt(self) -> BasePrompt[TPromptVar]:
299
+ if _storage_instance is None:
300
+ raise RuntimeError("Prompt storage has not been initialized.")
301
+ if self._prompt is None:
302
+ untyped_prompt = _storage_instance.get(self.id)
303
+ self._prompt = BasePrompt.from_untyped(
304
+ untyped_prompt,
305
+ variables_definition=self.variables_definition,
306
+ )
307
+ schema_from_storage = untyped_prompt.get_variables_schema()
308
+ schema_from_definition = self.get_variables_schema()
309
+ if not isSubschema(schema_from_definition, schema_from_storage):
310
+ raise TypeError(
311
+ "Schema from definition is not a subschema of the schema from storage."
312
+ )
313
+ return self._prompt
314
+
315
+ def actualize(self) -> Self:
316
+ self._get_prompt()
317
+ return self
318
+
319
+ def exists_in_storage(self) -> bool:
320
+ if _storage_instance is None:
321
+ raise RuntimeError("Prompt storage has not been initialized.")
322
+ try:
323
+ self.actualize()
324
+ return True
325
+ except KeyError:
326
+ return False
327
+
328
+ def get_versions(self) -> dict[str, str]:
329
+ prompt = self._get_prompt()
330
+ return prompt.get_versions()
331
+
332
+ def get_version_creation_time(self, version_id: str) -> float:
333
+ if _storage_instance is None:
334
+ raise RuntimeError("Prompt storage has not been initialized.")
335
+ prompt_with_ctime = _storage_instance.get(self.id)
336
+ if not prompt_with_ctime:
337
+ raise KeyError(f"Prompt with id '{self.id}' not found in storage.")
338
+ return prompt_with_ctime.get_version_creation_time(version_id)
339
+
340
+ def get_version_count(self) -> int:
341
+ try:
342
+ prompt = self._get_prompt()
343
+ versions_dict = prompt.get_versions()
344
+ return len(versions_dict)
345
+ except KeyError:
346
+ return 0
347
+
348
+ def get_default_version_id(self) -> str:
349
+ prompt = self._get_prompt()
350
+ return prompt.get_default_version_id()
351
+
352
+ def compile(
353
+ self,
354
+ variables: TPromptVar = None,
355
+ *,
356
+ version_id: str | None = None,
357
+ ) -> str:
358
+ prompt = self._get_prompt()
359
+ return prompt.compile(variables=variables, version_id=version_id)
360
+
361
+ def append_version(
362
+ self,
363
+ version_id: str,
364
+ content: str,
365
+ set_as_default: bool = False,
366
+ ) -> BasePrompt[TPromptVar]:
367
+ if _storage_instance is None:
368
+ raise RuntimeError("Prompt storage has not been initialized.")
369
+ if self.exists_in_storage():
370
+ prompt = self._get_prompt()
371
+ prompt.append_version(
372
+ version_id=version_id,
373
+ content=content,
374
+ set_as_default=set_as_default,
375
+ )
376
+ _storage_instance.save(prompt)
377
+ return prompt
378
+ else:
379
+ # it should be safe to assume there's no actualized prompt for this id
380
+ # thus it should be same to create a new instance of BasePrompt
381
+ new_prompt = BasePrompt(
382
+ id=self.id,
383
+ versions={version_id: content},
384
+ variables_definition=self.variables_definition,
385
+ default_version_id=version_id,
386
+ )
387
+ _storage_instance.save(new_prompt)
388
+ return new_prompt
389
+
390
+ def update_default_version_id(
391
+ self,
392
+ version_id: str,
393
+ ) -> BasePrompt[TPromptVar]:
394
+ if _storage_instance is None:
395
+ raise RuntimeError("Prompt storage has not been initialized.")
396
+ prompt = self._get_prompt()
397
+ prompt.update_default_version_id(version_id)
398
+ _storage_instance.save(prompt)
399
+ return prompt
@@ -0,0 +1,36 @@
1
+ Metadata-Version: 2.4
2
+ Name: pixie-prompts
3
+ Version: 0.1.1
4
+ Summary: Code-first, type-safe prompt management
5
+ License: MIT
6
+ License-File: LICENSE
7
+ Author: Yiou Li
8
+ Author-email: yol@gopixie.ai
9
+ Requires-Python: >=3.10,<4.0
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Provides-Extra: server
18
+ Requires-Dist: colorlog (>=6.10.1) ; extra == "server"
19
+ Requires-Dist: dotenv (>=0.9.9) ; extra == "server"
20
+ Requires-Dist: fastapi (>=0.128.0) ; extra == "server"
21
+ Requires-Dist: jinja2 (>=3.1.6,<4.0.0)
22
+ Requires-Dist: jsonsubschema (>=0.0.7,<0.0.8)
23
+ Requires-Dist: pydantic (>=2.12.5,<3.0.0)
24
+ Requires-Dist: strawberry-graphql (>=0.288.1) ; extra == "server"
25
+ Requires-Dist: uvicorn (>=0.40.0) ; extra == "server"
26
+ Requires-Dist: watchdog (>=6.0.0) ; extra == "server"
27
+ Project-URL: Changelog, https://github.com/yiouli/pixie-prompts/commits/main/
28
+ Project-URL: Documentation, https://yiouli.github.io/pixie-prompts/
29
+ Project-URL: Homepage, https://gopixie.ai
30
+ Project-URL: Issues, https://github.com/yiouli/pixie-prompts/issues
31
+ Project-URL: Repository, https://github.com/yiouli/pixie-prompts
32
+ Description-Content-Type: text/markdown
33
+
34
+ # pixie-prompts
35
+ Code-first, type-checked prompt management.
36
+
@@ -0,0 +1,12 @@
1
+ pixie/prompts/__init__.py,sha256=ZueU9cJ7aiVHBQYH4g3MXAFtjQwTfvvpy3d8ZTtBQ2c,396
2
+ pixie/prompts/file_watcher.py,sha256=F-p84r820en6qb3vNjJleWyUp4AR2UC7ZCGvWNJq0sM,10530
3
+ pixie/prompts/graphql.py,sha256=5pouvFlKdZxJYxxd2cRvxHBCmvpc5JEAReflPYfzqko,6548
4
+ pixie/prompts/prompt.py,sha256=7nBn1PCXNOVL6OflHak7MG9rlZ4Ooa14eTamYk2mE3I,11472
5
+ pixie/prompts/prompt_management.py,sha256=gq5Eklqy2_Sq8jATVae4eANNmyFE8s8a9cedxWs2P_Y,2816
6
+ pixie/prompts/server.py,sha256=_BsPfE_VJTvqNOaJPf14LXT-ubYRWbNi1NPFvAgXi5s,6433
7
+ pixie/prompts/storage.py,sha256=syVHO5IWZXtN20ozPoBq_Anbu0NAH056EWbvlNNWLGU,14448
8
+ pixie_prompts-0.1.1.dist-info/METADATA,sha256=2rG-8DTU6Y9aXGYM3PwrK74wwG1Y9rCaBT0HsCSN9MA,1478
9
+ pixie_prompts-0.1.1.dist-info/WHEEL,sha256=3ny-bZhpXrU6vSQ1UPG34FoxZBp3lVcvK0LkgUz6VLk,88
10
+ pixie_prompts-0.1.1.dist-info/entry_points.txt,sha256=SWOSFuUXDxkJMmf28u7E0Go_LcEpofz7NAlV70Cp8Es,48
11
+ pixie_prompts-0.1.1.dist-info/licenses/LICENSE,sha256=nZoehBpdSXe6iTF2ZWzM-fgXdXECUZ0J8LrW_1tBwyk,1064
12
+ pixie_prompts-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.3.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ pp=pixie.prompts.server:main
3
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yiou Li
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.