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.
@@ -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
+ [![PyPI](https://img.shields.io/pypi/v/barricator-client?label=PyPI)](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
+ [![PyPI](https://img.shields.io/pypi/v/barricator-client?label=PyPI)](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
+ [![PyPI](https://img.shields.io/pypi/v/barricator-client?label=PyPI)](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,4 @@
1
+
2
+ [dev]
3
+ pytest>=7
4
+ build>=1.2
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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()