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/lint.py ADDED
@@ -0,0 +1,322 @@
1
+ """spanforge.lint — SDK instrumentation linter.
2
+
3
+ Inspects Python source files for common spanforge instrumentation mistakes
4
+ before the code runs. Ships as:
5
+
6
+ 1. A **Python API** — call ``run_checks()`` from test suites or CI scripts.
7
+ 2. A **flake8 plugin** — registered via ``[project.entry-points."flake8.extension"]``
8
+ in ``pyproject.toml`` so AO-codes appear inline in editor linting.
9
+ 3. A **CLI** — ``python -m spanforge.lint [FILES_OR_DIRS...]``.
10
+
11
+ AO error codes
12
+ --------------
13
+ AO000 Syntax error in source file.
14
+ AO001 Event() is missing a required field ('event_type', 'source', or 'payload').
15
+ AO002 Identity field ('actor_id', 'session_id', 'user_id') receives a bare str literal.
16
+ AO003 event_type string is not a registered EventType value.
17
+ AO004 LLM provider call detected outside a tracer span context.
18
+ AO005 emit_span / emit_agent_* called outside agent_run() / agent_step() context.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import ast
24
+ import re
25
+ from collections.abc import Iterator
26
+ from dataclasses import dataclass
27
+ from pathlib import Path
28
+
29
+ __all__ = [
30
+ "LintError",
31
+ "SpanForgeChecker",
32
+ "run_checks",
33
+ ]
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # LintError
37
+ # ---------------------------------------------------------------------------
38
+
39
+ _REQUIRED_EVENT_FIELDS = frozenset({"event_type", "source", "payload"})
40
+ _IDENTITY_FIELDS = frozenset({"actor_id", "session_id", "user_id"})
41
+
42
+ # Patterns for LLM provider calls (AO004)
43
+ _LLM_CALL_PATTERNS = re.compile(
44
+ r"(?:chat\.completions\.create|messages\.create|completions\.create|"
45
+ r"\.generate\s*\(|\.complete\s*\()"
46
+ )
47
+
48
+ # Context-manager call names for AO004/AO005 checks
49
+ _SPAN_CONTEXT_NAMES = frozenset({"span", "agent_run", "agent_step"})
50
+ _EMIT_NAMES = frozenset({"emit_span", "emit_agent_run", "emit_agent_step"})
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class LintError:
55
+ """An immutable lint finding.
56
+
57
+ Attributes:
58
+ code: AO-code, e.g. ``"AO001"``.
59
+ message: Human-readable description.
60
+ filename: File the error was found in.
61
+ line: 1-based line number.
62
+ col: 1-based column number.
63
+ """
64
+
65
+ code: str
66
+ message: str
67
+ filename: str
68
+ line: int
69
+ col: int
70
+
71
+ def __str__(self) -> str:
72
+ return f"{self.filename}:{self.line}:{self.col}: {self.code} {self.message}"
73
+
74
+
75
+ # ---------------------------------------------------------------------------
76
+ # AST visitor
77
+ # ---------------------------------------------------------------------------
78
+
79
+
80
+ class _SpanForgeVisitor(ast.NodeVisitor):
81
+ """Walk an AST and collect AO-code lint errors."""
82
+
83
+ def __init__(self, filename: str) -> None:
84
+ self.filename = filename
85
+ self.errors: list[LintError] = []
86
+ # Stack tracking whether we are inside a span/agent context manager
87
+ self._in_span_context: int = 0
88
+
89
+ # ------------------------------------------------------------------
90
+ # AO001 — Missing required Event() field
91
+ # ------------------------------------------------------------------
92
+
93
+ def visit_Call(self, node: ast.Call) -> None: # noqa: N802
94
+ func_name = _get_call_name(node)
95
+
96
+ if func_name in ("Event", "spanforge.Event"):
97
+ keyword_names = {kw.arg for kw in node.keywords}
98
+ for required in _REQUIRED_EVENT_FIELDS:
99
+ if required not in keyword_names:
100
+ self.errors.append(
101
+ LintError(
102
+ code="AO001",
103
+ message=f"Event() is missing required field '{required}'",
104
+ filename=self.filename,
105
+ line=node.lineno,
106
+ col=node.col_offset + 1,
107
+ )
108
+ )
109
+
110
+ # AO002 — bare str literal for identity field
111
+ for kw in node.keywords:
112
+ if (
113
+ kw.arg in _IDENTITY_FIELDS
114
+ and isinstance(kw.value, ast.Constant)
115
+ and isinstance(kw.value.value, str)
116
+ ):
117
+ self.errors.append(
118
+ LintError(
119
+ code="AO002",
120
+ message=(
121
+ f"'{kw.arg}' receives a bare str literal; wrap with Redactable()"
122
+ ),
123
+ filename=self.filename,
124
+ line=kw.value.lineno,
125
+ col=kw.value.col_offset + 1,
126
+ )
127
+ )
128
+
129
+ # AO003 — unknown event_type string
130
+ for kw in node.keywords:
131
+ if (
132
+ kw.arg == "event_type"
133
+ and isinstance(kw.value, ast.Constant)
134
+ and isinstance(kw.value.value, str)
135
+ ):
136
+ value = kw.value.value
137
+ if not _is_registered_event_type(value):
138
+ self.errors.append(
139
+ LintError(
140
+ code="AO003",
141
+ message=f"event_type string '{value}' is not a registered EventType value",
142
+ filename=self.filename,
143
+ line=kw.value.lineno,
144
+ col=kw.value.col_offset + 1,
145
+ )
146
+ )
147
+
148
+ # AO005 — emit_* outside agent context
149
+ if func_name in _EMIT_NAMES and self._in_span_context == 0:
150
+ self.errors.append(
151
+ LintError(
152
+ code="AO005",
153
+ message=f"{func_name} called outside agent_run() / agent_step() context",
154
+ filename=self.filename,
155
+ line=node.lineno,
156
+ col=node.col_offset + 1,
157
+ )
158
+ )
159
+
160
+ self.generic_visit(node)
161
+
162
+ # ------------------------------------------------------------------
163
+ # AO004 — LLM provider call outside span context (with-statement tracking)
164
+ # ------------------------------------------------------------------
165
+
166
+ def visit_With(self, node: ast.With) -> None: # noqa: N802
167
+ is_span = any(
168
+ isinstance(item.context_expr, ast.Call)
169
+ and _get_call_name(item.context_expr) in _SPAN_CONTEXT_NAMES
170
+ for item in node.items
171
+ )
172
+ if is_span:
173
+ self._in_span_context += 1
174
+ self.generic_visit(node)
175
+ if is_span:
176
+ self._in_span_context -= 1
177
+
178
+ def visit_AsyncWith(self, node: ast.AsyncWith) -> None: # noqa: N802
179
+ is_span = any(
180
+ isinstance(item.context_expr, ast.Call)
181
+ and _get_call_name(item.context_expr) in _SPAN_CONTEXT_NAMES
182
+ for item in node.items
183
+ )
184
+ if is_span:
185
+ self._in_span_context += 1
186
+ self.generic_visit(node)
187
+ if is_span:
188
+ self._in_span_context -= 1
189
+
190
+
191
+ def _get_call_name(node: ast.Call) -> str:
192
+ """Return a dotted name string for a Call node's function, or empty string."""
193
+ func = node.func
194
+ if isinstance(func, ast.Name):
195
+ return func.id
196
+ if isinstance(func, ast.Attribute):
197
+ return func.attr
198
+ return ""
199
+
200
+
201
+ def _is_registered_event_type(value: str) -> bool:
202
+ """Return True if *value* is a registered EventType string (best-effort)."""
203
+ try:
204
+ from spanforge.types import EventType as _ET
205
+
206
+ return value in {m.value for m in _ET}
207
+ except (ImportError, TypeError):
208
+ # If we cannot import EventType, assume valid to avoid false positives
209
+ return True
210
+
211
+
212
+ # ---------------------------------------------------------------------------
213
+ # Public run_checks() API
214
+ # ---------------------------------------------------------------------------
215
+
216
+
217
+ def run_checks(source: str, filename: str = "<string>") -> list[LintError]:
218
+ """Parse *source* as valid Python 3 and return a list of :class:`LintError` objects.
219
+
220
+ Args:
221
+ source: UTF-8 Python source code to analyse.
222
+ filename: File path; used in :attr:`LintError.filename`.
223
+
224
+ Returns:
225
+ List of :class:`LintError` objects sorted by ``(line, col)``.
226
+ Empty list when the file is clean.
227
+ """
228
+ try:
229
+ tree = ast.parse(source, filename=filename)
230
+ except SyntaxError as exc:
231
+ return [
232
+ LintError(
233
+ code="AO000",
234
+ message=f"Syntax error: {exc.msg}",
235
+ filename=filename,
236
+ line=exc.lineno or 1,
237
+ col=exc.offset or 1,
238
+ )
239
+ ]
240
+
241
+ visitor = _SpanForgeVisitor(filename=filename)
242
+ visitor.visit(tree)
243
+ return sorted(visitor.errors, key=lambda e: (e.line, e.col))
244
+
245
+
246
+ # ---------------------------------------------------------------------------
247
+ # Filesystem helpers used by CLI and flake8 plugin
248
+ # ---------------------------------------------------------------------------
249
+
250
+
251
+ def _iter_python_files(paths: list[str]) -> Iterator[Path]:
252
+ """Yield all ``*.py`` files under the given *paths*."""
253
+ for p_str in paths:
254
+ path = Path(p_str)
255
+ if path.is_file() and path.suffix == ".py":
256
+ yield path
257
+ elif path.is_dir():
258
+ yield from path.rglob("*.py")
259
+
260
+
261
+ # ---------------------------------------------------------------------------
262
+ # flake8 plugin shim (imported via entry-point AO = spanforge.lint._flake8)
263
+ # ---------------------------------------------------------------------------
264
+
265
+ # The _flake8 sub-module is registered as the entry-point; provide a minimal
266
+ # SpanForgeChecker class here too so ``from spanforge.lint import SpanForgeChecker``
267
+ # works without importing a separate sub-module.
268
+
269
+
270
+ class SpanForgeChecker:
271
+ """Minimal flake8-compatible checker that delegates to :func:`run_checks`.
272
+
273
+ flake8 discovers this class via the ``flake8.extension`` entry-point and
274
+ calls ``check_file()`` for each file it processes.
275
+ """
276
+
277
+ name = "spanforge-lint"
278
+ version = "2.0.0"
279
+
280
+ def __init__(
281
+ self, tree: ast.AST, filename: str = "<string>", lines: list[str] | None = None
282
+ ) -> None:
283
+ self._tree = tree
284
+ self._filename = filename
285
+ self._source = "".join(lines) if lines else ""
286
+
287
+ def run(self) -> Iterator[tuple[int, int, str, type]]:
288
+ """Yield ``(line, col, message, type)`` tuples for flake8."""
289
+ errors = run_checks(self._source, filename=self._filename)
290
+ for err in errors:
291
+ yield (err.line, err.col - 1, f"{err.code} {err.message}", type(self))
292
+
293
+
294
+ # ---------------------------------------------------------------------------
295
+ # CLI entry point: python -m spanforge.lint [FILES_OR_DIRS...]
296
+ # ---------------------------------------------------------------------------
297
+
298
+ if __name__ == "__main__":
299
+ import sys
300
+
301
+ paths = sys.argv[1:] or ["."]
302
+ total_errors = 0
303
+ files_with_errors = 0
304
+
305
+ for py_file in _iter_python_files(paths):
306
+ try:
307
+ source_code = py_file.read_text(encoding="utf-8")
308
+ except OSError as read_err:
309
+ print(f"spanforge.lint: cannot read {py_file}: {read_err}", file=sys.stderr)
310
+ sys.exit(2)
311
+
312
+ file_errors = run_checks(source_code, filename=str(py_file))
313
+ for err in file_errors:
314
+ print(str(err))
315
+ if file_errors:
316
+ total_errors += len(file_errors)
317
+ files_with_errors += 1
318
+
319
+ if total_errors:
320
+ print(f"{total_errors} error(s) in {files_with_errors} file(s).")
321
+ sys.exit(1)
322
+ sys.exit(0)