barricator-client 0.1.0__tar.gz
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.
- barricator_client-0.1.0/PKG-INFO +86 -0
- barricator_client-0.1.0/README.md +61 -0
- barricator_client-0.1.0/barricator/__init__.py +11 -0
- barricator_client-0.1.0/barricator/aio.py +168 -0
- barricator_client-0.1.0/barricator/client.py +167 -0
- barricator_client-0.1.0/barricator/context.py +46 -0
- barricator_client-0.1.0/barricator/evaluation.py +185 -0
- barricator_client-0.1.0/barricator/murmur.py +78 -0
- barricator_client-0.1.0/barricator/store.py +78 -0
- barricator_client-0.1.0/barricator/transport.py +83 -0
- barricator_client-0.1.0/barricator_client.egg-info/PKG-INFO +86 -0
- barricator_client-0.1.0/barricator_client.egg-info/SOURCES.txt +16 -0
- barricator_client-0.1.0/barricator_client.egg-info/dependency_links.txt +1 -0
- barricator_client-0.1.0/barricator_client.egg-info/requires.txt +4 -0
- barricator_client-0.1.0/barricator_client.egg-info/top_level.txt +1 -0
- barricator_client-0.1.0/pyproject.toml +36 -0
- barricator_client-0.1.0/setup.cfg +4 -0
- barricator_client-0.1.0/tests/test_evaluation.py +79 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: barricator-client
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Barricator production-grade Python Server SDK (local evaluation, SSE sync, telemetry)
|
|
5
|
+
Author: Jorge Spiropulo
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/barricator/barricator-python-client
|
|
8
|
+
Project-URL: Source, https://github.com/barricator/barricator-python-client
|
|
9
|
+
Project-URL: Changelog, https://github.com/barricator/barricator-python-client/blob/main/CHANGELOG.md
|
|
10
|
+
Keywords: feature-flags,barricator,sdk,rollout,ab-testing
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
24
|
+
Requires-Dist: build>=1.2; extra == "dev"
|
|
25
|
+
|
|
26
|
+
# barricator-python-client
|
|
27
|
+
|
|
28
|
+
[](https://pypi.org/project/barricator-client/)
|
|
29
|
+
|
|
30
|
+
Production-grade **Python Server SDK** for Barricator. Standard library only (no third-party runtime
|
|
31
|
+
deps), Python 3.9+.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install barricator-client
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Guarantees
|
|
40
|
+
|
|
41
|
+
- **Zero-latency evaluation.** `is_enabled(...)` is a synchronous in-memory dict lookup — no I/O.
|
|
42
|
+
- **Local evaluation** that mirrors the backend engine exactly (incl. MurmurHash3 bucketing —
|
|
43
|
+
verified byte-identical with the Java SDK).
|
|
44
|
+
- **Real-time sync** over SSE on a daemon thread, with exponential backoff + jitter on disconnect and
|
|
45
|
+
graceful fallback to cached state.
|
|
46
|
+
- **Async telemetry** flushed every 30s; never blocks the host application.
|
|
47
|
+
- **Two tracks:** synchronous `BarricatorClient` and asyncio-native `AsyncBarricatorClient`.
|
|
48
|
+
|
|
49
|
+
## Sync usage
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from barricator import BarricatorClient, UserContext
|
|
53
|
+
|
|
54
|
+
with BarricatorClient("sdk-srv-...", base_url="https://app.barricator.io") as client:
|
|
55
|
+
user = UserContext("user-123", email="user@enterprise.com", custom={"plan": "pro"})
|
|
56
|
+
if client.is_enabled("premium-pricing", user):
|
|
57
|
+
...
|
|
58
|
+
theme = client.string_variation("homepage-theme", user, "control")
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Async usage
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from barricator import AsyncBarricatorClient, UserContext
|
|
65
|
+
|
|
66
|
+
async with await AsyncBarricatorClient.create("sdk-srv-...") as client:
|
|
67
|
+
user = UserContext("user-123", country="US")
|
|
68
|
+
enabled = client.is_enabled("beta-feature", user) # evaluation is sync (in-memory)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Test
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
python3 -m unittest discover -s tests -v
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Layout
|
|
78
|
+
|
|
79
|
+
| Module | Responsibility |
|
|
80
|
+
|--------|----------------|
|
|
81
|
+
| `barricator.client` / `barricator.aio` | Sync / async clients |
|
|
82
|
+
| `barricator.evaluation` | Local targeting engine |
|
|
83
|
+
| `barricator.store` | Thread-safe `FlagStore` + `MetricsBuffer` |
|
|
84
|
+
| `barricator.transport` | urllib bootstrap/flush + SSE parsing |
|
|
85
|
+
| `barricator.murmur` | MurmurHash3 (cross-SDK consistent) |
|
|
86
|
+
| `barricator.context` | `UserContext` |
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# barricator-python-client
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/barricator-client/)
|
|
4
|
+
|
|
5
|
+
Production-grade **Python Server SDK** for Barricator. Standard library only (no third-party runtime
|
|
6
|
+
deps), Python 3.9+.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pip install barricator-client
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Guarantees
|
|
15
|
+
|
|
16
|
+
- **Zero-latency evaluation.** `is_enabled(...)` is a synchronous in-memory dict lookup — no I/O.
|
|
17
|
+
- **Local evaluation** that mirrors the backend engine exactly (incl. MurmurHash3 bucketing —
|
|
18
|
+
verified byte-identical with the Java SDK).
|
|
19
|
+
- **Real-time sync** over SSE on a daemon thread, with exponential backoff + jitter on disconnect and
|
|
20
|
+
graceful fallback to cached state.
|
|
21
|
+
- **Async telemetry** flushed every 30s; never blocks the host application.
|
|
22
|
+
- **Two tracks:** synchronous `BarricatorClient` and asyncio-native `AsyncBarricatorClient`.
|
|
23
|
+
|
|
24
|
+
## Sync usage
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from barricator import BarricatorClient, UserContext
|
|
28
|
+
|
|
29
|
+
with BarricatorClient("sdk-srv-...", base_url="https://app.barricator.io") as client:
|
|
30
|
+
user = UserContext("user-123", email="user@enterprise.com", custom={"plan": "pro"})
|
|
31
|
+
if client.is_enabled("premium-pricing", user):
|
|
32
|
+
...
|
|
33
|
+
theme = client.string_variation("homepage-theme", user, "control")
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Async usage
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from barricator import AsyncBarricatorClient, UserContext
|
|
40
|
+
|
|
41
|
+
async with await AsyncBarricatorClient.create("sdk-srv-...") as client:
|
|
42
|
+
user = UserContext("user-123", country="US")
|
|
43
|
+
enabled = client.is_enabled("beta-feature", user) # evaluation is sync (in-memory)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Test
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
python3 -m unittest discover -s tests -v
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Layout
|
|
53
|
+
|
|
54
|
+
| Module | Responsibility |
|
|
55
|
+
|--------|----------------|
|
|
56
|
+
| `barricator.client` / `barricator.aio` | Sync / async clients |
|
|
57
|
+
| `barricator.evaluation` | Local targeting engine |
|
|
58
|
+
| `barricator.store` | Thread-safe `FlagStore` + `MetricsBuffer` |
|
|
59
|
+
| `barricator.transport` | urllib bootstrap/flush + SSE parsing |
|
|
60
|
+
| `barricator.murmur` | MurmurHash3 (cross-SDK consistent) |
|
|
61
|
+
| `barricator.context` | `UserContext` |
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Barricator Python Server SDK.
|
|
2
|
+
|
|
3
|
+
Local evaluation, SSE synchronization, MurmurHash3 rollout bucketing, and async telemetry, with both
|
|
4
|
+
synchronous (:class:`BarricatorClient`) and asyncio (:class:`AsyncBarricatorClient`) tracks.
|
|
5
|
+
"""
|
|
6
|
+
from .aio import AsyncBarricatorClient
|
|
7
|
+
from .client import BarricatorClient
|
|
8
|
+
from .context import UserContext
|
|
9
|
+
|
|
10
|
+
__all__ = ["BarricatorClient", "AsyncBarricatorClient", "UserContext"]
|
|
11
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Asyncio-native Barricator server SDK client.
|
|
2
|
+
|
|
3
|
+
Evaluation stays synchronous (it is a pure in-memory O(1) lookup — there is nothing to await), but
|
|
4
|
+
all network I/O is non-blocking: the initial bootstrap and periodic flush run via ``asyncio.to_thread``
|
|
5
|
+
and an ``asyncio`` task, while the blocking SSE iteration runs on a dedicated daemon thread that feeds
|
|
6
|
+
the shared, thread-safe store.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import random
|
|
14
|
+
import threading
|
|
15
|
+
from typing import Any, Optional
|
|
16
|
+
|
|
17
|
+
from . import evaluation
|
|
18
|
+
from .context import UserContext
|
|
19
|
+
from .store import FlagStore, MetricsBuffer
|
|
20
|
+
from .transport import Transport
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger("barricator.aio")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AsyncBarricatorClient:
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
sdk_key: str,
|
|
29
|
+
base_url: str = "https://app.barricator.io",
|
|
30
|
+
*,
|
|
31
|
+
streaming_enabled: bool = True,
|
|
32
|
+
metrics_enabled: bool = True,
|
|
33
|
+
metrics_flush_interval: float = 30.0,
|
|
34
|
+
bootstrap_timeout: float = 5.0,
|
|
35
|
+
initial_reconnect_delay: float = 1.0,
|
|
36
|
+
max_reconnect_delay: float = 60.0,
|
|
37
|
+
) -> None:
|
|
38
|
+
if not sdk_key:
|
|
39
|
+
raise ValueError("sdk_key is required")
|
|
40
|
+
self._transport = Transport(base_url, sdk_key)
|
|
41
|
+
self._store = FlagStore()
|
|
42
|
+
self._metrics = MetricsBuffer()
|
|
43
|
+
self._streaming_enabled = streaming_enabled
|
|
44
|
+
self._metrics_enabled = metrics_enabled
|
|
45
|
+
self._metrics_flush_interval = metrics_flush_interval
|
|
46
|
+
self._bootstrap_timeout = bootstrap_timeout
|
|
47
|
+
self._initial_reconnect_delay = initial_reconnect_delay
|
|
48
|
+
self._max_reconnect_delay = max_reconnect_delay
|
|
49
|
+
|
|
50
|
+
self._closed = threading.Event()
|
|
51
|
+
self._sse_conn: Optional[Any] = None
|
|
52
|
+
self._sse_thread: Optional[threading.Thread] = None
|
|
53
|
+
self._flush_task: Optional[asyncio.Task] = None
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
async def create(cls, sdk_key: str, base_url: str = "https://app.barricator.io", **kwargs: Any) -> "AsyncBarricatorClient":
|
|
57
|
+
client = cls(sdk_key, base_url, **kwargs)
|
|
58
|
+
await client.start()
|
|
59
|
+
return client
|
|
60
|
+
|
|
61
|
+
async def start(self) -> None:
|
|
62
|
+
await self._safe_bootstrap()
|
|
63
|
+
if self._streaming_enabled:
|
|
64
|
+
self._sse_thread = threading.Thread(target=self._stream_loop, name="barricator-sse", daemon=True)
|
|
65
|
+
self._sse_thread.start()
|
|
66
|
+
if self._metrics_enabled:
|
|
67
|
+
self._flush_task = asyncio.create_task(self._flush_loop())
|
|
68
|
+
|
|
69
|
+
# --- synchronous, in-memory evaluation (no await needed) ---
|
|
70
|
+
|
|
71
|
+
def is_enabled(self, flag_key: str, user: UserContext, default: bool = False) -> bool:
|
|
72
|
+
value = self._evaluate(flag_key, user, default).value
|
|
73
|
+
return value if isinstance(value, bool) else default
|
|
74
|
+
|
|
75
|
+
def string_variation(self, flag_key: str, user: UserContext, default: str) -> str:
|
|
76
|
+
value = self._evaluate(flag_key, user, default).value
|
|
77
|
+
return default if value is None else str(value)
|
|
78
|
+
|
|
79
|
+
def number_variation(self, flag_key: str, user: UserContext, default: float) -> float:
|
|
80
|
+
value = self._evaluate(flag_key, user, default).value
|
|
81
|
+
try:
|
|
82
|
+
return float(value) if value is not None else default
|
|
83
|
+
except (TypeError, ValueError):
|
|
84
|
+
return default
|
|
85
|
+
|
|
86
|
+
def json_variation(self, flag_key: str, user: UserContext, default: Any) -> Any:
|
|
87
|
+
return self._evaluate(flag_key, user, default).value
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def initialized(self) -> bool:
|
|
91
|
+
return self._store.initialized
|
|
92
|
+
|
|
93
|
+
def _evaluate(self, flag_key: str, user: UserContext, fallback: Any) -> evaluation.EvaluationResult:
|
|
94
|
+
result = evaluation.evaluate(self._store.get(flag_key), user, fallback)
|
|
95
|
+
if self._metrics_enabled:
|
|
96
|
+
self._metrics.record(flag_key, result.variation_id, result.is_defaulted)
|
|
97
|
+
return result
|
|
98
|
+
|
|
99
|
+
# --- lifecycle ---
|
|
100
|
+
|
|
101
|
+
async def _safe_bootstrap(self) -> None:
|
|
102
|
+
try:
|
|
103
|
+
resp = await asyncio.to_thread(self._transport.bootstrap, self._bootstrap_timeout)
|
|
104
|
+
flags = {f["key"]: f for f in (resp.get("flags") or [])}
|
|
105
|
+
self._store.replace_all(flags, int(resp.get("rulesVersion", 0)))
|
|
106
|
+
except Exception as exc: # noqa: BLE001
|
|
107
|
+
logger.warning("Async bootstrap failed (%s); serving cached/defaults", exc)
|
|
108
|
+
|
|
109
|
+
def _stream_loop(self) -> None:
|
|
110
|
+
delay = self._initial_reconnect_delay
|
|
111
|
+
while not self._closed.is_set():
|
|
112
|
+
try:
|
|
113
|
+
conn = self._transport.open_stream()
|
|
114
|
+
self._sse_conn = conn
|
|
115
|
+
delay = self._initial_reconnect_delay
|
|
116
|
+
for evt in conn.events():
|
|
117
|
+
if self._closed.is_set():
|
|
118
|
+
break
|
|
119
|
+
self._apply_event(evt)
|
|
120
|
+
except Exception as exc: # noqa: BLE001
|
|
121
|
+
if self._closed.is_set():
|
|
122
|
+
break
|
|
123
|
+
logger.debug("SSE disconnected (%s); reconnecting in %.1fs", exc, delay)
|
|
124
|
+
self._closed.wait(delay + random.uniform(0, delay / 2))
|
|
125
|
+
delay = min(delay * 2, self._max_reconnect_delay)
|
|
126
|
+
|
|
127
|
+
def _apply_event(self, evt: dict) -> None:
|
|
128
|
+
if evt.get("event") != "flag-change":
|
|
129
|
+
return
|
|
130
|
+
try:
|
|
131
|
+
payload = json.loads(evt["data"])
|
|
132
|
+
if payload.get("type") == "DELETE":
|
|
133
|
+
self._store.remove(payload.get("flagKey"))
|
|
134
|
+
elif payload.get("flag"):
|
|
135
|
+
self._store.upsert(payload["flag"])
|
|
136
|
+
except Exception as exc: # noqa: BLE001
|
|
137
|
+
logger.debug("Failed to apply SSE delta: %s", exc)
|
|
138
|
+
|
|
139
|
+
async def _flush_loop(self) -> None:
|
|
140
|
+
try:
|
|
141
|
+
while not self._closed.is_set():
|
|
142
|
+
await asyncio.sleep(self._metrics_flush_interval)
|
|
143
|
+
await self.flush()
|
|
144
|
+
except asyncio.CancelledError:
|
|
145
|
+
pass
|
|
146
|
+
|
|
147
|
+
async def flush(self) -> None:
|
|
148
|
+
events = self._metrics.drain()
|
|
149
|
+
if events:
|
|
150
|
+
try:
|
|
151
|
+
await asyncio.to_thread(self._transport.flush_metrics, events)
|
|
152
|
+
except Exception as exc: # noqa: BLE001
|
|
153
|
+
logger.debug("Async metrics flush failed: %s", exc)
|
|
154
|
+
|
|
155
|
+
async def aclose(self) -> None:
|
|
156
|
+
self._closed.set()
|
|
157
|
+
if self._flush_task is not None:
|
|
158
|
+
self._flush_task.cancel()
|
|
159
|
+
if self._sse_conn is not None:
|
|
160
|
+
self._sse_conn.close()
|
|
161
|
+
await self.flush()
|
|
162
|
+
|
|
163
|
+
async def __aenter__(self) -> "AsyncBarricatorClient":
|
|
164
|
+
await self.start()
|
|
165
|
+
return self
|
|
166
|
+
|
|
167
|
+
async def __aexit__(self, *exc: Any) -> None:
|
|
168
|
+
await self.aclose()
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Synchronous Barricator server SDK client."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
import random
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
from typing import Any, Optional
|
|
9
|
+
|
|
10
|
+
from . import evaluation
|
|
11
|
+
from .context import UserContext
|
|
12
|
+
from .store import FlagStore, MetricsBuffer
|
|
13
|
+
from .transport import Transport
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger("barricator")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class BarricatorClient:
|
|
19
|
+
"""Server SDK client.
|
|
20
|
+
|
|
21
|
+
Evaluation methods are synchronous, in-memory, and never perform I/O or raise. A daemon thread
|
|
22
|
+
keeps the cache fresh via SSE (with exponential backoff on disconnect), and another daemon thread
|
|
23
|
+
flushes aggregated telemetry every ``metrics_flush_interval`` seconds. Use as a context manager
|
|
24
|
+
or call :meth:`close`.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
sdk_key: str,
|
|
30
|
+
base_url: str = "https://app.barricator.io",
|
|
31
|
+
*,
|
|
32
|
+
streaming_enabled: bool = True,
|
|
33
|
+
metrics_enabled: bool = True,
|
|
34
|
+
metrics_flush_interval: float = 30.0,
|
|
35
|
+
bootstrap_timeout: float = 5.0,
|
|
36
|
+
initial_reconnect_delay: float = 1.0,
|
|
37
|
+
max_reconnect_delay: float = 60.0,
|
|
38
|
+
) -> None:
|
|
39
|
+
if not sdk_key:
|
|
40
|
+
raise ValueError("sdk_key is required")
|
|
41
|
+
self._transport = Transport(base_url, sdk_key)
|
|
42
|
+
self._store = FlagStore()
|
|
43
|
+
self._metrics = MetricsBuffer()
|
|
44
|
+
self._metrics_enabled = metrics_enabled
|
|
45
|
+
self._metrics_flush_interval = metrics_flush_interval
|
|
46
|
+
self._bootstrap_timeout = bootstrap_timeout
|
|
47
|
+
self._initial_reconnect_delay = initial_reconnect_delay
|
|
48
|
+
self._max_reconnect_delay = max_reconnect_delay
|
|
49
|
+
|
|
50
|
+
self._closed = threading.Event()
|
|
51
|
+
self._sse_conn = None # type: Optional[Any]
|
|
52
|
+
|
|
53
|
+
self._safe_bootstrap()
|
|
54
|
+
if streaming_enabled:
|
|
55
|
+
self._sse_thread = threading.Thread(target=self._stream_loop, name="barricator-sse", daemon=True)
|
|
56
|
+
self._sse_thread.start()
|
|
57
|
+
if metrics_enabled:
|
|
58
|
+
self._flush_thread = threading.Thread(target=self._flush_loop, name="barricator-metrics", daemon=True)
|
|
59
|
+
self._flush_thread.start()
|
|
60
|
+
|
|
61
|
+
# --- public evaluation API ---
|
|
62
|
+
|
|
63
|
+
def is_enabled(self, flag_key: str, user: UserContext, default: bool = False) -> bool:
|
|
64
|
+
value = self._evaluate(flag_key, user, default).value
|
|
65
|
+
return value if isinstance(value, bool) else default
|
|
66
|
+
|
|
67
|
+
def bool_variation(self, flag_key: str, user: UserContext, default: bool) -> bool:
|
|
68
|
+
return self.is_enabled(flag_key, user, default)
|
|
69
|
+
|
|
70
|
+
def string_variation(self, flag_key: str, user: UserContext, default: str) -> str:
|
|
71
|
+
value = self._evaluate(flag_key, user, default).value
|
|
72
|
+
return default if value is None else str(value)
|
|
73
|
+
|
|
74
|
+
def number_variation(self, flag_key: str, user: UserContext, default: float) -> float:
|
|
75
|
+
value = self._evaluate(flag_key, user, default).value
|
|
76
|
+
try:
|
|
77
|
+
return float(value) if value is not None else default
|
|
78
|
+
except (TypeError, ValueError):
|
|
79
|
+
return default
|
|
80
|
+
|
|
81
|
+
def json_variation(self, flag_key: str, user: UserContext, default: Any) -> Any:
|
|
82
|
+
return self._evaluate(flag_key, user, default).value
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def initialized(self) -> bool:
|
|
86
|
+
return self._store.initialized
|
|
87
|
+
|
|
88
|
+
def _evaluate(self, flag_key: str, user: UserContext, fallback: Any) -> evaluation.EvaluationResult:
|
|
89
|
+
flag = self._store.get(flag_key)
|
|
90
|
+
result = evaluation.evaluate(flag, user, fallback)
|
|
91
|
+
if self._metrics_enabled:
|
|
92
|
+
self._metrics.record(flag_key, result.variation_id, result.is_defaulted)
|
|
93
|
+
return result
|
|
94
|
+
|
|
95
|
+
# --- lifecycle ---
|
|
96
|
+
|
|
97
|
+
def _safe_bootstrap(self) -> None:
|
|
98
|
+
try:
|
|
99
|
+
resp = self._transport.bootstrap(timeout=self._bootstrap_timeout)
|
|
100
|
+
flags = {f["key"]: f for f in (resp.get("flags") or [])}
|
|
101
|
+
self._store.replace_all(flags, int(resp.get("rulesVersion", 0)))
|
|
102
|
+
logger.debug("Barricator bootstrap: %d flags (v%s)", len(flags), resp.get("rulesVersion"))
|
|
103
|
+
except Exception as exc: # noqa: BLE001 - never fatal
|
|
104
|
+
logger.warning("Barricator bootstrap failed (%s); serving cached/defaults", exc)
|
|
105
|
+
|
|
106
|
+
def _stream_loop(self) -> None:
|
|
107
|
+
delay = self._initial_reconnect_delay
|
|
108
|
+
while not self._closed.is_set():
|
|
109
|
+
try:
|
|
110
|
+
conn = self._transport.open_stream()
|
|
111
|
+
self._sse_conn = conn
|
|
112
|
+
# Reconnected: re-bootstrap to recover any deltas missed while offline.
|
|
113
|
+
self._safe_bootstrap()
|
|
114
|
+
delay = self._initial_reconnect_delay
|
|
115
|
+
for evt in conn.events():
|
|
116
|
+
if self._closed.is_set():
|
|
117
|
+
break
|
|
118
|
+
self._apply_event(evt)
|
|
119
|
+
except Exception as exc: # noqa: BLE001
|
|
120
|
+
if self._closed.is_set():
|
|
121
|
+
break
|
|
122
|
+
logger.debug("SSE disconnected (%s); reconnecting in %.1fs", exc, delay)
|
|
123
|
+
self._sleep_with_jitter(delay)
|
|
124
|
+
delay = min(delay * 2, self._max_reconnect_delay)
|
|
125
|
+
|
|
126
|
+
def _apply_event(self, evt: dict) -> None:
|
|
127
|
+
if evt.get("event") != "flag-change":
|
|
128
|
+
return
|
|
129
|
+
try:
|
|
130
|
+
import json
|
|
131
|
+
|
|
132
|
+
payload = json.loads(evt["data"])
|
|
133
|
+
if payload.get("type") == "DELETE":
|
|
134
|
+
self._store.remove(payload.get("flagKey"))
|
|
135
|
+
else:
|
|
136
|
+
flag = payload.get("flag")
|
|
137
|
+
if flag:
|
|
138
|
+
self._store.upsert(flag)
|
|
139
|
+
except Exception as exc: # noqa: BLE001
|
|
140
|
+
logger.debug("Failed to apply SSE delta: %s", exc)
|
|
141
|
+
|
|
142
|
+
def _flush_loop(self) -> None:
|
|
143
|
+
while not self._closed.wait(self._metrics_flush_interval):
|
|
144
|
+
self.flush()
|
|
145
|
+
|
|
146
|
+
def flush(self) -> None:
|
|
147
|
+
try:
|
|
148
|
+
events = self._metrics.drain()
|
|
149
|
+
if events:
|
|
150
|
+
self._transport.flush_metrics(events)
|
|
151
|
+
except Exception as exc: # noqa: BLE001
|
|
152
|
+
logger.debug("Metrics flush failed: %s", exc)
|
|
153
|
+
|
|
154
|
+
def _sleep_with_jitter(self, base: float) -> None:
|
|
155
|
+
self._closed.wait(base + random.uniform(0, base / 2))
|
|
156
|
+
|
|
157
|
+
def close(self) -> None:
|
|
158
|
+
self._closed.set()
|
|
159
|
+
if self._sse_conn is not None:
|
|
160
|
+
self._sse_conn.close()
|
|
161
|
+
self.flush()
|
|
162
|
+
|
|
163
|
+
def __enter__(self) -> "BarricatorClient":
|
|
164
|
+
return self
|
|
165
|
+
|
|
166
|
+
def __exit__(self, *exc: Any) -> None:
|
|
167
|
+
self.close()
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""User context passed to evaluation."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any, Dict, Optional
|
|
6
|
+
|
|
7
|
+
_BUILTINS = {"key", "name", "email", "country", "anonymous"}
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class UserContext:
|
|
12
|
+
"""The evaluation subject.
|
|
13
|
+
|
|
14
|
+
``key`` is the stable identity used for deterministic percentage rollouts and must be consistent
|
|
15
|
+
across calls for a given subject. Custom attributes are addressable by targeting clauses.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
key: str
|
|
19
|
+
name: Optional[str] = None
|
|
20
|
+
email: Optional[str] = None
|
|
21
|
+
country: Optional[str] = None
|
|
22
|
+
anonymous: bool = False
|
|
23
|
+
custom: Dict[str, Any] = field(default_factory=dict)
|
|
24
|
+
|
|
25
|
+
def __post_init__(self) -> None:
|
|
26
|
+
if not self.key:
|
|
27
|
+
raise ValueError("UserContext.key must be a non-empty string")
|
|
28
|
+
|
|
29
|
+
def attribute(self, attribute: str) -> Any:
|
|
30
|
+
"""Resolve an attribute by clause name (built-ins take precedence over custom)."""
|
|
31
|
+
if attribute == "key":
|
|
32
|
+
return self.key
|
|
33
|
+
if attribute == "name":
|
|
34
|
+
return self.name
|
|
35
|
+
if attribute == "email":
|
|
36
|
+
return self.email
|
|
37
|
+
if attribute == "country":
|
|
38
|
+
return self.country
|
|
39
|
+
if attribute == "anonymous":
|
|
40
|
+
return self.anonymous
|
|
41
|
+
return self.custom.get(attribute)
|
|
42
|
+
|
|
43
|
+
def has_attribute(self, attribute: str) -> bool:
|
|
44
|
+
if attribute in _BUILTINS:
|
|
45
|
+
return self.attribute(attribute) is not None
|
|
46
|
+
return attribute in self.custom
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Pure, synchronous targeting evaluator — a faithful port of the backend engine."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
from .context import UserContext
|
|
9
|
+
from .murmur import bucket_100k
|
|
10
|
+
|
|
11
|
+
REASON_OFF = "OFF"
|
|
12
|
+
REASON_RULE_MATCH = "RULE_MATCH"
|
|
13
|
+
REASON_FALLTHROUGH = "FALLTHROUGH"
|
|
14
|
+
REASON_FLAG_NOT_FOUND = "FLAG_NOT_FOUND"
|
|
15
|
+
REASON_ERROR = "ERROR"
|
|
16
|
+
|
|
17
|
+
_DEFAULTED_REASONS = {REASON_FLAG_NOT_FOUND, REASON_ERROR}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class EvaluationResult:
|
|
22
|
+
value: Any
|
|
23
|
+
variation_id: Optional[str]
|
|
24
|
+
reason: str
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def is_defaulted(self) -> bool:
|
|
28
|
+
return self.reason in _DEFAULTED_REASONS
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def evaluate(flag: Optional[Dict[str, Any]], ctx: UserContext, fallback: Any) -> EvaluationResult:
|
|
32
|
+
"""Evaluate a flag (raw dict from the ruleset) for a user. Never raises."""
|
|
33
|
+
if flag is None:
|
|
34
|
+
return EvaluationResult(fallback, None, REASON_FLAG_NOT_FOUND)
|
|
35
|
+
try:
|
|
36
|
+
if not flag.get("on", False):
|
|
37
|
+
return _served(flag, flag.get("offVariationId"), REASON_OFF, fallback)
|
|
38
|
+
|
|
39
|
+
for rule in flag.get("rules") or []:
|
|
40
|
+
if _rule_matches(rule, ctx):
|
|
41
|
+
if rule.get("rollout"):
|
|
42
|
+
variation_id = _bucket(flag["key"], rule["rollout"], ctx)
|
|
43
|
+
else:
|
|
44
|
+
variation_id = rule.get("variationId")
|
|
45
|
+
return _served(flag, variation_id, REASON_RULE_MATCH, fallback)
|
|
46
|
+
|
|
47
|
+
fallthrough_rollout = flag.get("fallthroughRollout")
|
|
48
|
+
if fallthrough_rollout:
|
|
49
|
+
variation_id = _bucket(flag["key"], fallthrough_rollout, ctx)
|
|
50
|
+
return _served(flag, variation_id, REASON_FALLTHROUGH, fallback)
|
|
51
|
+
return _served(flag, flag.get("fallthroughVariationId"), REASON_FALLTHROUGH, fallback)
|
|
52
|
+
except Exception: # noqa: BLE001 - evaluation must never propagate
|
|
53
|
+
return EvaluationResult(fallback, None, REASON_ERROR)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _served(flag: Dict[str, Any], variation_id: Optional[str], reason: str, fallback: Any) -> EvaluationResult:
|
|
57
|
+
value = _variation_value(flag, variation_id)
|
|
58
|
+
if value is _MISSING:
|
|
59
|
+
value = flag.get("defaultValue", fallback)
|
|
60
|
+
if value is None:
|
|
61
|
+
value = fallback
|
|
62
|
+
return EvaluationResult(value, variation_id, reason)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
_MISSING = object()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _variation_value(flag: Dict[str, Any], variation_id: Optional[str]) -> Any:
|
|
69
|
+
if variation_id is None:
|
|
70
|
+
return _MISSING
|
|
71
|
+
for variation in flag.get("variations") or []:
|
|
72
|
+
if variation.get("id") == variation_id:
|
|
73
|
+
return variation.get("value")
|
|
74
|
+
return _MISSING
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _bucket(flag_key: str, rollout: Dict[str, Any], ctx: UserContext) -> Optional[str]:
|
|
78
|
+
weighted: List[Dict[str, Any]] = rollout.get("variations") or []
|
|
79
|
+
if not weighted:
|
|
80
|
+
return None
|
|
81
|
+
bucket_by = rollout.get("bucketBy") or "key"
|
|
82
|
+
attr = ctx.attribute(bucket_by)
|
|
83
|
+
bucket_by_value = str(attr) if attr is not None else ctx.key
|
|
84
|
+
b = bucket_100k(flag_key, rollout.get("salt") or "", bucket_by_value)
|
|
85
|
+
cumulative = 0
|
|
86
|
+
for wv in weighted:
|
|
87
|
+
cumulative += int(wv.get("weight", 0))
|
|
88
|
+
if b < cumulative:
|
|
89
|
+
return wv.get("variationId")
|
|
90
|
+
return weighted[-1].get("variationId")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _rule_matches(rule: Dict[str, Any], ctx: UserContext) -> bool:
|
|
94
|
+
clauses = rule.get("clauses") or []
|
|
95
|
+
if not clauses:
|
|
96
|
+
return False
|
|
97
|
+
return all(_clause_matches(c, ctx) for c in clauses)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _clause_matches(clause: Dict[str, Any], ctx: UserContext) -> bool:
|
|
101
|
+
attr = ctx.attribute(clause.get("attribute"))
|
|
102
|
+
result = attr is not None and _apply_operator(clause.get("op"), attr, clause.get("values") or [])
|
|
103
|
+
return clause.get("negate", False) != result
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _apply_operator(op: Optional[str], attr: Any, values: List[Any]) -> bool:
|
|
107
|
+
if not op:
|
|
108
|
+
return False
|
|
109
|
+
s = lambda x: "" if x is None else str(x) # noqa: E731
|
|
110
|
+
if op == "IN":
|
|
111
|
+
return any(_loose_eq(attr, v) for v in values)
|
|
112
|
+
if op == "NOT_IN":
|
|
113
|
+
return all(not _loose_eq(attr, v) for v in values)
|
|
114
|
+
if op == "EQUALS":
|
|
115
|
+
return bool(values) and _loose_eq(attr, values[0])
|
|
116
|
+
if op == "NOT_EQUALS":
|
|
117
|
+
return not values or not _loose_eq(attr, values[0])
|
|
118
|
+
if op == "CONTAINS":
|
|
119
|
+
return any(s(v) in s(attr) for v in values)
|
|
120
|
+
if op == "NOT_CONTAINS":
|
|
121
|
+
return all(s(v) not in s(attr) for v in values)
|
|
122
|
+
if op == "STARTS_WITH":
|
|
123
|
+
return any(s(attr).startswith(s(v)) for v in values)
|
|
124
|
+
if op == "ENDS_WITH":
|
|
125
|
+
return any(s(attr).endswith(s(v)) for v in values)
|
|
126
|
+
if op == "MATCHES_REGEX":
|
|
127
|
+
return any(_safe_regex(s(attr), s(v)) for v in values)
|
|
128
|
+
if op in ("GREATER_THAN", "AFTER"):
|
|
129
|
+
return _cmp_num(attr, values) > 0
|
|
130
|
+
if op == "GREATER_THAN_OR_EQUAL":
|
|
131
|
+
return _cmp_num(attr, values) >= 0
|
|
132
|
+
if op in ("LESS_THAN", "BEFORE"):
|
|
133
|
+
return _cmp_num(attr, values) < 0
|
|
134
|
+
if op == "LESS_THAN_OR_EQUAL":
|
|
135
|
+
return _cmp_num(attr, values) <= 0
|
|
136
|
+
if op == "SEMVER_EQUAL":
|
|
137
|
+
return _cmp_semver(attr, values) == 0
|
|
138
|
+
if op == "SEMVER_GREATER_THAN":
|
|
139
|
+
return _cmp_semver(attr, values) > 0
|
|
140
|
+
if op == "SEMVER_LESS_THAN":
|
|
141
|
+
return _cmp_semver(attr, values) < 0
|
|
142
|
+
return False
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _loose_eq(a: Any, b: Any) -> bool:
|
|
146
|
+
if a is None or b is None:
|
|
147
|
+
return a is b
|
|
148
|
+
if isinstance(a, (int, float)) and isinstance(b, (int, float)):
|
|
149
|
+
return float(a) == float(b)
|
|
150
|
+
return a == b or str(a) == str(b)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _safe_regex(value: str, pattern: str) -> bool:
|
|
154
|
+
try:
|
|
155
|
+
return re.search(pattern, value) is not None
|
|
156
|
+
except re.error:
|
|
157
|
+
return False
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _cmp_num(attr: Any, values: List[Any]) -> int:
|
|
161
|
+
if not values:
|
|
162
|
+
return 0
|
|
163
|
+
try:
|
|
164
|
+
a, b = float(attr), float(values[0])
|
|
165
|
+
return (a > b) - (a < b)
|
|
166
|
+
except (TypeError, ValueError):
|
|
167
|
+
return 0
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _parse_semver(v: str) -> List[int]:
|
|
171
|
+
core = re.split(r"[-+]", str(v))[0]
|
|
172
|
+
parts = [0, 0, 0]
|
|
173
|
+
for i, piece in enumerate(core.split(".")[:3]):
|
|
174
|
+
try:
|
|
175
|
+
parts[i] = int(piece.strip())
|
|
176
|
+
except ValueError:
|
|
177
|
+
parts[i] = 0
|
|
178
|
+
return parts
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _cmp_semver(attr: Any, values: List[Any]) -> int:
|
|
182
|
+
if not values:
|
|
183
|
+
return 0
|
|
184
|
+
a, b = _parse_semver(attr), _parse_semver(values[0])
|
|
185
|
+
return (a > b) - (a < b)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""MurmurHash3 (x86 32-bit).
|
|
2
|
+
|
|
3
|
+
Bit-for-bit compatible with the Java/backend implementation so a user buckets into the same
|
|
4
|
+
variation across every SDK. Bucketing hashes ``"<flag_key>.<salt>.<bucket_by_value>"``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
_C1 = 0xCC9E2D51
|
|
8
|
+
_C2 = 0x1B873593
|
|
9
|
+
_MASK = 0xFFFFFFFF
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _rotl32(x: int, r: int) -> int:
|
|
13
|
+
x &= _MASK
|
|
14
|
+
return ((x << r) | (x >> (32 - r))) & _MASK
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _fmix(h: int) -> int:
|
|
18
|
+
h ^= h >> 16
|
|
19
|
+
h = (h * 0x85EBCA6B) & _MASK
|
|
20
|
+
h ^= h >> 13
|
|
21
|
+
h = (h * 0xC2B2AE35) & _MASK
|
|
22
|
+
h ^= h >> 16
|
|
23
|
+
return h & _MASK
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def hash32(data: bytes, seed: int = 0) -> int:
|
|
27
|
+
"""Return the signed 32-bit MurmurHash3 (matching Java's ``int`` result)."""
|
|
28
|
+
h1 = seed & _MASK
|
|
29
|
+
length = len(data)
|
|
30
|
+
rounded_end = length & 0xFFFFFFFC
|
|
31
|
+
|
|
32
|
+
for i in range(0, rounded_end, 4):
|
|
33
|
+
k1 = (data[i] & 0xFF) \
|
|
34
|
+
| ((data[i + 1] & 0xFF) << 8) \
|
|
35
|
+
| ((data[i + 2] & 0xFF) << 16) \
|
|
36
|
+
| ((data[i + 3] & 0xFF) << 24)
|
|
37
|
+
k1 = (k1 * _C1) & _MASK
|
|
38
|
+
k1 = _rotl32(k1, 15)
|
|
39
|
+
k1 = (k1 * _C2) & _MASK
|
|
40
|
+
h1 ^= k1
|
|
41
|
+
h1 = _rotl32(h1, 13)
|
|
42
|
+
h1 = (h1 * 5 + 0xE6546B64) & _MASK
|
|
43
|
+
|
|
44
|
+
k1 = 0
|
|
45
|
+
tail = length & 0x03
|
|
46
|
+
if tail == 3:
|
|
47
|
+
k1 = (data[rounded_end + 2] & 0xFF) << 16
|
|
48
|
+
if tail >= 2:
|
|
49
|
+
k1 |= (data[rounded_end + 1] & 0xFF) << 8
|
|
50
|
+
if tail >= 1:
|
|
51
|
+
k1 |= (data[rounded_end] & 0xFF)
|
|
52
|
+
k1 = (k1 * _C1) & _MASK
|
|
53
|
+
k1 = _rotl32(k1, 15)
|
|
54
|
+
k1 = (k1 * _C2) & _MASK
|
|
55
|
+
h1 ^= k1
|
|
56
|
+
|
|
57
|
+
h1 ^= length
|
|
58
|
+
h1 = _fmix(h1)
|
|
59
|
+
|
|
60
|
+
# Convert to signed 32-bit so that ``% n`` matches Java's Math.floorMod(signedInt, n).
|
|
61
|
+
if h1 >= 0x80000000:
|
|
62
|
+
h1 -= 0x100000000
|
|
63
|
+
return h1
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _unsigned_hash(flag_key: str, salt: str, bucket_by: str) -> int:
|
|
67
|
+
composite = f"{flag_key}.{salt or ''}.{bucket_by}"
|
|
68
|
+
return hash32(composite.encode("utf-8"), 0)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def bucket_0_to_99(flag_key: str, salt: str, bucket_by: str) -> int:
|
|
72
|
+
"""Deterministic bucket in ``[0, 100)``."""
|
|
73
|
+
return _unsigned_hash(flag_key, salt, bucket_by) % 100
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def bucket_100k(flag_key: str, salt: str, bucket_by: str) -> int:
|
|
77
|
+
"""Deterministic bucket in ``[0, 100000)`` for sub-percent rollouts."""
|
|
78
|
+
return _unsigned_hash(flag_key, salt, bucket_by) % 100_000
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Thread-safe in-memory flag store and metrics buffer."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import threading
|
|
5
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class FlagStore:
|
|
9
|
+
"""Holds the environment ruleset. Reads are guarded by an ``RLock`` for consistency under
|
|
10
|
+
concurrent SSE updates; lookups are O(1) dict access."""
|
|
11
|
+
|
|
12
|
+
def __init__(self) -> None:
|
|
13
|
+
self._lock = threading.RLock()
|
|
14
|
+
self._flags: Dict[str, Dict[str, Any]] = {}
|
|
15
|
+
self._rules_version = 0
|
|
16
|
+
self._initialized = False
|
|
17
|
+
|
|
18
|
+
def get(self, key: str) -> Optional[Dict[str, Any]]:
|
|
19
|
+
with self._lock:
|
|
20
|
+
return self._flags.get(key)
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def initialized(self) -> bool:
|
|
24
|
+
with self._lock:
|
|
25
|
+
return self._initialized
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def rules_version(self) -> int:
|
|
29
|
+
with self._lock:
|
|
30
|
+
return self._rules_version
|
|
31
|
+
|
|
32
|
+
def replace_all(self, flags: Dict[str, Dict[str, Any]], version: int) -> None:
|
|
33
|
+
with self._lock:
|
|
34
|
+
self._flags = dict(flags)
|
|
35
|
+
self._rules_version = version
|
|
36
|
+
self._initialized = True
|
|
37
|
+
|
|
38
|
+
def upsert(self, flag: Dict[str, Any]) -> None:
|
|
39
|
+
if not flag or not flag.get("key"):
|
|
40
|
+
return
|
|
41
|
+
with self._lock:
|
|
42
|
+
self._flags[flag["key"]] = flag
|
|
43
|
+
self._rules_version = max(self._rules_version, int(flag.get("version", 0)))
|
|
44
|
+
self._initialized = True
|
|
45
|
+
|
|
46
|
+
def remove(self, key: Optional[str]) -> None:
|
|
47
|
+
if not key:
|
|
48
|
+
return
|
|
49
|
+
with self._lock:
|
|
50
|
+
self._flags.pop(key, None)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class MetricsBuffer:
|
|
54
|
+
"""Lock-guarded aggregation of evaluation counts keyed by (flag, variation, defaulted)."""
|
|
55
|
+
|
|
56
|
+
def __init__(self) -> None:
|
|
57
|
+
self._lock = threading.Lock()
|
|
58
|
+
self._counts: Dict[Tuple[str, Optional[str], bool], int] = {}
|
|
59
|
+
|
|
60
|
+
def record(self, flag_key: str, variation_id: Optional[str], defaulted: bool) -> None:
|
|
61
|
+
key = (flag_key, variation_id, defaulted)
|
|
62
|
+
with self._lock:
|
|
63
|
+
self._counts[key] = self._counts.get(key, 0) + 1
|
|
64
|
+
|
|
65
|
+
def drain(self) -> List[Dict[str, Any]]:
|
|
66
|
+
with self._lock:
|
|
67
|
+
snapshot = self._counts
|
|
68
|
+
self._counts = {}
|
|
69
|
+
return [
|
|
70
|
+
{
|
|
71
|
+
"flagKey": flag_key,
|
|
72
|
+
"variationId": variation_id,
|
|
73
|
+
"count": count,
|
|
74
|
+
"defaulted": defaulted,
|
|
75
|
+
}
|
|
76
|
+
for (flag_key, variation_id, defaulted), count in snapshot.items()
|
|
77
|
+
if count > 0
|
|
78
|
+
]
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""HTTP transport using only the standard library (urllib)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import urllib.error
|
|
6
|
+
import urllib.request
|
|
7
|
+
from typing import Any, Dict, Iterator, List, Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Transport:
|
|
11
|
+
"""Bootstrap + metrics flush (request/response) and SSE line streaming, over urllib."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, base_url: str, sdk_key: str, connect_timeout: float = 10.0) -> None:
|
|
14
|
+
self._base_url = base_url.rstrip("/")
|
|
15
|
+
self._sdk_key = sdk_key
|
|
16
|
+
self._connect_timeout = connect_timeout
|
|
17
|
+
|
|
18
|
+
def _headers(self, accept: str) -> Dict[str, str]:
|
|
19
|
+
return {"Authorization": f"Bearer {self._sdk_key}", "Accept": accept}
|
|
20
|
+
|
|
21
|
+
def bootstrap(self, timeout: float = 5.0) -> Dict[str, Any]:
|
|
22
|
+
req = urllib.request.Request(
|
|
23
|
+
f"{self._base_url}/api/v1/flags/bootstrap",
|
|
24
|
+
headers=self._headers("application/json"),
|
|
25
|
+
method="GET",
|
|
26
|
+
)
|
|
27
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
28
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
29
|
+
|
|
30
|
+
def flush_metrics(self, events: List[Dict[str, Any]], timeout: float = 10.0) -> bool:
|
|
31
|
+
body = json.dumps({"events": events}).encode("utf-8")
|
|
32
|
+
headers = self._headers("application/json")
|
|
33
|
+
headers["Content-Type"] = "application/json"
|
|
34
|
+
req = urllib.request.Request(
|
|
35
|
+
f"{self._base_url}/api/v1/metrics/flush",
|
|
36
|
+
data=body,
|
|
37
|
+
headers=headers,
|
|
38
|
+
method="POST",
|
|
39
|
+
)
|
|
40
|
+
try:
|
|
41
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
42
|
+
return 200 <= resp.status < 300
|
|
43
|
+
except urllib.error.HTTPError:
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
def open_stream(self) -> "SseConnection":
|
|
47
|
+
"""Open the SSE stream. Caller iterates events and must close the connection."""
|
|
48
|
+
req = urllib.request.Request(
|
|
49
|
+
f"{self._base_url}/api/v1/flags/stream",
|
|
50
|
+
headers=self._headers("text/event-stream"),
|
|
51
|
+
method="GET",
|
|
52
|
+
)
|
|
53
|
+
resp = urllib.request.urlopen(req, timeout=self._connect_timeout)
|
|
54
|
+
return SseConnection(resp)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class SseConnection:
|
|
58
|
+
"""Parses a Server-Sent Events byte stream into (event, data) tuples."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, response: Any) -> None:
|
|
61
|
+
self._response = response
|
|
62
|
+
|
|
63
|
+
def events(self) -> Iterator[Dict[str, Optional[str]]]:
|
|
64
|
+
event: Optional[str] = None
|
|
65
|
+
data_parts: List[str] = []
|
|
66
|
+
for raw in self._response:
|
|
67
|
+
line = raw.decode("utf-8").rstrip("\n").rstrip("\r")
|
|
68
|
+
if line == "":
|
|
69
|
+
if event is not None and data_parts:
|
|
70
|
+
yield {"event": event, "data": "".join(data_parts)}
|
|
71
|
+
event, data_parts = None, []
|
|
72
|
+
elif line.startswith(":"):
|
|
73
|
+
continue # heartbeat/comment
|
|
74
|
+
elif line.startswith("event:"):
|
|
75
|
+
event = line[len("event:"):].strip()
|
|
76
|
+
elif line.startswith("data:"):
|
|
77
|
+
data_parts.append(line[len("data:"):].strip())
|
|
78
|
+
|
|
79
|
+
def close(self) -> None:
|
|
80
|
+
try:
|
|
81
|
+
self._response.close()
|
|
82
|
+
except Exception: # noqa: BLE001
|
|
83
|
+
pass
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: barricator-client
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Barricator production-grade Python Server SDK (local evaluation, SSE sync, telemetry)
|
|
5
|
+
Author: Jorge Spiropulo
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/barricator/barricator-python-client
|
|
8
|
+
Project-URL: Source, https://github.com/barricator/barricator-python-client
|
|
9
|
+
Project-URL: Changelog, https://github.com/barricator/barricator-python-client/blob/main/CHANGELOG.md
|
|
10
|
+
Keywords: feature-flags,barricator,sdk,rollout,ab-testing
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
24
|
+
Requires-Dist: build>=1.2; extra == "dev"
|
|
25
|
+
|
|
26
|
+
# barricator-python-client
|
|
27
|
+
|
|
28
|
+
[](https://pypi.org/project/barricator-client/)
|
|
29
|
+
|
|
30
|
+
Production-grade **Python Server SDK** for Barricator. Standard library only (no third-party runtime
|
|
31
|
+
deps), Python 3.9+.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install barricator-client
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Guarantees
|
|
40
|
+
|
|
41
|
+
- **Zero-latency evaluation.** `is_enabled(...)` is a synchronous in-memory dict lookup — no I/O.
|
|
42
|
+
- **Local evaluation** that mirrors the backend engine exactly (incl. MurmurHash3 bucketing —
|
|
43
|
+
verified byte-identical with the Java SDK).
|
|
44
|
+
- **Real-time sync** over SSE on a daemon thread, with exponential backoff + jitter on disconnect and
|
|
45
|
+
graceful fallback to cached state.
|
|
46
|
+
- **Async telemetry** flushed every 30s; never blocks the host application.
|
|
47
|
+
- **Two tracks:** synchronous `BarricatorClient` and asyncio-native `AsyncBarricatorClient`.
|
|
48
|
+
|
|
49
|
+
## Sync usage
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from barricator import BarricatorClient, UserContext
|
|
53
|
+
|
|
54
|
+
with BarricatorClient("sdk-srv-...", base_url="https://app.barricator.io") as client:
|
|
55
|
+
user = UserContext("user-123", email="user@enterprise.com", custom={"plan": "pro"})
|
|
56
|
+
if client.is_enabled("premium-pricing", user):
|
|
57
|
+
...
|
|
58
|
+
theme = client.string_variation("homepage-theme", user, "control")
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Async usage
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from barricator import AsyncBarricatorClient, UserContext
|
|
65
|
+
|
|
66
|
+
async with await AsyncBarricatorClient.create("sdk-srv-...") as client:
|
|
67
|
+
user = UserContext("user-123", country="US")
|
|
68
|
+
enabled = client.is_enabled("beta-feature", user) # evaluation is sync (in-memory)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Test
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
python3 -m unittest discover -s tests -v
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Layout
|
|
78
|
+
|
|
79
|
+
| Module | Responsibility |
|
|
80
|
+
|--------|----------------|
|
|
81
|
+
| `barricator.client` / `barricator.aio` | Sync / async clients |
|
|
82
|
+
| `barricator.evaluation` | Local targeting engine |
|
|
83
|
+
| `barricator.store` | Thread-safe `FlagStore` + `MetricsBuffer` |
|
|
84
|
+
| `barricator.transport` | urllib bootstrap/flush + SSE parsing |
|
|
85
|
+
| `barricator.murmur` | MurmurHash3 (cross-SDK consistent) |
|
|
86
|
+
| `barricator.context` | `UserContext` |
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
barricator/__init__.py
|
|
4
|
+
barricator/aio.py
|
|
5
|
+
barricator/client.py
|
|
6
|
+
barricator/context.py
|
|
7
|
+
barricator/evaluation.py
|
|
8
|
+
barricator/murmur.py
|
|
9
|
+
barricator/store.py
|
|
10
|
+
barricator/transport.py
|
|
11
|
+
barricator_client.egg-info/PKG-INFO
|
|
12
|
+
barricator_client.egg-info/SOURCES.txt
|
|
13
|
+
barricator_client.egg-info/dependency_links.txt
|
|
14
|
+
barricator_client.egg-info/requires.txt
|
|
15
|
+
barricator_client.egg-info/top_level.txt
|
|
16
|
+
tests/test_evaluation.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
barricator
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "barricator-client"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Barricator production-grade Python Server SDK (local evaluation, SSE sync, telemetry)"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "Apache-2.0" }
|
|
12
|
+
authors = [{ name = "Jorge Spiropulo" }]
|
|
13
|
+
keywords = ["feature-flags", "barricator", "sdk", "rollout", "ab-testing"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: Apache Software License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.9",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Topic :: Software Development :: Libraries",
|
|
24
|
+
]
|
|
25
|
+
dependencies = [] # standard library only (urllib, threading, asyncio)
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://github.com/barricator/barricator-python-client"
|
|
29
|
+
Source = "https://github.com/barricator/barricator-python-client"
|
|
30
|
+
Changelog = "https://github.com/barricator/barricator-python-client/blob/main/CHANGELOG.md"
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
dev = ["pytest>=7", "build>=1.2"]
|
|
34
|
+
|
|
35
|
+
[tool.setuptools.packages.find]
|
|
36
|
+
include = ["barricator*"]
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
|
|
3
|
+
from barricator import UserContext
|
|
4
|
+
from barricator import evaluation
|
|
5
|
+
from barricator.murmur import bucket_0_to_99, bucket_100k
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _flag(**kwargs):
|
|
9
|
+
base = {
|
|
10
|
+
"key": "f",
|
|
11
|
+
"on": True,
|
|
12
|
+
"variations": [
|
|
13
|
+
{"id": "on", "value": True},
|
|
14
|
+
{"id": "off", "value": False},
|
|
15
|
+
],
|
|
16
|
+
"rules": [],
|
|
17
|
+
"offVariationId": "off",
|
|
18
|
+
"fallthroughVariationId": "off",
|
|
19
|
+
}
|
|
20
|
+
base.update(kwargs)
|
|
21
|
+
return base
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class EvaluationTests(unittest.TestCase):
|
|
25
|
+
def test_off_serves_off_variation(self):
|
|
26
|
+
result = evaluation.evaluate(_flag(on=False), UserContext("u1"), True)
|
|
27
|
+
self.assertFalse(result.value)
|
|
28
|
+
self.assertEqual(result.reason, evaluation.REASON_OFF)
|
|
29
|
+
|
|
30
|
+
def test_rule_ends_with_email(self):
|
|
31
|
+
flag = _flag(
|
|
32
|
+
rules=[{
|
|
33
|
+
"id": "r1",
|
|
34
|
+
"clauses": [{"attribute": "email", "op": "ENDS_WITH", "values": ["@enterprise.com"]}],
|
|
35
|
+
"variationId": "on",
|
|
36
|
+
}],
|
|
37
|
+
)
|
|
38
|
+
enterprise = UserContext("u1", email="user@enterprise.com")
|
|
39
|
+
consumer = UserContext("u2", email="user@gmail.com")
|
|
40
|
+
self.assertTrue(evaluation.evaluate(flag, enterprise, False).value)
|
|
41
|
+
self.assertFalse(evaluation.evaluate(flag, consumer, False).value)
|
|
42
|
+
|
|
43
|
+
def test_missing_flag_returns_fallback(self):
|
|
44
|
+
result = evaluation.evaluate(None, UserContext("u1"), "fallback")
|
|
45
|
+
self.assertEqual(result.value, "fallback")
|
|
46
|
+
self.assertTrue(result.is_defaulted)
|
|
47
|
+
|
|
48
|
+
def test_rollout_is_deterministic(self):
|
|
49
|
+
flag = _flag(fallthroughRollout={
|
|
50
|
+
"variations": [
|
|
51
|
+
{"variationId": "on", "weight": 50000},
|
|
52
|
+
{"variationId": "off", "weight": 50000},
|
|
53
|
+
],
|
|
54
|
+
})
|
|
55
|
+
user = UserContext("stable-user-123")
|
|
56
|
+
first = evaluation.evaluate(flag, user, False).value
|
|
57
|
+
second = evaluation.evaluate(flag, user, False).value
|
|
58
|
+
self.assertEqual(first, second)
|
|
59
|
+
|
|
60
|
+
def test_custom_attribute_targeting(self):
|
|
61
|
+
flag = _flag(rules=[{
|
|
62
|
+
"id": "r1",
|
|
63
|
+
"clauses": [{"attribute": "plan", "op": "IN", "values": ["pro", "enterprise"]}],
|
|
64
|
+
"variationId": "on",
|
|
65
|
+
}])
|
|
66
|
+
self.assertTrue(evaluation.evaluate(flag, UserContext("u", custom={"plan": "pro"}), False).value)
|
|
67
|
+
self.assertFalse(evaluation.evaluate(flag, UserContext("u", custom={"plan": "free"}), False).value)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class MurmurTests(unittest.TestCase):
|
|
71
|
+
def test_bucket_in_range_and_stable(self):
|
|
72
|
+
b = bucket_0_to_99("flag", "salt", "user-1")
|
|
73
|
+
self.assertTrue(0 <= b < 100)
|
|
74
|
+
self.assertEqual(b, bucket_0_to_99("flag", "salt", "user-1"))
|
|
75
|
+
self.assertTrue(0 <= bucket_100k("flag", "salt", "user-1") < 100000)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
if __name__ == "__main__":
|
|
79
|
+
unittest.main()
|