pixie-prompts 0.0.0__tar.gz → 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.
@@ -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,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,320 @@
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
+ # Load the module with a unique name based on path
52
+ relative_path = py_file.relative_to(cwd)
53
+ module_name = str(relative_path.with_suffix("")).replace("/", ".")
54
+ spec = importlib.util.spec_from_file_location(module_name, py_file)
55
+ if spec and spec.loader:
56
+ try:
57
+ module = importlib.util.module_from_spec(spec)
58
+ sys.modules[module_name] = module
59
+ spec.loader.exec_module(module)
60
+ loaded_count += 1
61
+ except Exception as e:
62
+ logger.error("Failed to load module %s: %s", module_name, e)
63
+
64
+
65
+ def _reload_prompt_storage() -> None:
66
+ """Reload prompts from disk and refresh any actualized prompts."""
67
+ from pixie.prompts import storage as storage_module
68
+
69
+ storage_instance = storage_module._storage_instance
70
+ if storage_instance is None:
71
+ logger.warning("Prompt storage is not initialized; skip reload.")
72
+ return
73
+
74
+ storage_instance.load()
75
+
76
+
77
+ async def _debounced_reload(delay_seconds: float) -> None:
78
+ """Reload storage after a debounce window to collapse bursty events."""
79
+ try:
80
+ await asyncio.sleep(delay_seconds)
81
+ logger.info("Detected prompt storage change; reloading prompts...")
82
+ _reload_prompt_storage()
83
+ except asyncio.CancelledError:
84
+ raise
85
+ except Exception:
86
+ logger.exception("Failed to reload prompts after storage change.")
87
+
88
+
89
+ class _StorageChangeHandler(FileSystemEventHandler):
90
+ """Watchdog handler that schedules prompt reloads on any change."""
91
+
92
+ # Patterns to ignore
93
+ IGNORE_PATTERNS = {
94
+ ".vscode",
95
+ ".git",
96
+ "__pycache__",
97
+ ".pytest_cache",
98
+ ".mypy_cache",
99
+ ".ruff_cache",
100
+ ".ipynb_checkpoints",
101
+ "~", # Vim swap files
102
+ ".swp",
103
+ ".swo",
104
+ ".swx",
105
+ ".tmp",
106
+ ".temp",
107
+ ".DS_Store",
108
+ "thumbs.db",
109
+ ".lock",
110
+ }
111
+
112
+ ALLOWED_EVENT_TYPES = {"created", "modified", "deleted", "moved"}
113
+
114
+ def __init__(
115
+ self,
116
+ loop: asyncio.AbstractEventLoop,
117
+ debounce_seconds: float,
118
+ watch_extensions: set[str] | None = None,
119
+ ) -> None:
120
+ self._loop = loop
121
+ self._debounce_seconds = debounce_seconds
122
+ # Default to common prompt file formats if not specified
123
+ self._watch_extensions = watch_extensions or {
124
+ ".json",
125
+ ".jinja",
126
+ }
127
+ self._last_seen: dict[Path, tuple[float | None, int | None]] = {}
128
+
129
+ def _should_ignore_event(self, event: FileSystemEvent) -> bool:
130
+ """Check if event should be ignored."""
131
+ src = event.src_path
132
+ if not isinstance(src, str):
133
+ return True
134
+
135
+ path = Path(src)
136
+
137
+ # Check primary path
138
+ if self._is_ignored_path(path):
139
+ return True
140
+
141
+ # For move events, also check destination
142
+ if hasattr(event, "dest_path") and event.dest_path:
143
+ dest = event.dest_path
144
+ if not isinstance(dest, str):
145
+ return True
146
+ dest_path = Path(dest)
147
+ # Ignore if either source or dest should be ignored
148
+ if self._is_ignored_path(dest_path):
149
+ return True
150
+
151
+ return False
152
+
153
+ def _is_ignored_path(self, path: Path) -> bool:
154
+ """Check if a path should be ignored."""
155
+ path_str = str(path)
156
+
157
+ # Ignore if path contains any ignore pattern
158
+ if any(pattern in path_str for pattern in self.IGNORE_PATTERNS):
159
+ return True
160
+
161
+ # Ignore temporary files (various editor patterns)
162
+ filename = path.name
163
+
164
+ # VSCode creates files like: .file.txt.12345~
165
+ if filename.startswith(".") and "~" in filename:
166
+ return True
167
+
168
+ # Hidden files starting with .
169
+ if filename.startswith("."):
170
+ return True
171
+
172
+ # Backup files
173
+ if filename.startswith("~") or filename.endswith("~"):
174
+ return True
175
+
176
+ # Temporary suffixes
177
+ if any(
178
+ filename.endswith(suffix)
179
+ for suffix in (".tmp", ".temp", ".swp", ".swo", ".swx", ".bak", ".lock")
180
+ ):
181
+ return True
182
+
183
+ # For file events (not directories), check extension
184
+ if path.is_file() or (not path.exists() and path.suffix):
185
+ if path.suffix not in self._watch_extensions:
186
+ return True
187
+
188
+ return False
189
+
190
+ def on_any_event(self, event: FileSystemEvent) -> None:
191
+ # Filter out unwanted events
192
+ if event.event_type not in self.ALLOWED_EVENT_TYPES:
193
+ return
194
+ if self._should_ignore_event(event):
195
+ return
196
+
197
+ # Skip duplicate events where the file's mtime/size did not change
198
+ if not event.is_directory and event.event_type in {"created", "modified"}:
199
+ src = event.src_path
200
+ if isinstance(src, str):
201
+ path = Path(src)
202
+ if self._is_ignored_path(path):
203
+ return
204
+ if self._is_duplicate_event(path):
205
+ return
206
+
207
+ # Ignore pure directory events (unless it's a delete/move that could affect contents)
208
+ # We still want to know if a directory containing prompt files was deleted/moved
209
+ if event.is_directory and event.event_type not in ("deleted", "moved"):
210
+ return
211
+
212
+ logger.debug(
213
+ "Storage change detected: %s (%s)%s",
214
+ event.src_path,
215
+ event.event_type,
216
+ f" -> {event.dest_path}" if hasattr(event, "dest_path") else "",
217
+ )
218
+
219
+ # Avoid blocking the watchdog thread; schedule reload on the event loop.
220
+ self._loop.call_soon_threadsafe(self._trigger_reload)
221
+
222
+ def _trigger_reload(self) -> None:
223
+ global _storage_reload_task
224
+
225
+ # Cancel existing reload task to reset the debounce timer
226
+ if _storage_reload_task is not None and not _storage_reload_task.done():
227
+ _storage_reload_task.cancel()
228
+
229
+ _storage_reload_task = self._loop.create_task(
230
+ _debounced_reload(self._debounce_seconds)
231
+ )
232
+
233
+ def _is_duplicate_event(self, path: Path) -> bool:
234
+ """Return True if the event is a repeat with unchanged mtime/size."""
235
+ try:
236
+ stat = path.stat()
237
+ fingerprint = (stat.st_mtime, stat.st_size)
238
+ except FileNotFoundError:
239
+ self._last_seen.pop(path, None)
240
+ return False
241
+
242
+ last = self._last_seen.get(path)
243
+ if last == fingerprint:
244
+ return True
245
+
246
+ self._last_seen[path] = fingerprint
247
+ return False
248
+
249
+
250
+ async def start_storage_watcher(
251
+ storage_directory: Path,
252
+ debounce_seconds: float,
253
+ watch_extensions: set[str] | None = None,
254
+ ) -> None:
255
+ """Start a watchdog observer on the prompt storage directory.
256
+
257
+ Args:
258
+ storage_directory: Directory to watch for changes
259
+ debounce_seconds: Delay before triggering reload (collapses rapid events)
260
+ watch_extensions: Set of file extensions to watch (e.g., {'.yaml', '.json'})
261
+ """
262
+ global _storage_observer
263
+
264
+ if _storage_observer is not None:
265
+ return
266
+
267
+ loop = asyncio.get_running_loop()
268
+ storage_directory.mkdir(parents=True, exist_ok=True)
269
+
270
+ handler = _StorageChangeHandler(loop, debounce_seconds, watch_extensions)
271
+ observer = Observer()
272
+ observer.schedule(handler, str(storage_directory), recursive=True)
273
+ observer.start()
274
+
275
+ _storage_observer = observer
276
+
277
+ extensions_str = ", ".join(sorted(handler._watch_extensions))
278
+ logger.info(
279
+ "Watching prompt storage at %s for %s files (debounce %.2fs)",
280
+ storage_directory,
281
+ extensions_str,
282
+ debounce_seconds,
283
+ )
284
+
285
+
286
+ async def stop_storage_watcher() -> None:
287
+ """Stop the watchdog observer if running."""
288
+ global _storage_observer, _storage_reload_task
289
+
290
+ if _storage_reload_task is not None:
291
+ _storage_reload_task.cancel()
292
+ try:
293
+ await _storage_reload_task
294
+ except asyncio.CancelledError:
295
+ pass
296
+ _storage_reload_task = None
297
+
298
+ if _storage_observer is not None:
299
+ _storage_observer.stop()
300
+ await asyncio.to_thread(_storage_observer.join, 5.0)
301
+ _storage_observer = None
302
+
303
+
304
+ def init_prompt_storage():
305
+
306
+ storage_directory = os.getenv("PIXIE_PROMPT_STORAGE_DIR", ".pixie/prompts")
307
+ initialize_prompt_storage(storage_directory)
308
+
309
+ @asynccontextmanager
310
+ async def lifespan(app: FastAPI):
311
+ try:
312
+ nonlocal storage_directory
313
+ storage_path = Path(storage_directory)
314
+ watch_interval = float(os.getenv("PIXIE_PROMPT_WATCH_INTERVAL", "1.0"))
315
+ await start_storage_watcher(storage_path, watch_interval)
316
+ yield
317
+ finally:
318
+ await stop_storage_watcher()
319
+
320
+ return lifespan
@@ -9,7 +9,6 @@ from strawberry.scalars import JSON
9
9
 
10
10
  from pixie.prompts.prompt import variables_definition_to_schema
11
11
  from pixie.prompts.prompt_management import get_prompt, list_prompts
12
- from importlib.metadata import PackageNotFoundError, version
13
12
 
14
13
  logger = logging.getLogger(__name__)
15
14
 
@@ -41,13 +40,22 @@ class TKeyValue:
41
40
  value: str
42
41
 
43
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
+
44
52
  @strawberry.type
45
53
  class Prompt:
46
54
  """Full prompt information including versions."""
47
55
 
48
56
  id: strawberry.ID
49
57
  variables_schema: JSON
50
- versions: list[TKeyValue]
58
+ versions: list[PromptVersion]
51
59
  default_version_id: str | None
52
60
  """default version id can only be None if versions is empty"""
53
61
  description: Optional[str] = None
@@ -62,13 +70,7 @@ class Query:
62
70
  async def health_check(self) -> str:
63
71
  """Health check endpoint."""
64
72
  logger.debug("Health check endpoint called")
65
- try:
66
- version_str = version("pixie-sdk")
67
- logger.debug("Pixie SDK version: %s", version_str)
68
- return version_str
69
- except PackageNotFoundError as e:
70
- logger.warning("Failed to get Pixie SDK version: %s", str(e))
71
- return "0.0.0"
73
+ return "0.0.0"
72
74
 
73
75
  @strawberry.field
74
76
  def list_prompts(self) -> list[PromptMetadata]:
@@ -123,7 +125,12 @@ class Query:
123
125
  module=prompt_with_registration.module,
124
126
  )
125
127
  versions_dict = prompt.get_versions()
126
- versions = [TKeyValue(key=k, value=v) for k, v in versions_dict.items()]
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
+ ]
127
134
  default_version_id: str = prompt.get_default_version_id()
128
135
  variables_schema = prompt.get_variables_schema()
129
136
  return Prompt(
@@ -11,12 +11,12 @@ from jsonsubschema import isSubschema
11
11
  from pydantic import BaseModel
12
12
 
13
13
 
14
- class PromptVariables(BaseModel):
14
+ class Variables(BaseModel):
15
15
  # TODO add validation to prevent fields using reserved names
16
16
  pass
17
17
 
18
18
 
19
- TPromptVar = TypeVar("TPromptVar", bound=PromptVariables | None)
19
+ TPromptVar = TypeVar("TPromptVar", bound=Variables | None)
20
20
 
21
21
 
22
22
  _prompt_registry: dict[str, "BasePrompt"] = {}
@@ -36,7 +36,7 @@ class _CompiledPrompt:
36
36
  value: str
37
37
  prompt: "BasePrompt | OutdatedPrompt"
38
38
  version_id: str
39
- variables: PromptVariables | None
39
+ variables: Variables | None
40
40
 
41
41
 
42
42
  _compiled_prompt_registry: dict[int, _CompiledPrompt] = {}
@@ -186,7 +186,7 @@ def variables_definition_to_schema(definition: type[TPromptVar]) -> dict[str, An
186
186
  if definition is NoneType:
187
187
  return EMPTY_VARIABLES_SCHEMA
188
188
 
189
- return cast(type[PromptVariables], definition).model_json_schema()
189
+ return cast(type[Variables], definition).model_json_schema()
190
190
 
191
191
 
192
192
  class BasePrompt(BaseUntypedPrompt, Generic[TPromptVar]):
@@ -4,9 +4,6 @@ import argparse
4
4
  import os
5
5
  import colorlog
6
6
  import logging
7
- import sys
8
- import importlib.util
9
- from pathlib import Path
10
7
  from urllib.parse import quote
11
8
 
12
9
  import dotenv
@@ -15,7 +12,10 @@ from fastapi.middleware.cors import CORSMiddleware
15
12
  from strawberry.fastapi import GraphQLRouter
16
13
  import uvicorn
17
14
 
18
- from pixie.prompts.storage import initialize_prompt_storage
15
+ from pixie.prompts.file_watcher import (
16
+ discover_and_load_modules,
17
+ init_prompt_storage,
18
+ )
19
19
  from pixie.prompts.graphql import schema
20
20
 
21
21
 
@@ -25,42 +25,6 @@ logger = logging.getLogger(__name__)
25
25
  _logging_mode: str = "default"
26
26
 
27
27
 
28
- def discover_and_load_prompts():
29
- """Discover and load all Python files that use pixie.prompts.create_prompt, or pixie.create_prompt.
30
-
31
- This function recursively searches the current working directory for Python files
32
- """
33
- cwd = Path.cwd()
34
- # Recursively find all Python files
35
- python_files = list(cwd.rglob("*.py"))
36
-
37
- if not python_files:
38
- return
39
-
40
- # Add current directory to Python path if not already there
41
- if str(cwd) not in sys.path:
42
- sys.path.insert(0, str(cwd))
43
-
44
- loaded_count = 0
45
- for py_file in python_files:
46
- # Skip __init__.py, private files, and anything in site-packages/venv
47
- if py_file.name.startswith("_") or any(
48
- part in py_file.parts
49
- for part in ["site-packages", ".venv", "venv", "__pycache__"]
50
- ):
51
- continue
52
-
53
- # Load the module with a unique name based on path
54
- relative_path = py_file.relative_to(cwd)
55
- module_name = str(relative_path.with_suffix("")).replace("/", ".")
56
- spec = importlib.util.spec_from_file_location(module_name, py_file)
57
- if spec and spec.loader:
58
- module = importlib.util.module_from_spec(spec)
59
- sys.modules[module_name] = module
60
- spec.loader.exec_module(module)
61
- loaded_count += 1
62
-
63
-
64
28
  def setup_logging(mode: str = "default"):
65
29
  """Configure logging for the entire application.
66
30
 
@@ -123,22 +87,26 @@ def create_app() -> FastAPI:
123
87
  setup_logging(_logging_mode)
124
88
 
125
89
  # Discover and load applications on every app creation (including reloads)
126
- discover_and_load_prompts()
90
+ discover_and_load_modules()
127
91
 
128
92
  dotenv.load_dotenv(os.getcwd() + "/.env")
129
- storage_directory = os.getenv("PIXIE_PROMPT_STORAGE_DIR", ".pixie/prompts")
130
- initialize_prompt_storage(storage_directory)
93
+ lifespan = init_prompt_storage()
131
94
 
132
95
  app = FastAPI(
133
- title="Pixie SDK Server",
134
- description="Server for running AI applications and agents",
96
+ title="Pixie Prompts Dev Server",
97
+ description="Server for managing prompts",
135
98
  version="0.1.0",
99
+ lifespan=lifespan,
136
100
  )
137
-
101
+ # Matches:
102
+ # 1. http://localhost followed by an optional port (:8080, :3000, etc.)
103
+ # 2. http://127.0.0.1 followed by an optional port
104
+ # 3. https://yourdomain.com (the production domain)
105
+ origins_regex = r"http://(localhost|127\.0\.0\.1)(:\d+)?|https://gopixie\.ai"
138
106
  # Add CORS middleware
139
107
  app.add_middleware(
140
108
  CORSMiddleware,
141
- allow_origins=["*"], # Allows all origins
109
+ allow_origin_regex=origins_regex,
142
110
  allow_credentials=True,
143
111
  allow_methods=["*"], # Allows all methods
144
112
  allow_headers=["*"], # Allows all headers
@@ -155,7 +123,7 @@ def create_app() -> FastAPI:
155
123
  @app.get("/")
156
124
  async def root():
157
125
  return {
158
- "message": "Pixie SDK Server",
126
+ "message": "Pixie Prompts Dev Server",
159
127
  "graphiql": "/graphql",
160
128
  "version": "0.1.0",
161
129
  }