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.
- spanforge/__init__.py +815 -0
- spanforge/_ansi.py +93 -0
- spanforge/_batch_exporter.py +409 -0
- spanforge/_cli.py +2094 -0
- spanforge/_cli_audit.py +639 -0
- spanforge/_cli_compliance.py +711 -0
- spanforge/_cli_cost.py +243 -0
- spanforge/_cli_ops.py +791 -0
- spanforge/_cli_phase11.py +356 -0
- spanforge/_hooks.py +337 -0
- spanforge/_server.py +1708 -0
- spanforge/_span.py +1036 -0
- spanforge/_store.py +288 -0
- spanforge/_stream.py +664 -0
- spanforge/_trace.py +335 -0
- spanforge/_tracer.py +254 -0
- spanforge/actor.py +141 -0
- spanforge/alerts.py +469 -0
- spanforge/auto.py +464 -0
- spanforge/baseline.py +335 -0
- spanforge/cache.py +635 -0
- spanforge/compliance.py +325 -0
- spanforge/config.py +532 -0
- spanforge/consent.py +228 -0
- spanforge/consumer.py +377 -0
- spanforge/core/__init__.py +5 -0
- spanforge/core/compliance_mapping.py +1254 -0
- spanforge/cost.py +600 -0
- spanforge/debug.py +548 -0
- spanforge/deprecations.py +205 -0
- spanforge/drift.py +482 -0
- spanforge/egress.py +58 -0
- spanforge/eval.py +648 -0
- spanforge/event.py +1064 -0
- spanforge/exceptions.py +240 -0
- spanforge/explain.py +178 -0
- spanforge/export/__init__.py +69 -0
- spanforge/export/append_only.py +337 -0
- spanforge/export/cloud.py +357 -0
- spanforge/export/datadog.py +497 -0
- spanforge/export/grafana.py +320 -0
- spanforge/export/jsonl.py +195 -0
- spanforge/export/openinference.py +158 -0
- spanforge/export/otel_bridge.py +294 -0
- spanforge/export/otlp.py +811 -0
- spanforge/export/otlp_bridge.py +233 -0
- spanforge/export/redis_backend.py +282 -0
- spanforge/export/siem_schema.py +98 -0
- spanforge/export/siem_splunk.py +264 -0
- spanforge/export/siem_syslog.py +212 -0
- spanforge/export/webhook.py +299 -0
- spanforge/exporters/__init__.py +30 -0
- spanforge/exporters/console.py +271 -0
- spanforge/exporters/jsonl.py +144 -0
- spanforge/exporters/sqlite.py +142 -0
- spanforge/gate.py +1150 -0
- spanforge/governance.py +181 -0
- spanforge/hitl.py +295 -0
- spanforge/http.py +187 -0
- spanforge/inspect.py +427 -0
- spanforge/integrations/__init__.py +45 -0
- spanforge/integrations/_pricing.py +280 -0
- spanforge/integrations/anthropic.py +388 -0
- spanforge/integrations/azure_openai.py +133 -0
- spanforge/integrations/bedrock.py +292 -0
- spanforge/integrations/crewai.py +251 -0
- spanforge/integrations/gemini.py +351 -0
- spanforge/integrations/groq.py +442 -0
- spanforge/integrations/langchain.py +349 -0
- spanforge/integrations/langgraph.py +306 -0
- spanforge/integrations/llamaindex.py +373 -0
- spanforge/integrations/ollama.py +287 -0
- spanforge/integrations/openai.py +368 -0
- spanforge/integrations/together.py +483 -0
- spanforge/io.py +214 -0
- spanforge/lint.py +322 -0
- spanforge/metrics.py +417 -0
- spanforge/metrics_export.py +343 -0
- spanforge/migrate.py +402 -0
- spanforge/model_registry.py +278 -0
- spanforge/models.py +389 -0
- spanforge/namespaces/__init__.py +254 -0
- spanforge/namespaces/audit.py +256 -0
- spanforge/namespaces/cache.py +237 -0
- spanforge/namespaces/chain.py +77 -0
- spanforge/namespaces/confidence.py +72 -0
- spanforge/namespaces/consent.py +92 -0
- spanforge/namespaces/cost.py +179 -0
- spanforge/namespaces/decision.py +143 -0
- spanforge/namespaces/diff.py +157 -0
- spanforge/namespaces/drift.py +80 -0
- spanforge/namespaces/eval_.py +251 -0
- spanforge/namespaces/feedback.py +241 -0
- spanforge/namespaces/fence.py +193 -0
- spanforge/namespaces/guard.py +105 -0
- spanforge/namespaces/hitl.py +91 -0
- spanforge/namespaces/latency.py +72 -0
- spanforge/namespaces/prompt.py +190 -0
- spanforge/namespaces/redact.py +173 -0
- spanforge/namespaces/retrieval.py +379 -0
- spanforge/namespaces/runtime_governance.py +494 -0
- spanforge/namespaces/template.py +208 -0
- spanforge/namespaces/tool_call.py +77 -0
- spanforge/namespaces/trace.py +1029 -0
- spanforge/normalizer.py +171 -0
- spanforge/plugins.py +82 -0
- spanforge/presidio_backend.py +349 -0
- spanforge/processor.py +258 -0
- spanforge/prompt_registry.py +418 -0
- spanforge/py.typed +0 -0
- spanforge/redact.py +914 -0
- spanforge/regression.py +192 -0
- spanforge/runtime_policy.py +159 -0
- spanforge/sampling.py +511 -0
- spanforge/schema.py +183 -0
- spanforge/schemas/v1.0/schema.json +170 -0
- spanforge/schemas/v2.0/schema.json +536 -0
- spanforge/sdk/__init__.py +625 -0
- spanforge/sdk/_base.py +584 -0
- spanforge/sdk/_base.pyi +71 -0
- spanforge/sdk/_exceptions.py +1096 -0
- spanforge/sdk/_types.py +2184 -0
- spanforge/sdk/alert.py +1514 -0
- spanforge/sdk/alert.pyi +56 -0
- spanforge/sdk/audit.py +1196 -0
- spanforge/sdk/audit.pyi +67 -0
- spanforge/sdk/cec.py +1215 -0
- spanforge/sdk/cec.pyi +37 -0
- spanforge/sdk/config.py +641 -0
- spanforge/sdk/config.pyi +55 -0
- spanforge/sdk/enterprise.py +714 -0
- spanforge/sdk/enterprise.pyi +79 -0
- spanforge/sdk/explain.py +170 -0
- spanforge/sdk/fallback.py +432 -0
- spanforge/sdk/feedback.py +351 -0
- spanforge/sdk/gate.py +874 -0
- spanforge/sdk/gate.pyi +51 -0
- spanforge/sdk/identity.py +2114 -0
- spanforge/sdk/identity.pyi +47 -0
- spanforge/sdk/lineage.py +175 -0
- spanforge/sdk/observe.py +1065 -0
- spanforge/sdk/observe.pyi +50 -0
- spanforge/sdk/operator.py +338 -0
- spanforge/sdk/pii.py +1473 -0
- spanforge/sdk/pii.pyi +119 -0
- spanforge/sdk/pipelines.py +458 -0
- spanforge/sdk/pipelines.pyi +39 -0
- spanforge/sdk/policy.py +930 -0
- spanforge/sdk/rag.py +594 -0
- spanforge/sdk/rbac.py +280 -0
- spanforge/sdk/registry.py +430 -0
- spanforge/sdk/registry.pyi +46 -0
- spanforge/sdk/scope.py +279 -0
- spanforge/sdk/secrets.py +293 -0
- spanforge/sdk/secrets.pyi +25 -0
- spanforge/sdk/security.py +560 -0
- spanforge/sdk/security.pyi +57 -0
- spanforge/sdk/trust.py +472 -0
- spanforge/sdk/trust.pyi +41 -0
- spanforge/secrets.py +799 -0
- spanforge/signing.py +1179 -0
- spanforge/stats.py +100 -0
- spanforge/stream.py +560 -0
- spanforge/testing.py +378 -0
- spanforge/testing_mocks.py +1052 -0
- spanforge/trace.py +199 -0
- spanforge/types.py +696 -0
- spanforge/ulid.py +300 -0
- spanforge/validate.py +379 -0
- spanforge-1.0.0.dist-info/METADATA +1509 -0
- spanforge-1.0.0.dist-info/RECORD +174 -0
- spanforge-1.0.0.dist-info/WHEEL +4 -0
- spanforge-1.0.0.dist-info/entry_points.txt +5 -0
- 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
|