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,19 @@
1
+ from . import graphql
2
+ from .prompt import Prompt, Variables
3
+ from .prompt_management import (
4
+ create_prompt,
5
+ get_prompt,
6
+ list_prompts,
7
+ )
8
+ from .storage import initialize_prompt_storage, StorageBackedPrompt
9
+
10
+ __all__ = [
11
+ "Prompt",
12
+ "Variables",
13
+ "StorageBackedPrompt",
14
+ "create_prompt",
15
+ "get_prompt",
16
+ "graphql",
17
+ "initialize_prompt_storage",
18
+ "list_prompts",
19
+ ]
@@ -0,0 +1,327 @@
1
+ from contextlib import asynccontextmanager
2
+ import os
3
+ import re
4
+ import sys
5
+ import importlib.util
6
+ from pathlib import Path
7
+ from fastapi import FastAPI
8
+ from watchdog.events import FileSystemEventHandler, FileSystemEvent
9
+ from watchdog.observers import Observer
10
+ import asyncio
11
+ import logging
12
+
13
+ from pixie.prompts.storage import initialize_prompt_storage
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ _storage_observer: Observer | None = None # type: ignore
18
+ _storage_reload_task: asyncio.Task | None = None
19
+
20
+
21
+ def discover_and_load_modules():
22
+ """Discover and load all Python files that use pixie.prompts.create_prompt, or pixie.create_prompt.
23
+
24
+ This function recursively searches the current working directory for Python files
25
+ """
26
+ cwd = Path.cwd()
27
+ # Recursively find all Python files
28
+ python_files = list(cwd.rglob("*.py"))
29
+
30
+ if not python_files:
31
+ return
32
+
33
+ # Add current directory to Python path if not already there
34
+ if str(cwd) not in sys.path:
35
+ sys.path.insert(0, str(cwd))
36
+
37
+ loaded_count = 0
38
+ for py_file in python_files:
39
+ # Skip __init__.py, private files, and anything in site-packages/venv
40
+ if py_file.name.startswith("_") or any(
41
+ part in py_file.parts
42
+ for part in ["site-packages", ".venv", "venv", "__pycache__"]
43
+ ):
44
+ continue
45
+
46
+ # Quick check if file imports register_application
47
+ content = py_file.read_text()
48
+ if not re.search(r"(?:^|\s)pixie(?=[\s.])", content):
49
+ continue
50
+
51
+ # Check for ignore directive
52
+ if any(
53
+ re.fullmatch(r"#\s*pixie\s*:\s*ignore", line.strip())
54
+ for line in content.splitlines()
55
+ ):
56
+ continue
57
+
58
+ # Load the module with a unique name based on path
59
+ relative_path = py_file.relative_to(cwd)
60
+ module_name = str(relative_path.with_suffix("")).replace("/", ".")
61
+ spec = importlib.util.spec_from_file_location(module_name, py_file)
62
+ if spec and spec.loader:
63
+ try:
64
+ module = importlib.util.module_from_spec(spec)
65
+ sys.modules[module_name] = module
66
+ spec.loader.exec_module(module)
67
+ loaded_count += 1
68
+ except Exception as e:
69
+ logger.error("Failed to load module %s: %s", module_name, e)
70
+
71
+
72
+ def _reload_prompt_storage() -> None:
73
+ """Reload prompts from disk and refresh any actualized prompts."""
74
+ from pixie.prompts import storage as storage_module
75
+
76
+ storage_instance = storage_module._storage_instance
77
+ if storage_instance is None:
78
+ logger.warning("Prompt storage is not initialized; skip reload.")
79
+ return
80
+
81
+ storage_instance.load()
82
+
83
+
84
+ async def _debounced_reload(delay_seconds: float) -> None:
85
+ """Reload storage after a debounce window to collapse bursty events."""
86
+ try:
87
+ await asyncio.sleep(delay_seconds)
88
+ logger.info("Detected prompt storage change; reloading prompts...")
89
+ _reload_prompt_storage()
90
+ except asyncio.CancelledError:
91
+ raise
92
+ except Exception:
93
+ logger.exception("Failed to reload prompts after storage change.")
94
+
95
+
96
+ class _StorageChangeHandler(FileSystemEventHandler):
97
+ """Watchdog handler that schedules prompt reloads on any change."""
98
+
99
+ # Patterns to ignore
100
+ IGNORE_PATTERNS = {
101
+ ".vscode",
102
+ ".git",
103
+ "__pycache__",
104
+ ".pytest_cache",
105
+ ".mypy_cache",
106
+ ".ruff_cache",
107
+ ".ipynb_checkpoints",
108
+ "~", # Vim swap files
109
+ ".swp",
110
+ ".swo",
111
+ ".swx",
112
+ ".tmp",
113
+ ".temp",
114
+ ".DS_Store",
115
+ "thumbs.db",
116
+ ".lock",
117
+ }
118
+
119
+ ALLOWED_EVENT_TYPES = {"created", "modified", "deleted", "moved"}
120
+
121
+ def __init__(
122
+ self,
123
+ loop: asyncio.AbstractEventLoop,
124
+ debounce_seconds: float,
125
+ watch_extensions: set[str] | None = None,
126
+ ) -> None:
127
+ self._loop = loop
128
+ self._debounce_seconds = debounce_seconds
129
+ # Default to common prompt file formats if not specified
130
+ self._watch_extensions = watch_extensions or {
131
+ ".json",
132
+ ".jinja",
133
+ }
134
+ self._last_seen: dict[Path, tuple[float | None, int | None]] = {}
135
+
136
+ def _should_ignore_event(self, event: FileSystemEvent) -> bool:
137
+ """Check if event should be ignored."""
138
+ src = event.src_path
139
+ if not isinstance(src, str):
140
+ return True
141
+
142
+ path = Path(src)
143
+
144
+ # Check primary path
145
+ if self._is_ignored_path(path):
146
+ return True
147
+
148
+ # For move events, also check destination
149
+ if hasattr(event, "dest_path") and event.dest_path:
150
+ dest = event.dest_path
151
+ if not isinstance(dest, str):
152
+ return True
153
+ dest_path = Path(dest)
154
+ # Ignore if either source or dest should be ignored
155
+ if self._is_ignored_path(dest_path):
156
+ return True
157
+
158
+ return False
159
+
160
+ def _is_ignored_path(self, path: Path) -> bool:
161
+ """Check if a path should be ignored."""
162
+ path_str = str(path)
163
+
164
+ # Ignore if path contains any ignore pattern
165
+ if any(pattern in path_str for pattern in self.IGNORE_PATTERNS):
166
+ return True
167
+
168
+ # Ignore temporary files (various editor patterns)
169
+ filename = path.name
170
+
171
+ # VSCode creates files like: .file.txt.12345~
172
+ if filename.startswith(".") and "~" in filename:
173
+ return True
174
+
175
+ # Hidden files starting with .
176
+ if filename.startswith("."):
177
+ return True
178
+
179
+ # Backup files
180
+ if filename.startswith("~") or filename.endswith("~"):
181
+ return True
182
+
183
+ # Temporary suffixes
184
+ if any(
185
+ filename.endswith(suffix)
186
+ for suffix in (".tmp", ".temp", ".swp", ".swo", ".swx", ".bak", ".lock")
187
+ ):
188
+ return True
189
+
190
+ # For file events (not directories), check extension
191
+ if path.is_file() or (not path.exists() and path.suffix):
192
+ if path.suffix not in self._watch_extensions:
193
+ return True
194
+
195
+ return False
196
+
197
+ def on_any_event(self, event: FileSystemEvent) -> None:
198
+ # Filter out unwanted events
199
+ if event.event_type not in self.ALLOWED_EVENT_TYPES:
200
+ return
201
+ if self._should_ignore_event(event):
202
+ return
203
+
204
+ # Skip duplicate events where the file's mtime/size did not change
205
+ if not event.is_directory and event.event_type in {"created", "modified"}:
206
+ src = event.src_path
207
+ if isinstance(src, str):
208
+ path = Path(src)
209
+ if self._is_ignored_path(path):
210
+ return
211
+ if self._is_duplicate_event(path):
212
+ return
213
+
214
+ # Ignore pure directory events (unless it's a delete/move that could affect contents)
215
+ # We still want to know if a directory containing prompt files was deleted/moved
216
+ if event.is_directory and event.event_type not in ("deleted", "moved"):
217
+ return
218
+
219
+ logger.debug(
220
+ "Storage change detected: %s (%s)%s",
221
+ event.src_path,
222
+ event.event_type,
223
+ f" -> {event.dest_path}" if hasattr(event, "dest_path") else "",
224
+ )
225
+
226
+ # Avoid blocking the watchdog thread; schedule reload on the event loop.
227
+ self._loop.call_soon_threadsafe(self._trigger_reload)
228
+
229
+ def _trigger_reload(self) -> None:
230
+ global _storage_reload_task
231
+
232
+ # Cancel existing reload task to reset the debounce timer
233
+ if _storage_reload_task is not None and not _storage_reload_task.done():
234
+ _storage_reload_task.cancel()
235
+
236
+ _storage_reload_task = self._loop.create_task(
237
+ _debounced_reload(self._debounce_seconds)
238
+ )
239
+
240
+ def _is_duplicate_event(self, path: Path) -> bool:
241
+ """Return True if the event is a repeat with unchanged mtime/size."""
242
+ try:
243
+ stat = path.stat()
244
+ fingerprint = (stat.st_mtime, stat.st_size)
245
+ except FileNotFoundError:
246
+ self._last_seen.pop(path, None)
247
+ return False
248
+
249
+ last = self._last_seen.get(path)
250
+ if last == fingerprint:
251
+ return True
252
+
253
+ self._last_seen[path] = fingerprint
254
+ return False
255
+
256
+
257
+ async def start_storage_watcher(
258
+ storage_directory: Path,
259
+ debounce_seconds: float,
260
+ watch_extensions: set[str] | None = None,
261
+ ) -> None:
262
+ """Start a watchdog observer on the prompt storage directory.
263
+
264
+ Args:
265
+ storage_directory: Directory to watch for changes
266
+ debounce_seconds: Delay before triggering reload (collapses rapid events)
267
+ watch_extensions: Set of file extensions to watch (e.g., {'.yaml', '.json'})
268
+ """
269
+ global _storage_observer
270
+
271
+ if _storage_observer is not None:
272
+ return
273
+
274
+ loop = asyncio.get_running_loop()
275
+ storage_directory.mkdir(parents=True, exist_ok=True)
276
+
277
+ handler = _StorageChangeHandler(loop, debounce_seconds, watch_extensions)
278
+ observer = Observer()
279
+ observer.schedule(handler, str(storage_directory), recursive=True)
280
+ observer.start()
281
+
282
+ _storage_observer = observer
283
+
284
+ extensions_str = ", ".join(sorted(handler._watch_extensions))
285
+ logger.info(
286
+ "Watching prompt storage at %s for %s files (debounce %.2fs)",
287
+ storage_directory,
288
+ extensions_str,
289
+ debounce_seconds,
290
+ )
291
+
292
+
293
+ async def stop_storage_watcher() -> None:
294
+ """Stop the watchdog observer if running."""
295
+ global _storage_observer, _storage_reload_task
296
+
297
+ if _storage_reload_task is not None:
298
+ _storage_reload_task.cancel()
299
+ try:
300
+ await _storage_reload_task
301
+ except asyncio.CancelledError:
302
+ pass
303
+ _storage_reload_task = None
304
+
305
+ if _storage_observer is not None:
306
+ _storage_observer.stop()
307
+ await asyncio.to_thread(_storage_observer.join, 5.0)
308
+ _storage_observer = None
309
+
310
+
311
+ def init_prompt_storage():
312
+
313
+ storage_directory = os.getenv("PIXIE_PROMPT_STORAGE_DIR", ".pixie/prompts")
314
+ initialize_prompt_storage(storage_directory)
315
+
316
+ @asynccontextmanager
317
+ async def lifespan(app: FastAPI):
318
+ try:
319
+ nonlocal storage_directory
320
+ storage_path = Path(storage_directory)
321
+ watch_interval = float(os.getenv("PIXIE_PROMPT_WATCH_INTERVAL", "1.0"))
322
+ await start_storage_watcher(storage_path, watch_interval)
323
+ yield
324
+ finally:
325
+ await stop_storage_watcher()
326
+
327
+ return lifespan
@@ -0,0 +1,212 @@
1
+ """GraphQL schema for SDK server."""
2
+
3
+ import logging
4
+ from typing import Optional
5
+
6
+ from graphql import GraphQLError
7
+ import strawberry
8
+ from strawberry.scalars import JSON
9
+
10
+ from pixie.prompts.prompt import variables_definition_to_schema
11
+ from pixie.prompts.prompt_management import get_prompt, list_prompts
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ @strawberry.type
17
+ class PromptMetadata:
18
+ """Metadata for a registered prompt via create_prompt."""
19
+
20
+ id: strawberry.ID
21
+ variables_schema: JSON
22
+ version_count: int
23
+ description: Optional[str] = None
24
+ module: Optional[str] = None
25
+
26
+
27
+ @strawberry.input
28
+ class IKeyValue:
29
+ """Key-value attribute."""
30
+
31
+ key: str
32
+ value: str
33
+
34
+
35
+ @strawberry.type
36
+ class TKeyValue:
37
+ """Key-value attribute."""
38
+
39
+ key: str
40
+ value: str
41
+
42
+
43
+ @strawberry.type
44
+ class PromptVersion:
45
+ """Prompt version information."""
46
+
47
+ version_id: str
48
+ content: str
49
+ created_at: float
50
+
51
+
52
+ @strawberry.type
53
+ class Prompt:
54
+ """Full prompt information including versions."""
55
+
56
+ id: strawberry.ID
57
+ variables_schema: JSON
58
+ versions: list[PromptVersion]
59
+ default_version_id: str | None
60
+ """default version id can only be None if versions is empty"""
61
+ description: Optional[str] = None
62
+ module: Optional[str] = None
63
+
64
+
65
+ @strawberry.type
66
+ class Query:
67
+ """GraphQL queries."""
68
+
69
+ @strawberry.field
70
+ async def health_check(self) -> str:
71
+ """Health check endpoint."""
72
+ logger.debug("Health check endpoint called")
73
+ return "0.0.0"
74
+
75
+ @strawberry.field
76
+ def list_prompts(self) -> list[PromptMetadata]:
77
+ """List all registered prompt templates.
78
+
79
+ Returns:
80
+ A list of PromptMetadata objects containing id, variables_schema, version_count,
81
+ description, and module for each registered prompt.
82
+ """
83
+
84
+ return [
85
+ PromptMetadata(
86
+ id=strawberry.ID(p.prompt.id),
87
+ variables_schema=JSON(
88
+ # NOTE: avoid p.get_variables_schema() to prevent potential fetching from storage
89
+ # this in theory could be different from the stored schema but in practice should not be
90
+ variables_definition_to_schema(p.prompt.variables_definition)
91
+ ),
92
+ version_count=p.prompt.get_version_count(),
93
+ description=p.description,
94
+ module=p.module,
95
+ )
96
+ for p in list_prompts()
97
+ ]
98
+
99
+ @strawberry.field
100
+ async def get_prompt(self, id: strawberry.ID) -> Prompt:
101
+ """Get full prompt information including versions.
102
+
103
+ Args:
104
+ id: The unique identifier of the prompt.
105
+ Returns:
106
+ Prompt object containing id, variables_schema, versions,
107
+ and default_version_id.
108
+ Raises:
109
+ GraphQLError: If prompt with given id is not found.
110
+ """
111
+ prompt_with_registration = get_prompt((str(id)))
112
+ if prompt_with_registration is None:
113
+ raise GraphQLError(f"Prompt with id '{id}' not found.")
114
+ prompt = prompt_with_registration.prompt
115
+ if not prompt.exists_in_storage():
116
+ return Prompt(
117
+ id=id,
118
+ variables_schema=JSON(
119
+ # NOTE: avoid prompt.get_variables_schema() to prevent potential fetching from storage
120
+ variables_definition_to_schema(prompt.variables_definition)
121
+ ),
122
+ versions=[],
123
+ default_version_id=None,
124
+ description=prompt_with_registration.description,
125
+ module=prompt_with_registration.module,
126
+ )
127
+ versions_dict = prompt.get_versions()
128
+ versions = [
129
+ PromptVersion(
130
+ version_id=k, content=v, created_at=prompt.get_version_creation_time(k)
131
+ )
132
+ for k, v in versions_dict.items()
133
+ ]
134
+ default_version_id: str = prompt.get_default_version_id()
135
+ variables_schema = prompt.get_variables_schema()
136
+ return Prompt(
137
+ id=id,
138
+ variables_schema=JSON(variables_schema),
139
+ versions=versions,
140
+ default_version_id=default_version_id,
141
+ description=prompt_with_registration.description,
142
+ module=prompt_with_registration.module,
143
+ )
144
+
145
+
146
+ @strawberry.type
147
+ class Mutation:
148
+ """GraphQL mutations."""
149
+
150
+ @strawberry.mutation
151
+ async def add_prompt_version(
152
+ self,
153
+ prompt_id: strawberry.ID,
154
+ version_id: str,
155
+ content: str,
156
+ set_as_default: bool = False,
157
+ ) -> str:
158
+ """Add a new version to an existing prompt.
159
+
160
+ Args:
161
+ prompt_id: The unique identifier of the prompt.
162
+ version_id: The identifier for the new version.
163
+ content: The content of the new prompt version.
164
+ set_as_default: Whether to set this version as the default.
165
+
166
+ Returns:
167
+ The updated BasePrompt object.
168
+ """
169
+ prompt_with_registration = get_prompt((str(prompt_id)))
170
+ if prompt_with_registration is None:
171
+ raise GraphQLError(f"Prompt with id '{prompt_id}' not found.")
172
+ prompt = prompt_with_registration.prompt
173
+ try:
174
+ prompt.append_version(
175
+ version_id=version_id,
176
+ content=content,
177
+ set_as_default=set_as_default,
178
+ )
179
+ except Exception as e:
180
+ raise GraphQLError(f"Failed to add prompt version: {str(e)}") from e
181
+ return "OK"
182
+
183
+ @strawberry.mutation
184
+ async def update_default_prompt_version(
185
+ self,
186
+ prompt_id: strawberry.ID,
187
+ default_version_id: str,
188
+ ) -> str:
189
+ """Update the default version of an existing prompt.
190
+
191
+ Args:
192
+ prompt_id: The unique identifier of the prompt.
193
+ default_version_id: The identifier of the version to set as default.
194
+
195
+ Returns:
196
+ True if the update was successful.
197
+ """
198
+ prompt_with_registration = get_prompt((str(prompt_id)))
199
+ if prompt_with_registration is None:
200
+ raise GraphQLError(f"Prompt with id '{prompt_id}' not found.")
201
+ prompt = prompt_with_registration.prompt
202
+ try:
203
+ prompt.update_default_version_id(default_version_id)
204
+ except Exception as e:
205
+ raise GraphQLError(
206
+ f"Failed to update default prompt version: {str(e)}"
207
+ ) from e
208
+ return "OK"
209
+
210
+
211
+ # Create the schema
212
+ schema = strawberry.Schema(query=Query, mutation=Mutation)