spanforge 1.0.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.
Files changed (174) hide show
  1. spanforge/__init__.py +815 -0
  2. spanforge/_ansi.py +93 -0
  3. spanforge/_batch_exporter.py +409 -0
  4. spanforge/_cli.py +2094 -0
  5. spanforge/_cli_audit.py +639 -0
  6. spanforge/_cli_compliance.py +711 -0
  7. spanforge/_cli_cost.py +243 -0
  8. spanforge/_cli_ops.py +791 -0
  9. spanforge/_cli_phase11.py +356 -0
  10. spanforge/_hooks.py +337 -0
  11. spanforge/_server.py +1708 -0
  12. spanforge/_span.py +1036 -0
  13. spanforge/_store.py +288 -0
  14. spanforge/_stream.py +664 -0
  15. spanforge/_trace.py +335 -0
  16. spanforge/_tracer.py +254 -0
  17. spanforge/actor.py +141 -0
  18. spanforge/alerts.py +469 -0
  19. spanforge/auto.py +464 -0
  20. spanforge/baseline.py +335 -0
  21. spanforge/cache.py +635 -0
  22. spanforge/compliance.py +325 -0
  23. spanforge/config.py +532 -0
  24. spanforge/consent.py +228 -0
  25. spanforge/consumer.py +377 -0
  26. spanforge/core/__init__.py +5 -0
  27. spanforge/core/compliance_mapping.py +1254 -0
  28. spanforge/cost.py +600 -0
  29. spanforge/debug.py +548 -0
  30. spanforge/deprecations.py +205 -0
  31. spanforge/drift.py +482 -0
  32. spanforge/egress.py +58 -0
  33. spanforge/eval.py +648 -0
  34. spanforge/event.py +1064 -0
  35. spanforge/exceptions.py +240 -0
  36. spanforge/explain.py +178 -0
  37. spanforge/export/__init__.py +69 -0
  38. spanforge/export/append_only.py +337 -0
  39. spanforge/export/cloud.py +357 -0
  40. spanforge/export/datadog.py +497 -0
  41. spanforge/export/grafana.py +320 -0
  42. spanforge/export/jsonl.py +195 -0
  43. spanforge/export/openinference.py +158 -0
  44. spanforge/export/otel_bridge.py +294 -0
  45. spanforge/export/otlp.py +811 -0
  46. spanforge/export/otlp_bridge.py +233 -0
  47. spanforge/export/redis_backend.py +282 -0
  48. spanforge/export/siem_schema.py +98 -0
  49. spanforge/export/siem_splunk.py +264 -0
  50. spanforge/export/siem_syslog.py +212 -0
  51. spanforge/export/webhook.py +299 -0
  52. spanforge/exporters/__init__.py +30 -0
  53. spanforge/exporters/console.py +271 -0
  54. spanforge/exporters/jsonl.py +144 -0
  55. spanforge/exporters/sqlite.py +142 -0
  56. spanforge/gate.py +1150 -0
  57. spanforge/governance.py +181 -0
  58. spanforge/hitl.py +295 -0
  59. spanforge/http.py +187 -0
  60. spanforge/inspect.py +427 -0
  61. spanforge/integrations/__init__.py +45 -0
  62. spanforge/integrations/_pricing.py +280 -0
  63. spanforge/integrations/anthropic.py +388 -0
  64. spanforge/integrations/azure_openai.py +133 -0
  65. spanforge/integrations/bedrock.py +292 -0
  66. spanforge/integrations/crewai.py +251 -0
  67. spanforge/integrations/gemini.py +351 -0
  68. spanforge/integrations/groq.py +442 -0
  69. spanforge/integrations/langchain.py +349 -0
  70. spanforge/integrations/langgraph.py +306 -0
  71. spanforge/integrations/llamaindex.py +373 -0
  72. spanforge/integrations/ollama.py +287 -0
  73. spanforge/integrations/openai.py +368 -0
  74. spanforge/integrations/together.py +483 -0
  75. spanforge/io.py +214 -0
  76. spanforge/lint.py +322 -0
  77. spanforge/metrics.py +417 -0
  78. spanforge/metrics_export.py +343 -0
  79. spanforge/migrate.py +402 -0
  80. spanforge/model_registry.py +278 -0
  81. spanforge/models.py +389 -0
  82. spanforge/namespaces/__init__.py +254 -0
  83. spanforge/namespaces/audit.py +256 -0
  84. spanforge/namespaces/cache.py +237 -0
  85. spanforge/namespaces/chain.py +77 -0
  86. spanforge/namespaces/confidence.py +72 -0
  87. spanforge/namespaces/consent.py +92 -0
  88. spanforge/namespaces/cost.py +179 -0
  89. spanforge/namespaces/decision.py +143 -0
  90. spanforge/namespaces/diff.py +157 -0
  91. spanforge/namespaces/drift.py +80 -0
  92. spanforge/namespaces/eval_.py +251 -0
  93. spanforge/namespaces/feedback.py +241 -0
  94. spanforge/namespaces/fence.py +193 -0
  95. spanforge/namespaces/guard.py +105 -0
  96. spanforge/namespaces/hitl.py +91 -0
  97. spanforge/namespaces/latency.py +72 -0
  98. spanforge/namespaces/prompt.py +190 -0
  99. spanforge/namespaces/redact.py +173 -0
  100. spanforge/namespaces/retrieval.py +379 -0
  101. spanforge/namespaces/runtime_governance.py +494 -0
  102. spanforge/namespaces/template.py +208 -0
  103. spanforge/namespaces/tool_call.py +77 -0
  104. spanforge/namespaces/trace.py +1029 -0
  105. spanforge/normalizer.py +171 -0
  106. spanforge/plugins.py +82 -0
  107. spanforge/presidio_backend.py +349 -0
  108. spanforge/processor.py +258 -0
  109. spanforge/prompt_registry.py +418 -0
  110. spanforge/py.typed +0 -0
  111. spanforge/redact.py +914 -0
  112. spanforge/regression.py +192 -0
  113. spanforge/runtime_policy.py +159 -0
  114. spanforge/sampling.py +511 -0
  115. spanforge/schema.py +183 -0
  116. spanforge/schemas/v1.0/schema.json +170 -0
  117. spanforge/schemas/v2.0/schema.json +536 -0
  118. spanforge/sdk/__init__.py +625 -0
  119. spanforge/sdk/_base.py +584 -0
  120. spanforge/sdk/_base.pyi +71 -0
  121. spanforge/sdk/_exceptions.py +1096 -0
  122. spanforge/sdk/_types.py +2184 -0
  123. spanforge/sdk/alert.py +1514 -0
  124. spanforge/sdk/alert.pyi +56 -0
  125. spanforge/sdk/audit.py +1196 -0
  126. spanforge/sdk/audit.pyi +67 -0
  127. spanforge/sdk/cec.py +1215 -0
  128. spanforge/sdk/cec.pyi +37 -0
  129. spanforge/sdk/config.py +641 -0
  130. spanforge/sdk/config.pyi +55 -0
  131. spanforge/sdk/enterprise.py +714 -0
  132. spanforge/sdk/enterprise.pyi +79 -0
  133. spanforge/sdk/explain.py +170 -0
  134. spanforge/sdk/fallback.py +432 -0
  135. spanforge/sdk/feedback.py +351 -0
  136. spanforge/sdk/gate.py +874 -0
  137. spanforge/sdk/gate.pyi +51 -0
  138. spanforge/sdk/identity.py +2114 -0
  139. spanforge/sdk/identity.pyi +47 -0
  140. spanforge/sdk/lineage.py +175 -0
  141. spanforge/sdk/observe.py +1065 -0
  142. spanforge/sdk/observe.pyi +50 -0
  143. spanforge/sdk/operator.py +338 -0
  144. spanforge/sdk/pii.py +1473 -0
  145. spanforge/sdk/pii.pyi +119 -0
  146. spanforge/sdk/pipelines.py +458 -0
  147. spanforge/sdk/pipelines.pyi +39 -0
  148. spanforge/sdk/policy.py +930 -0
  149. spanforge/sdk/rag.py +594 -0
  150. spanforge/sdk/rbac.py +280 -0
  151. spanforge/sdk/registry.py +430 -0
  152. spanforge/sdk/registry.pyi +46 -0
  153. spanforge/sdk/scope.py +279 -0
  154. spanforge/sdk/secrets.py +293 -0
  155. spanforge/sdk/secrets.pyi +25 -0
  156. spanforge/sdk/security.py +560 -0
  157. spanforge/sdk/security.pyi +57 -0
  158. spanforge/sdk/trust.py +472 -0
  159. spanforge/sdk/trust.pyi +41 -0
  160. spanforge/secrets.py +799 -0
  161. spanforge/signing.py +1179 -0
  162. spanforge/stats.py +100 -0
  163. spanforge/stream.py +560 -0
  164. spanforge/testing.py +378 -0
  165. spanforge/testing_mocks.py +1052 -0
  166. spanforge/trace.py +199 -0
  167. spanforge/types.py +696 -0
  168. spanforge/ulid.py +300 -0
  169. spanforge/validate.py +379 -0
  170. spanforge-1.0.0.dist-info/METADATA +1509 -0
  171. spanforge-1.0.0.dist-info/RECORD +174 -0
  172. spanforge-1.0.0.dist-info/WHEEL +4 -0
  173. spanforge-1.0.0.dist-info/entry_points.txt +5 -0
  174. spanforge-1.0.0.dist-info/licenses/LICENSE +128 -0
spanforge/processor.py ADDED
@@ -0,0 +1,258 @@
1
+ """spanforge.processor — Span processor pipeline (RFC-0001 §18).
2
+
3
+ Span processors let users hook into the span lifecycle **before** and
4
+ **after** a span is exported. Common uses:
5
+
6
+ * Attribute enrichment (e.g. add ``k8s.pod_name`` to every span)
7
+ * Redaction of sensitive fields (complementing built-in :class:`~spanforge.redact.RedactionPolicy`)
8
+ * Custom metrics counters / latency histograms
9
+ * Distributed context propagation helpers
10
+
11
+ Usage::
12
+
13
+ from spanforge import configure
14
+ from spanforge.processor import SpanProcessor, ProcessorChain
15
+
16
+ class EnrichProcessor(SpanProcessor):
17
+ def on_start(self, span) -> None:
18
+ span.set_attribute("service.region", "us-east-1")
19
+
20
+ def on_end(self, span) -> None:
21
+ # span is already finalised with status / duration
22
+ if span.status == "error":
23
+ span.set_attribute("alert.triggered", True)
24
+
25
+ configure(span_processors=[EnrichProcessor()])
26
+
27
+ Processors receive the *live* :class:`~spanforge._span.Span` object.
28
+ Mutations made in ``on_start`` are visible to user code inside the ``with``
29
+ block. Mutations made in ``on_end`` appear in the exported payload.
30
+
31
+ Thread-safety
32
+ -------------
33
+ Processors are called from the thread that owns the span context manager, so
34
+ they run in the same thread/task as the user code. Processors MUST NOT
35
+ block the event loop; long-running work should be dispatched to a background
36
+ thread or asyncio task.
37
+
38
+ Error handling
39
+ --------------
40
+ Exceptions propagating from a processor are silently caught so that a buggy
41
+ processor never aborts user code. Errors are logged at ``WARNING`` level.
42
+ """
43
+
44
+ from __future__ import annotations
45
+
46
+ import contextlib
47
+ import logging
48
+ import threading
49
+ from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
50
+
51
+ if TYPE_CHECKING:
52
+ from spanforge._span import Span
53
+
54
+ __all__ = [
55
+ "NoopSpanProcessor",
56
+ "ProcessorChain",
57
+ "SpanProcessor",
58
+ "add_processor",
59
+ "clear_processors",
60
+ ]
61
+
62
+ _proc_logger = logging.getLogger("spanforge.processor")
63
+
64
+
65
+ # ---------------------------------------------------------------------------
66
+ # Protocol
67
+ # ---------------------------------------------------------------------------
68
+
69
+
70
+ @runtime_checkable
71
+ class SpanProcessor(Protocol):
72
+ """Protocol implemented by all span processors.
73
+
74
+ Both methods are optional — a processor that only enriches on start can
75
+ omit ``on_end``, and vice-versa. The default no-op implementations
76
+ defined in this protocol mean partial implementations work correctly.
77
+ """
78
+
79
+ def on_start(self, span: Span) -> None:
80
+ """Called synchronously immediately after the span is created.
81
+
82
+ The span has been pushed onto the context stack and its start time
83
+ recorded. Attributes may be freely added or mutated here.
84
+
85
+ Args:
86
+ span: The newly created :class:`~spanforge._span.Span` (mutable).
87
+ """
88
+ ...
89
+
90
+ def on_end(self, span: Span) -> None:
91
+ """Called synchronously after the span is finalised but before export.
92
+
93
+ ``span.end_ns``, ``span.duration_ms``, and ``span.status`` are all
94
+ set by the time this method runs. Attributes may still be mutated
95
+ and will appear in the exported :class:`~spanforge.namespaces.trace.SpanPayload`.
96
+
97
+ Args:
98
+ span: The finalised :class:`~spanforge._span.Span` (still mutable).
99
+ """
100
+ ...
101
+
102
+
103
+ # ---------------------------------------------------------------------------
104
+ # No-op implementation (default)
105
+ # ---------------------------------------------------------------------------
106
+
107
+
108
+ class NoopSpanProcessor:
109
+ """Span processor that does nothing. Used as the default."""
110
+
111
+ def on_start(self, span: Span) -> None:
112
+ """No-op span start hook."""
113
+ pass
114
+
115
+ def on_end(self, span: Span) -> None:
116
+ """No-op span end hook."""
117
+ pass
118
+
119
+
120
+ # ---------------------------------------------------------------------------
121
+ # Processor chain
122
+ # ---------------------------------------------------------------------------
123
+
124
+
125
+ class ProcessorChain:
126
+ """An ordered chain of :class:`SpanProcessor` implementations.
127
+
128
+ Processors are called in insertion order for ``on_start`` and in the
129
+ **same** order for ``on_end``. Errors are caught per-processor so a
130
+ bug in one processor does not prevent subsequent processors from running.
131
+
132
+ Args:
133
+ processors: Initial list of processors.
134
+
135
+ Example::
136
+
137
+ chain = ProcessorChain([EnrichProcessor(), RedactProcessor()])
138
+ chain.on_start(span)
139
+ # ... later ...
140
+ chain.on_end(span)
141
+ """
142
+
143
+ def __init__(self, processors: list[Any] | None = None) -> None:
144
+ self._processors: list[Any] = list(processors or [])
145
+ self._lock = threading.Lock()
146
+
147
+ def add(self, processor: Any) -> None:
148
+ """Append *processor* to the chain."""
149
+ with self._lock:
150
+ self._processors.append(processor)
151
+
152
+ def remove(self, processor: Any) -> None:
153
+ """Remove *processor* from the chain (no-op if not present)."""
154
+ with self._lock, contextlib.suppress(ValueError):
155
+ self._processors.remove(processor)
156
+
157
+ def clear(self) -> None:
158
+ """Remove all processors from the chain."""
159
+ with self._lock:
160
+ self._processors.clear()
161
+
162
+ def on_start(self, span: Span) -> None:
163
+ """Fire ``on_start`` on all processors in order."""
164
+ with self._lock:
165
+ procs = list(self._processors) # snapshot to avoid holding lock during callbacks
166
+ for proc in procs:
167
+ try:
168
+ proc.on_start(span)
169
+ except Exception as exc: # NOSONAR
170
+ _proc_logger.warning(
171
+ "SpanProcessor.on_start error in %r: %s", type(proc).__name__, exc
172
+ )
173
+
174
+ def on_end(self, span: Span) -> None:
175
+ """Fire ``on_end`` on all processors in order."""
176
+ with self._lock:
177
+ procs = list(self._processors) # snapshot to avoid holding lock during callbacks
178
+ for proc in procs:
179
+ try:
180
+ proc.on_end(span)
181
+ except Exception as exc: # NOSONAR
182
+ _proc_logger.warning(
183
+ "SpanProcessor.on_end error in %r: %s", type(proc).__name__, exc
184
+ )
185
+
186
+ def __len__(self) -> int:
187
+ with self._lock:
188
+ return len(self._processors)
189
+
190
+ def __repr__(self) -> str:
191
+ with self._lock:
192
+ names = [type(p).__name__ for p in self._processors]
193
+ return f"ProcessorChain({names!r})"
194
+
195
+
196
+ # ---------------------------------------------------------------------------
197
+ # Module-level helpers — called from _span.py
198
+ # ---------------------------------------------------------------------------
199
+
200
+
201
+ def _run_on_start(span: Span) -> None:
202
+ """Fire ``on_start`` on all processors registered in the active config."""
203
+ try:
204
+ from spanforge.config import get_config
205
+
206
+ processors = get_config().span_processors
207
+ except Exception: # NOSONAR
208
+ return
209
+ for proc in processors:
210
+ try:
211
+ proc.on_start(span)
212
+ except Exception as exc: # NOSONAR
213
+ _proc_logger.warning("SpanProcessor.on_start error in %r: %s", type(proc).__name__, exc)
214
+
215
+
216
+ def _run_on_end(span: Span) -> None:
217
+ """Fire ``on_end`` on all processors registered in the active config."""
218
+ try:
219
+ from spanforge.config import get_config
220
+
221
+ processors = get_config().span_processors
222
+ except Exception: # NOSONAR
223
+ return
224
+ for proc in processors:
225
+ try:
226
+ proc.on_end(span)
227
+ except Exception as exc: # NOSONAR
228
+ _proc_logger.warning("SpanProcessor.on_end error in %r: %s", type(proc).__name__, exc)
229
+
230
+
231
+ def add_processor(processor: Any) -> None:
232
+ """Append *processor* to the global span processor list in the active config.
233
+
234
+ Convenience wrapper around ``configure(span_processors=[...])``.
235
+
236
+ Args:
237
+ processor: Any object implementing :class:`SpanProcessor` protocol.
238
+
239
+ Example::
240
+
241
+ from spanforge.processor import add_processor, SpanProcessor
242
+
243
+ class Enricher(SpanProcessor):
244
+ def on_start(self, span): span.set_attribute("region", "eu-west-1")
245
+ def on_end(self, span): pass
246
+
247
+ add_processor(Enricher())
248
+ """
249
+ from spanforge.config import get_config
250
+
251
+ get_config().span_processors.append(processor)
252
+
253
+
254
+ def clear_processors() -> None:
255
+ """Remove all span processors from the active config."""
256
+ from spanforge.config import get_config
257
+
258
+ get_config().span_processors.clear()
@@ -0,0 +1,418 @@
1
+ """spanforge.prompt_registry — Prompt registry with versioning and W3C-event emission.
2
+
3
+ The prompt registry provides a centralised store for prompt templates so that
4
+ every rendered prompt is linked to the exact version that produced it. This
5
+ enables:
6
+
7
+ * **Reproducibility** — re-run any historical span with the same prompt.
8
+ * **A/B testing** — route traffic between prompt versions and compare results.
9
+ * **Audit trail** — the RFC-0001 ``llm.prompt.*`` events capture template
10
+ load, version change, and render events.
11
+
12
+ Quick start
13
+ -----------
14
+ ::
15
+
16
+ from spanforge.prompt_registry import PromptRegistry
17
+
18
+ registry = PromptRegistry()
19
+ registry.register(
20
+ name="rag_system",
21
+ template="You are {role}. Answer only from: {context}",
22
+ version="1.0.0",
23
+ )
24
+
25
+ rendered = registry.render("rag_system", {"role": "expert", "context": "...docs..."})
26
+ print(rendered)
27
+ # You are expert. Answer only from: ...docs...
28
+
29
+ # Later, update the template — version change event is emitted automatically.
30
+ registry.register(
31
+ name="rag_system",
32
+ template="You are {role}. Use ONLY these documents: {context}",
33
+ version="1.1.0",
34
+ )
35
+
36
+ Module-level singleton
37
+ ----------------------
38
+ A module-level ``_DEFAULT_REGISTRY`` is provided. Helper functions
39
+ :func:`register_prompt`, :func:`get_prompt_version`, and :func:`render_prompt`
40
+ delegate to it for convenience::
41
+
42
+ from spanforge.prompt_registry import register_prompt, render_prompt
43
+
44
+ register_prompt("greet", "Hello, {name}!", version="1.0.0")
45
+ text = render_prompt("greet", {"name": "world"})
46
+ """
47
+
48
+ from __future__ import annotations
49
+
50
+ import logging
51
+ import re
52
+ import time
53
+ from dataclasses import dataclass, field
54
+ from typing import Any
55
+
56
+ __all__ = [
57
+ "PromptRegistry",
58
+ "PromptVersion",
59
+ "get_prompt_version",
60
+ "register_prompt",
61
+ "render_prompt",
62
+ ]
63
+
64
+ _log = logging.getLogger("spanforge.prompt_registry")
65
+
66
+ # Simple {placeholder} pattern (not Jinja — zero runtime dependencies).
67
+ _PLACEHOLDER_RE = re.compile(r"\{(\w+)\}")
68
+
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # PromptVersion dataclass
72
+ # ---------------------------------------------------------------------------
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class PromptVersion:
77
+ """An immutable snapshot of a versioned prompt template.
78
+
79
+ Args:
80
+ name: Registry name (e.g. ``"rag_system"``).
81
+ template: Raw template string with ``{variable}`` placeholders.
82
+ version: Semantic version string (e.g. ``"1.0.0"``).
83
+ variables: List of placeholder names extracted from *template*.
84
+ created_at: Unix timestamp when this version was registered.
85
+ metadata: Free-form metadata dict (author, model hint, etc.).
86
+ """
87
+
88
+ name: str
89
+ template: str
90
+ version: str
91
+ variables: list[str] = field(default_factory=list)
92
+ created_at: float = field(default_factory=time.time)
93
+ metadata: dict[str, Any] | None = None
94
+
95
+ def render(self, variables: dict[str, Any]) -> str:
96
+ """Render the template by substituting *variables*.
97
+
98
+ Args:
99
+ variables: Dict of ``{placeholder: value}`` pairs.
100
+
101
+ Returns:
102
+ The rendered string.
103
+
104
+ Raises:
105
+ KeyError: If a required placeholder is missing from *variables*.
106
+
107
+ Example::
108
+
109
+ pv = PromptVersion("greet", "Hello, {name}!", "1.0.0", ["name"])
110
+ pv.render({"name": "Alice"})
111
+ # "Hello, Alice!"
112
+ """
113
+ missing = [v for v in self.variables if v not in variables]
114
+ if missing:
115
+ raise KeyError(
116
+ f"PromptVersion '{self.name}@{self.version}' requires variables "
117
+ f"{missing!r} but they were not supplied."
118
+ )
119
+ return self.template.format(**variables)
120
+
121
+ def to_dict(self) -> dict[str, Any]:
122
+ """Serialise to a plain dict."""
123
+ d: dict[str, Any] = {
124
+ "name": self.name,
125
+ "template": self.template,
126
+ "version": self.version,
127
+ "variables": self.variables,
128
+ "created_at": self.created_at,
129
+ }
130
+ if self.metadata is not None:
131
+ d["metadata"] = self.metadata
132
+ return d
133
+
134
+ @classmethod
135
+ def from_dict(cls, data: dict[str, Any]) -> PromptVersion:
136
+ """Deserialise from a plain dict."""
137
+ return cls(
138
+ name=data["name"],
139
+ template=data["template"],
140
+ version=data["version"],
141
+ variables=list(data.get("variables", [])),
142
+ created_at=float(data.get("created_at", time.time())),
143
+ metadata=data.get("metadata"),
144
+ )
145
+
146
+
147
+ # ---------------------------------------------------------------------------
148
+ # PromptRegistry
149
+ # ---------------------------------------------------------------------------
150
+
151
+
152
+ class PromptRegistry:
153
+ """Thread-safe registry of versioned prompt templates.
154
+
155
+ Multiple versions of the same template name are stored independently.
156
+ The *latest* version (most recently registered) is used by default when
157
+ calling :meth:`render`.
158
+
159
+ Example::
160
+
161
+ registry = PromptRegistry()
162
+ registry.register("system", "You are {role}.", version="1.0.0")
163
+ registry.register("system", "You are a helpful {role}.", version="2.0.0")
164
+
165
+ # Uses version 2.0.0 (latest).
166
+ registry.render("system", {"role": "assistant"})
167
+ """
168
+
169
+ def __init__(self) -> None:
170
+ import threading
171
+
172
+ self._lock = threading.RLock()
173
+ # {name: {version: PromptVersion}}
174
+ self._store: dict[str, dict[str, PromptVersion]] = {}
175
+ # {name: version_string} — last registered version = default
176
+ self._latest: dict[str, str] = {}
177
+
178
+ # ------------------------------------------------------------------
179
+ # Registration
180
+ # ------------------------------------------------------------------
181
+
182
+ def register(
183
+ self,
184
+ name: str,
185
+ template: str,
186
+ *,
187
+ version: str = "1.0.0",
188
+ metadata: dict[str, Any] | None = None,
189
+ ) -> PromptVersion:
190
+ """Register (or update) a prompt template.
191
+
192
+ Emits:
193
+ * ``llm.prompt.template.loaded`` on first registration.
194
+ * ``llm.prompt.version.changed`` when a *name* already exists
195
+ (even if the version string is the same).
196
+
197
+ Args:
198
+ name: Unique prompt name within this registry.
199
+ template: Template string with ``{variable}`` placeholders.
200
+ version: Semantic version string.
201
+ metadata: Optional free-form metadata.
202
+
203
+ Returns:
204
+ The newly created :class:`PromptVersion`.
205
+ """
206
+ variables = _PLACEHOLDER_RE.findall(template)
207
+ pv = PromptVersion(
208
+ name=name,
209
+ template=template,
210
+ version=version,
211
+ variables=variables,
212
+ metadata=metadata,
213
+ )
214
+ with self._lock:
215
+ existing = self._store.get(name)
216
+ is_new = existing is None
217
+ self._store.setdefault(name, {})[version] = pv
218
+ previous_version = self._latest.get(name)
219
+ self._latest[name] = version
220
+
221
+ # Emit RFC-0001 events outside the lock.
222
+ self._emit_register_events(pv, is_new=is_new, previous_version=previous_version)
223
+ return pv
224
+
225
+ # ------------------------------------------------------------------
226
+ # Retrieval
227
+ # ------------------------------------------------------------------
228
+
229
+ def get(self, name: str, version: str | None = None) -> PromptVersion:
230
+ """Return the :class:`PromptVersion` for *name*.
231
+
232
+ Args:
233
+ name: Prompt name.
234
+ version: Explicit version string, or ``None`` for the latest.
235
+
236
+ Raises:
237
+ KeyError: If *name* or *version* is not found.
238
+ """
239
+ with self._lock:
240
+ versions = self._store.get(name)
241
+ if versions is None:
242
+ raise KeyError(f"No prompt registered with name={name!r}")
243
+ if version is None:
244
+ version = self._latest[name]
245
+ pv = versions.get(version)
246
+ if pv is None:
247
+ raise KeyError(
248
+ f"Prompt {name!r} has no version {version!r}. Available: {sorted(versions)!r}"
249
+ )
250
+ return pv
251
+
252
+ #: Alias for :meth:`get` (F-26).
253
+ get_version = get
254
+
255
+ def list_versions(self, name: str) -> list[str]:
256
+ """Return all registered version strings for *name*, sorted ascending."""
257
+ with self._lock:
258
+ versions = self._store.get(name)
259
+ if versions is None:
260
+ raise KeyError(f"No prompt registered with name={name!r}")
261
+ return sorted(versions.keys())
262
+
263
+ def list_names(self) -> list[str]:
264
+ """Return all registered prompt names, sorted."""
265
+ with self._lock:
266
+ return sorted(self._store.keys())
267
+
268
+ # ------------------------------------------------------------------
269
+ # Rendering
270
+ # ------------------------------------------------------------------
271
+
272
+ def render(
273
+ self,
274
+ name: str,
275
+ variables: dict[str, Any],
276
+ *,
277
+ version: str | None = None,
278
+ span_id: str | None = None,
279
+ trace_id: str | None = None,
280
+ ) -> str:
281
+ """Render a prompt template, emitting a ``llm.prompt.rendered`` event.
282
+
283
+ Args:
284
+ name: Prompt name.
285
+ variables: Substitution variables.
286
+ version: Optional version string; defaults to latest.
287
+ span_id: Optional parent span ID for event correlation.
288
+ trace_id: Optional trace ID for event correlation.
289
+
290
+ Returns:
291
+ The rendered template string.
292
+
293
+ Raises:
294
+ KeyError: If the prompt name or version is not found, or if a
295
+ required variable is missing.
296
+ """
297
+ pv = self.get(name, version)
298
+ rendered = pv.render(variables)
299
+ self._emit_rendered_event(pv, rendered, span_id=span_id, trace_id=trace_id)
300
+ return rendered
301
+
302
+ # ------------------------------------------------------------------
303
+ # Serialisation
304
+ # ------------------------------------------------------------------
305
+
306
+ def export_all(self) -> list[dict[str, Any]]:
307
+ """Return a list of ``to_dict()`` dicts for all registered prompt versions."""
308
+ with self._lock:
309
+ return [pv.to_dict() for versions in self._store.values() for pv in versions.values()]
310
+
311
+ def import_all(self, records: list[dict[str, Any]]) -> None:
312
+ """Bulk-import prompt versions from a list of dicts (no events emitted)."""
313
+ with self._lock:
314
+ for rec in records:
315
+ pv = PromptVersion.from_dict(rec)
316
+ self._store.setdefault(pv.name, {})[pv.version] = pv
317
+ self._latest[pv.name] = pv.version
318
+
319
+ # ------------------------------------------------------------------
320
+ # Internal event helpers
321
+ # ------------------------------------------------------------------
322
+
323
+ def _emit_register_events(
324
+ self,
325
+ pv: PromptVersion,
326
+ *,
327
+ is_new: bool,
328
+ previous_version: str | None,
329
+ ) -> None:
330
+ try:
331
+ from spanforge._stream import emit_rfc_event
332
+ from spanforge.types import EventType
333
+
334
+ if is_new:
335
+ emit_rfc_event(
336
+ EventType.PROMPT_TEMPLATE_LOADED,
337
+ payload=pv.to_dict(),
338
+ )
339
+ else:
340
+ emit_rfc_event(
341
+ EventType.PROMPT_VERSION_CHANGED,
342
+ payload={
343
+ **pv.to_dict(),
344
+ "previous_version": previous_version,
345
+ },
346
+ )
347
+ except Exception as exc: # NOSONAR
348
+ _log.debug("prompt_registry: failed to emit register event: %s", exc)
349
+
350
+ def _emit_rendered_event(
351
+ self,
352
+ pv: PromptVersion,
353
+ rendered: str,
354
+ *,
355
+ span_id: str | None,
356
+ trace_id: str | None,
357
+ ) -> None:
358
+ try:
359
+ from spanforge._stream import emit_rfc_event
360
+ from spanforge.types import EventType
361
+
362
+ emit_rfc_event(
363
+ EventType.PROMPT_RENDERED,
364
+ payload={
365
+ "name": pv.name,
366
+ "version": pv.version,
367
+ # Omit the rendered text to avoid leaking PII; include
368
+ # only the prompt name/version for correlation.
369
+ "rendered_length": len(rendered),
370
+ },
371
+ span_id=span_id,
372
+ trace_id=trace_id,
373
+ )
374
+ except Exception as exc: # NOSONAR
375
+ _log.debug("prompt_registry: failed to emit rendered event: %s", exc)
376
+
377
+
378
+ # ---------------------------------------------------------------------------
379
+ # Module-level singleton + helpers
380
+ # ---------------------------------------------------------------------------
381
+
382
+ _DEFAULT_REGISTRY = PromptRegistry()
383
+
384
+
385
+ def register_prompt(
386
+ name: str,
387
+ template: str,
388
+ *,
389
+ version: str = "1.0.0",
390
+ metadata: dict[str, Any] | None = None,
391
+ ) -> PromptVersion:
392
+ """Register a prompt in the module-level default registry.
393
+
394
+ Convenience wrapper around :meth:`PromptRegistry.register`.
395
+ """
396
+ return _DEFAULT_REGISTRY.register(name, template, version=version, metadata=metadata)
397
+
398
+
399
+ def get_prompt_version(name: str, version: str | None = None) -> PromptVersion:
400
+ """Get a :class:`PromptVersion` from the module-level default registry."""
401
+ return _DEFAULT_REGISTRY.get(name, version)
402
+
403
+
404
+ def render_prompt(
405
+ name: str,
406
+ variables: dict[str, Any],
407
+ *,
408
+ version: str | None = None,
409
+ span_id: str | None = None,
410
+ trace_id: str | None = None,
411
+ ) -> str:
412
+ """Render a prompt from the module-level default registry.
413
+
414
+ Convenience wrapper around :meth:`PromptRegistry.render`.
415
+ """
416
+ return _DEFAULT_REGISTRY.render(
417
+ name, variables, version=version, span_id=span_id, trace_id=trace_id
418
+ )
spanforge/py.typed ADDED
File without changes