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 +45 -0
- traffical/_fork.py +41 -0
- traffical/async_client.py +335 -0
- traffical/client.py +637 -0
- traffical/config/__init__.py +38 -0
- traffical/config/poller.py +289 -0
- traffical/config/readiness.py +128 -0
- traffical/config/source.py +199 -0
- traffical/engine/__init__.py +57 -0
- traffical/engine/conditions.py +159 -0
- traffical/engine/contextual.py +141 -0
- traffical/engine/hashing.py +55 -0
- traffical/engine/ids.py +51 -0
- traffical/engine/resolution.py +404 -0
- traffical/engine/strings.py +90 -0
- traffical/engine/types.py +489 -0
- traffical/engine/weighted.py +40 -0
- traffical/events/__init__.py +45 -0
- traffical/events/assignments.py +177 -0
- traffical/events/dedup.py +98 -0
- traffical/events/flusher.py +378 -0
- traffical/events/logger.py +204 -0
- traffical/events/queue.py +65 -0
- traffical/options.py +88 -0
- traffical/py.typed +0 -0
- traffical-0.3.0.dist-info/METADATA +283 -0
- traffical-0.3.0.dist-info/RECORD +29 -0
- traffical-0.3.0.dist-info/WHEEL +4 -0
- traffical-0.3.0.dist-info/licenses/LICENSE +21 -0
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)
|