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/sdk/_base.py ADDED
@@ -0,0 +1,584 @@
1
+ """spanforge.sdk._base — Infrastructure base classes for the SpanForge service SDK.
2
+
3
+ Provides:
4
+ * :class:`_CircuitBreaker` — thread-safe circuit breaker (5 failures → OPEN, 30 s reset).
5
+ * :class:`_SlidingWindowRateLimiter` — per-key sliding window rate limiter.
6
+ * :class:`SFClientConfig` — configuration dataclass loaded from env vars.
7
+ * :class:`SFServiceClient` — abstract base with HTTP retry + circuit breaker.
8
+
9
+ Security requirements
10
+ ---------------------
11
+ * ``SFClientConfig.api_key`` is a :class:`~spanforge.sdk._types.SecretStr`.
12
+ * HTTP request bodies are sent as application/json; no credentials are ever
13
+ logged.
14
+ * Retry jitter uses :mod:`random` (not :mod:`secrets`) — non-secret values
15
+ only. Cryptographic randomness is reserved for key generation.
16
+ * TLS verification is enabled by default and only disabled explicitly.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import abc
22
+ import json
23
+ import logging
24
+ import os
25
+ import random
26
+ import ssl
27
+ import threading
28
+ import time
29
+ import urllib.error
30
+ import urllib.request
31
+ from collections import deque
32
+ from dataclasses import dataclass, field
33
+ from datetime import datetime, timezone
34
+ from typing import Any
35
+
36
+ from spanforge.sdk._exceptions import (
37
+ SFAuthError,
38
+ SFRateLimitError,
39
+ SFServiceUnavailableError,
40
+ )
41
+ from spanforge.sdk._types import RateLimitInfo, SecretStr
42
+
43
+ __all__ = [
44
+ "SFClientConfig",
45
+ "SFServiceClient",
46
+ "_CircuitBreaker",
47
+ "_SlidingWindowRateLimiter",
48
+ ]
49
+
50
+ _log = logging.getLogger(__name__)
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # Circuit breaker
54
+ # ---------------------------------------------------------------------------
55
+
56
+ _CB_THRESHOLD_DEFAULT: int = 5
57
+ _CB_RESET_DEFAULT: float = 30.0
58
+
59
+
60
+ class _CircuitBreaker:
61
+ """Thread-safe circuit breaker.
62
+
63
+ Transitions:
64
+ * CLOSED → OPEN after *threshold* consecutive failures.
65
+ * OPEN → CLOSED automatically after *reset_seconds* have elapsed.
66
+ * Any call to :meth:`record_success` while OPEN also resets to CLOSED.
67
+
68
+ This matches the pattern established in
69
+ :mod:`spanforge._batch_exporter`.
70
+ """
71
+
72
+ CLOSED = "closed"
73
+ OPEN = "open"
74
+
75
+ def __init__(
76
+ self,
77
+ threshold: int = _CB_THRESHOLD_DEFAULT,
78
+ reset_seconds: float = _CB_RESET_DEFAULT,
79
+ ) -> None:
80
+ self._threshold = threshold
81
+ self._reset_seconds = reset_seconds
82
+ self._failures: int = 0
83
+ self._state: str = self.CLOSED
84
+ self._opened_at: float = 0.0
85
+ self._lock = threading.Lock()
86
+
87
+ # ------------------------------------------------------------------
88
+ # Public API
89
+ # ------------------------------------------------------------------
90
+
91
+ @property
92
+ def state(self) -> str:
93
+ """Return the *current* state, auto-resetting if the reset window has elapsed."""
94
+ with self._lock:
95
+ return self._state_unlocked
96
+
97
+ @property
98
+ def _state_unlocked(self) -> str:
99
+ """Inner state check; MUST be called with ``self._lock`` held."""
100
+ if self._state == self.OPEN and time.monotonic() - self._opened_at >= self._reset_seconds:
101
+ self._state = self.CLOSED
102
+ self._failures = 0
103
+ return self._state
104
+
105
+ def is_open(self) -> bool:
106
+ """Return ``True`` when the circuit is open (requests should be blocked)."""
107
+ with self._lock:
108
+ return self._state_unlocked == self.OPEN
109
+
110
+ def record_success(self) -> None:
111
+ """Reset failure counter and close the circuit."""
112
+ with self._lock:
113
+ self._failures = 0
114
+ self._state = self.CLOSED
115
+
116
+ def record_failure(self) -> None:
117
+ """Increment failure counter and open the circuit if the threshold is reached."""
118
+ with self._lock:
119
+ self._failures += 1
120
+ if self._failures >= self._threshold:
121
+ self._state = self.OPEN
122
+ self._opened_at = time.monotonic()
123
+
124
+ def reset(self) -> None:
125
+ """Forcibly reset the circuit to CLOSED with zero failures."""
126
+ with self._lock:
127
+ self._failures = 0
128
+ self._state = self.CLOSED
129
+ self._opened_at = 0.0
130
+
131
+
132
+ # ---------------------------------------------------------------------------
133
+ # Sliding window rate limiter
134
+ # ---------------------------------------------------------------------------
135
+
136
+
137
+ class _SlidingWindowRateLimiter:
138
+ """Thread-safe per-key sliding window rate limiter.
139
+
140
+ Args:
141
+ limit: Maximum requests allowed per window.
142
+ window_seconds: Duration of the sliding window in seconds.
143
+
144
+ Example::
145
+
146
+ limiter = _SlidingWindowRateLimiter(limit=600, window_seconds=60)
147
+ ok = limiter.record("my_key") # True if within limit
148
+ info = limiter.check("my_key") # inspect without counting
149
+ """
150
+
151
+ def __init__(
152
+ self,
153
+ limit: int = 600,
154
+ window_seconds: float = 60.0,
155
+ ) -> None:
156
+ if limit < 1:
157
+ raise ValueError("limit must be >= 1")
158
+ if window_seconds <= 0:
159
+ raise ValueError("window_seconds must be > 0")
160
+ self._limit = limit
161
+ self._window = window_seconds
162
+ self._windows: dict[str, deque[float]] = {}
163
+ self._lock = threading.Lock()
164
+
165
+ def _evict(self, timestamps: deque[float], now: float) -> None:
166
+ """Remove timestamps outside the current window. NOT thread-safe alone."""
167
+ while timestamps and now - timestamps[0] >= self._window:
168
+ timestamps.popleft()
169
+
170
+ def check(self, key_id: str) -> RateLimitInfo:
171
+ """Return rate-limit state without counting the call as a request."""
172
+ with self._lock:
173
+ now = time.monotonic()
174
+ timestamps = self._windows.setdefault(key_id, deque())
175
+ self._evict(timestamps, now)
176
+ remaining = max(0, self._limit - len(timestamps))
177
+ reset_at = datetime.now(timezone.utc)
178
+ return RateLimitInfo(
179
+ limit=self._limit,
180
+ remaining=remaining,
181
+ reset_at=reset_at,
182
+ )
183
+
184
+ def record(self, key_id: str) -> bool:
185
+ """Count a request.
186
+
187
+ Returns:
188
+ ``True`` if the request is within the limit (allowed).
189
+ ``False`` if the limit has been reached (caller should 429).
190
+ """
191
+ with self._lock:
192
+ now = time.monotonic()
193
+ timestamps = self._windows.setdefault(key_id, deque())
194
+ self._evict(timestamps, now)
195
+ if len(timestamps) >= self._limit:
196
+ return False
197
+ timestamps.append(now)
198
+ return True
199
+
200
+ def remaining(self, key_id: str) -> int:
201
+ """Return remaining requests in the current window without counting."""
202
+ info = self.check(key_id)
203
+ return info.remaining
204
+
205
+ def clear(self, key_id: str) -> None:
206
+ """Remove all recorded timestamps for *key_id* (e.g. for testing)."""
207
+ with self._lock:
208
+ self._windows.pop(key_id, None)
209
+
210
+
211
+ # ---------------------------------------------------------------------------
212
+ # Token-expiry warning threshold (seconds)
213
+ # ---------------------------------------------------------------------------
214
+
215
+ #: Warn / trigger refresh when token TTL drops below this many seconds.
216
+ _TOKEN_EXPIRY_WARN_SECS: int = 60
217
+
218
+ # ---------------------------------------------------------------------------
219
+ # Known SPANFORGE_ environment variable names
220
+ # ---------------------------------------------------------------------------
221
+
222
+ _KNOWN_SPANFORGE_VARS: frozenset[str] = frozenset(
223
+ {
224
+ "SPANFORGE_ENDPOINT",
225
+ "SPANFORGE_API_KEY",
226
+ "SPANFORGE_PROJECT_ID",
227
+ "SPANFORGE_TIMEOUT_MS",
228
+ "SPANFORGE_MAX_RETRIES",
229
+ "SPANFORGE_LOCAL_FALLBACK",
230
+ "SPANFORGE_TLS_VERIFY",
231
+ "SPANFORGE_PROXY",
232
+ "SPANFORGE_SIGNING_KEY",
233
+ "SPANFORGE_MAGIC_SECRET",
234
+ # Phase 9 — Integration Config & Local Fallback (CFG-006)
235
+ "SPANFORGE_PII_THRESHOLD",
236
+ "SPANFORGE_SECRETS_AUTO_BLOCK",
237
+ "SPANFORGE_FALLBACK_MAX_RETRIES",
238
+ "SPANFORGE_FALLBACK_TIMEOUT_MS",
239
+ "SPANFORGE_LOCAL_TOKEN",
240
+ # Phase 8 — CI/CD Gate Pipeline
241
+ "SPANFORGE_GATE_ARTIFACT_DIR",
242
+ "SPANFORGE_GATE_ARTIFACT_RETENTION_DAYS",
243
+ "SPANFORGE_GATE_PRRI_RED_THRESHOLD",
244
+ "SPANFORGE_GATE_HRI_CRITICAL_THRESHOLD",
245
+ "SPANFORGE_GATE_PII_WINDOW_HOURS",
246
+ "SPANFORGE_GATE_SECRETS_WINDOW_HOURS",
247
+ }
248
+ )
249
+
250
+ _cfg_log = logging.getLogger(__name__)
251
+
252
+ # ---------------------------------------------------------------------------
253
+ # Client configuration
254
+ # ---------------------------------------------------------------------------
255
+
256
+
257
+ @dataclass
258
+ class SFClientConfig:
259
+ """Configuration for a SpanForge service client.
260
+
261
+ All fields have sensible defaults. When ``endpoint`` is empty the client
262
+ operates in *local mode* — all logic is executed in-process with no
263
+ network calls.
264
+
265
+ Attributes:
266
+ endpoint: Base URL of the SpanForge service
267
+ (e.g. ``"https://api.spanforge.dev"``).
268
+ **Empty string** enables local/fallback mode.
269
+ api_key: SpanForge API key. Must be :class:`~spanforge.sdk._types.SecretStr`.
270
+ project_id: Default project scope for new keys.
271
+ timeout_ms: HTTP timeout in milliseconds (default: 2 000 ms).
272
+ max_retries: Number of additional attempts after the first failure
273
+ (default: 3; so 4 total attempts).
274
+ local_fallback_enabled: If ``True`` (default), fall back to local
275
+ logic when the remote service is unreachable.
276
+ tls_verify: Verify TLS certificates (default: ``True``).
277
+ **Never set to ``False`` in production.**
278
+ proxy: Optional HTTP/HTTPS proxy URL.
279
+ signing_key: Secret used for local HS256 JWT signing. Loaded from
280
+ ``SPANFORGE_SIGNING_KEY`` env var if not set here.
281
+ magic_secret: Secret for HMAC magic-link tokens. Loaded from
282
+ ``SPANFORGE_MAGIC_SECRET`` env var if not set here.
283
+ """
284
+
285
+ endpoint: str = ""
286
+ api_key: SecretStr = field(default_factory=lambda: SecretStr(""))
287
+ project_id: str = ""
288
+ timeout_ms: int = 2_000
289
+ max_retries: int = 3
290
+ local_fallback_enabled: bool = True
291
+ tls_verify: bool = True
292
+ proxy: str | None = None
293
+ signing_key: str = ""
294
+ magic_secret: str = ""
295
+
296
+ @classmethod
297
+ def from_env(cls) -> SFClientConfig:
298
+ """Build a config from environment variables.
299
+
300
+ Variable mapping::
301
+
302
+ SPANFORGE_ENDPOINT → endpoint
303
+ SPANFORGE_API_KEY → api_key
304
+ SPANFORGE_PROJECT_ID → project_id
305
+ SPANFORGE_TIMEOUT_MS → timeout_ms
306
+ SPANFORGE_MAX_RETRIES → max_retries
307
+ SPANFORGE_LOCAL_FALLBACK → local_fallback_enabled
308
+ SPANFORGE_TLS_VERIFY → tls_verify
309
+ SPANFORGE_PROXY → proxy
310
+ SPANFORGE_SIGNING_KEY → signing_key
311
+ SPANFORGE_MAGIC_SECRET → magic_secret
312
+ """
313
+ raw_fallback = os.environ.get("SPANFORGE_LOCAL_FALLBACK", "true").lower()
314
+ local_fallback = raw_fallback not in ("false", "0", "no")
315
+
316
+ raw_tls = os.environ.get("SPANFORGE_TLS_VERIFY", "true").lower()
317
+ tls_verify = raw_tls not in ("false", "0", "no")
318
+
319
+ # ID-005: Warn on unknown SPANFORGE_* env vars
320
+ for env_key in os.environ:
321
+ if env_key.startswith("SPANFORGE_") and env_key not in _KNOWN_SPANFORGE_VARS:
322
+ _cfg_log.warning(
323
+ "Unknown SPANFORGE_ environment variable: %r — this variable "
324
+ "is not recognised by the SpanForge SDK and will be ignored.",
325
+ env_key,
326
+ )
327
+
328
+ return cls(
329
+ endpoint=os.environ.get("SPANFORGE_ENDPOINT", ""),
330
+ api_key=SecretStr(os.environ.get("SPANFORGE_API_KEY", "")),
331
+ project_id=os.environ.get("SPANFORGE_PROJECT_ID", ""),
332
+ timeout_ms=int(os.environ.get("SPANFORGE_TIMEOUT_MS", "2000")),
333
+ max_retries=int(os.environ.get("SPANFORGE_MAX_RETRIES", "3")),
334
+ local_fallback_enabled=local_fallback,
335
+ tls_verify=tls_verify,
336
+ proxy=os.environ.get("SPANFORGE_PROXY"),
337
+ signing_key=os.environ.get("SPANFORGE_SIGNING_KEY", ""),
338
+ magic_secret=os.environ.get("SPANFORGE_MAGIC_SECRET", ""),
339
+ )
340
+
341
+
342
+ # ---------------------------------------------------------------------------
343
+ # Abstract service client base
344
+ # ---------------------------------------------------------------------------
345
+
346
+
347
+ _HTTP_429: int = 429
348
+ _HTTP_401_403: frozenset[int] = frozenset({401, 403})
349
+
350
+
351
+ class SFServiceClient(abc.ABC):
352
+ """Abstract base class for SpanForge service clients.
353
+
354
+ Provides:
355
+ * Circuit breaker (5-failure threshold, 30 s reset).
356
+ * Retry with exponential back-off and random jitter.
357
+ * Structured error translation (429 → :exc:`~spanforge.sdk._exceptions.SFRateLimitError`,
358
+ 401/403 → :exc:`~spanforge.sdk._exceptions.SFAuthError`).
359
+ * Local-mode detection via :meth:`_is_local_mode`.
360
+
361
+ Concrete subclasses implement service-specific methods; they call
362
+ :meth:`_request` for remote operations and fall back to local in-process
363
+ logic when :meth:`_is_local_mode` returns ``True``.
364
+ """
365
+
366
+ def __init__(
367
+ self,
368
+ config: SFClientConfig,
369
+ service_name: str,
370
+ ) -> None:
371
+ self._config = config
372
+ self._service_name = service_name
373
+ self._circuit_breaker = _CircuitBreaker(
374
+ threshold=_CB_THRESHOLD_DEFAULT,
375
+ reset_seconds=_CB_RESET_DEFAULT,
376
+ )
377
+ # Install proxy handler if configured
378
+ if config.proxy:
379
+ proxy_handler = urllib.request.ProxyHandler(
380
+ {"http": config.proxy, "https": config.proxy}
381
+ )
382
+ opener = urllib.request.build_opener(proxy_handler)
383
+ urllib.request.install_opener(opener)
384
+
385
+ # ------------------------------------------------------------------
386
+ # ID-003: Token refresh hook (subclasses override)
387
+ # ------------------------------------------------------------------
388
+
389
+ def _on_token_near_expiry(self, seconds_remaining: int) -> None:
390
+ """Called when the auth token is about to expire.
391
+
392
+ Triggered when the ``X-SF-Token-Expires`` response header reports fewer
393
+ than ``_TOKEN_EXPIRY_WARN_SECS`` seconds until expiry. The default
394
+ implementation emits a warning log. :class:`SFIdentityClient` overrides
395
+ this to perform an inline token refresh (ID-003).
396
+
397
+ Args:
398
+ seconds_remaining: Seconds until token expiry per the response header.
399
+ """
400
+ _log.warning(
401
+ "sf-%s auth token expires in %ds; consider calling refresh_token()",
402
+ self._service_name,
403
+ seconds_remaining,
404
+ )
405
+
406
+ # ------------------------------------------------------------------
407
+ # Helpers
408
+ # ------------------------------------------------------------------
409
+
410
+ def _is_local_mode(self) -> bool:
411
+ """Return ``True`` when no service endpoint is configured."""
412
+ return not self._config.endpoint.strip()
413
+
414
+ def _is_sandbox(self) -> bool:
415
+ """Return ``True`` when sandbox mode is enabled via config or env."""
416
+ import os
417
+
418
+ if os.environ.get("SPANFORGE_SANDBOX", "").lower() in ("1", "true", "yes"):
419
+ return True
420
+ try:
421
+ from spanforge.sdk.config import load_config_file
422
+
423
+ cfg = load_config_file()
424
+ return cfg.sandbox
425
+ except Exception:
426
+ return False
427
+
428
+ def _build_opener(self) -> urllib.request.OpenerDirector:
429
+ """Build a URL opener, optionally with proxy support."""
430
+ handlers: list[urllib.request.BaseHandler] = []
431
+ if not self._config.tls_verify:
432
+ ctx = ssl.create_default_context()
433
+ ctx.check_hostname = False
434
+ ctx.verify_mode = ssl.CERT_NONE
435
+ handlers.append(urllib.request.HTTPSHandler(context=ctx))
436
+ if self._config.proxy:
437
+ handlers.append(
438
+ urllib.request.ProxyHandler(
439
+ {"http": self._config.proxy, "https": self._config.proxy}
440
+ )
441
+ )
442
+ return urllib.request.build_opener(*handlers) if handlers else urllib.request.build_opener()
443
+
444
+ def _request(
445
+ self,
446
+ method: str,
447
+ path: str,
448
+ body: dict[str, Any] | None = None,
449
+ ) -> dict[str, Any]:
450
+ """Make an authenticated JSON request to the remote service.
451
+
452
+ Behaviour:
453
+ * If the circuit breaker is OPEN, raises
454
+ :exc:`~spanforge.sdk._exceptions.SFServiceUnavailableError`
455
+ immediately without making a network call.
456
+ * On ``429`` responses, raises
457
+ :exc:`~spanforge.sdk._exceptions.SFRateLimitError` with
458
+ ``Retry-After`` seconds.
459
+ * On ``401``/``403`` responses, raises
460
+ :exc:`~spanforge.sdk._exceptions.SFAuthError`.
461
+ * On other failures, retries up to ``config.max_retries`` times with
462
+ exponential back-off + jitter, then raises
463
+ :exc:`~spanforge.sdk._exceptions.SFServiceUnavailableError` (if
464
+ ``local_fallback_enabled=False``) or re-raises the last exception.
465
+
466
+ Security:
467
+ * The ``X-SF-API-Key`` header carries the raw key value only. The
468
+ value is never logged here.
469
+ * Request bodies are JSON-serialised; no sensitive fields should
470
+ appear in ``body`` (callers are responsible for that).
471
+ """
472
+ if self._circuit_breaker.is_open():
473
+ raise SFServiceUnavailableError(self._service_name)
474
+
475
+ url = f"{self._config.endpoint.rstrip('/')}{path}"
476
+ api_key_value = self._config.api_key.get_secret_value()
477
+ headers: dict[str, str] = {
478
+ "Content-Type": "application/json",
479
+ "Accept": "application/json",
480
+ "X-SF-API-Key": api_key_value,
481
+ }
482
+ encoded_body: bytes | None = (
483
+ json.dumps(body, separators=(",", ":")).encode() if body else None
484
+ )
485
+
486
+ last_exc: Exception | None = None
487
+ total_attempts = self._config.max_retries + 1
488
+
489
+ opener = self._build_opener()
490
+
491
+ for attempt in range(total_attempts):
492
+ if attempt > 0:
493
+ # Exponential back-off: 0.5, 1.0, 2.0, … up to 10 s + jitter
494
+ delay = min(0.5 * (2**attempt), 10.0) + random.uniform(0.0, 0.1) # nosec B311 -- timing jitter only, not crypto
495
+ time.sleep(delay)
496
+
497
+ try:
498
+ req = urllib.request.Request(
499
+ url,
500
+ data=encoded_body,
501
+ headers=headers,
502
+ method=method.upper(),
503
+ )
504
+ timeout_s = self._config.timeout_ms / 1_000.0
505
+ with opener.open(req, timeout=timeout_s) as resp:
506
+ # ID-003: Check token expiry header and call refresh hook
507
+ token_expires_header = resp.headers.get("X-SF-Token-Expires")
508
+ if token_expires_header:
509
+ try:
510
+ token_ttl = int(token_expires_header)
511
+ if token_ttl < _TOKEN_EXPIRY_WARN_SECS:
512
+ self._on_token_near_expiry(token_ttl)
513
+ except (ValueError, TypeError):
514
+ pass
515
+ raw = resp.read()
516
+ self._circuit_breaker.record_success()
517
+ return json.loads(raw) if raw else {}
518
+
519
+ except urllib.error.HTTPError as exc:
520
+ if exc.code == _HTTP_429:
521
+ retry_after = int(exc.headers.get("Retry-After", "60"))
522
+ raise SFRateLimitError(retry_after) from exc
523
+ if exc.code in _HTTP_401_403:
524
+ raise SFAuthError(f"HTTP {exc.code} from sf-{self._service_name}") from exc
525
+ _log.debug(
526
+ "sf-%s request failed (attempt %d/%d): HTTP %s",
527
+ self._service_name,
528
+ attempt + 1,
529
+ total_attempts,
530
+ exc.code,
531
+ )
532
+ self._circuit_breaker.record_failure()
533
+ last_exc = exc
534
+
535
+ except (urllib.error.URLError, OSError, TimeoutError) as exc:
536
+ _log.debug(
537
+ "sf-%s request failed (attempt %d/%d): %s",
538
+ self._service_name,
539
+ attempt + 1,
540
+ total_attempts,
541
+ type(exc).__name__,
542
+ )
543
+ self._circuit_breaker.record_failure()
544
+ last_exc = exc
545
+
546
+ # All retries exhausted
547
+ if not self._config.local_fallback_enabled:
548
+ raise SFServiceUnavailableError(self._service_name)
549
+
550
+ # Caller should handle the fallback in local mode
551
+ _log.warning(
552
+ "sf-%s unreachable after %d attempt(s); falling back to local mode",
553
+ self._service_name,
554
+ total_attempts,
555
+ )
556
+ if last_exc is not None:
557
+ raise last_exc
558
+ raise SFServiceUnavailableError(self._service_name) # pragma: no cover
559
+
560
+ async def _request_async(
561
+ self,
562
+ method: str,
563
+ path: str,
564
+ body: dict[str, Any] | None = None,
565
+ ) -> dict[str, Any]:
566
+ """Async wrapper around :meth:`_request`.
567
+
568
+ Runs the blocking :meth:`_request` in the default
569
+ :class:`~concurrent.futures.ThreadPoolExecutor` so the event loop is
570
+ not blocked. This enables callers to ``await`` SDK calls in async
571
+ application contexts without any additional dependencies.
572
+
573
+ Args:
574
+ method: HTTP method (e.g. ``"GET"``, ``"POST"``).
575
+ path: URL path component (e.g. ``"/v1/secrets/scan"``).
576
+ body: Optional JSON-serialisable request body.
577
+
578
+ Returns:
579
+ Parsed JSON response dict, or ``{}`` on empty body.
580
+ """
581
+ import asyncio
582
+
583
+ loop = asyncio.get_event_loop()
584
+ return await loop.run_in_executor(None, self._request, method, path, body)
@@ -0,0 +1,71 @@
1
+ """Type stubs for spanforge.sdk._base (DX-001)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import abc
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+ from spanforge.sdk._types import RateLimitInfo, SecretStr
10
+
11
+ __all__ = [
12
+ "SFClientConfig",
13
+ "SFServiceClient",
14
+ "_CircuitBreaker",
15
+ "_SlidingWindowRateLimiter",
16
+ ]
17
+
18
+ class _CircuitBreaker:
19
+ CLOSED: str
20
+ OPEN: str
21
+ def __init__(
22
+ self,
23
+ threshold: int = 5,
24
+ reset_seconds: float = 30.0,
25
+ ) -> None: ...
26
+ @property
27
+ def state(self) -> str: ...
28
+ def is_open(self) -> bool: ...
29
+ def record_success(self) -> None: ...
30
+ def record_failure(self) -> None: ...
31
+ def reset(self) -> None: ...
32
+
33
+ class _SlidingWindowRateLimiter:
34
+ def __init__(
35
+ self,
36
+ limit: int = 600,
37
+ window_seconds: float = 60.0,
38
+ ) -> None: ...
39
+ def check(self, key_id: str) -> RateLimitInfo: ...
40
+ def record(self, key_id: str) -> bool: ...
41
+ def remaining(self, key_id: str) -> int: ...
42
+ def clear(self, key_id: str) -> None: ...
43
+
44
+ @dataclass
45
+ class SFClientConfig:
46
+ endpoint: str = ""
47
+ api_key: SecretStr = ...
48
+ project_id: str = ""
49
+ timeout_ms: int = 2_000
50
+ max_retries: int = 3
51
+ local_fallback_enabled: bool = True
52
+ tls_verify: bool = True
53
+ proxy: str | None = None
54
+ signing_key: str = ""
55
+ magic_secret: str = ""
56
+ @classmethod
57
+ def from_env(cls) -> SFClientConfig: ...
58
+
59
+ class SFServiceClient(abc.ABC):
60
+ _config: SFClientConfig
61
+ _service_name: str
62
+ _circuit_breaker: _CircuitBreaker
63
+ def __init__(self, config: SFClientConfig, service_name: str) -> None: ...
64
+ def _is_local_mode(self) -> bool: ...
65
+ def _request(
66
+ self,
67
+ method: str,
68
+ path: str,
69
+ body: dict[str, Any] | None = None,
70
+ ) -> dict[str, Any]: ...
71
+ def _on_token_near_expiry(self, seconds_remaining: int) -> None: ...