pixie-prompts 0.0.0__tar.gz → 0.1.1__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.1
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,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
@@ -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
  }