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/testing.py ADDED
@@ -0,0 +1,378 @@
1
+ """spanforge.testing — Test utilities for SpanForge SDK consumers.
2
+
3
+ Provides helpers for writing unit and integration tests that involve
4
+ SpanForge events, exporters, and the trace store. Designed to be imported
5
+ only in test code (not in production).
6
+
7
+ Usage::
8
+
9
+ from spanforge.testing import capture_events, MockExporter, assert_event_schema_valid
10
+
11
+ # Capture all events emitted during a block
12
+ with capture_events() as captured:
13
+ # code that emits events
14
+ ...
15
+
16
+ assert len(captured) == 1
17
+ assert captured[0].event_type == "llm.trace.span.completed"
18
+ assert_event_schema_valid(captured[0])
19
+
20
+ # Inject a mock exporter
21
+ mock = MockExporter()
22
+ with mock.installed():
23
+ # code that emits events
24
+ ...
25
+ assert len(mock.events) == 1
26
+
27
+ # Isolated TraceStore for one test
28
+ from spanforge.testing import trace_store
29
+ with trace_store() as store:
30
+ configure(enable_trace_store=True)
31
+ # ... emit events ...
32
+ events = store.get_trace(trace_id)
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import contextlib
38
+ import threading
39
+ from typing import TYPE_CHECKING, Any
40
+
41
+ if TYPE_CHECKING:
42
+ from collections.abc import Generator
43
+
44
+ from spanforge._span import Span
45
+ from spanforge._store import TraceStore
46
+ from spanforge.event import Event
47
+
48
+ __all__ = [
49
+ "MockExporter",
50
+ "assert_event_schema_valid",
51
+ "assert_span_emitted",
52
+ "capture_events",
53
+ "captured_spans",
54
+ "trace_store",
55
+ ]
56
+
57
+
58
+ # ---------------------------------------------------------------------------
59
+ # MockExporter
60
+ # ---------------------------------------------------------------------------
61
+
62
+
63
+ class MockExporter:
64
+ """A synchronous in-memory exporter for testing.
65
+
66
+ Records every event passed to :meth:`export` into :attr:`events`.
67
+ Supports optional filtering, ordered access, and a context manager
68
+ that temporarily replaces the global exporter.
69
+
70
+ Args:
71
+ raise_on_export: When set to an :class:`Exception` subclass or
72
+ instance, :meth:`export` raises it to simulate
73
+ export failures.
74
+
75
+ Attributes:
76
+ events: List of all :class:`~spanforge.event.Event` objects exported
77
+ in chronological order.
78
+
79
+ Example::
80
+
81
+ mock = MockExporter()
82
+ with mock.installed():
83
+ tracer.span("test").__enter__().__exit__(None, None, None)
84
+
85
+ assert mock.events[0].event_type == "llm.trace.span.completed"
86
+ """
87
+
88
+ def __init__(
89
+ self,
90
+ raise_on_export: type[Exception] | Exception | None = None,
91
+ ) -> None:
92
+ self.events: list[Event] = []
93
+ self._lock = threading.Lock()
94
+ self._raise_on_export = raise_on_export
95
+
96
+ def export(self, event: Event) -> None:
97
+ """Record *event*. Raises configured exception if one is set.
98
+
99
+ Args:
100
+ event: The event to record.
101
+
102
+ Raises:
103
+ Exception: The configured ``raise_on_export`` exception, if set.
104
+ """
105
+ if self._raise_on_export is not None:
106
+ if isinstance(self._raise_on_export, type):
107
+ raise self._raise_on_export("MockExporter.raise_on_export triggered")
108
+ raise self._raise_on_export
109
+ with self._lock:
110
+ self.events.append(event)
111
+
112
+ async def export_batch(self, events: Any) -> None: # NOSONAR
113
+ """Async batch export — records all events in *events*.
114
+
115
+ Args:
116
+ events: Iterable of :class:`~spanforge.event.Event` objects.
117
+ """
118
+ for event in events:
119
+ self.export(event)
120
+
121
+ def clear(self) -> None:
122
+ """Remove all recorded events."""
123
+ with self._lock:
124
+ self.events.clear()
125
+
126
+ def filter_by_type(self, event_type: str) -> list[Event]:
127
+ """Return all recorded events matching *event_type*.
128
+
129
+ Args:
130
+ event_type: Dotted event type string, e.g.
131
+ ``"llm.trace.span.completed"``.
132
+
133
+ Returns:
134
+ Filtered, ordered list of matching events.
135
+ """
136
+ et = str(event_type)
137
+ with self._lock:
138
+ return [
139
+ e
140
+ for e in self.events
141
+ if (e.event_type.value if hasattr(e.event_type, "value") else str(e.event_type))
142
+ == et
143
+ ]
144
+
145
+ @contextlib.contextmanager
146
+ def installed(self) -> Generator[MockExporter, None, None]:
147
+ """Context manager that installs this exporter as the global exporter.
148
+
149
+ Replaces the SDK's active exporter for the duration of the block,
150
+ then restores the original state::
151
+
152
+ mock = MockExporter()
153
+ with mock.installed():
154
+ ... # all events go to mock.events
155
+
156
+ Yields:
157
+ This :class:`MockExporter` instance.
158
+ """
159
+ from spanforge import _stream
160
+ from spanforge._stream import _exporter_lock
161
+
162
+ # Save state
163
+ with _exporter_lock:
164
+ original = _stream._cached_exporter
165
+ _stream._cached_exporter = self
166
+ try:
167
+ yield self
168
+ finally:
169
+ with _exporter_lock:
170
+ _stream._cached_exporter = original
171
+
172
+ def __repr__(self) -> str:
173
+ return f"MockExporter(events={len(self.events)})"
174
+
175
+
176
+ # ---------------------------------------------------------------------------
177
+ # capture_events()
178
+ # ---------------------------------------------------------------------------
179
+
180
+
181
+ @contextlib.contextmanager
182
+ def capture_events() -> Generator[list[Event], None, None]:
183
+ """Context manager that captures all events emitted during the block.
184
+
185
+ Events are collected into a list that is yielded and grows in real-time
186
+ as events are emitted. The original exporter is restored on exit.
187
+
188
+ Example::
189
+
190
+ with capture_events() as events:
191
+ with tracer.span("test"):
192
+ pass
193
+
194
+ assert events[0].payload["span_name"] == "test"
195
+
196
+ Yields:
197
+ A live ``list[Event]`` that is populated as events are emitted.
198
+ """
199
+ mock = MockExporter()
200
+ with mock.installed():
201
+ yield mock.events
202
+
203
+
204
+ # ---------------------------------------------------------------------------
205
+ # assert_event_schema_valid()
206
+ # ---------------------------------------------------------------------------
207
+
208
+
209
+ def assert_event_schema_valid(event: Event) -> None:
210
+ """Assert that *event* passes SDK schema validation.
211
+
212
+ Calls :func:`~spanforge.validate.validate_event` and re-raises any
213
+ :class:`~spanforge.exceptions.SchemaValidationError` as an
214
+ :class:`AssertionError` with the original message — so failures
215
+ surface cleanly in :func:`pytest.raises` and ``assert`` blocks.
216
+
217
+ Args:
218
+ event: The event to validate.
219
+
220
+ Raises:
221
+ AssertionError: If *event* fails schema validation.
222
+
223
+ Example::
224
+
225
+ from spanforge.testing import assert_event_schema_valid
226
+ assert_event_schema_valid(my_event)
227
+ """
228
+ from spanforge.exceptions import SchemaValidationError
229
+ from spanforge.validate import validate_event
230
+
231
+ try:
232
+ validate_event(event)
233
+ except SchemaValidationError as exc:
234
+ raise AssertionError(f"Event failed schema validation: {exc}") from exc
235
+
236
+
237
+ # ---------------------------------------------------------------------------
238
+ # trace_store() context manager
239
+ # ---------------------------------------------------------------------------
240
+
241
+
242
+ @contextlib.contextmanager
243
+ def trace_store(max_traces: int = 100) -> Generator[TraceStore, None, None]:
244
+ """Context manager that provides an isolated :class:`~spanforge._store.TraceStore`.
245
+
246
+ Creates a fresh ``TraceStore`` scoped to the block and temporarily
247
+ installs it as the global store. The original store is restored on
248
+ exit, making this safe to use in parallel tests.
249
+
250
+ Args:
251
+ max_traces: Maximum number of traces to retain in the isolated store.
252
+
253
+ Yields:
254
+ A new :class:`~spanforge._store.TraceStore` instance scoped to the
255
+ ``with`` block.
256
+
257
+ Example::
258
+
259
+ from spanforge import configure
260
+ from spanforge.testing import trace_store
261
+
262
+ with trace_store() as store:
263
+ configure(enable_trace_store=True)
264
+ with tracer.span("test") as s:
265
+ pass
266
+ events = store.get_trace(s.trace_id)
267
+ assert events is not None
268
+ """
269
+ from spanforge._store import trace_store as _store_trace_store
270
+
271
+ with _store_trace_store(max_traces=max_traces) as store:
272
+ yield store
273
+
274
+
275
+ # ---------------------------------------------------------------------------
276
+ # captured_spans() pytest fixture
277
+ # ---------------------------------------------------------------------------
278
+
279
+ try:
280
+ import pytest as _pytest
281
+
282
+ @_pytest.fixture
283
+ def captured_spans() -> Generator[list[Span], None, None]:
284
+ """Pytest fixture that captures all Span objects completed during a test.
285
+
286
+ Captures :class:`~spanforge._span.Span` instances regardless of operation type.
287
+
288
+ Import this fixture in your test module (or ``conftest.py``) to make it
289
+ available::
290
+
291
+ from spanforge.testing import captured_spans # re-export for pytest
292
+
293
+ def test_my_fn(captured_spans):
294
+ call_my_function()
295
+ assert any(s.name == "my-step" for s in captured_spans)
296
+
297
+ Each test gets an empty list; spans accumulate as the test runs.
298
+
299
+ Yields:
300
+ A live ``list[Span]`` populated as spans are completed.
301
+ """
302
+ from spanforge._hooks import hooks
303
+
304
+ spans: list[Any] = []
305
+
306
+ def _cb(span: Any) -> None:
307
+ spans.append(span)
308
+
309
+ hooks.on_span_end(_cb)
310
+ try:
311
+ yield spans
312
+ finally:
313
+ with hooks._lock, contextlib.suppress(ValueError):
314
+ hooks._all_end_hooks.remove(_cb)
315
+
316
+ except ImportError:
317
+ # pytest not installed — skip fixture definition (production environments).
318
+ pass
319
+
320
+
321
+ # ---------------------------------------------------------------------------
322
+ # assert_span_emitted()
323
+ # ---------------------------------------------------------------------------
324
+
325
+
326
+ def assert_span_emitted(
327
+ spans: list[Any],
328
+ *,
329
+ name: str,
330
+ model: str | None = None,
331
+ status: str | None = None,
332
+ operation: str | None = None,
333
+ ) -> Any:
334
+ """Assert that a span matching the given criteria appears in *spans*.
335
+
336
+ Typically used with the :func:`captured_spans` fixture.
337
+
338
+ Args:
339
+ spans: List of :class:`~spanforge._span.Span` objects (from fixture).
340
+ name: Required span name to match.
341
+ model: When provided, also checks ``span.model == model``.
342
+ status: When provided, also checks ``span.status == status``.
343
+ operation: When provided, also checks ``span.operation == operation``.
344
+
345
+ Returns:
346
+ The first matching :class:`~spanforge._span.Span`.
347
+
348
+ Raises:
349
+ AssertionError: If no span matches all criteria.
350
+
351
+ Example::
352
+
353
+ def test_llm_call(captured_spans):
354
+ run_agent()
355
+ assert_span_emitted(captured_spans, name="llm-call", model="gpt-4o")
356
+ """
357
+ for span in spans:
358
+ if span.name != name:
359
+ continue
360
+ if model is not None and span.model != model:
361
+ continue
362
+ if status is not None and span.status != status:
363
+ continue
364
+ if operation is not None and str(span.operation) != operation:
365
+ continue
366
+ return span
367
+
368
+ criteria = f"name={name!r}"
369
+ if model is not None:
370
+ criteria += f", model={model!r}"
371
+ if status is not None:
372
+ criteria += f", status={status!r}"
373
+ if operation is not None:
374
+ criteria += f", operation={operation!r}"
375
+ raise AssertionError(
376
+ f"No span matching {criteria} found in {len(spans)} captured span(s). "
377
+ f"Got: {[s.name for s in spans]}"
378
+ )