promptkeep 0.1.0__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.
promptkeep/__init__.py ADDED
@@ -0,0 +1,29 @@
1
+ """promptkeep: versioned prompt templates with lineage and run tracking.
2
+
3
+ Public API:
4
+
5
+ from promptkeep import Prompt, prompt, wrap, configure, history
6
+ """
7
+
8
+ from . import history
9
+ from .config import configure, get_settings
10
+ from .decorator import prompt
11
+ from .integrations import wrap
12
+ from .prompts import Prompt, RenderedText
13
+ from .rendering import MissingVariableError, TemplateParseError, extract_placeholders
14
+
15
+ __version__ = "0.1.0"
16
+
17
+ __all__ = [
18
+ "Prompt",
19
+ "RenderedText",
20
+ "prompt",
21
+ "wrap",
22
+ "configure",
23
+ "get_settings",
24
+ "history",
25
+ "extract_placeholders",
26
+ "MissingVariableError",
27
+ "TemplateParseError",
28
+ "__version__",
29
+ ]
promptkeep/config.py ADDED
@@ -0,0 +1,82 @@
1
+ """Global library configuration: DB location, tracking on/off, strict rendering.
2
+
3
+ Settings are resolved fresh on every access with a simple precedence:
4
+ explicit ``configure()`` overrides win, then environment variables
5
+ (``PROMPTKEEP_DB``, ``PROMPTKEEP_DISABLED``), then defaults.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import threading
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+ from typing import Optional, Union
15
+
16
+ DEFAULT_DB_FILENAME = ".promptkeep.db"
17
+
18
+ # configure() overrides live here; guarded by a lock since wrapped clients
19
+ # may resolve settings from multiple threads.
20
+ _lock = threading.Lock()
21
+ _overrides: dict = {}
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Settings:
26
+ """A resolved, immutable snapshot of the library's configuration."""
27
+
28
+ db_path: Path
29
+ enabled: bool
30
+ strict: bool
31
+
32
+
33
+ def configure(
34
+ db_path: Optional[Union[str, Path]] = None,
35
+ enabled: Optional[bool] = None,
36
+ strict: Optional[bool] = None,
37
+ ) -> None:
38
+ """Override library settings. Only the arguments you pass are changed.
39
+
40
+ - db_path: where the SQLite database lives (default: ./.promptkeep.db,
41
+ or the PROMPTKEEP_DB env var).
42
+ - enabled: turn persistence on/off entirely (default: on, unless
43
+ PROMPTKEEP_DISABLED is set). Rendering works either way.
44
+ - strict: raise on missing variables instead of leaving `{name}` literal
45
+ (default: False).
46
+ """
47
+ with _lock:
48
+ if db_path is not None:
49
+ _overrides["db_path"] = Path(db_path)
50
+ if enabled is not None:
51
+ _overrides["enabled"] = bool(enabled)
52
+ if strict is not None:
53
+ _overrides["strict"] = bool(strict)
54
+
55
+
56
+ def get_settings() -> Settings:
57
+ """Resolve the current settings: configure() overrides > env vars > defaults."""
58
+ with _lock:
59
+ # DB path: explicit override, then $PROMPTKEEP_DB, then ./.promptkeep.db.
60
+ db_path = _overrides.get("db_path")
61
+ if db_path is None:
62
+ env_path = os.environ.get("PROMPTKEEP_DB")
63
+ db_path = Path(env_path) if env_path else Path.cwd() / DEFAULT_DB_FILENAME
64
+
65
+ # Tracking: on by default; $PROMPTKEEP_DISABLED=1/true/yes/on kills it.
66
+ enabled = _overrides.get("enabled")
67
+ if enabled is None:
68
+ disabled = os.environ.get("PROMPTKEEP_DISABLED", "").strip().lower()
69
+ enabled = disabled not in ("1", "true", "yes", "on")
70
+
71
+ # Rendering strictness: lenient unless explicitly opted in.
72
+ strict = _overrides.get("strict", False)
73
+ return Settings(db_path=db_path, enabled=enabled, strict=strict)
74
+
75
+
76
+ def reset() -> None:
77
+ """Clear all configure() overrides and drop cached DB state. Mainly for tests."""
78
+ with _lock:
79
+ _overrides.clear()
80
+ from . import storage
81
+
82
+ storage.reset_caches()
@@ -0,0 +1,97 @@
1
+ """@prompt decorator for computed prompts.
2
+
3
+ The decorated function returns the *raw template* (placeholders intact); the
4
+ decorator turns each call into a Prompt object, using the call's arguments as
5
+ the variables dict. Both raw and rendered live on the returned Prompt, so
6
+ lineage and run tracking work exactly like literal prompts.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import functools
12
+ import hashlib
13
+ import inspect
14
+ import textwrap
15
+ from typing import Any, Callable, Optional
16
+
17
+ from .prompts import Prompt
18
+
19
+
20
+ def _function_source_hash(fn: Callable) -> Optional[str]:
21
+ """Hash the function's source so history can tell code changes apart from
22
+ same-code output changes. None when source isn't available (e.g. REPL)."""
23
+ try:
24
+ source = textwrap.dedent(inspect.getsource(fn))
25
+ except (OSError, TypeError):
26
+ return None
27
+ return hashlib.sha256(source.encode("utf-8")).hexdigest()
28
+
29
+
30
+ def prompt(name: str, strict: Optional[bool] = None):
31
+ """Turn a template-building function into a Prompt factory.
32
+
33
+ @prompt(name="REVIEW_SYSTEM")
34
+ def review_sys_prompt(var1="some value"):
35
+ return "You are a reviewer. Focus on {var1}."
36
+
37
+ p = review_sys_prompt(var1="security") # -> Prompt
38
+ p.raw # the template the function returned
39
+ p.text # rendered with the call's arguments
40
+
41
+ The version identity is the returned template text (content-hash dedup);
42
+ a hash of the function's source is stored alongside each version so
43
+ history can tell "code changed" apart from "same code, different output".
44
+ """
45
+ # Catch the bare-decorator mistake (@prompt without parentheses) early.
46
+ if callable(name):
47
+ raise TypeError(
48
+ '@prompt requires a name: use @prompt(name="MY_PROMPT") — '
49
+ "the name is the prompt's stable identity"
50
+ )
51
+ if not isinstance(name, str) or not name.strip():
52
+ raise ValueError("@prompt requires a non-empty name string")
53
+
54
+ def decorate(fn: Callable[..., str]):
55
+ """Wrap fn so each call yields a Prompt built from its return value."""
56
+ # Computed once at decoration time; identical for every call.
57
+ fn_hash = _function_source_hash(fn)
58
+ signature = inspect.signature(fn)
59
+
60
+ @functools.wraps(fn)
61
+ def wrapper(*args: Any, **kwargs: Any) -> Prompt:
62
+ """Call fn, then package its returned template into a Prompt."""
63
+ # Capture the full call (including defaults) as the variables dict.
64
+ bound = signature.bind(*args, **kwargs)
65
+ bound.apply_defaults()
66
+ variables: dict = {}
67
+ for param_name, value in bound.arguments.items():
68
+ kind = signature.parameters[param_name].kind
69
+ if kind is inspect.Parameter.VAR_KEYWORD:
70
+ # **kwargs entries become top-level variables.
71
+ variables.update(value)
72
+ elif kind is inspect.Parameter.VAR_POSITIONAL:
73
+ # *args recorded as a list under the parameter's name.
74
+ variables[param_name] = list(value)
75
+ else:
76
+ variables[param_name] = value
77
+
78
+ # The function's return value is the raw template.
79
+ template = fn(*args, **kwargs)
80
+ if not isinstance(template, str):
81
+ raise TypeError(
82
+ f"@prompt function {fn.__name__!r} must return a template string,"
83
+ f" got {type(template).__name__}"
84
+ )
85
+ return Prompt(
86
+ template,
87
+ variables,
88
+ name=name,
89
+ strict=strict,
90
+ source="decorator",
91
+ fn_source_hash=fn_hash,
92
+ )
93
+
94
+ wrapper.prompt_name = name # type: ignore[attr-defined]
95
+ return wrapper
96
+
97
+ return decorate
promptkeep/history.py ADDED
@@ -0,0 +1,117 @@
1
+ """Query the lineage and run history of a prompt by name.
2
+
3
+ The read-side API: storage returns raw dict rows; this module shapes them
4
+ into typed, immutable dataclasses that are pleasant to work with.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import difflib
10
+ import json
11
+ from dataclasses import dataclass
12
+ from typing import Any, Dict, List, Optional
13
+
14
+ from . import storage
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class VersionInfo:
19
+ """One version of a prompt's template, as recorded in the lineage."""
20
+
21
+ version: int
22
+ template: str
23
+ template_hash: str
24
+ source: str
25
+ fn_source_hash: Optional[str]
26
+ created_at: str
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class RunInfo:
31
+ """One recorded execution: which version ran, with what, and what came back."""
32
+
33
+ id: int
34
+ prompt_name: str
35
+ version: int
36
+ variables: Optional[Dict[str, Any]]
37
+ rendered_text: str
38
+ provider: str
39
+ model: Optional[str]
40
+ request_params: Optional[Dict[str, Any]]
41
+ response_id: Optional[str]
42
+ output_text: Optional[str]
43
+ prompt_tokens: Optional[int]
44
+ completion_tokens: Optional[int]
45
+ total_tokens: Optional[int]
46
+ latency_ms: Optional[int]
47
+ status: str
48
+ error: Optional[str]
49
+ created_at: str
50
+
51
+
52
+ def _load_json(value: Optional[str]):
53
+ """Decode a stored JSON column; malformed/missing data becomes None."""
54
+ if value is None:
55
+ return None
56
+ try:
57
+ return json.loads(value)
58
+ except (ValueError, TypeError):
59
+ return None
60
+
61
+
62
+ def versions(name: str) -> List[VersionInfo]:
63
+ """All versions of a prompt, oldest first."""
64
+ return [
65
+ VersionInfo(
66
+ version=row["version"],
67
+ template=row["template"],
68
+ template_hash=row["template_hash"],
69
+ source=row["source"],
70
+ fn_source_hash=row["fn_source_hash"],
71
+ created_at=row["created_at"],
72
+ )
73
+ for row in storage.fetch_versions(name)
74
+ ]
75
+
76
+
77
+ def diff(name: str, old: int, new: int) -> str:
78
+ """Unified diff between two versions of a prompt's template."""
79
+ # Load the lineage once and validate both requested versions exist.
80
+ by_number = {v.version: v for v in versions(name)}
81
+ for wanted in (old, new):
82
+ if wanted not in by_number:
83
+ raise ValueError(f"prompt {name!r} has no version {wanted}")
84
+ lines = difflib.unified_diff(
85
+ by_number[old].template.splitlines(),
86
+ by_number[new].template.splitlines(),
87
+ fromfile=f"{name} v{old}",
88
+ tofile=f"{name} v{new}",
89
+ lineterm="",
90
+ )
91
+ return "\n".join(lines)
92
+
93
+
94
+ def runs(name: str, version: Optional[int] = None, limit: int = 50) -> List[RunInfo]:
95
+ """Recorded runs for a prompt (optionally one version), newest first."""
96
+ return [
97
+ RunInfo(
98
+ id=row["id"],
99
+ prompt_name=row["prompt_name"],
100
+ version=row["version"],
101
+ variables=_load_json(row["variables"]),
102
+ rendered_text=row["rendered_text"],
103
+ provider=row["provider"],
104
+ model=row["model"],
105
+ request_params=_load_json(row["request_params"]),
106
+ response_id=row["response_id"],
107
+ output_text=row["output_text"],
108
+ prompt_tokens=row["prompt_tokens"],
109
+ completion_tokens=row["completion_tokens"],
110
+ total_tokens=row["total_tokens"],
111
+ latency_ms=row["latency_ms"],
112
+ status=row["status"],
113
+ error=row["error"],
114
+ created_at=row["created_at"],
115
+ )
116
+ for row in storage.fetch_runs(name, version=version, limit=limit)
117
+ ]
@@ -0,0 +1,33 @@
1
+ """Provider integrations. `wrap()` is the public entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def wrap(target):
7
+ """Wrap an OpenAI client class or instance so Prompt objects work as
8
+ message content and every call is recorded as a run.
9
+
10
+ from openai import OpenAI
11
+ from promptkeep import wrap
12
+
13
+ OpenAI = wrap(OpenAI) # wrap the class...
14
+ client = OpenAI(api_key=...) # ...then use it exactly as before
15
+
16
+ # or wrap a live client:
17
+ client = wrap(OpenAI(api_key=...))
18
+ """
19
+ from .openai_wrapper import wrap_openai_class, wrap_openai_instance
20
+
21
+ if isinstance(target, type):
22
+ return wrap_openai_class(target)
23
+ chat = getattr(target, "chat", None)
24
+ completions = getattr(chat, "completions", None)
25
+ if completions is not None and hasattr(completions, "create"):
26
+ return wrap_openai_instance(target)
27
+ raise TypeError(
28
+ "wrap() expects an OpenAI client class or instance"
29
+ f" (an object with .chat.completions.create); got {target!r}"
30
+ )
31
+
32
+
33
+ __all__ = ["wrap"]