switchbox-flags 0.2.0__tar.gz → 0.4.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.
- {switchbox_flags-0.2.0 → switchbox_flags-0.4.0}/.github/workflows/publish.yml +2 -2
- {switchbox_flags-0.2.0 → switchbox_flags-0.4.0}/.github/workflows/test.yml +2 -2
- {switchbox_flags-0.2.0 → switchbox_flags-0.4.0}/PKG-INFO +9 -9
- {switchbox_flags-0.2.0 → switchbox_flags-0.4.0}/README.md +8 -8
- {switchbox_flags-0.2.0 → switchbox_flags-0.4.0}/pyproject.toml +1 -1
- switchbox_flags-0.4.0/switchbox/__init__.py +5 -0
- {switchbox_flags-0.2.0 → switchbox_flags-0.4.0}/switchbox/client.py +19 -13
- switchbox_flags-0.4.0/switchbox/evaluator.py +157 -0
- {switchbox_flags-0.2.0 → switchbox_flags-0.4.0}/switchbox/models.py +28 -9
- {switchbox_flags-0.2.0 → switchbox_flags-0.4.0}/switchbox/sync.py +1 -1
- switchbox_flags-0.4.0/tests/parity_vectors.json +39 -0
- {switchbox_flags-0.2.0 → switchbox_flags-0.4.0}/tests/test_client.py +16 -13
- {switchbox_flags-0.2.0 → switchbox_flags-0.4.0}/tests/test_models.py +27 -1
- switchbox_flags-0.4.0/tests/test_parity_vectors.py +55 -0
- {switchbox_flags-0.2.0 → switchbox_flags-0.4.0}/uv.lock +1 -1
- switchbox_flags-0.2.0/switchbox/__init__.py +0 -5
- switchbox_flags-0.2.0/switchbox/evaluator.py +0 -104
- {switchbox_flags-0.2.0 → switchbox_flags-0.4.0}/.coverage +0 -0
- {switchbox_flags-0.2.0 → switchbox_flags-0.4.0}/.gitignore +0 -0
- {switchbox_flags-0.2.0 → switchbox_flags-0.4.0}/LICENSE +0 -0
- {switchbox_flags-0.2.0 → switchbox_flags-0.4.0}/switchbox/cache.py +0 -0
- {switchbox_flags-0.2.0 → switchbox_flags-0.4.0}/switchbox/exceptions.py +0 -0
- {switchbox_flags-0.2.0 → switchbox_flags-0.4.0}/tests/__init__.py +0 -0
- {switchbox_flags-0.2.0 → switchbox_flags-0.4.0}/tests/test_cache.py +0 -0
- {switchbox_flags-0.2.0 → switchbox_flags-0.4.0}/tests/test_evaluator.py +0 -0
|
@@ -13,9 +13,9 @@ jobs:
|
|
|
13
13
|
matrix:
|
|
14
14
|
python-version: ["3.10", "3.11", "3.12"]
|
|
15
15
|
steps:
|
|
16
|
-
- uses: actions/checkout@
|
|
16
|
+
- uses: actions/checkout@v6
|
|
17
17
|
|
|
18
|
-
- uses: actions/setup-python@
|
|
18
|
+
- uses: actions/setup-python@v6
|
|
19
19
|
with:
|
|
20
20
|
python-version: ${{ matrix.python-version }}
|
|
21
21
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: switchbox-flags
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Feature flag SDK with zero dependencies
|
|
5
5
|
Project-URL: Homepage, https://github.com/ignat14/switchbox-sdk-python
|
|
6
6
|
Project-URL: Repository, https://github.com/ignat14/switchbox-sdk-python
|
|
@@ -45,9 +45,9 @@ pip install switchbox-flags
|
|
|
45
45
|
## Quick Start
|
|
46
46
|
|
|
47
47
|
```python
|
|
48
|
-
from switchbox import
|
|
48
|
+
from switchbox import Switchbox
|
|
49
49
|
|
|
50
|
-
client =
|
|
50
|
+
client = Switchbox(sdk_key="your-sdk-key-from-dashboard")
|
|
51
51
|
|
|
52
52
|
if client.enabled("new_checkout", user={"user_id": "42"}):
|
|
53
53
|
show_new_checkout()
|
|
@@ -63,16 +63,16 @@ client.close()
|
|
|
63
63
|
- **Background polling** — syncs configs every 30 seconds (configurable)
|
|
64
64
|
- **Offline resilient** — keeps working on cached configs if the CDN is unreachable
|
|
65
65
|
- **Thread-safe** — safe to use from multiple threads
|
|
66
|
-
- **Context manager** — supports `with
|
|
66
|
+
- **Context manager** — supports `with Switchbox(...) as client:` for automatic cleanup
|
|
67
67
|
|
|
68
68
|
## Usage
|
|
69
69
|
|
|
70
70
|
### Boolean Flags
|
|
71
71
|
|
|
72
72
|
```python
|
|
73
|
-
from switchbox import
|
|
73
|
+
from switchbox import Switchbox
|
|
74
74
|
|
|
75
|
-
client =
|
|
75
|
+
client = Switchbox(sdk_key="your-sdk-key-from-dashboard")
|
|
76
76
|
|
|
77
77
|
if client.enabled("dark_mode"):
|
|
78
78
|
enable_dark_mode()
|
|
@@ -142,7 +142,7 @@ If the SDK has never successfully fetched a config (e.g., CDN is down on first s
|
|
|
142
142
|
### Context Manager
|
|
143
143
|
|
|
144
144
|
```python
|
|
145
|
-
with
|
|
145
|
+
with Switchbox(sdk_key="your-sdk-key-from-dashboard") as client:
|
|
146
146
|
if client.enabled("new_checkout", user={"user_id": "42"}):
|
|
147
147
|
show_new_checkout()
|
|
148
148
|
# client.close() is called automatically
|
|
@@ -151,7 +151,7 @@ with Client(sdk_key="your-sdk-key-from-dashboard") as client:
|
|
|
151
151
|
## Configuration
|
|
152
152
|
|
|
153
153
|
```python
|
|
154
|
-
client =
|
|
154
|
+
client = Switchbox(
|
|
155
155
|
sdk_key="your-sdk-key-from-dashboard", # required — get from Environments tab
|
|
156
156
|
poll_interval=60, # seconds between polls (default: 30)
|
|
157
157
|
on_error=lambda e: logger.warning(e), # called on fetch errors (default: None)
|
|
@@ -198,7 +198,7 @@ The API server is only in the write path. All read traffic goes to the CDN.
|
|
|
198
198
|
|
|
199
199
|
## API Reference
|
|
200
200
|
|
|
201
|
-
### `
|
|
201
|
+
### `Switchbox(sdk_key, poll_interval=30, on_error=None)`
|
|
202
202
|
|
|
203
203
|
Creates a new client. Performs an initial synchronous fetch on creation, then starts background polling.
|
|
204
204
|
|
|
@@ -19,9 +19,9 @@ pip install switchbox-flags
|
|
|
19
19
|
## Quick Start
|
|
20
20
|
|
|
21
21
|
```python
|
|
22
|
-
from switchbox import
|
|
22
|
+
from switchbox import Switchbox
|
|
23
23
|
|
|
24
|
-
client =
|
|
24
|
+
client = Switchbox(sdk_key="your-sdk-key-from-dashboard")
|
|
25
25
|
|
|
26
26
|
if client.enabled("new_checkout", user={"user_id": "42"}):
|
|
27
27
|
show_new_checkout()
|
|
@@ -37,16 +37,16 @@ client.close()
|
|
|
37
37
|
- **Background polling** — syncs configs every 30 seconds (configurable)
|
|
38
38
|
- **Offline resilient** — keeps working on cached configs if the CDN is unreachable
|
|
39
39
|
- **Thread-safe** — safe to use from multiple threads
|
|
40
|
-
- **Context manager** — supports `with
|
|
40
|
+
- **Context manager** — supports `with Switchbox(...) as client:` for automatic cleanup
|
|
41
41
|
|
|
42
42
|
## Usage
|
|
43
43
|
|
|
44
44
|
### Boolean Flags
|
|
45
45
|
|
|
46
46
|
```python
|
|
47
|
-
from switchbox import
|
|
47
|
+
from switchbox import Switchbox
|
|
48
48
|
|
|
49
|
-
client =
|
|
49
|
+
client = Switchbox(sdk_key="your-sdk-key-from-dashboard")
|
|
50
50
|
|
|
51
51
|
if client.enabled("dark_mode"):
|
|
52
52
|
enable_dark_mode()
|
|
@@ -116,7 +116,7 @@ If the SDK has never successfully fetched a config (e.g., CDN is down on first s
|
|
|
116
116
|
### Context Manager
|
|
117
117
|
|
|
118
118
|
```python
|
|
119
|
-
with
|
|
119
|
+
with Switchbox(sdk_key="your-sdk-key-from-dashboard") as client:
|
|
120
120
|
if client.enabled("new_checkout", user={"user_id": "42"}):
|
|
121
121
|
show_new_checkout()
|
|
122
122
|
# client.close() is called automatically
|
|
@@ -125,7 +125,7 @@ with Client(sdk_key="your-sdk-key-from-dashboard") as client:
|
|
|
125
125
|
## Configuration
|
|
126
126
|
|
|
127
127
|
```python
|
|
128
|
-
client =
|
|
128
|
+
client = Switchbox(
|
|
129
129
|
sdk_key="your-sdk-key-from-dashboard", # required — get from Environments tab
|
|
130
130
|
poll_interval=60, # seconds between polls (default: 30)
|
|
131
131
|
on_error=lambda e: logger.warning(e), # called on fetch errors (default: None)
|
|
@@ -172,7 +172,7 @@ The API server is only in the write path. All read traffic goes to the CDN.
|
|
|
172
172
|
|
|
173
173
|
## API Reference
|
|
174
174
|
|
|
175
|
-
### `
|
|
175
|
+
### `Switchbox(sdk_key, poll_interval=30, on_error=None)`
|
|
176
176
|
|
|
177
177
|
Creates a new client. Performs an initial synchronous fetch on creation, then starts background polling.
|
|
178
178
|
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
from typing import Any, Callable
|
|
2
4
|
|
|
3
5
|
from switchbox.cache import FlagCache
|
|
@@ -7,21 +9,21 @@ from switchbox.sync import SyncWorker
|
|
|
7
9
|
CDN_BASE_URL = "https://cdn.switchbox.dev"
|
|
8
10
|
|
|
9
11
|
|
|
10
|
-
class
|
|
12
|
+
class Switchbox:
|
|
11
13
|
"""Switchbox feature flag client.
|
|
12
14
|
|
|
13
15
|
Fetches flag configs from a CDN and evaluates them locally.
|
|
14
16
|
|
|
15
17
|
Usage::
|
|
16
18
|
|
|
17
|
-
client =
|
|
19
|
+
client = Switchbox(sdk_key="your-sdk-key")
|
|
18
20
|
if client.enabled("new_feature", user={"user_id": "42"}):
|
|
19
21
|
...
|
|
20
22
|
client.close()
|
|
21
23
|
|
|
22
24
|
Or as a context manager::
|
|
23
25
|
|
|
24
|
-
with
|
|
26
|
+
with Switchbox(sdk_key="your-sdk-key") as client:
|
|
25
27
|
if client.enabled("new_feature"):
|
|
26
28
|
...
|
|
27
29
|
"""
|
|
@@ -45,16 +47,23 @@ class Client:
|
|
|
45
47
|
"""Return True when configs have been loaded at least once."""
|
|
46
48
|
return self._cache.get_config() is not None
|
|
47
49
|
|
|
50
|
+
def _eval_flag(self, flag_key: str, user: dict | None, fallback: Any) -> Any:
|
|
51
|
+
"""Look up a flag and evaluate it, returning *fallback* if it's absent.
|
|
52
|
+
|
|
53
|
+
The shared path behind enabled()/get_value() — they differ only in
|
|
54
|
+
their fallback and how they coerce the result.
|
|
55
|
+
"""
|
|
56
|
+
flag = self._cache.get_flag(flag_key)
|
|
57
|
+
if flag is None:
|
|
58
|
+
return fallback
|
|
59
|
+
return evaluate(flag, user)
|
|
60
|
+
|
|
48
61
|
def enabled(self, flag_key: str, user: dict | None = None) -> bool:
|
|
49
62
|
"""Check if a boolean flag is enabled for a user.
|
|
50
63
|
|
|
51
64
|
Returns False if the flag doesn't exist (safe default).
|
|
52
65
|
"""
|
|
53
|
-
|
|
54
|
-
if flag is None:
|
|
55
|
-
return False
|
|
56
|
-
result = evaluate(flag, user)
|
|
57
|
-
return bool(result)
|
|
66
|
+
return bool(self._eval_flag(flag_key, user, False))
|
|
58
67
|
|
|
59
68
|
def get_value(
|
|
60
69
|
self, flag_key: str, user: dict | None = None, default: Any = None
|
|
@@ -63,10 +72,7 @@ class Client:
|
|
|
63
72
|
|
|
64
73
|
Returns *default* if the flag doesn't exist.
|
|
65
74
|
"""
|
|
66
|
-
|
|
67
|
-
if flag is None:
|
|
68
|
-
return default
|
|
69
|
-
return evaluate(flag, user)
|
|
75
|
+
return self._eval_flag(flag_key, user, default)
|
|
70
76
|
|
|
71
77
|
def get_all_flags(self, user: dict | None = None) -> dict[str, Any]:
|
|
72
78
|
"""Get all flag values resolved for a user."""
|
|
@@ -79,7 +85,7 @@ class Client:
|
|
|
79
85
|
"""Stop the background sync. Call on shutdown."""
|
|
80
86
|
self._sync.stop()
|
|
81
87
|
|
|
82
|
-
def __enter__(self) ->
|
|
88
|
+
def __enter__(self) -> Switchbox:
|
|
83
89
|
return self
|
|
84
90
|
|
|
85
91
|
def __exit__(self, *args: object) -> None:
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Pure evaluation engine for Switchbox feature flags.
|
|
2
|
+
|
|
3
|
+
Zero external dependencies — only Python stdlib.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import hashlib
|
|
7
|
+
import re
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from switchbox.models import Flag, Rule, RuleGroup
|
|
11
|
+
|
|
12
|
+
# Leading numeric prefix, matching JS parseFloat (e.g. "25px" -> 25, "1e3" -> 1000).
|
|
13
|
+
_NUMERIC_PREFIX = re.compile(r"[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _js_str(value: Any) -> str:
|
|
17
|
+
"""Coerce a value to a string the way JavaScript's String() does — the
|
|
18
|
+
canonical coercion for cross-SDK rule parity (SEC-4, ADR-013):
|
|
19
|
+
|
|
20
|
+
- booleans are lowercase ("true"/"false"), not Python's "True"/"False"
|
|
21
|
+
- None becomes "null"
|
|
22
|
+
- an integer-valued float drops its trailing ".0" (JS has no int/float split)
|
|
23
|
+
"""
|
|
24
|
+
if isinstance(value, bool):
|
|
25
|
+
return "true" if value else "false"
|
|
26
|
+
if value is None:
|
|
27
|
+
return "null"
|
|
28
|
+
if isinstance(value, float) and value.is_integer():
|
|
29
|
+
return str(int(value))
|
|
30
|
+
return str(value)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _to_number(value: Any) -> float | None:
|
|
34
|
+
"""Mimic JS parseFloat(String(value)): parse a leading numeric prefix, or
|
|
35
|
+
return None (JS NaN) when there isn't one. Booleans are NaN, matching
|
|
36
|
+
parseFloat("true")."""
|
|
37
|
+
if isinstance(value, bool):
|
|
38
|
+
return None
|
|
39
|
+
if isinstance(value, (int, float)):
|
|
40
|
+
return float(value)
|
|
41
|
+
match = _NUMERIC_PREFIX.match(str(value).lstrip())
|
|
42
|
+
return float(match.group(0)) if match else None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def evaluate(flag: Flag, user_context: dict | None = None) -> bool | str | int | Any:
|
|
46
|
+
"""Evaluate a flag for a given user context.
|
|
47
|
+
|
|
48
|
+
Returns the flag's resolved value.
|
|
49
|
+
|
|
50
|
+
Evaluation order:
|
|
51
|
+
1. Flag disabled → default_value
|
|
52
|
+
2. No user context → enabled value if rollout == 100, else default_value
|
|
53
|
+
3. Rules match (OR logic) → enabled value
|
|
54
|
+
4. Rollout percentage check → enabled value or default_value
|
|
55
|
+
5. Nothing matched → default_value
|
|
56
|
+
"""
|
|
57
|
+
try:
|
|
58
|
+
# 1. Disabled flag always returns default
|
|
59
|
+
if not flag.enabled:
|
|
60
|
+
return flag.default_value
|
|
61
|
+
|
|
62
|
+
# 2. No user context
|
|
63
|
+
if not user_context:
|
|
64
|
+
if flag.rollout_pct == 100:
|
|
65
|
+
return _enabled_value(flag)
|
|
66
|
+
return flag.default_value
|
|
67
|
+
|
|
68
|
+
# 3. Check rule groups: OR across groups, AND within a group (two-level
|
|
69
|
+
# DNF). Any matching group wins.
|
|
70
|
+
for group in _to_groups(flag.rules):
|
|
71
|
+
if _match_group(group, user_context):
|
|
72
|
+
return _enabled_value(flag)
|
|
73
|
+
|
|
74
|
+
# 4. Rollout. Resolve the id with a null-only fallback (JS `??`, not
|
|
75
|
+
# `or`): an empty-string user_id is a real id, not falsy.
|
|
76
|
+
user_id = user_context.get("user_id")
|
|
77
|
+
if user_id is None:
|
|
78
|
+
user_id = user_context.get("id")
|
|
79
|
+
if user_id is not None:
|
|
80
|
+
in_bucket = _check_rollout(_js_str(user_id), flag.key, flag.rollout_pct)
|
|
81
|
+
return _enabled_value(flag) if in_bucket else flag.default_value
|
|
82
|
+
|
|
83
|
+
# 5. No usable id to hash → only a full (100%) rollout reaches everyone
|
|
84
|
+
# (matches the no-user-context branch above; ADR-008).
|
|
85
|
+
return _enabled_value(flag) if flag.rollout_pct == 100 else flag.default_value
|
|
86
|
+
except Exception:
|
|
87
|
+
return flag.default_value
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _enabled_value(flag: Flag) -> bool | str | int | Any:
|
|
91
|
+
"""Return the appropriate 'enabled' value based on flag type."""
|
|
92
|
+
if flag.flag_type == "boolean":
|
|
93
|
+
return True
|
|
94
|
+
return flag.default_value
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _to_groups(rules: list) -> list[RuleGroup]:
|
|
98
|
+
"""Normalise a flag's rules into RuleGroups. ``from_dict`` already yields
|
|
99
|
+
RuleGroups; this also tolerates a flat list of ``Rule`` (legacy/direct
|
|
100
|
+
construction) — each becomes its own single-condition group, preserving the
|
|
101
|
+
old OR semantics. Idempotent."""
|
|
102
|
+
groups: list[RuleGroup] = []
|
|
103
|
+
for r in rules:
|
|
104
|
+
if isinstance(r, RuleGroup):
|
|
105
|
+
groups.append(r)
|
|
106
|
+
elif isinstance(r, Rule):
|
|
107
|
+
groups.append(RuleGroup(conditions=[r]))
|
|
108
|
+
return groups
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _match_group(group: RuleGroup, user_context: dict) -> bool:
|
|
112
|
+
"""A group matches when ALL its conditions match (AND). An empty group
|
|
113
|
+
matches no one (never 'match all')."""
|
|
114
|
+
return bool(group.conditions) and all(
|
|
115
|
+
_match_rule(c, user_context) for c in group.conditions
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _match_rule(rule: Rule, user_context: dict) -> bool:
|
|
120
|
+
"""Check if a single rule matches the user context.
|
|
121
|
+
|
|
122
|
+
All string comparisons coerce via _js_str (so `equals "true"` matches a
|
|
123
|
+
boolean True, and a None value coerces to "null" rather than never matching
|
|
124
|
+
— matching the JS SDK). gt/lt parse a leading numeric prefix like
|
|
125
|
+
parseFloat. See SEC-4 / ADR-013.
|
|
126
|
+
"""
|
|
127
|
+
if rule.attribute not in user_context:
|
|
128
|
+
return False
|
|
129
|
+
|
|
130
|
+
context_value = user_context[rule.attribute]
|
|
131
|
+
|
|
132
|
+
if rule.operator == "equals":
|
|
133
|
+
return _js_str(context_value) == _js_str(rule.value)
|
|
134
|
+
elif rule.operator == "not_equals":
|
|
135
|
+
return _js_str(context_value) != _js_str(rule.value)
|
|
136
|
+
elif rule.operator == "contains":
|
|
137
|
+
return _js_str(rule.value) in _js_str(context_value)
|
|
138
|
+
elif rule.operator == "ends_with":
|
|
139
|
+
return _js_str(context_value).endswith(_js_str(rule.value))
|
|
140
|
+
elif rule.operator == "in_list":
|
|
141
|
+
return _js_str(context_value) in rule.value
|
|
142
|
+
elif rule.operator == "gt":
|
|
143
|
+
a, b = _to_number(context_value), _to_number(rule.value)
|
|
144
|
+
return a is not None and b is not None and a > b
|
|
145
|
+
elif rule.operator == "lt":
|
|
146
|
+
a, b = _to_number(context_value), _to_number(rule.value)
|
|
147
|
+
return a is not None and b is not None and a < b
|
|
148
|
+
|
|
149
|
+
return False
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _check_rollout(user_id: str, flag_key: str, rollout_pct: int) -> bool:
|
|
153
|
+
"""Deterministic percentage rollout using consistent hashing."""
|
|
154
|
+
hash_input = f"{user_id}:{flag_key}"
|
|
155
|
+
hash_value = int(hashlib.sha256(hash_input.encode()).hexdigest(), 16)
|
|
156
|
+
bucket = hash_value % 100
|
|
157
|
+
return bucket < rollout_pct
|
|
@@ -1,14 +1,27 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
from dataclasses import dataclass, field
|
|
2
4
|
from typing import Any
|
|
3
5
|
|
|
4
6
|
|
|
5
7
|
@dataclass
|
|
6
8
|
class Rule:
|
|
9
|
+
"""A single condition. The atom of targeting."""
|
|
10
|
+
|
|
7
11
|
attribute: str
|
|
8
12
|
operator: str # equals | not_equals | contains | ends_with | in_list | gt | lt
|
|
9
13
|
value: Any
|
|
10
14
|
|
|
11
15
|
|
|
16
|
+
@dataclass
|
|
17
|
+
class RuleGroup:
|
|
18
|
+
"""An AND-group of conditions. Groups are OR'd by the evaluator — two-level
|
|
19
|
+
DNF (ADR-015): a flag matches if any group matches; a group matches if all
|
|
20
|
+
its conditions match."""
|
|
21
|
+
|
|
22
|
+
conditions: list[Rule] = field(default_factory=list)
|
|
23
|
+
|
|
24
|
+
|
|
12
25
|
@dataclass
|
|
13
26
|
class Flag:
|
|
14
27
|
key: str
|
|
@@ -16,7 +29,20 @@ class Flag:
|
|
|
16
29
|
rollout_pct: int
|
|
17
30
|
flag_type: str # boolean | string | number | json
|
|
18
31
|
default_value: Any
|
|
19
|
-
rules: list[
|
|
32
|
+
rules: list[RuleGroup] = field(default_factory=list)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _parse_condition(d: dict) -> Rule:
|
|
36
|
+
return Rule(attribute=d["attribute"], operator=d["operator"], value=d["value"])
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _parse_rule_group(entry: dict) -> RuleGroup:
|
|
40
|
+
"""Parse one rules entry, accepting the two-level shape
|
|
41
|
+
``{"conditions": [...]}`` and the legacy flat ``{attribute, operator, value}``
|
|
42
|
+
(a pre-DNF config) — the latter becomes a single-condition group."""
|
|
43
|
+
if "conditions" in entry:
|
|
44
|
+
return RuleGroup(conditions=[_parse_condition(c) for c in entry["conditions"]])
|
|
45
|
+
return RuleGroup(conditions=[_parse_condition(entry)])
|
|
20
46
|
|
|
21
47
|
|
|
22
48
|
@dataclass
|
|
@@ -29,14 +55,7 @@ class FlagConfig:
|
|
|
29
55
|
"""Parse the CDN JSON into a FlagConfig object."""
|
|
30
56
|
flags = {}
|
|
31
57
|
for key, flag_data in data.get("flags", {}).items():
|
|
32
|
-
rules = [
|
|
33
|
-
Rule(
|
|
34
|
-
attribute=r["attribute"],
|
|
35
|
-
operator=r["operator"],
|
|
36
|
-
value=r["value"],
|
|
37
|
-
)
|
|
38
|
-
for r in flag_data.get("rules", [])
|
|
39
|
-
]
|
|
58
|
+
rules = [_parse_rule_group(r) for r in flag_data.get("rules", [])]
|
|
40
59
|
flags[key] = Flag(
|
|
41
60
|
key=key,
|
|
42
61
|
enabled=flag_data["enabled"],
|
|
@@ -58,7 +58,7 @@ class SyncWorker:
|
|
|
58
58
|
try:
|
|
59
59
|
req = urllib.request.Request(
|
|
60
60
|
self._cdn_url,
|
|
61
|
-
headers={"User-Agent": "switchbox-python/0.
|
|
61
|
+
headers={"User-Agent": "switchbox-python/0.3.0"},
|
|
62
62
|
)
|
|
63
63
|
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
|
|
64
64
|
data = json.loads(resp.read().decode("utf-8"))
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "SEC-4 cross-SDK parity vectors. This file MUST be byte-identical to switchbox-sdk-js/packages/core/tests/parity_vectors.json — it is duplicated into each repo because each SDK is built/tested by its own CI. Canonical semantics are JS-style: lowercase booleans (String(true)='true'), parseFloat-style prefix numeric parsing, null coerces to 'null', user_id resolved with ??-style null-only fallback, and a user context lacking a usable id serves only 100% rollouts (ADR-008). See DECISIONS.md ADR-013. When you change one copy, change the other and run both suites.",
|
|
3
|
+
"rule_match": [
|
|
4
|
+
{"name": "boolean true matches lowercase 'true'", "rule": {"attribute": "is_beta", "operator": "equals", "value": "true"}, "context": {"is_beta": true}, "expected": true},
|
|
5
|
+
{"name": "boolean false matches lowercase 'false'", "rule": {"attribute": "is_beta", "operator": "equals", "value": "false"}, "context": {"is_beta": false}, "expected": true},
|
|
6
|
+
{"name": "boolean true does not match 'True' (no Python-style capitalization)", "rule": {"attribute": "is_beta", "operator": "equals", "value": "True"}, "context": {"is_beta": true}, "expected": false},
|
|
7
|
+
{"name": "boolean true not_equals 'false'", "rule": {"attribute": "is_beta", "operator": "not_equals", "value": "false"}, "context": {"is_beta": true}, "expected": true},
|
|
8
|
+
{"name": "integer-valued float coerces without trailing .0", "rule": {"attribute": "price", "operator": "equals", "value": "42"}, "context": {"price": 42.0}, "expected": true},
|
|
9
|
+
{"name": "null context value coerces to the string 'null'", "rule": {"attribute": "ref", "operator": "equals", "value": "null"}, "context": {"ref": null}, "expected": true},
|
|
10
|
+
{"name": "absent attribute never matches", "rule": {"attribute": "missing", "operator": "equals", "value": "x"}, "context": {"other": "x"}, "expected": false},
|
|
11
|
+
{"name": "contains substring", "rule": {"attribute": "email", "operator": "contains", "value": "company"}, "context": {"email": "a@company.com"}, "expected": true},
|
|
12
|
+
{"name": "ends_with suffix", "rule": {"attribute": "email", "operator": "ends_with", "value": "@company.com"}, "context": {"email": "a@company.com"}, "expected": true},
|
|
13
|
+
{"name": "gt with non-numeric suffix (parseFloat prefix parse)", "rule": {"attribute": "age", "operator": "gt", "value": "18"}, "context": {"age": "25px"}, "expected": true},
|
|
14
|
+
{"name": "gt prefix parse below threshold", "rule": {"attribute": "v", "operator": "gt", "value": "18"}, "context": {"v": "5px"}, "expected": false},
|
|
15
|
+
{"name": "gt with fully non-numeric string is NaN -> false", "rule": {"attribute": "v", "operator": "gt", "value": "1"}, "context": {"v": "abc"}, "expected": false},
|
|
16
|
+
{"name": "gt with boolean context is NaN -> false (parseFloat('true'))", "rule": {"attribute": "flag", "operator": "gt", "value": "0"}, "context": {"flag": true}, "expected": false},
|
|
17
|
+
{"name": "lt numeric", "rule": {"attribute": "age", "operator": "lt", "value": "18"}, "context": {"age": "5"}, "expected": true},
|
|
18
|
+
{"name": "in_list string member matches", "rule": {"attribute": "country", "operator": "in_list", "value": ["US", "CA", "UK"]}, "context": {"country": "US"}, "expected": true},
|
|
19
|
+
{"name": "in_list stringified number does not match raw number item (shared footgun)", "rule": {"attribute": "n", "operator": "in_list", "value": [42]}, "context": {"n": 42}, "expected": false},
|
|
20
|
+
{"name": "in_list stringified number matches string item", "rule": {"attribute": "n", "operator": "in_list", "value": ["42"]}, "context": {"n": 42}, "expected": true}
|
|
21
|
+
],
|
|
22
|
+
"evaluate": [
|
|
23
|
+
{"name": "disabled flag returns default", "flag_key": "f", "flag": {"enabled": false, "rollout_pct": 100, "flag_type": "boolean", "default_value": "off", "rules": []}, "user": {"user_id": "1"}, "expected": "off"},
|
|
24
|
+
{"name": "no user context, rollout 100 -> enabled", "flag_key": "f", "flag": {"enabled": true, "rollout_pct": 100, "flag_type": "boolean", "default_value": false, "rules": []}, "user": null, "expected": true},
|
|
25
|
+
{"name": "no user context, rollout 50 -> default", "flag_key": "f", "flag": {"enabled": true, "rollout_pct": 50, "flag_type": "boolean", "default_value": false, "rules": []}, "user": null, "expected": false},
|
|
26
|
+
{"name": "rule match beats rollout 0", "flag_key": "f", "flag": {"enabled": true, "rollout_pct": 0, "flag_type": "boolean", "default_value": false, "rules": [{"attribute": "plan", "operator": "equals", "value": "pro"}]}, "user": {"plan": "pro"}, "expected": true},
|
|
27
|
+
{"name": "boolean-coercion rule matches end to end", "flag_key": "f", "flag": {"enabled": true, "rollout_pct": 0, "flag_type": "boolean", "default_value": false, "rules": [{"attribute": "is_beta", "operator": "equals", "value": "true"}]}, "user": {"is_beta": true}, "expected": true},
|
|
28
|
+
{"name": "has user_id, rollout 0, no match -> default", "flag_key": "f", "flag": {"enabled": true, "rollout_pct": 0, "flag_type": "boolean", "default_value": false, "rules": []}, "user": {"user_id": "x"}, "expected": false},
|
|
29
|
+
{"name": "user context without id, rollout 100 -> enabled (ADR-008)", "flag_key": "f", "flag": {"enabled": true, "rollout_pct": 100, "flag_type": "boolean", "default_value": false, "rules": []}, "user": {"plan": "pro"}, "expected": true},
|
|
30
|
+
{"name": "user context without id, rollout 50 -> default", "flag_key": "f", "flag": {"enabled": true, "rollout_pct": 50, "flag_type": "boolean", "default_value": false, "rules": []}, "user": {"plan": "pro"}, "expected": false},
|
|
31
|
+
{"name": "empty-string user_id is treated as present (rollout 100)", "flag_key": "f", "flag": {"enabled": true, "rollout_pct": 100, "flag_type": "boolean", "default_value": false, "rules": []}, "user": {"user_id": "", "id": "ignored"}, "expected": true},
|
|
32
|
+
{"name": "DNF: AND-group with all conditions matching -> enabled", "flag_key": "f", "flag": {"enabled": true, "rollout_pct": 0, "flag_type": "boolean", "default_value": false, "rules": [{"conditions": [{"attribute": "country", "operator": "equals", "value": "US"}, {"attribute": "device", "operator": "equals", "value": "mobile"}]}]}, "user": {"country": "US", "device": "mobile"}, "expected": true},
|
|
33
|
+
{"name": "DNF: AND-group with one failing condition -> default", "flag_key": "f", "flag": {"enabled": true, "rollout_pct": 0, "flag_type": "boolean", "default_value": false, "rules": [{"conditions": [{"attribute": "country", "operator": "equals", "value": "US"}, {"attribute": "device", "operator": "equals", "value": "mobile"}]}]}, "user": {"country": "US", "device": "desktop"}, "expected": false},
|
|
34
|
+
{"name": "DNF: second group matches (OR across groups) -> enabled", "flag_key": "f", "flag": {"enabled": true, "rollout_pct": 0, "flag_type": "boolean", "default_value": false, "rules": [{"conditions": [{"attribute": "country", "operator": "equals", "value": "US"}]}, {"conditions": [{"attribute": "plan", "operator": "equals", "value": "ent"}]}]}, "user": {"plan": "ent"}, "expected": true},
|
|
35
|
+
{"name": "DNF: no group matches (OR across groups) -> default", "flag_key": "f", "flag": {"enabled": true, "rollout_pct": 0, "flag_type": "boolean", "default_value": false, "rules": [{"conditions": [{"attribute": "country", "operator": "equals", "value": "US"}]}, {"conditions": [{"attribute": "plan", "operator": "equals", "value": "ent"}]}]}, "user": {"plan": "free", "country": "EU"}, "expected": false},
|
|
36
|
+
{"name": "DNF: empty-conditions group never matches -> default", "flag_key": "f", "flag": {"enabled": true, "rollout_pct": 0, "flag_type": "boolean", "default_value": false, "rules": [{"conditions": []}]}, "user": {"any": "thing"}, "expected": false},
|
|
37
|
+
{"name": "DNF back-compat: legacy flat rule still matches -> enabled", "flag_key": "f", "flag": {"enabled": true, "rollout_pct": 0, "flag_type": "boolean", "default_value": false, "rules": [{"attribute": "plan", "operator": "equals", "value": "pro"}]}, "user": {"plan": "pro"}, "expected": true}
|
|
38
|
+
]
|
|
39
|
+
}
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import json
|
|
2
2
|
from unittest.mock import MagicMock, patch
|
|
3
3
|
|
|
4
|
-
from switchbox.client import
|
|
4
|
+
from switchbox.client import Switchbox
|
|
5
|
+
|
|
6
|
+
TEST_SDK_KEY = "dGVzdC1rZXktZm9yLXVuaXQtdGVzdHM"
|
|
7
|
+
TEST_CDN = "https://example.com"
|
|
5
8
|
|
|
6
9
|
SAMPLE_CONFIG = {
|
|
7
10
|
"version": "2026-04-07T12:00:00Z",
|
|
@@ -36,21 +39,21 @@ def _mock_urlopen(data):
|
|
|
36
39
|
@patch("switchbox.sync.urllib.request.urlopen")
|
|
37
40
|
def test_client_returns_false_for_nonexistent_flag(mock_urlopen):
|
|
38
41
|
mock_urlopen.return_value = _mock_urlopen(SAMPLE_CONFIG)
|
|
39
|
-
with
|
|
42
|
+
with Switchbox(sdk_key=TEST_SDK_KEY, cdn_base_url=TEST_CDN) as client:
|
|
40
43
|
assert client.enabled("nonexistent") is False
|
|
41
44
|
|
|
42
45
|
|
|
43
46
|
@patch("switchbox.sync.urllib.request.urlopen")
|
|
44
47
|
def test_client_get_value_returns_default_for_nonexistent_flag(mock_urlopen):
|
|
45
48
|
mock_urlopen.return_value = _mock_urlopen(SAMPLE_CONFIG)
|
|
46
|
-
with
|
|
49
|
+
with Switchbox(sdk_key=TEST_SDK_KEY, cdn_base_url=TEST_CDN) as client:
|
|
47
50
|
assert client.get_value("nonexistent", default="fallback") == "fallback"
|
|
48
51
|
|
|
49
52
|
|
|
50
53
|
@patch("switchbox.sync.urllib.request.urlopen")
|
|
51
54
|
def test_client_works_with_mock_cdn(mock_urlopen):
|
|
52
55
|
mock_urlopen.return_value = _mock_urlopen(SAMPLE_CONFIG)
|
|
53
|
-
with
|
|
56
|
+
with Switchbox(sdk_key=TEST_SDK_KEY, cdn_base_url=TEST_CDN) as client:
|
|
54
57
|
assert client.enabled("new_dashboard", user={"user_id": "1"}) is True
|
|
55
58
|
assert client.get_value("search_version", user={"user_id": "1"}) == "v1"
|
|
56
59
|
|
|
@@ -58,7 +61,7 @@ def test_client_works_with_mock_cdn(mock_urlopen):
|
|
|
58
61
|
@patch("switchbox.sync.urllib.request.urlopen")
|
|
59
62
|
def test_client_handles_cdn_failure_gracefully(mock_urlopen):
|
|
60
63
|
mock_urlopen.side_effect = Exception("Network error")
|
|
61
|
-
with
|
|
64
|
+
with Switchbox(sdk_key=TEST_SDK_KEY, cdn_base_url=TEST_CDN) as client:
|
|
62
65
|
# Should return safe defaults, not crash
|
|
63
66
|
assert client.enabled("new_dashboard") is False
|
|
64
67
|
assert client.get_value("search_version", default="v1") == "v1"
|
|
@@ -67,7 +70,7 @@ def test_client_handles_cdn_failure_gracefully(mock_urlopen):
|
|
|
67
70
|
@patch("switchbox.sync.urllib.request.urlopen")
|
|
68
71
|
def test_client_get_all_flags_empty_when_no_flags(mock_urlopen):
|
|
69
72
|
mock_urlopen.return_value = _mock_urlopen({"version": "v1", "flags": {}})
|
|
70
|
-
with
|
|
73
|
+
with Switchbox(sdk_key=TEST_SDK_KEY, cdn_base_url=TEST_CDN) as client:
|
|
71
74
|
assert client.get_all_flags() == {}
|
|
72
75
|
|
|
73
76
|
|
|
@@ -78,7 +81,7 @@ def test_client_handles_invalid_json(mock_urlopen):
|
|
|
78
81
|
resp.__enter__ = lambda s: s
|
|
79
82
|
resp.__exit__ = MagicMock(return_value=False)
|
|
80
83
|
mock_urlopen.return_value = resp
|
|
81
|
-
with
|
|
84
|
+
with Switchbox(sdk_key=TEST_SDK_KEY, cdn_base_url=TEST_CDN) as client:
|
|
82
85
|
assert client.enabled("any_flag") is False
|
|
83
86
|
|
|
84
87
|
|
|
@@ -86,7 +89,7 @@ def test_client_handles_invalid_json(mock_urlopen):
|
|
|
86
89
|
def test_client_handles_404(mock_urlopen):
|
|
87
90
|
from urllib.error import HTTPError
|
|
88
91
|
mock_urlopen.side_effect = HTTPError("https://example.com", 404, "Not Found", {}, None)
|
|
89
|
-
with
|
|
92
|
+
with Switchbox(sdk_key=TEST_SDK_KEY, cdn_base_url=TEST_CDN) as client:
|
|
90
93
|
assert client.enabled("any") is False
|
|
91
94
|
|
|
92
95
|
|
|
@@ -94,7 +97,7 @@ def test_client_handles_404(mock_urlopen):
|
|
|
94
97
|
def test_client_handles_500(mock_urlopen):
|
|
95
98
|
from urllib.error import HTTPError
|
|
96
99
|
mock_urlopen.side_effect = HTTPError("https://example.com", 500, "Server Error", {}, None)
|
|
97
|
-
with
|
|
100
|
+
with Switchbox(sdk_key=TEST_SDK_KEY, cdn_base_url=TEST_CDN) as client:
|
|
98
101
|
assert client.enabled("any") is False
|
|
99
102
|
|
|
100
103
|
|
|
@@ -102,7 +105,7 @@ def test_client_handles_500(mock_urlopen):
|
|
|
102
105
|
def test_client_handles_timeout(mock_urlopen):
|
|
103
106
|
from urllib.error import URLError
|
|
104
107
|
mock_urlopen.side_effect = URLError("timed out")
|
|
105
|
-
with
|
|
108
|
+
with Switchbox(sdk_key=TEST_SDK_KEY, cdn_base_url=TEST_CDN) as client:
|
|
106
109
|
assert client.enabled("any") is False
|
|
107
110
|
|
|
108
111
|
|
|
@@ -111,7 +114,7 @@ def test_client_keeps_cached_data_after_failure(mock_urlopen):
|
|
|
111
114
|
"""First call succeeds, second fails — client should keep using cached data."""
|
|
112
115
|
good_resp = _mock_urlopen(SAMPLE_CONFIG)
|
|
113
116
|
mock_urlopen.return_value = good_resp
|
|
114
|
-
client =
|
|
117
|
+
client = Switchbox(sdk_key=TEST_SDK_KEY, cdn_base_url=TEST_CDN, poll_interval=9999)
|
|
115
118
|
assert client.enabled("new_dashboard") is True
|
|
116
119
|
|
|
117
120
|
# Now simulate failure on next poll
|
|
@@ -124,7 +127,7 @@ def test_client_keeps_cached_data_after_failure(mock_urlopen):
|
|
|
124
127
|
@patch("switchbox.sync.urllib.request.urlopen")
|
|
125
128
|
def test_client_context_manager(mock_urlopen):
|
|
126
129
|
mock_urlopen.return_value = _mock_urlopen(SAMPLE_CONFIG)
|
|
127
|
-
with
|
|
130
|
+
with Switchbox(sdk_key=TEST_SDK_KEY, cdn_base_url=TEST_CDN) as c:
|
|
128
131
|
assert c.enabled("new_dashboard") is True
|
|
129
132
|
# After exiting context, sync should be stopped
|
|
130
133
|
assert c._sync._stop_event.is_set()
|
|
@@ -133,6 +136,6 @@ def test_client_context_manager(mock_urlopen):
|
|
|
133
136
|
@patch("switchbox.sync.urllib.request.urlopen")
|
|
134
137
|
def test_client_close_stops_sync(mock_urlopen):
|
|
135
138
|
mock_urlopen.return_value = _mock_urlopen(SAMPLE_CONFIG)
|
|
136
|
-
c =
|
|
139
|
+
c = Switchbox(sdk_key=TEST_SDK_KEY, cdn_base_url=TEST_CDN)
|
|
137
140
|
c.close()
|
|
138
141
|
assert c._sync._stop_event.is_set()
|
|
@@ -22,8 +22,9 @@ def test_flag_config_from_dict_valid():
|
|
|
22
22
|
flag = config.flags["feature_a"]
|
|
23
23
|
assert flag.enabled is True
|
|
24
24
|
assert flag.rollout_pct == 50
|
|
25
|
+
# Legacy flat rule is parsed into a single-condition group (back-compat).
|
|
25
26
|
assert len(flag.rules) == 1
|
|
26
|
-
assert flag.rules[0].attribute == "country"
|
|
27
|
+
assert flag.rules[0].conditions[0].attribute == "country"
|
|
27
28
|
|
|
28
29
|
|
|
29
30
|
def test_flag_config_from_dict_missing_fields():
|
|
@@ -69,6 +70,31 @@ def test_flag_config_from_dict_with_rules():
|
|
|
69
70
|
assert len(config.flags["f"].rules) == 2
|
|
70
71
|
|
|
71
72
|
|
|
73
|
+
def test_flag_config_from_dict_with_dnf_groups():
|
|
74
|
+
"""The two-level shape: a group with multiple AND'd conditions."""
|
|
75
|
+
data = {
|
|
76
|
+
"version": "v1",
|
|
77
|
+
"flags": {
|
|
78
|
+
"f": {
|
|
79
|
+
"enabled": True,
|
|
80
|
+
"rules": [
|
|
81
|
+
{
|
|
82
|
+
"conditions": [
|
|
83
|
+
{"attribute": "country", "operator": "equals", "value": "US"},
|
|
84
|
+
{"attribute": "device", "operator": "equals", "value": "mobile"},
|
|
85
|
+
]
|
|
86
|
+
},
|
|
87
|
+
{"conditions": [{"attribute": "plan", "operator": "equals", "value": "ent"}]},
|
|
88
|
+
],
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
}
|
|
92
|
+
flag = FlagConfig.from_dict(data).flags["f"]
|
|
93
|
+
assert len(flag.rules) == 2 # two groups (OR)
|
|
94
|
+
assert len(flag.rules[0].conditions) == 2 # AND within the first group
|
|
95
|
+
assert flag.rules[1].conditions[0].attribute == "plan"
|
|
96
|
+
|
|
97
|
+
|
|
72
98
|
def test_flag_defaults():
|
|
73
99
|
flag = Flag(key="f", enabled=True, rollout_pct=100, flag_type="boolean", default_value=False)
|
|
74
100
|
assert flag.rules == []
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Runs the shared cross-SDK parity vectors (SEC-4).
|
|
2
|
+
|
|
3
|
+
`parity_vectors.json` is byte-identical to the copy in switchbox-sdk-js; the JS
|
|
4
|
+
suite runs the same file. Both must stay green so the two SDKs evaluate identical
|
|
5
|
+
inputs identically. See the sdk-parity skill and DECISIONS.md ADR-013.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from switchbox.evaluator import _match_rule, evaluate
|
|
14
|
+
from switchbox.models import FlagConfig, Rule
|
|
15
|
+
|
|
16
|
+
_VECTORS = json.loads((Path(__file__).parent / "parity_vectors.json").read_text())
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _flag_from(case):
|
|
20
|
+
"""Build a Flag from a vector's CDN-shaped flag dict (reuses the real parser)."""
|
|
21
|
+
config = FlagConfig.from_dict({"flags": {case["flag_key"]: case["flag"]}})
|
|
22
|
+
return config.flags[case["flag_key"]]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.mark.parametrize("case", _VECTORS["rule_match"], ids=lambda c: c["name"])
|
|
26
|
+
def test_rule_match_vectors(case):
|
|
27
|
+
rule = Rule(**case["rule"])
|
|
28
|
+
assert _match_rule(rule, case["context"]) == case["expected"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@pytest.mark.parametrize("case", _VECTORS["evaluate"], ids=lambda c: c["name"])
|
|
32
|
+
def test_evaluate_vectors(case):
|
|
33
|
+
assert evaluate(_flag_from(case), case["user"]) == case["expected"]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_user_id_resolution_ignores_id_when_user_id_present():
|
|
37
|
+
"""Null-only (`??`) fallback: `user_id` wins over `id`, so two contexts with
|
|
38
|
+
the same user_id but different id must bucket — and therefore resolve —
|
|
39
|
+
identically. (An empty-string user_id is a real id, not falsy.)"""
|
|
40
|
+
flag = FlagConfig.from_dict(
|
|
41
|
+
{
|
|
42
|
+
"flags": {
|
|
43
|
+
"f": {
|
|
44
|
+
"enabled": True,
|
|
45
|
+
"rollout_pct": 50,
|
|
46
|
+
"flag_type": "boolean",
|
|
47
|
+
"default_value": False,
|
|
48
|
+
"rules": [],
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
).flags["f"]
|
|
53
|
+
a = evaluate(flag, {"user_id": "stable", "id": "aaa"})
|
|
54
|
+
b = evaluate(flag, {"user_id": "stable", "id": "bbb"})
|
|
55
|
+
assert a == b
|
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
"""Pure evaluation engine for Switchbox feature flags.
|
|
2
|
-
|
|
3
|
-
Zero external dependencies — only Python stdlib.
|
|
4
|
-
"""
|
|
5
|
-
|
|
6
|
-
import hashlib
|
|
7
|
-
from typing import Any
|
|
8
|
-
|
|
9
|
-
from switchbox.models import Flag, Rule
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
def evaluate(flag: Flag, user_context: dict | None = None) -> bool | str | int | Any:
|
|
13
|
-
"""Evaluate a flag for a given user context.
|
|
14
|
-
|
|
15
|
-
Returns the flag's resolved value.
|
|
16
|
-
|
|
17
|
-
Evaluation order:
|
|
18
|
-
1. Flag disabled → default_value
|
|
19
|
-
2. No user context → enabled value if rollout == 100, else default_value
|
|
20
|
-
3. Rules match (OR logic) → enabled value
|
|
21
|
-
4. Rollout percentage check → enabled value or default_value
|
|
22
|
-
5. Nothing matched → default_value
|
|
23
|
-
"""
|
|
24
|
-
try:
|
|
25
|
-
# 1. Disabled flag always returns default
|
|
26
|
-
if not flag.enabled:
|
|
27
|
-
return flag.default_value
|
|
28
|
-
|
|
29
|
-
# 2. No user context
|
|
30
|
-
if not user_context:
|
|
31
|
-
if flag.rollout_pct == 100:
|
|
32
|
-
return _enabled_value(flag)
|
|
33
|
-
return flag.default_value
|
|
34
|
-
|
|
35
|
-
# 3. Check rules (OR logic — any match wins)
|
|
36
|
-
if flag.rules:
|
|
37
|
-
for rule in flag.rules:
|
|
38
|
-
if _match_rule(rule, user_context):
|
|
39
|
-
return _enabled_value(flag)
|
|
40
|
-
|
|
41
|
-
# 4. Rollout percentage
|
|
42
|
-
user_id = user_context.get("user_id") or user_context.get("id")
|
|
43
|
-
if user_id is not None:
|
|
44
|
-
if _check_rollout(str(user_id), flag.key, flag.rollout_pct):
|
|
45
|
-
return _enabled_value(flag)
|
|
46
|
-
else:
|
|
47
|
-
# No user ID for hashing — can only serve 100% rollouts
|
|
48
|
-
if flag.rollout_pct == 100:
|
|
49
|
-
return _enabled_value(flag)
|
|
50
|
-
return flag.default_value
|
|
51
|
-
|
|
52
|
-
# 5. Nothing matched
|
|
53
|
-
return flag.default_value
|
|
54
|
-
except Exception:
|
|
55
|
-
return flag.default_value
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
def _enabled_value(flag: Flag) -> bool | str | int | Any:
|
|
59
|
-
"""Return the appropriate 'enabled' value based on flag type."""
|
|
60
|
-
if flag.flag_type == "boolean":
|
|
61
|
-
return True
|
|
62
|
-
return flag.default_value
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
def _match_rule(rule: Rule, user_context: dict) -> bool:
|
|
66
|
-
"""Check if a single rule matches the user context."""
|
|
67
|
-
if rule.attribute not in user_context:
|
|
68
|
-
return False
|
|
69
|
-
|
|
70
|
-
context_value = user_context[rule.attribute]
|
|
71
|
-
|
|
72
|
-
if context_value is None:
|
|
73
|
-
return False
|
|
74
|
-
|
|
75
|
-
if rule.operator == "equals":
|
|
76
|
-
return str(context_value) == str(rule.value)
|
|
77
|
-
elif rule.operator == "not_equals":
|
|
78
|
-
return str(context_value) != str(rule.value)
|
|
79
|
-
elif rule.operator == "contains":
|
|
80
|
-
return str(rule.value) in str(context_value)
|
|
81
|
-
elif rule.operator == "ends_with":
|
|
82
|
-
return str(context_value).endswith(str(rule.value))
|
|
83
|
-
elif rule.operator == "in_list":
|
|
84
|
-
return str(context_value) in rule.value
|
|
85
|
-
elif rule.operator == "gt":
|
|
86
|
-
try:
|
|
87
|
-
return float(context_value) > float(rule.value)
|
|
88
|
-
except (ValueError, TypeError):
|
|
89
|
-
return False
|
|
90
|
-
elif rule.operator == "lt":
|
|
91
|
-
try:
|
|
92
|
-
return float(context_value) < float(rule.value)
|
|
93
|
-
except (ValueError, TypeError):
|
|
94
|
-
return False
|
|
95
|
-
|
|
96
|
-
return False
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
def _check_rollout(user_id: str, flag_key: str, rollout_pct: int) -> bool:
|
|
100
|
-
"""Deterministic percentage rollout using consistent hashing."""
|
|
101
|
-
hash_input = f"{user_id}:{flag_key}"
|
|
102
|
-
hash_value = int(hashlib.sha256(hash_input.encode()).hexdigest(), 16)
|
|
103
|
-
bucket = hash_value % 100
|
|
104
|
-
return bucket < rollout_pct
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|