traffical 0.3.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.
traffical/__init__.py ADDED
@@ -0,0 +1,45 @@
1
+ """Traffical Python SDK.
2
+
3
+ Public surface: TrafficalClient / AsyncTrafficalClient / TrafficalClientOptions
4
+ plus the engine result types, the BYO assignment-logger types and the
5
+ exceptions raised by the public constructors. The pure engine lives in
6
+ traffical.engine.
7
+ """
8
+
9
+ from traffical.async_client import AsyncTrafficalClient
10
+ from traffical.client import TrafficalClient
11
+ from traffical.config.source import ConfigSourceError
12
+ from traffical.engine.types import (
13
+ ConfigBundle,
14
+ Context,
15
+ Decision,
16
+ DecisionMetadata,
17
+ LayerMetadata,
18
+ ParameterValue,
19
+ )
20
+ from traffical.events.assignments import (
21
+ AssignmentLogEntry,
22
+ AssignmentLogger,
23
+ AssignmentType,
24
+ )
25
+ from traffical.options import SDK_VERSION, EvaluationMode, TrafficalClientOptions
26
+
27
+ __version__ = SDK_VERSION
28
+
29
+ __all__ = [
30
+ "AssignmentLogEntry",
31
+ "AssignmentLogger",
32
+ "AssignmentType",
33
+ "AsyncTrafficalClient",
34
+ "ConfigBundle",
35
+ "ConfigSourceError",
36
+ "Context",
37
+ "Decision",
38
+ "DecisionMetadata",
39
+ "EvaluationMode",
40
+ "LayerMetadata",
41
+ "ParameterValue",
42
+ "TrafficalClient",
43
+ "TrafficalClientOptions",
44
+ "__version__",
45
+ ]
traffical/_fork.py ADDED
@@ -0,0 +1,41 @@
1
+ """Shared fork-safety registry.
2
+
3
+ Threads and lock state do not survive ``os.fork()``: the child inherits locks
4
+ that may be held by threads that no longer exist. Components with locks or
5
+ background threads register here; an ``os.register_at_fork`` hook calls their
6
+ ``handle_fork()`` in the child to re-arm locks and drop dead thread handles so
7
+ they restart lazily.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import threading
14
+ import weakref
15
+ from typing import Protocol
16
+
17
+
18
+ class ForkAware(Protocol):
19
+ def handle_fork(self) -> None: ...
20
+
21
+
22
+ _registry: weakref.WeakSet[ForkAware] = weakref.WeakSet()
23
+ _registry_lock = threading.Lock()
24
+ _hook_installed = False
25
+
26
+
27
+ def _after_in_child() -> None:
28
+ global _registry_lock
29
+ _registry_lock = threading.Lock()
30
+ for member in list(_registry):
31
+ member.handle_fork()
32
+
33
+
34
+ def register_fork_aware(member: ForkAware) -> None:
35
+ """Registers ``member`` (weakly) for ``handle_fork()`` in forked children."""
36
+ global _hook_installed
37
+ with _registry_lock:
38
+ _registry.add(member)
39
+ if not _hook_installed and hasattr(os, "register_at_fork"):
40
+ os.register_at_fork(after_in_child=_after_in_child)
41
+ _hook_installed = True
@@ -0,0 +1,335 @@
1
+ """Asyncio Traffical client: same surface as TrafficalClient with async methods.
2
+
3
+ Evaluation is pure CPU, so there is no global evaluation lock; only config
4
+ delivery and event flushing run as asyncio tasks. The background poller is
5
+ started lazily on the first async call (or via ``async with`` / ``start()``)
6
+ because task creation needs a running event loop.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import logging
13
+ import time
14
+ from collections.abc import Mapping
15
+ from types import TracebackType
16
+ from typing import Any
17
+
18
+ import httpx
19
+
20
+ from traffical.client import (
21
+ RESOLVE_PATH,
22
+ DecisionCache,
23
+ build_decision_event,
24
+ build_exposure_event,
25
+ build_track_event,
26
+ decision_from_resolve_response,
27
+ fallback_decision,
28
+ overlay_assignments,
29
+ resolve_headers,
30
+ resolve_request_body,
31
+ surviving_exposure_layers,
32
+ utc_now_iso,
33
+ )
34
+ from traffical.config.poller import DEFAULT_INTERVAL_SECONDS, AsyncConfigPoller
35
+ from traffical.config.readiness import AsyncBundleStore
36
+ from traffical.config.source import ConfigSource, HTTPConfigSource, coerce_bundle
37
+ from traffical.engine.ids import new_decision_id
38
+ from traffical.engine.resolution import decide as engine_decide
39
+ from traffical.engine.resolution import resolve_parameters
40
+ from traffical.engine.types import ConfigBundle, Context, Decision, ParameterValue
41
+ from traffical.events.assignments import AssignmentLogEmitter
42
+ from traffical.events.dedup import (
43
+ DecisionDeduplicator,
44
+ decision_dedup_key,
45
+ hash_assignments,
46
+ )
47
+ from traffical.events.logger import AsyncEventPipeline
48
+ from traffical.options import SDK_NAME, SDK_VERSION, TrafficalClientOptions
49
+
50
+ logger = logging.getLogger("traffical")
51
+
52
+
53
+ class AsyncTrafficalClient:
54
+ """Asyncio Traffical SDK client (bundle or server evaluation mode)."""
55
+
56
+ def __init__(self, options: TrafficalClientOptions) -> None:
57
+ self._options = options
58
+ self._closed = False
59
+ self._started = False
60
+
61
+ initial: ConfigBundle | None = (
62
+ coerce_bundle(options.local_config) if options.local_config is not None else None
63
+ )
64
+ self._store = AsyncBundleStore(initial=initial)
65
+
66
+ self._source: ConfigSource | None = None
67
+ self._owns_source = False
68
+ self._poller: AsyncConfigPoller | None = None
69
+ self._oneshot_task: asyncio.Task[float] | None = None
70
+ self._resolve_client: httpx.AsyncClient | None = None
71
+
72
+ if options.evaluation_mode == "bundle":
73
+ source = options.config_source
74
+ if source is None:
75
+ source = HTTPConfigSource(
76
+ project_id=options.project_id,
77
+ env=options.env,
78
+ api_key=options.api_key,
79
+ base_url=options.base_url,
80
+ timeout=options.config_timeout_seconds,
81
+ transport=options.transport,
82
+ )
83
+ self._owns_source = True
84
+ self._source = source
85
+ interval = (
86
+ options.refresh_interval_seconds
87
+ if options.refresh_interval_seconds > 0
88
+ else DEFAULT_INTERVAL_SECONDS
89
+ )
90
+ self._poller = AsyncConfigPoller(source, self._store, interval=interval)
91
+ else:
92
+ self._resolve_client = httpx.AsyncClient(
93
+ timeout=options.resolve_timeout_seconds, transport=options.async_transport
94
+ )
95
+
96
+ self._pipeline = AsyncEventPipeline(
97
+ base_url=options.base_url,
98
+ api_key=options.api_key,
99
+ batch_size=options.batch_size,
100
+ flush_interval=options.flush_interval_seconds,
101
+ max_queue_size=options.max_event_queue_size,
102
+ max_retries=options.event_max_retries,
103
+ request_timeout=options.events_timeout_seconds,
104
+ transport=options.async_transport,
105
+ disable_cloud_events=options.disable_cloud_events,
106
+ )
107
+ self._decision_dedup = DecisionDeduplicator(ttl_seconds=options.decision_dedup_ttl_seconds)
108
+ self._exposure_dedup = (
109
+ DecisionDeduplicator(ttl_seconds=options.exposure_session_ttl_seconds)
110
+ if options.deduplicate_exposures
111
+ else None
112
+ )
113
+ self._decision_cache = DecisionCache()
114
+ self._assignments = AssignmentLogEmitter(
115
+ options.assignment_logger,
116
+ deduplicate=options.deduplicate_assignment_logger,
117
+ org_id=options.org_id,
118
+ project_id=options.project_id,
119
+ env=options.env,
120
+ sdk_name=SDK_NAME,
121
+ sdk_version=SDK_VERSION,
122
+ )
123
+
124
+ @property
125
+ def options(self) -> TrafficalClientOptions:
126
+ return self._options
127
+
128
+ @property
129
+ def bundle(self) -> ConfigBundle | None:
130
+ return self._store.bundle
131
+
132
+ def start(self) -> None:
133
+ """Starts background config delivery; requires a running event loop."""
134
+ if self._closed or self._poller is None:
135
+ return
136
+ self._started = True
137
+ if self._options.refresh_interval_seconds > 0:
138
+ self._poller.start()
139
+ elif self._oneshot_task is None:
140
+ self._oneshot_task = asyncio.get_running_loop().create_task(self._poller.poll_once())
141
+
142
+ async def wait_for_ready(self, timeout: float | None = None) -> bool:
143
+ """Waits until a config bundle is available (always True in server mode)."""
144
+ if self._options.evaluation_mode == "server":
145
+ return True
146
+ self._ensure_alive()
147
+ return await self._store.wait_for_ready(timeout)
148
+
149
+ async def get_params(
150
+ self, context: Context, defaults: Mapping[str, ParameterValue]
151
+ ) -> dict[str, ParameterValue]:
152
+ """Resolved values for exactly the keys in ``defaults`` (fail-open)."""
153
+ try:
154
+ self._ensure_alive()
155
+ if self._options.evaluation_mode == "server":
156
+ data = await self._post_resolve(context, defaults)
157
+ if data is None:
158
+ return dict(defaults)
159
+ return overlay_assignments(defaults, data.get("assignments") or {})
160
+ return resolve_parameters(self._store.bundle, context, defaults)
161
+ except Exception:
162
+ logger.exception("Traffical get_params failed; returning defaults")
163
+ return dict(defaults)
164
+
165
+ async def decide(self, context: Context, defaults: Mapping[str, ParameterValue]) -> Decision:
166
+ """Full decision with tracking metadata (fail-open to defaults)."""
167
+ timestamp = utc_now_iso()
168
+ try:
169
+ self._ensure_alive()
170
+ start = time.monotonic()
171
+ if self._options.evaluation_mode == "server":
172
+ data = await self._post_resolve(context, defaults)
173
+ decision = (
174
+ decision_from_resolve_response(data, defaults)
175
+ if data is not None
176
+ else fallback_decision(defaults, timestamp)
177
+ )
178
+ else:
179
+ decision = engine_decide(
180
+ self._store.bundle,
181
+ context,
182
+ defaults,
183
+ decision_id=new_decision_id(),
184
+ timestamp=timestamp,
185
+ )
186
+ latency_ms = round((time.monotonic() - start) * 1000.0)
187
+ self._decision_cache.put(decision)
188
+ except Exception:
189
+ logger.exception("Traffical decide failed; returning defaults")
190
+ return fallback_decision(defaults, timestamp)
191
+ try:
192
+ if self._options.track_decisions:
193
+ self._track_decision(decision, list(defaults), latency_ms)
194
+ self._assignments.emit(decision, "decision")
195
+ except Exception:
196
+ logger.exception("Traffical decision tracking failed; decision still returned")
197
+ return decision
198
+
199
+ def track_exposure(self, decision: Decision) -> None:
200
+ """Emits at most one exposure event for a decision (S4; fail-open).
201
+
202
+ The event carries only newly-exposed, non-attributionOnly layers;
203
+ session-deduped layers are suppressed and no event is emitted when
204
+ nothing survives filtering.
205
+ """
206
+ try:
207
+ self._assignments.emit(decision, "exposure")
208
+ layers = surviving_exposure_layers(decision, self._exposure_dedup)
209
+ event = build_exposure_event(decision, self._options, utc_now_iso(), layers)
210
+ if event is not None:
211
+ self._pipeline.emit(event)
212
+ except Exception:
213
+ logger.exception("Traffical track_exposure failed; event dropped")
214
+
215
+ def track(
216
+ self,
217
+ event_name: str,
218
+ properties: Mapping[str, Any] | None = None,
219
+ *,
220
+ decision_id: str | None = None,
221
+ unit_key: str | None = None,
222
+ value: float | None = None,
223
+ values: Mapping[str, float] | None = None,
224
+ event_timestamp: str | None = None,
225
+ user_id: str | None = None,
226
+ anonymous_id: str | None = None,
227
+ session_id: str | None = None,
228
+ ) -> None:
229
+ """Tracks a user event, auto-attributing from a cached decision (fail-open).
230
+
231
+ ``value``/``values`` carry primary and multi-objective numeric metrics;
232
+ ``event_timestamp`` records the original time for delayed events;
233
+ ``user_id``/``anonymous_id``/``session_id`` are optional identity fields.
234
+ """
235
+ try:
236
+ event = build_track_event(
237
+ event_name,
238
+ self._options,
239
+ utc_now_iso(),
240
+ properties,
241
+ decision_id,
242
+ unit_key,
243
+ self._decision_cache.attribution(decision_id),
244
+ value=value,
245
+ values=values,
246
+ event_timestamp=event_timestamp,
247
+ user_id=user_id,
248
+ anonymous_id=anonymous_id,
249
+ session_id=session_id,
250
+ )
251
+ self._pipeline.emit(event)
252
+ except Exception:
253
+ logger.exception("Traffical track failed; event dropped")
254
+
255
+ async def flush_events(self, timeout: float | None = None) -> bool:
256
+ """Flushes pending events; True if the queue drained within the timeout."""
257
+ try:
258
+ return await self._pipeline.flush(timeout)
259
+ except Exception:
260
+ logger.exception("Traffical flush_events failed")
261
+ return False
262
+
263
+ async def close(self, timeout: float | None = None) -> None:
264
+ """Flushes events and releases all background resources (idempotent)."""
265
+ if self._closed:
266
+ return
267
+ self._closed = True
268
+ try:
269
+ if self._poller is not None:
270
+ await self._poller.stop()
271
+ if self._oneshot_task is not None and not self._oneshot_task.done():
272
+ self._oneshot_task.cancel()
273
+ if self._owns_source and isinstance(self._source, HTTPConfigSource):
274
+ self._source.close()
275
+ if self._resolve_client is not None:
276
+ await self._resolve_client.aclose()
277
+ await self._pipeline.close(timeout)
278
+ except Exception:
279
+ logger.exception("Traffical close failed")
280
+
281
+ async def __aenter__(self) -> AsyncTrafficalClient:
282
+ self.start()
283
+ return self
284
+
285
+ async def __aexit__(
286
+ self,
287
+ exc_type: type[BaseException] | None,
288
+ exc: BaseException | None,
289
+ tb: TracebackType | None,
290
+ ) -> None:
291
+ await self.close()
292
+
293
+ def _ensure_alive(self) -> None:
294
+ if self._poller is None or self._closed:
295
+ return
296
+ if not self._started:
297
+ self.start()
298
+ return
299
+ self._poller.ensure_alive()
300
+
301
+ async def _post_resolve(
302
+ self, context: Context, defaults: Mapping[str, ParameterValue]
303
+ ) -> Mapping[str, Any] | None:
304
+ assert self._resolve_client is not None
305
+ url = f"{self._options.base_url.rstrip('/')}{RESOLVE_PATH}"
306
+ try:
307
+ response = await self._resolve_client.post(
308
+ url,
309
+ json=resolve_request_body(context, self._options.env, defaults),
310
+ headers=resolve_headers(self._options),
311
+ )
312
+ except httpx.HTTPError as exc:
313
+ logger.warning("Traffical resolve request failed: %s", exc)
314
+ return None
315
+ if not response.is_success:
316
+ logger.warning("Traffical resolve returned HTTP %s", response.status_code)
317
+ return None
318
+ data = response.json()
319
+ if not isinstance(data, Mapping):
320
+ logger.warning("Traffical resolve returned a non-object payload")
321
+ return None
322
+ return data
323
+
324
+ def _track_decision(
325
+ self, decision: Decision, requested_parameters: list[str], latency_ms: int
326
+ ) -> None:
327
+ unit_key = decision.metadata.unit_key_value
328
+ if not unit_key:
329
+ return
330
+ key = decision_dedup_key(unit_key, hash_assignments(decision.assignments))
331
+ if not self._decision_dedup.check_and_mark(key):
332
+ return
333
+ event = build_decision_event(decision, self._options, requested_parameters, latency_ms)
334
+ if event is not None:
335
+ self._pipeline.emit(event)