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/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) -> None: ...
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) -> None: ...
73
+ def save(self, prompt: BaseUntypedPrompt) -> bool: ...
28
74
 
29
- def get(self, prompt_id: str) -> BaseUntypedPrompt: ...
75
+ def get(self, prompt_id: str) -> BaseUntypedPromptWithCreationTime: ...
30
76
 
31
77
 
32
- class _BasePromptJson(TypedDict):
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, BaseUntypedPrompt] = {}
43
- self.load()
44
-
45
- def load(self) -> None:
46
- """prompts that are in storage"""
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 filename in os.listdir(self._directory):
50
- if filename.endswith(".json"):
51
- prompt_id = filename[:-5] # remove .json
52
- filepath = os.path.join(self._directory, filename)
53
- with open(filepath, "r") as f:
54
- data: _BasePromptJson = json.load(f)
55
- versions = data["versions"]
56
- default_version_id = data["defaultVersionId"]
57
- variables_schema = data["variablesSchema"]
58
- prompt = BaseUntypedPrompt(
59
- id=prompt_id,
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
- self._prompts[prompt_id] = prompt
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
- data: _BasePromptJson = {
80
- "versions": prompt.get_versions(),
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
- filepath = os.path.join(self._directory, f"{prompt_id}.json")
85
- with open(filepath, "w") as f:
86
- json.dump(data, f, indent=2)
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(prompt)
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] = prompt
253
+ self._prompts[prompt_id] = stored_prompt
94
254
  return original is None
95
255
 
96
- def get(self, prompt_id: str) -> BaseUntypedPrompt:
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
- _storage_instance = _FilePromptStorage(directory)
109
- logger.info(f"Initialized prompt storage at directory: {directory}")
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.0.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,,
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ pp=pixie.prompts.server:main
3
+
pixie/tests/__init__.py DELETED
File without changes