pixie-prompts 0.1.8.dev6__tar.gz → 0.1.9__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.9
4
4
  Summary: Code-first, type-safe prompt management
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -18,6 +18,85 @@ _storage_observer: Observer | None = None # type: ignore
18
18
  _storage_reload_task: asyncio.Task | None = None
19
19
 
20
20
 
21
+ def _preload_pixie_modules(cwd: Path) -> None:
22
+ """Pre-load pixie modules from the local source directory.
23
+
24
+ This ensures that when dynamically loaded modules import pixie.registry,
25
+ they get the same module instance as the rest of the application.
26
+
27
+ Without this, namespace package collisions can occur when pixie-prompts
28
+ is installed as a package while pixie-sdk-py is run from source. The
29
+ dynamic module loading via importlib.util.spec_from_file_location() can
30
+ create separate module instances, leading to separate _registry dicts.
31
+
32
+ Args:
33
+ cwd: The current working directory to search for pixie modules.
34
+ """
35
+ pixie_dir = cwd / "pixie"
36
+ if not pixie_dir.is_dir():
37
+ return
38
+
39
+ # Find all pixie submodules that exist locally and pre-import them
40
+ # This ensures they're in sys.modules before any dynamic loading
41
+ pixie_modules_to_preload = []
42
+
43
+ for py_file in pixie_dir.rglob("*.py"):
44
+ if py_file.name.startswith("_"):
45
+ continue
46
+ # Skip if in __pycache__ or similar
47
+ if "__pycache__" in py_file.parts:
48
+ continue
49
+
50
+ relative_path = py_file.relative_to(cwd)
51
+ module_name = str(relative_path.with_suffix("")).replace("/", ".")
52
+ pixie_modules_to_preload.append((module_name, py_file))
53
+
54
+ # Sort to ensure parent modules are loaded before children
55
+ pixie_modules_to_preload.sort(key=lambda x: x[0])
56
+
57
+ for module_name, py_file in pixie_modules_to_preload:
58
+ if module_name in sys.modules:
59
+ continue
60
+
61
+ # Ensure parent packages are in sys.modules
62
+ parts = module_name.split(".")
63
+ for i in range(1, len(parts)):
64
+ parent_name = ".".join(parts[:i])
65
+ if parent_name not in sys.modules:
66
+ parent_path = cwd / Path(*parts[:i])
67
+ init_file = parent_path / "__init__.py"
68
+ if init_file.exists():
69
+ spec = importlib.util.spec_from_file_location(
70
+ parent_name, init_file
71
+ )
72
+ if spec and spec.loader:
73
+ module = importlib.util.module_from_spec(spec)
74
+ sys.modules[parent_name] = module
75
+ try:
76
+ spec.loader.exec_module(module)
77
+ except Exception:
78
+ pass
79
+ else:
80
+ # Create a namespace package
81
+ import types
82
+
83
+ ns_module = types.ModuleType(parent_name)
84
+ ns_module.__path__ = [str(parent_path)]
85
+ sys.modules[parent_name] = ns_module
86
+
87
+ # Now load the actual module
88
+ spec = importlib.util.spec_from_file_location(module_name, py_file)
89
+ if spec and spec.loader:
90
+ try:
91
+ module = importlib.util.module_from_spec(spec)
92
+ sys.modules[module_name] = module
93
+ spec.loader.exec_module(module)
94
+ except Exception:
95
+ # Don't fail if a module can't be loaded - it might have
96
+ # dependencies that aren't available yet
97
+ pass
98
+
99
+
21
100
  def discover_and_load_modules():
22
101
  """Discover and load all Python files that use pixie.prompts.create_prompt, or pixie.create_prompt.
23
102
 
@@ -34,6 +113,10 @@ def discover_and_load_modules():
34
113
  if str(cwd) not in sys.path:
35
114
  sys.path.insert(0, str(cwd))
36
115
 
116
+ # Pre-load pixie modules from local source to avoid namespace package collisions
117
+ # This ensures dynamically loaded modules use the same pixie.registry instance
118
+ _preload_pixie_modules(cwd)
119
+
37
120
  loaded_count = 0
38
121
  for py_file in python_files:
39
122
  # Skip __init__.py, private files, and anything in site-packages/venv
@@ -58,6 +141,12 @@ def discover_and_load_modules():
58
141
  # Load the module with a unique name based on path
59
142
  relative_path = py_file.relative_to(cwd)
60
143
  module_name = str(relative_path.with_suffix("")).replace("/", ".")
144
+
145
+ # Skip if module was already loaded (e.g., during preload)
146
+ if module_name in sys.modules:
147
+ loaded_count += 1
148
+ continue
149
+
61
150
  spec = importlib.util.spec_from_file_location(module_name, py_file)
62
151
  if spec and spec.loader:
63
152
  try:
@@ -309,10 +398,14 @@ async def stop_storage_watcher() -> None:
309
398
 
310
399
 
311
400
  def init_prompt_storage():
401
+ """Initialize prompt storage and return lifespan context manager.
312
402
 
403
+ Storage directory is read from PIXIE_PROMPT_STORAGE_DIR environment variable,
404
+ defaulting to '.pixie/prompts'.
405
+ """
313
406
  storage_directory = os.getenv("PIXIE_PROMPT_STORAGE_DIR", ".pixie/prompts")
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().
@@ -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
@@ -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,39 @@ class _FilePromptStorage(PromptStorage):
260
260
  _storage_instance: PromptStorage | None = None
261
261
 
262
262
 
263
+ def _ensure_storage_initialized() -> PromptStorage:
264
+ """Ensure storage is initialized, initializing it if necessary.
265
+
266
+ Returns:
267
+ The initialized storage instance.
268
+ """
269
+ global _storage_instance
270
+ if _storage_instance is None:
271
+ storage_directory = os.getenv("PIXIE_PROMPT_STORAGE_DIR", ".pixie/prompts")
272
+ _storage_instance = _FilePromptStorage(storage_directory, raise_on_error=False)
273
+ logger.info(
274
+ "Auto-initialized prompt storage at directory: %s", storage_directory
275
+ )
276
+ if _storage_instance.load_failures:
277
+ raise PromptLoadError(_storage_instance.load_failures)
278
+ return _storage_instance
279
+
280
+
263
281
  # TODO allow other storage types later
264
- def initialize_prompt_storage(directory: str) -> None:
282
+ def initialize_prompt_storage() -> None:
283
+ """Initialize prompt storage.
284
+
285
+ The storage directory is read from the PIXIE_PROMPT_STORAGE_DIR environment
286
+ variable, defaulting to '.pixie/prompts'.
287
+
288
+ Raises:
289
+ RuntimeError: If storage has already been initialized.
290
+ PromptLoadError: If there are failures loading prompts.
291
+ """
265
292
  global _storage_instance
266
293
  if _storage_instance is not None:
267
294
  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)
295
+ _ensure_storage_initialized()
273
296
 
274
297
 
275
298
  class StorageBackedPrompt(Prompt[TPromptVar]):
@@ -296,10 +319,9 @@ class StorageBackedPrompt(Prompt[TPromptVar]):
296
319
  return variables_definition_to_schema(self._variables_definition)
297
320
 
298
321
  def _get_prompt(self) -> BasePrompt[TPromptVar]:
299
- if _storage_instance is None:
300
- raise RuntimeError("Prompt storage has not been initialized.")
322
+ storage = _ensure_storage_initialized()
301
323
  if self._prompt is None:
302
- untyped_prompt = _storage_instance.get(self.id)
324
+ untyped_prompt = storage.get(self.id)
303
325
  self._prompt = BasePrompt.from_untyped(
304
326
  untyped_prompt,
305
327
  variables_definition=self.variables_definition,
@@ -317,8 +339,7 @@ class StorageBackedPrompt(Prompt[TPromptVar]):
317
339
  return self
318
340
 
319
341
  def exists_in_storage(self) -> bool:
320
- if _storage_instance is None:
321
- raise RuntimeError("Prompt storage has not been initialized.")
342
+ _ensure_storage_initialized()
322
343
  try:
323
344
  self.actualize()
324
345
  return True
@@ -330,9 +351,8 @@ class StorageBackedPrompt(Prompt[TPromptVar]):
330
351
  return prompt.get_versions()
331
352
 
332
353
  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)
354
+ storage = _ensure_storage_initialized()
355
+ prompt_with_ctime = storage.get(self.id)
336
356
  if not prompt_with_ctime:
337
357
  raise KeyError(f"Prompt with id '{self.id}' not found in storage.")
338
358
  return prompt_with_ctime.get_version_creation_time(version_id)
@@ -364,8 +384,7 @@ class StorageBackedPrompt(Prompt[TPromptVar]):
364
384
  content: str,
365
385
  set_as_default: bool = False,
366
386
  ) -> BasePrompt[TPromptVar]:
367
- if _storage_instance is None:
368
- raise RuntimeError("Prompt storage has not been initialized.")
387
+ storage = _ensure_storage_initialized()
369
388
  if self.exists_in_storage():
370
389
  prompt = self._get_prompt()
371
390
  prompt.append_version(
@@ -373,7 +392,7 @@ class StorageBackedPrompt(Prompt[TPromptVar]):
373
392
  content=content,
374
393
  set_as_default=set_as_default,
375
394
  )
376
- _storage_instance.save(prompt)
395
+ storage.save(prompt)
377
396
  return prompt
378
397
  else:
379
398
  # it should be safe to assume there's no actualized prompt for this id
@@ -384,16 +403,15 @@ class StorageBackedPrompt(Prompt[TPromptVar]):
384
403
  variables_definition=self.variables_definition,
385
404
  default_version_id=version_id,
386
405
  )
387
- _storage_instance.save(new_prompt)
406
+ storage.save(new_prompt)
388
407
  return new_prompt
389
408
 
390
409
  def update_default_version_id(
391
410
  self,
392
411
  version_id: str,
393
412
  ) -> BasePrompt[TPromptVar]:
394
- if _storage_instance is None:
395
- raise RuntimeError("Prompt storage has not been initialized.")
413
+ storage = _ensure_storage_initialized()
396
414
  prompt = self._get_prompt()
397
415
  prompt.update_default_version_id(version_id)
398
- _storage_instance.save(prompt)
416
+ storage.save(prompt)
399
417
  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.9"
8
8
  description = "Code-first, type-safe prompt management"
9
9
  authors = ["Yiou Li <yol@gopixie.ai>"]
10
10
  license = "MIT"