pixie-prompts 0.0.0__py3-none-any.whl → 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.
- pixie/prompts/__init__.py +19 -0
- pixie/prompts/file_watcher.py +320 -0
- pixie/prompts/graphql.py +17 -10
- pixie/prompts/prompt.py +4 -4
- pixie/prompts/server.py +16 -48
- pixie/prompts/storage.py +206 -35
- {pixie_prompts-0.0.0.dist-info → pixie_prompts-0.1.0.dist-info}/METADATA +2 -1
- pixie_prompts-0.1.0.dist-info/RECORD +12 -0
- pixie_prompts-0.1.0.dist-info/entry_points.txt +3 -0
- pixie/tests/__init__.py +0 -0
- pixie/tests/test_prompt.py +0 -1321
- pixie/tests/test_prompt_management.py +0 -117
- pixie/tests/test_prompt_storage.py +0 -1453
- pixie_prompts-0.0.0.dist-info/RECORD +0 -15
- pixie_prompts-0.0.0.dist-info/entry_points.txt +0 -3
- {pixie_prompts-0.0.0.dist-info → pixie_prompts-0.1.0.dist-info}/WHEEL +0 -0
- {pixie_prompts-0.0.0.dist-info → pixie_prompts-0.1.0.dist-info}/licenses/LICENSE +0 -0
pixie/prompts/storage.py
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import json
|
|
2
2
|
import logging
|
|
3
3
|
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
4
5
|
from types import NoneType
|
|
5
|
-
from typing import Any, Dict, Protocol, Self, TypedDict
|
|
6
|
+
from typing import Any, Dict, NotRequired, Protocol, Self, TypedDict
|
|
6
7
|
|
|
7
8
|
from jsonsubschema import isSubschema
|
|
8
9
|
|
|
@@ -18,50 +19,155 @@ from .prompt import (
|
|
|
18
19
|
logger = logging.getLogger(__name__)
|
|
19
20
|
|
|
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
|
+
|
|
21
67
|
class PromptStorage(Protocol):
|
|
22
68
|
|
|
23
|
-
def load(self) ->
|
|
69
|
+
def load(self, *, raise_on_error: bool = True) -> list[_PromptLoadFailure]: ...
|
|
24
70
|
|
|
25
71
|
def exists(self, prompt_id: str) -> bool: ...
|
|
26
72
|
|
|
27
|
-
def save(self, prompt: BaseUntypedPrompt) ->
|
|
73
|
+
def save(self, prompt: BaseUntypedPrompt) -> bool: ...
|
|
28
74
|
|
|
29
|
-
def get(self, prompt_id: str) ->
|
|
75
|
+
def get(self, prompt_id: str) -> BaseUntypedPromptWithCreationTime: ...
|
|
30
76
|
|
|
31
77
|
|
|
32
|
-
class
|
|
33
|
-
versions: Dict[str, str]
|
|
78
|
+
class _BasePromptMetadata(TypedDict):
|
|
34
79
|
defaultVersionId: str
|
|
35
|
-
variablesSchema: Dict[str, Any]
|
|
80
|
+
variablesSchema: NotRequired[Dict[str, Any]]
|
|
36
81
|
|
|
37
82
|
|
|
38
83
|
class _FilePromptStorage(PromptStorage):
|
|
39
84
|
|
|
40
|
-
def __init__(self, directory: str) -> None:
|
|
85
|
+
def __init__(self, directory: str, *, raise_on_error: bool = True) -> None:
|
|
41
86
|
self._directory = directory
|
|
42
|
-
self._prompts: Dict[str,
|
|
43
|
-
self.
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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 = []
|
|
47
99
|
if not os.path.exists(self._directory):
|
|
48
100
|
os.makedirs(self._directory)
|
|
49
|
-
for
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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,
|
|
60
144
|
versions=versions,
|
|
61
145
|
default_version_id=default_version_id,
|
|
62
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)
|
|
63
157
|
)
|
|
64
|
-
|
|
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)
|
|
65
171
|
|
|
66
172
|
def exists(self, prompt_id: str) -> bool:
|
|
67
173
|
return prompt_id in self._prompts
|
|
@@ -76,24 +182,78 @@ class _FilePromptStorage(PromptStorage):
|
|
|
76
182
|
raise TypeError(
|
|
77
183
|
"Original schema must be a subschema of the new schema."
|
|
78
184
|
)
|
|
79
|
-
|
|
80
|
-
|
|
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 = {
|
|
81
209
|
"defaultVersionId": prompt.get_default_version_id(),
|
|
82
210
|
"variablesSchema": prompt.get_variables_schema(),
|
|
83
211
|
}
|
|
84
|
-
|
|
85
|
-
with open(
|
|
86
|
-
json.dump(
|
|
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
|
+
|
|
87
247
|
try:
|
|
88
|
-
BasePrompt.update_prompt_registry(
|
|
248
|
+
BasePrompt.update_prompt_registry(stored_prompt)
|
|
89
249
|
except KeyError:
|
|
90
250
|
# Prompt not in type prompt registry yet, meaning there's no usage in code
|
|
91
251
|
# thus this untyped prompt would just be stored but not used in code
|
|
92
252
|
pass
|
|
93
|
-
self._prompts[prompt_id] =
|
|
253
|
+
self._prompts[prompt_id] = stored_prompt
|
|
94
254
|
return original is None
|
|
95
255
|
|
|
96
|
-
def get(self, prompt_id: str) ->
|
|
256
|
+
def get(self, prompt_id: str) -> BaseUntypedPromptWithCreationTime:
|
|
97
257
|
return self._prompts[prompt_id]
|
|
98
258
|
|
|
99
259
|
|
|
@@ -105,8 +265,11 @@ def initialize_prompt_storage(directory: str) -> None:
|
|
|
105
265
|
global _storage_instance
|
|
106
266
|
if _storage_instance is not None:
|
|
107
267
|
raise RuntimeError("Prompt storage has already been initialized.")
|
|
108
|
-
|
|
109
|
-
|
|
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)
|
|
110
273
|
|
|
111
274
|
|
|
112
275
|
class StorageBackedPrompt(Prompt[TPromptVar]):
|
|
@@ -166,6 +329,14 @@ class StorageBackedPrompt(Prompt[TPromptVar]):
|
|
|
166
329
|
prompt = self._get_prompt()
|
|
167
330
|
return prompt.get_versions()
|
|
168
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
|
+
|
|
169
340
|
def get_version_count(self) -> int:
|
|
170
341
|
try:
|
|
171
342
|
prompt = self._get_prompt()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pixie-prompts
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.1.0
|
|
4
4
|
Summary: Code-first, type-safe prompt management
|
|
5
5
|
License: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -23,6 +23,7 @@ Requires-Dist: jsonsubschema (>=0.0.7,<0.0.8)
|
|
|
23
23
|
Requires-Dist: pydantic (>=2.12.5,<3.0.0)
|
|
24
24
|
Requires-Dist: strawberry-graphql (>=0.288.1) ; extra == "server"
|
|
25
25
|
Requires-Dist: uvicorn (>=0.40.0) ; extra == "server"
|
|
26
|
+
Requires-Dist: watchdog (>=6.0.0) ; extra == "server"
|
|
26
27
|
Project-URL: Changelog, https://github.com/yiouli/pixie-prompts/commits/main/
|
|
27
28
|
Project-URL: Documentation, https://yiouli.github.io/pixie-prompts/
|
|
28
29
|
Project-URL: Homepage, https://gopixie.ai
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
pixie/prompts/__init__.py,sha256=ZueU9cJ7aiVHBQYH4g3MXAFtjQwTfvvpy3d8ZTtBQ2c,396
|
|
2
|
+
pixie/prompts/file_watcher.py,sha256=ZVMWC-SeFVMP5XlDcmY_l6vVcFFhrXKv2scVHIAwSZ0,10333
|
|
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.0.dist-info/METADATA,sha256=ah1ovdTpIcQaIe2X168K9SjRy-nH0cA1JnGjM-pez2g,1478
|
|
9
|
+
pixie_prompts-0.1.0.dist-info/WHEEL,sha256=3ny-bZhpXrU6vSQ1UPG34FoxZBp3lVcvK0LkgUz6VLk,88
|
|
10
|
+
pixie_prompts-0.1.0.dist-info/entry_points.txt,sha256=SWOSFuUXDxkJMmf28u7E0Go_LcEpofz7NAlV70Cp8Es,48
|
|
11
|
+
pixie_prompts-0.1.0.dist-info/licenses/LICENSE,sha256=nZoehBpdSXe6iTF2ZWzM-fgXdXECUZ0J8LrW_1tBwyk,1064
|
|
12
|
+
pixie_prompts-0.1.0.dist-info/RECORD,,
|
pixie/tests/__init__.py
DELETED
|
File without changes
|