pixie-prompts 0.1.8.dev6__tar.gz → 0.1.10__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.1.8.dev6
3
+ Version: 0.1.10
4
4
  Summary: Code-first, type-safe prompt management
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -10,7 +10,11 @@ from watchdog.observers import Observer
10
10
  import asyncio
11
11
  import logging
12
12
 
13
- from pixie.prompts.storage import PromptLoadError, initialize_prompt_storage
13
+ from pixie.prompts.storage import (
14
+ PromptLoadError,
15
+ get_storage_root_directory,
16
+ initialize_prompt_storage,
17
+ )
14
18
 
15
19
  logger = logging.getLogger(__name__)
16
20
 
@@ -18,6 +22,85 @@ _storage_observer: Observer | None = None # type: ignore
18
22
  _storage_reload_task: asyncio.Task | None = None
19
23
 
20
24
 
25
+ def _preload_pixie_modules(cwd: Path) -> None:
26
+ """Pre-load pixie modules from the local source directory.
27
+
28
+ This ensures that when dynamically loaded modules import pixie.registry,
29
+ they get the same module instance as the rest of the application.
30
+
31
+ Without this, namespace package collisions can occur when pixie-prompts
32
+ is installed as a package while pixie-sdk-py is run from source. The
33
+ dynamic module loading via importlib.util.spec_from_file_location() can
34
+ create separate module instances, leading to separate _registry dicts.
35
+
36
+ Args:
37
+ cwd: The current working directory to search for pixie modules.
38
+ """
39
+ pixie_dir = cwd / "pixie"
40
+ if not pixie_dir.is_dir():
41
+ return
42
+
43
+ # Find all pixie submodules that exist locally and pre-import them
44
+ # This ensures they're in sys.modules before any dynamic loading
45
+ pixie_modules_to_preload = []
46
+
47
+ for py_file in pixie_dir.rglob("*.py"):
48
+ if py_file.name.startswith("_"):
49
+ continue
50
+ # Skip if in __pycache__ or similar
51
+ if "__pycache__" in py_file.parts:
52
+ continue
53
+
54
+ relative_path = py_file.relative_to(cwd)
55
+ module_name = str(relative_path.with_suffix("")).replace("/", ".")
56
+ pixie_modules_to_preload.append((module_name, py_file))
57
+
58
+ # Sort to ensure parent modules are loaded before children
59
+ pixie_modules_to_preload.sort(key=lambda x: x[0])
60
+
61
+ for module_name, py_file in pixie_modules_to_preload:
62
+ if module_name in sys.modules:
63
+ continue
64
+
65
+ # Ensure parent packages are in sys.modules
66
+ parts = module_name.split(".")
67
+ for i in range(1, len(parts)):
68
+ parent_name = ".".join(parts[:i])
69
+ if parent_name not in sys.modules:
70
+ parent_path = cwd / Path(*parts[:i])
71
+ init_file = parent_path / "__init__.py"
72
+ if init_file.exists():
73
+ spec = importlib.util.spec_from_file_location(
74
+ parent_name, init_file
75
+ )
76
+ if spec and spec.loader:
77
+ module = importlib.util.module_from_spec(spec)
78
+ sys.modules[parent_name] = module
79
+ try:
80
+ spec.loader.exec_module(module)
81
+ except Exception:
82
+ pass
83
+ else:
84
+ # Create a namespace package
85
+ import types
86
+
87
+ ns_module = types.ModuleType(parent_name)
88
+ ns_module.__path__ = [str(parent_path)]
89
+ sys.modules[parent_name] = ns_module
90
+
91
+ # Now load the actual module
92
+ spec = importlib.util.spec_from_file_location(module_name, py_file)
93
+ if spec and spec.loader:
94
+ try:
95
+ module = importlib.util.module_from_spec(spec)
96
+ sys.modules[module_name] = module
97
+ spec.loader.exec_module(module)
98
+ except Exception:
99
+ # Don't fail if a module can't be loaded - it might have
100
+ # dependencies that aren't available yet
101
+ pass
102
+
103
+
21
104
  def discover_and_load_modules():
22
105
  """Discover and load all Python files that use pixie.prompts.create_prompt, or pixie.create_prompt.
23
106
 
@@ -34,6 +117,10 @@ def discover_and_load_modules():
34
117
  if str(cwd) not in sys.path:
35
118
  sys.path.insert(0, str(cwd))
36
119
 
120
+ # Pre-load pixie modules from local source to avoid namespace package collisions
121
+ # This ensures dynamically loaded modules use the same pixie.registry instance
122
+ _preload_pixie_modules(cwd)
123
+
37
124
  loaded_count = 0
38
125
  for py_file in python_files:
39
126
  # Skip __init__.py, private files, and anything in site-packages/venv
@@ -58,6 +145,12 @@ def discover_and_load_modules():
58
145
  # Load the module with a unique name based on path
59
146
  relative_path = py_file.relative_to(cwd)
60
147
  module_name = str(relative_path.with_suffix("")).replace("/", ".")
148
+
149
+ # Skip if module was already loaded (e.g., during preload)
150
+ if module_name in sys.modules:
151
+ loaded_count += 1
152
+ continue
153
+
61
154
  spec = importlib.util.spec_from_file_location(module_name, py_file)
62
155
  if spec and spec.loader:
63
156
  try:
@@ -309,10 +402,10 @@ async def stop_storage_watcher() -> None:
309
402
 
310
403
 
311
404
  def init_prompt_storage():
312
-
313
- storage_directory = os.getenv("PIXIE_PROMPT_STORAGE_DIR", ".pixie/prompts")
405
+ """Initialize prompt storage and return lifespan context manager."""
406
+ storage_directory = get_storage_root_directory()
314
407
  try:
315
- initialize_prompt_storage(storage_directory)
408
+ initialize_prompt_storage()
316
409
  except PromptLoadError as e:
317
410
  for err in e.failures:
318
411
  logger.error("Prompt load error: %s", err)
@@ -320,7 +413,6 @@ def init_prompt_storage():
320
413
  @asynccontextmanager
321
414
  async def lifespan(app: FastAPI):
322
415
  try:
323
- nonlocal storage_directory
324
416
  storage_path = Path(storage_directory)
325
417
  watch_interval = float(os.getenv("PIXIE_PROMPT_WATCH_INTERVAL", "1.0"))
326
418
  await start_storage_watcher(storage_path, watch_interval)
@@ -111,6 +111,8 @@ class Query:
111
111
  Returns:
112
112
  A list of model names supported by the server.
113
113
  """
114
+ if is_demo_mode():
115
+ return ["openai:gpt-4o-mini"]
114
116
  return list(get_args(KnownModelName.__value__))
115
117
 
116
118
  @strawberry.field
@@ -2,7 +2,7 @@ from copy import deepcopy
2
2
  from dataclasses import dataclass
3
3
  import json
4
4
  from types import NoneType
5
- from typing import Any, Generic, Protocol, Self, TypeVar, cast, overload
5
+ from typing import Any, Callable, Generic, Protocol, Self, TypeVar, cast, overload
6
6
  from uuid import uuid4
7
7
 
8
8
  import jinja2
@@ -32,14 +32,14 @@ def get_prompt_by_id(prompt_id: str) -> "BasePrompt":
32
32
 
33
33
 
34
34
  @dataclass(frozen=True)
35
- class _CompiledPrompt:
35
+ class CompiledPrompt:
36
36
  value: str
37
37
  prompt: "BasePrompt | OutdatedPrompt"
38
38
  version_id: str
39
39
  variables: Variables | None
40
40
 
41
41
 
42
- _compiled_prompt_registry: dict[int, _CompiledPrompt] = {}
42
+ _compiled_prompt_registry: dict[int, CompiledPrompt] = {}
43
43
  """Registry of all compiled prompts.
44
44
 
45
45
  This is to keep track of every result string returned by BasePrompt.compile().
@@ -49,7 +49,7 @@ key is the id() of the compiled string."""
49
49
  def _find_matching_prompt(obj):
50
50
  if isinstance(obj, str):
51
51
  for compiled in _compiled_prompt_registry.values():
52
- if compiled.value == obj:
52
+ if compiled.value in obj:
53
53
  return compiled
54
54
  return None
55
55
  elif isinstance(obj, dict):
@@ -68,7 +68,7 @@ def _find_matching_prompt(obj):
68
68
  return None
69
69
 
70
70
 
71
- def get_compiled_prompt(text: str) -> _CompiledPrompt | None:
71
+ def get_compiled_prompt(text: str) -> CompiledPrompt | None:
72
72
  """Find the compiled prompt metadata for a given compiled prompt string."""
73
73
  if not _compiled_prompt_registry:
74
74
  return None
@@ -76,7 +76,7 @@ def get_compiled_prompt(text: str) -> _CompiledPrompt | None:
76
76
  if direct_match:
77
77
  return direct_match
78
78
  for compiled in _compiled_prompt_registry.values():
79
- if compiled.value == text:
79
+ if compiled.value in text:
80
80
  return compiled
81
81
  try:
82
82
  obj = json.loads(text)
@@ -86,13 +86,31 @@ def get_compiled_prompt(text: str) -> _CompiledPrompt | None:
86
86
  return None
87
87
 
88
88
 
89
+ _PromptCompilationListener = Callable[[CompiledPrompt], None]
90
+ _prompt_compilation_listeners: list[_PromptCompilationListener] = []
91
+
92
+
93
+ def add_prompt_compilation_listener(listener: _PromptCompilationListener) -> None:
94
+ """Register a listener that will be called whenever a prompt is compiled.
95
+
96
+ The listener will be called with the prompt and the compiled prompt metadata.
97
+ """
98
+ _prompt_compilation_listeners.append(listener)
99
+
100
+
101
+ def remove_prompt_compilation_listener(listener: _PromptCompilationListener) -> None:
102
+ """Unregister a previously registered prompt compilation listener."""
103
+ if listener in _prompt_compilation_listeners:
104
+ _prompt_compilation_listeners.remove(listener)
105
+
106
+
89
107
  def _mark_compiled_prompts_outdated(
90
108
  prompt_id: str, outdated_prompt: "OutdatedPrompt"
91
109
  ) -> None:
92
110
  for key in list(_compiled_prompt_registry.keys()):
93
111
  compiled_prompt = _compiled_prompt_registry[key]
94
112
  if compiled_prompt.prompt.id == prompt_id:
95
- _compiled_prompt_registry[key] = _CompiledPrompt(
113
+ _compiled_prompt_registry[key] = CompiledPrompt(
96
114
  value=compiled_prompt.value,
97
115
  version_id=compiled_prompt.version_id,
98
116
  variables=compiled_prompt.variables,
@@ -257,12 +275,18 @@ class BasePrompt(BaseUntypedPrompt, Generic[TPromptVar]):
257
275
  ret = template.render(**variables.model_dump(mode="json"))
258
276
  else:
259
277
  ret = template_txt
260
- _compiled_prompt_registry[id(ret)] = _CompiledPrompt(
278
+ compiled = CompiledPrompt(
261
279
  value=ret,
262
280
  version_id=version_id,
263
281
  prompt=self,
264
282
  variables=variables,
265
283
  )
284
+ _compiled_prompt_registry[id(compiled)] = compiled
285
+ for listener in _prompt_compilation_listeners:
286
+ try:
287
+ listener(compiled)
288
+ except Exception:
289
+ pass
266
290
  return ret
267
291
 
268
292
  def _update(
@@ -355,7 +379,7 @@ class OutdatedPrompt(BasePrompt[TPromptVar]):
355
379
  *,
356
380
  versions: str | dict[str, str] | None = None,
357
381
  default_version_id: str | None = None,
358
- ) -> "OutdatedPrompt[TPromptVar]":
382
+ ) -> "tuple[Self, OutdatedPrompt[TPromptVar]]":
359
383
  raise ValueError("Cannot update an outdated prompt.")
360
384
 
361
385
  def get_default_version_id(self) -> str:
@@ -366,8 +390,8 @@ class OutdatedPrompt(BasePrompt[TPromptVar]):
366
390
 
367
391
  def compile(
368
392
  self,
369
- _variables: TPromptVar | None = None,
393
+ variables: TPromptVar | None = None,
370
394
  *,
371
- _version_id: str | None = None,
395
+ version_id: str | None = None,
372
396
  ) -> str:
373
397
  raise ValueError("This prompt is outdated and can no longer be used.")
@@ -260,16 +260,43 @@ class _FilePromptStorage(PromptStorage):
260
260
  _storage_instance: PromptStorage | None = None
261
261
 
262
262
 
263
+ def get_storage_root_directory() -> str:
264
+ return os.getenv("PIXIE_PROMPT_STORAGE_DIR", ".pixie/prompts")
265
+
266
+
267
+ def _ensure_storage_initialized() -> PromptStorage:
268
+ """Ensure storage is initialized, initializing it if necessary.
269
+
270
+ Returns:
271
+ The initialized storage instance.
272
+ """
273
+ global _storage_instance
274
+ if _storage_instance is None:
275
+ storage_directory = get_storage_root_directory()
276
+ _storage_instance = _FilePromptStorage(storage_directory, raise_on_error=False)
277
+ logger.info(
278
+ "Auto-initialized prompt storage at directory: %s", storage_directory
279
+ )
280
+ if _storage_instance.load_failures:
281
+ raise PromptLoadError(_storage_instance.load_failures)
282
+ return _storage_instance
283
+
284
+
263
285
  # TODO allow other storage types later
264
- def initialize_prompt_storage(directory: str) -> None:
286
+ def initialize_prompt_storage() -> None:
287
+ """Initialize prompt storage.
288
+
289
+ The storage directory is read from the PIXIE_PROMPT_STORAGE_DIR environment
290
+ variable, defaulting to '.pixie/prompts'.
291
+
292
+ Raises:
293
+ RuntimeError: If storage has already been initialized.
294
+ PromptLoadError: If there are failures loading prompts.
295
+ """
265
296
  global _storage_instance
266
297
  if _storage_instance is not None:
267
298
  raise RuntimeError("Prompt storage has already been initialized.")
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)
299
+ _ensure_storage_initialized()
273
300
 
274
301
 
275
302
  class StorageBackedPrompt(Prompt[TPromptVar]):
@@ -296,10 +323,9 @@ class StorageBackedPrompt(Prompt[TPromptVar]):
296
323
  return variables_definition_to_schema(self._variables_definition)
297
324
 
298
325
  def _get_prompt(self) -> BasePrompt[TPromptVar]:
299
- if _storage_instance is None:
300
- raise RuntimeError("Prompt storage has not been initialized.")
326
+ storage = _ensure_storage_initialized()
301
327
  if self._prompt is None:
302
- untyped_prompt = _storage_instance.get(self.id)
328
+ untyped_prompt = storage.get(self.id)
303
329
  self._prompt = BasePrompt.from_untyped(
304
330
  untyped_prompt,
305
331
  variables_definition=self.variables_definition,
@@ -317,8 +343,7 @@ class StorageBackedPrompt(Prompt[TPromptVar]):
317
343
  return self
318
344
 
319
345
  def exists_in_storage(self) -> bool:
320
- if _storage_instance is None:
321
- raise RuntimeError("Prompt storage has not been initialized.")
346
+ _ensure_storage_initialized()
322
347
  try:
323
348
  self.actualize()
324
349
  return True
@@ -330,9 +355,8 @@ class StorageBackedPrompt(Prompt[TPromptVar]):
330
355
  return prompt.get_versions()
331
356
 
332
357
  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)
358
+ storage = _ensure_storage_initialized()
359
+ prompt_with_ctime = storage.get(self.id)
336
360
  if not prompt_with_ctime:
337
361
  raise KeyError(f"Prompt with id '{self.id}' not found in storage.")
338
362
  return prompt_with_ctime.get_version_creation_time(version_id)
@@ -364,8 +388,7 @@ class StorageBackedPrompt(Prompt[TPromptVar]):
364
388
  content: str,
365
389
  set_as_default: bool = False,
366
390
  ) -> BasePrompt[TPromptVar]:
367
- if _storage_instance is None:
368
- raise RuntimeError("Prompt storage has not been initialized.")
391
+ storage = _ensure_storage_initialized()
369
392
  if self.exists_in_storage():
370
393
  prompt = self._get_prompt()
371
394
  prompt.append_version(
@@ -373,7 +396,7 @@ class StorageBackedPrompt(Prompt[TPromptVar]):
373
396
  content=content,
374
397
  set_as_default=set_as_default,
375
398
  )
376
- _storage_instance.save(prompt)
399
+ storage.save(prompt)
377
400
  return prompt
378
401
  else:
379
402
  # it should be safe to assume there's no actualized prompt for this id
@@ -384,16 +407,15 @@ class StorageBackedPrompt(Prompt[TPromptVar]):
384
407
  variables_definition=self.variables_definition,
385
408
  default_version_id=version_id,
386
409
  )
387
- _storage_instance.save(new_prompt)
410
+ storage.save(new_prompt)
388
411
  return new_prompt
389
412
 
390
413
  def update_default_version_id(
391
414
  self,
392
415
  version_id: str,
393
416
  ) -> BasePrompt[TPromptVar]:
394
- if _storage_instance is None:
395
- raise RuntimeError("Prompt storage has not been initialized.")
417
+ storage = _ensure_storage_initialized()
396
418
  prompt = self._get_prompt()
397
419
  prompt.update_default_version_id(version_id)
398
- _storage_instance.save(prompt)
420
+ storage.save(prompt)
399
421
  return prompt
@@ -4,7 +4,7 @@ packages = [
4
4
  { include = "pixie" },
5
5
  ]
6
6
 
7
- version = "0.1.8.dev6"
7
+ version = "0.1.10"
8
8
  description = "Code-first, type-safe prompt management"
9
9
  authors = ["Yiou Li <yol@gopixie.ai>"]
10
10
  license = "MIT"