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/consent.py ADDED
@@ -0,0 +1,228 @@
1
+ """Consent boundary enforcement for SpanForge compliance pipeline.
2
+
3
+ Provides runtime monitoring that flags agent decisions made on
4
+ out-of-consent data, distinct from PII redaction. Consent enforcement
5
+ checks whether data *should be used at all*, while redaction masks
6
+ sensitive values.
7
+
8
+ Configuration
9
+ -------------
10
+ * ``consent_enforcement=True`` on :class:`~spanforge.config.SpanForgeConfig`
11
+ activates consent boundary checks.
12
+ * Call :func:`grant_consent` / :func:`revoke_consent` to manage the
13
+ consent store, then :func:`check_consent` before data processing.
14
+
15
+ Emits ``consent.granted``, ``consent.revoked``, ``consent.violation``
16
+ events into the HMAC audit chain via :func:`emit_rfc_event`.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import contextlib
22
+ import threading
23
+ from dataclasses import dataclass, field
24
+ from typing import Any
25
+
26
+ from spanforge.namespaces.consent import ConsentPayload
27
+
28
+ __all__ = [
29
+ "ConsentBoundary",
30
+ "ConsentRecord",
31
+ "check_consent",
32
+ "grant_consent",
33
+ "revoke_consent",
34
+ ]
35
+
36
+
37
+ @dataclass
38
+ class ConsentRecord:
39
+ """A single consent grant for a data subject."""
40
+
41
+ subject_id: str
42
+ scope: str
43
+ purpose: str
44
+ legal_basis: str = "consent"
45
+ expiry: str | None = None # ISO 8601
46
+ data_categories: list[str] = field(default_factory=list)
47
+
48
+
49
+ class ConsentBoundary:
50
+ """Thread-safe runtime consent store and boundary enforcer.
51
+
52
+ Manages active consent records and checks data-use against them.
53
+ Emits HMAC-signed events for grants, revocations, and violations.
54
+ """
55
+
56
+ def __init__(self, *, auto_emit: bool = True) -> None:
57
+ self._lock = threading.Lock()
58
+ self._records: dict[tuple[str, str], ConsentRecord] = {}
59
+ self._auto_emit = auto_emit
60
+
61
+ def grant(
62
+ self,
63
+ subject_id: str,
64
+ scope: str,
65
+ purpose: str,
66
+ *,
67
+ legal_basis: str = "consent",
68
+ expiry: str | None = None,
69
+ agent_id: str | None = None,
70
+ data_categories: list[str] | None = None,
71
+ ) -> ConsentRecord:
72
+ """Record a consent grant and emit a ``consent.granted`` event."""
73
+ if not subject_id:
74
+ raise ValueError("subject_id must be non-empty")
75
+ if not scope:
76
+ raise ValueError("scope must be non-empty")
77
+ if not purpose:
78
+ raise ValueError("purpose must be non-empty")
79
+
80
+ record = ConsentRecord(
81
+ subject_id=subject_id,
82
+ scope=scope,
83
+ purpose=purpose,
84
+ legal_basis=legal_basis,
85
+ expiry=expiry,
86
+ data_categories=data_categories or [],
87
+ )
88
+ with self._lock:
89
+ self._records[(subject_id, scope)] = record
90
+
91
+ if self._auto_emit:
92
+ payload = ConsentPayload(
93
+ subject_id=subject_id,
94
+ scope=scope,
95
+ purpose=purpose,
96
+ status="granted",
97
+ legal_basis=legal_basis,
98
+ expiry=expiry,
99
+ agent_id=agent_id,
100
+ data_categories=data_categories or [],
101
+ )
102
+ self._emit(payload, "granted")
103
+ return record
104
+
105
+ def revoke(
106
+ self,
107
+ subject_id: str,
108
+ scope: str,
109
+ *,
110
+ reason: str = "user request",
111
+ agent_id: str | None = None,
112
+ ) -> bool:
113
+ """Revoke a consent record and emit a ``consent.revoked`` event.
114
+
115
+ Returns ``True`` if a matching record was found and removed.
116
+ """
117
+ with self._lock:
118
+ removed = self._records.pop((subject_id, scope), None)
119
+
120
+ if removed is not None and self._auto_emit:
121
+ payload = ConsentPayload(
122
+ subject_id=subject_id,
123
+ scope=scope,
124
+ purpose=removed.purpose,
125
+ status="revoked",
126
+ legal_basis=removed.legal_basis,
127
+ agent_id=agent_id,
128
+ violation_detail=reason,
129
+ )
130
+ self._emit(payload, "revoked")
131
+ return removed is not None
132
+
133
+ def check(
134
+ self,
135
+ subject_id: str,
136
+ scope: str,
137
+ *,
138
+ agent_id: str | None = None,
139
+ purpose: str = "",
140
+ ) -> bool:
141
+ """Check whether consent is active for the given subject + scope.
142
+
143
+ If no active consent exists, emits a ``consent.violation`` event
144
+ and returns ``False``.
145
+ """
146
+ with self._lock:
147
+ record = self._records.get((subject_id, scope))
148
+
149
+ if record is not None:
150
+ return True
151
+
152
+ # Violation: no consent for this subject + scope
153
+ if self._auto_emit:
154
+ payload = ConsentPayload(
155
+ subject_id=subject_id,
156
+ scope=scope,
157
+ purpose=purpose or "unspecified",
158
+ status="violation",
159
+ agent_id=agent_id,
160
+ violation_detail=f"No active consent for subject={subject_id!r} scope={scope!r}",
161
+ )
162
+ self._emit(payload, "violation")
163
+ return False
164
+
165
+ def has_consent(self, subject_id: str, scope: str) -> bool:
166
+ """Return ``True`` if an active consent record exists (no event emitted)."""
167
+ with self._lock:
168
+ return (subject_id, scope) in self._records
169
+
170
+ def list_consents(self, subject_id: str | None = None) -> list[ConsentRecord]:
171
+ """Return all active consent records, optionally filtered by subject."""
172
+ with self._lock:
173
+ if subject_id is None:
174
+ return list(self._records.values())
175
+ return [r for r in self._records.values() if r.subject_id == subject_id]
176
+
177
+ def clear(self) -> None:
178
+ """Remove all consent records (for testing)."""
179
+ with self._lock:
180
+ self._records.clear()
181
+
182
+ @staticmethod
183
+ def _emit(payload: ConsentPayload, status: str) -> None:
184
+ """Emit a consent event into the HMAC audit chain."""
185
+ try:
186
+ from spanforge._stream import emit_rfc_event
187
+ from spanforge.types import EventType
188
+
189
+ _status_to_event = {
190
+ "granted": EventType.CONSENT_GRANTED,
191
+ "revoked": EventType.CONSENT_REVOKED,
192
+ "violation": EventType.CONSENT_VIOLATION,
193
+ }
194
+ et = _status_to_event.get(status)
195
+ if et is not None:
196
+ with contextlib.suppress(Exception):
197
+ emit_rfc_event(
198
+ et, payload.to_dict()
199
+ ) # never let auto-emit failures disrupt the caller
200
+ except ImportError:
201
+ pass
202
+
203
+
204
+ # ---------------------------------------------------------------------------
205
+ # Module-level singleton & convenience functions
206
+ # ---------------------------------------------------------------------------
207
+
208
+ _boundary = ConsentBoundary()
209
+
210
+
211
+ def grant_consent(
212
+ subject_id: str,
213
+ scope: str,
214
+ purpose: str,
215
+ **kwargs: Any,
216
+ ) -> ConsentRecord:
217
+ """Grant consent via the module-level :class:`ConsentBoundary`."""
218
+ return _boundary.grant(subject_id, scope, purpose, **kwargs)
219
+
220
+
221
+ def revoke_consent(subject_id: str, scope: str, **kwargs: Any) -> bool:
222
+ """Revoke consent via the module-level :class:`ConsentBoundary`."""
223
+ return _boundary.revoke(subject_id, scope, **kwargs)
224
+
225
+
226
+ def check_consent(subject_id: str, scope: str, **kwargs: Any) -> bool:
227
+ """Check consent via the module-level :class:`ConsentBoundary`."""
228
+ return _boundary.check(subject_id, scope, **kwargs)
spanforge/consumer.py ADDED
@@ -0,0 +1,377 @@
1
+ """Consumer registration API for spanforge.
2
+
3
+ Provides a lightweight registry that downstream tools, services, and libraries
4
+ can use to declare which event namespaces and schema versions they depend on.
5
+ This enables proactive compatibility checking between producers and consumers
6
+ before runtime failures occur.
7
+
8
+ Typical usage::
9
+
10
+ from spanforge.consumer import register_consumer, assert_compatible
11
+
12
+ # Register your tool's schema requirements.
13
+ register_consumer(
14
+ tool_name="my-analytics-pipeline",
15
+ namespaces=["trace", "eval"],
16
+ schema_version="1.0",
17
+ )
18
+
19
+ # Later — verify all registered consumers are compatible with the current schema.
20
+ assert_compatible() # raises IncompatibleSchemaError if any consumer is incompatible
21
+
22
+ See :class:`ConsumerRegistry` for the full registry API.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import re
28
+ import threading
29
+ from dataclasses import dataclass, field
30
+ from typing import TYPE_CHECKING
31
+
32
+ if TYPE_CHECKING:
33
+ from collections.abc import Sequence
34
+
35
+ __all__ = [
36
+ "ConsumerRecord",
37
+ "ConsumerRegistry",
38
+ "IncompatibleSchemaError",
39
+ "assert_compatible",
40
+ "get_registry",
41
+ "register_consumer",
42
+ ]
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # Sentinel — current schema version understood by the library
46
+ # ---------------------------------------------------------------------------
47
+
48
+ _CURRENT_SCHEMA_VERSION = "2.0"
49
+
50
+ # Accepted schema version patterns (semver-like, e.g. "1.0", "1.1", "2.0")
51
+ _VERSION_RE = re.compile(r"^\d+\.\d+$")
52
+
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # Errors
56
+ # ---------------------------------------------------------------------------
57
+
58
+
59
+ class IncompatibleSchemaError(Exception):
60
+ """Raised when a consumer requires a schema version incompatible with the installed one.
61
+
62
+ Not compatible with the currently installed library version.
63
+
64
+ Attributes:
65
+ incompatible: List of ``(tool_name, required_version)`` pairs that
66
+ are incompatible with the installed schema version.
67
+ """
68
+
69
+ def __init__(self, incompatible: Sequence[tuple[str, str]]) -> None:
70
+ self.incompatible = list(incompatible)
71
+ pairs = ", ".join(f"{t!r} ({v})" for t, v in self.incompatible)
72
+ super().__init__(
73
+ f"Incompatible schema consumers: {pairs}. "
74
+ f"Installed schema version: {_CURRENT_SCHEMA_VERSION}"
75
+ )
76
+
77
+
78
+ # ---------------------------------------------------------------------------
79
+ # Data models
80
+ # ---------------------------------------------------------------------------
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class ConsumerRecord:
85
+ """A record of a registered consumer's schema requirements.
86
+
87
+ Attributes:
88
+ tool_name: Human-readable name of the consuming tool or service.
89
+ namespaces: Event namespaces the consumer depends on, e.g.
90
+ ``["trace", "eval"]``.
91
+ schema_version: Minimum schema version required. Must be in
92
+ ``MAJOR.MINOR`` format (e.g. ``"1.0"``).
93
+ contact: Optional contact info (e.g. email, team name, Slack
94
+ channel) for compatibility issue escalation.
95
+ metadata: Optional freeform metadata for tooling.
96
+ """
97
+
98
+ tool_name: str
99
+ namespaces: tuple[str, ...]
100
+ schema_version: str
101
+ contact: str | None = None
102
+ metadata: dict[str, str] = field(default_factory=dict)
103
+
104
+ def __repr__(self) -> str: # pragma: no cover
105
+ return (
106
+ f"ConsumerRecord(tool_name={self.tool_name!r}, "
107
+ f"namespaces={list(self.namespaces)!r}, "
108
+ f"schema_version={self.schema_version!r})"
109
+ )
110
+
111
+
112
+ # ---------------------------------------------------------------------------
113
+ # Registry
114
+ # ---------------------------------------------------------------------------
115
+
116
+
117
+ class ConsumerRegistry:
118
+ """Thread-safe registry of downstream consumer schema requirements.
119
+
120
+ Consumers register themselves with :meth:`register` declaring which
121
+ namespaces and schema version they depend on. Operators can then call
122
+ :meth:`assert_compatible` to validate all consumers before deploying a
123
+ new schema version.
124
+
125
+ Example::
126
+
127
+ registry = ConsumerRegistry()
128
+ registry.register("my-tool", namespaces=["trace"], schema_version="1.0")
129
+ registry.assert_compatible()
130
+ """
131
+
132
+ def __init__(self) -> None:
133
+ self._lock = threading.Lock()
134
+ self._records: list[ConsumerRecord] = []
135
+
136
+ # ------------------------------------------------------------------
137
+ # Registration
138
+ # ------------------------------------------------------------------
139
+
140
+ def register(
141
+ self,
142
+ tool_name: str,
143
+ *,
144
+ namespaces: Sequence[str],
145
+ schema_version: str,
146
+ contact: str | None = None,
147
+ metadata: dict[str, str] | None = None,
148
+ ) -> ConsumerRecord:
149
+ """Register a consumer's schema requirements.
150
+
151
+ Args:
152
+ tool_name: Name of the consuming tool or service.
153
+ namespaces: Event namespaces required (e.g. ``["trace", "eval"]``).
154
+ schema_version: Minimum schema version required (``"MAJOR.MINOR"``).
155
+ contact: Optional contact info for compatibility escalations.
156
+ metadata: Optional freeform metadata dict.
157
+
158
+ Returns:
159
+ The created :class:`ConsumerRecord`.
160
+
161
+ Raises:
162
+ ValueError: If *tool_name* is empty, *namespaces* is empty, or
163
+ *schema_version* is not in ``MAJOR.MINOR`` format.
164
+ """
165
+ if not tool_name or not tool_name.strip():
166
+ raise ValueError("tool_name must be a non-empty string")
167
+ if not namespaces:
168
+ raise ValueError("namespaces must contain at least one entry")
169
+ if not _VERSION_RE.match(schema_version):
170
+ raise ValueError(
171
+ f"schema_version must be in MAJOR.MINOR format (got {schema_version!r})"
172
+ )
173
+
174
+ record = ConsumerRecord(
175
+ tool_name=tool_name.strip(),
176
+ namespaces=tuple(str(ns).strip() for ns in namespaces),
177
+ schema_version=schema_version,
178
+ contact=contact,
179
+ metadata=dict(metadata) if metadata else {},
180
+ )
181
+ with self._lock:
182
+ self._records.append(record)
183
+ return record
184
+
185
+ # ------------------------------------------------------------------
186
+ # Querying
187
+ # ------------------------------------------------------------------
188
+
189
+ def all(self) -> list[ConsumerRecord]:
190
+ """Return a snapshot of all registered consumer records.
191
+
192
+ Returns:
193
+ List of all :class:`ConsumerRecord` instances.
194
+ """
195
+ with self._lock:
196
+ return list(self._records)
197
+
198
+ def by_namespace(self, namespace: str) -> list[ConsumerRecord]:
199
+ """Return all consumers that depend on *namespace*.
200
+
201
+ Args:
202
+ namespace: The namespace string to filter by (e.g. ``"trace"``).
203
+
204
+ Returns:
205
+ Filtered list of :class:`ConsumerRecord` instances.
206
+ """
207
+ with self._lock:
208
+ return [r for r in self._records if namespace in r.namespaces]
209
+
210
+ def by_tool(self, tool_name: str) -> ConsumerRecord | None:
211
+ """Return the first record registered under *tool_name*, or ``None``.
212
+
213
+ Args:
214
+ tool_name: The tool name to look up.
215
+
216
+ Returns:
217
+ The :class:`ConsumerRecord` or ``None`` if not found.
218
+ """
219
+ with self._lock:
220
+ for r in self._records:
221
+ if r.tool_name == tool_name:
222
+ return r
223
+ return None
224
+
225
+ # ------------------------------------------------------------------
226
+ # Compatibility checking
227
+ # ------------------------------------------------------------------
228
+
229
+ def check_compatible(
230
+ self,
231
+ installed_version: str = _CURRENT_SCHEMA_VERSION,
232
+ ) -> list[tuple[str, str]]:
233
+ """Check all consumers against *installed_version*.
234
+
235
+ A consumer is *compatible* if its ``schema_version`` major matches and
236
+ minor is less than or equal to the installed minor version. That is:
237
+
238
+ * Major version bump → always incompatible (breaking changes).
239
+ * Minor version bump → backwards-compatible (new events only).
240
+
241
+ Args:
242
+ installed_version: Schema version to check against. Defaults to
243
+ the current library schema version.
244
+
245
+ Returns:
246
+ List of ``(tool_name, required_version)`` pairs that are
247
+ incompatible. Empty list means everything is compatible.
248
+ """
249
+ try:
250
+ inst_major, inst_minor = _parse_version(installed_version)
251
+ except ValueError as exc:
252
+ raise ValueError(f"installed_version must be MAJOR.MINOR format: {exc}") from exc
253
+
254
+ incompatible: list[tuple[str, str]] = []
255
+ with self._lock:
256
+ for record in self._records:
257
+ req_major, req_minor = _parse_version(record.schema_version)
258
+ if req_major != inst_major or req_minor > inst_minor:
259
+ incompatible.append((record.tool_name, record.schema_version))
260
+ return incompatible
261
+
262
+ def assert_compatible(
263
+ self,
264
+ installed_version: str = _CURRENT_SCHEMA_VERSION,
265
+ ) -> None:
266
+ """Assert that all consumers are compatible with *installed_version*.
267
+
268
+ Args:
269
+ installed_version: Schema version to check against. Defaults to
270
+ the current library schema version.
271
+
272
+ Raises:
273
+ IncompatibleSchemaError: If any registered consumer is incompatible.
274
+ """
275
+ incompatible = self.check_compatible(installed_version)
276
+ if incompatible:
277
+ raise IncompatibleSchemaError(incompatible)
278
+
279
+ def clear(self) -> None:
280
+ """Remove all records from the registry (useful in tests).
281
+
282
+ .. warning::
283
+ Not safe to call from production code while other threads may be
284
+ registering consumers.
285
+ """
286
+ with self._lock:
287
+ self._records.clear()
288
+
289
+ def __len__(self) -> int:
290
+ with self._lock:
291
+ return len(self._records)
292
+
293
+ def __repr__(self) -> str: # pragma: no cover
294
+ return f"ConsumerRegistry(consumers={len(self)})"
295
+
296
+
297
+ # ---------------------------------------------------------------------------
298
+ # Module-level singleton and helpers
299
+ # ---------------------------------------------------------------------------
300
+
301
+ _GLOBAL_REGISTRY = ConsumerRegistry()
302
+
303
+
304
+ def get_registry() -> ConsumerRegistry:
305
+ """Return the module-level :class:`ConsumerRegistry` singleton.
306
+
307
+ Returns:
308
+ The global :class:`ConsumerRegistry` instance.
309
+ """
310
+ return _GLOBAL_REGISTRY
311
+
312
+
313
+ def register_consumer(
314
+ tool_name: str,
315
+ *,
316
+ namespaces: Sequence[str],
317
+ schema_version: str,
318
+ contact: str | None = None,
319
+ metadata: dict[str, str] | None = None,
320
+ ) -> ConsumerRecord:
321
+ """Register a consumer in the global registry.
322
+
323
+ Convenience wrapper around :meth:`ConsumerRegistry.register` that operates
324
+ on the global singleton registry.
325
+
326
+ Args:
327
+ tool_name: Name of the consuming tool or service.
328
+ namespaces: Event namespaces required (e.g. ``["trace", "eval"]``).
329
+ schema_version: Minimum schema version required (``"MAJOR.MINOR"``).
330
+ contact: Optional contact info for compatibility escalations.
331
+ metadata: Optional freeform metadata dict.
332
+
333
+ Returns:
334
+ The created :class:`ConsumerRecord`.
335
+
336
+ Raises:
337
+ ValueError: See :meth:`ConsumerRegistry.register`.
338
+ """
339
+ return _GLOBAL_REGISTRY.register(
340
+ tool_name,
341
+ namespaces=namespaces,
342
+ schema_version=schema_version,
343
+ contact=contact,
344
+ metadata=metadata,
345
+ )
346
+
347
+
348
+ def assert_compatible(
349
+ installed_version: str = _CURRENT_SCHEMA_VERSION,
350
+ ) -> None:
351
+ """Assert all globally registered consumers are compatible with *installed_version*.
352
+
353
+ Convenience wrapper around :meth:`ConsumerRegistry.assert_compatible` that
354
+ operates on the global singleton registry.
355
+
356
+ Args:
357
+ installed_version: Schema version to check against. Defaults to the
358
+ current library schema version.
359
+
360
+ Raises:
361
+ IncompatibleSchemaError: If any registered consumer is incompatible.
362
+ """
363
+ _GLOBAL_REGISTRY.assert_compatible(installed_version)
364
+
365
+
366
+ # ---------------------------------------------------------------------------
367
+ # Internal helpers
368
+ # ---------------------------------------------------------------------------
369
+
370
+
371
+ def _parse_version(version: str) -> tuple[int, int]:
372
+ """Parse a ``"MAJOR.MINOR"`` version string into ``(int, int)``."""
373
+ parts = version.split(".", 1)
374
+ try:
375
+ return int(parts[0]), int(parts[1])
376
+ except (IndexError, ValueError) as exc:
377
+ raise ValueError(f"Not a valid MAJOR.MINOR version: {version!r}") from exc
@@ -0,0 +1,5 @@
1
+ """spanforge.core — AI compliance platform core package."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__: list[str] = []