switchbox-flags 0.5.0__tar.gz → 0.6.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.5.0 → switchbox_flags-0.6.0}/PKG-INFO +30 -3
- {switchbox_flags-0.5.0 → switchbox_flags-0.6.0}/README.md +29 -2
- {switchbox_flags-0.5.0 → switchbox_flags-0.6.0}/pyproject.toml +1 -1
- {switchbox_flags-0.5.0 → switchbox_flags-0.6.0}/switchbox/client.py +6 -1
- {switchbox_flags-0.5.0 → switchbox_flags-0.6.0}/switchbox/sync.py +36 -8
- switchbox_flags-0.6.0/tests/fixtures/cdn-json/defaults.json +8 -0
- switchbox_flags-0.6.0/tests/fixtures/cdn-json/empty.json +4 -0
- switchbox_flags-0.6.0/tests/fixtures/cdn-json/full_config.json +133 -0
- switchbox_flags-0.6.0/tests/fixtures/cdn-json/legacy_flat_rules.json +14 -0
- switchbox_flags-0.6.0/tests/fixtures/cdn-json/unknown_fields.json +15 -0
- {switchbox_flags-0.5.0/tests → switchbox_flags-0.6.0/tests/fixtures/parity}/parity_vectors.json +13 -2
- {switchbox_flags-0.5.0 → switchbox_flags-0.6.0}/tests/test_client.py +42 -0
- switchbox_flags-0.6.0/tests/test_contract_fixtures.py +123 -0
- {switchbox_flags-0.5.0 → switchbox_flags-0.6.0}/tests/test_parity_vectors.py +24 -6
- {switchbox_flags-0.5.0 → switchbox_flags-0.6.0}/uv.lock +1 -1
- {switchbox_flags-0.5.0 → switchbox_flags-0.6.0}/.coverage +0 -0
- {switchbox_flags-0.5.0 → switchbox_flags-0.6.0}/.github/workflows/publish.yml +0 -0
- {switchbox_flags-0.5.0 → switchbox_flags-0.6.0}/.github/workflows/test.yml +0 -0
- {switchbox_flags-0.5.0 → switchbox_flags-0.6.0}/.gitignore +0 -0
- {switchbox_flags-0.5.0 → switchbox_flags-0.6.0}/LICENSE +0 -0
- {switchbox_flags-0.5.0 → switchbox_flags-0.6.0}/switchbox/__init__.py +0 -0
- {switchbox_flags-0.5.0 → switchbox_flags-0.6.0}/switchbox/cache.py +0 -0
- {switchbox_flags-0.5.0 → switchbox_flags-0.6.0}/switchbox/evaluator.py +0 -0
- {switchbox_flags-0.5.0 → switchbox_flags-0.6.0}/switchbox/exceptions.py +0 -0
- {switchbox_flags-0.5.0 → switchbox_flags-0.6.0}/switchbox/models.py +0 -0
- {switchbox_flags-0.5.0 → switchbox_flags-0.6.0}/tests/__init__.py +0 -0
- {switchbox_flags-0.5.0 → switchbox_flags-0.6.0}/tests/test_cache.py +0 -0
- {switchbox_flags-0.5.0 → switchbox_flags-0.6.0}/tests/test_evaluator.py +0 -0
- {switchbox_flags-0.5.0 → switchbox_flags-0.6.0}/tests/test_models.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: switchbox-flags
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.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
|
|
@@ -155,6 +155,7 @@ 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)
|
|
158
|
+
block_on_init=True, # block on the first fetch (default: True)
|
|
158
159
|
)
|
|
159
160
|
```
|
|
160
161
|
|
|
@@ -163,9 +164,32 @@ client = Switchbox(
|
|
|
163
164
|
| `sdk_key` | `str` | — | SDK key from the environment in the dashboard |
|
|
164
165
|
| `poll_interval` | `int` | `30` | Seconds between background config refreshes |
|
|
165
166
|
| `on_error` | `Callable[[Exception], None]` | `None` | Callback invoked when a fetch or parse fails |
|
|
167
|
+
| `timeout` | `int` | `10` | Per-fetch HTTP timeout in seconds |
|
|
168
|
+
| `block_on_init` | `bool` | `True` | Fetch the first config synchronously (see below) |
|
|
166
169
|
|
|
167
170
|
The SDK builds the CDN URL automatically from the SDK key. You can override with `cdn_base_url` if self-hosting.
|
|
168
171
|
|
|
172
|
+
### Blocking vs. non-blocking startup
|
|
173
|
+
|
|
174
|
+
By default (`block_on_init=True`) the constructor performs the **first fetch synchronously**, so
|
|
175
|
+
`client.ready` is `True` the moment `Switchbox(...)` returns — your first flag check already sees live
|
|
176
|
+
config. The trade-off: construction blocks up to `timeout` seconds if the CDN is slow or unreachable,
|
|
177
|
+
which can stall an app's startup path.
|
|
178
|
+
|
|
179
|
+
Set `block_on_init=False` to return immediately and fetch in the background instead. The client starts
|
|
180
|
+
**not ready** (flag checks fall back to your supplied defaults) and becomes ready as soon as the first
|
|
181
|
+
background fetch lands. Poll `client.ready` if you need to know when live config is available:
|
|
182
|
+
|
|
183
|
+
```python
|
|
184
|
+
client = Switchbox(sdk_key="...", block_on_init=False)
|
|
185
|
+
# returns instantly, even if the CDN is down — checks use defaults until ready
|
|
186
|
+
if client.enabled("new_checkout", user={"user_id": "42"}):
|
|
187
|
+
...
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
(The JavaScript SDK makes the same choice explicit at the API surface: `await Switchbox.create(...)`
|
|
191
|
+
blocks on the first fetch, while `new Switchbox(...)` without awaiting `init()` does not.)
|
|
192
|
+
|
|
169
193
|
## How It Works
|
|
170
194
|
|
|
171
195
|
```
|
|
@@ -198,9 +222,12 @@ The API server is only in the write path. All read traffic goes to the CDN.
|
|
|
198
222
|
|
|
199
223
|
## API Reference
|
|
200
224
|
|
|
201
|
-
### `Switchbox(sdk_key, poll_interval=30, on_error=None)`
|
|
225
|
+
### `Switchbox(sdk_key, poll_interval=30, on_error=None, timeout=10, block_on_init=True)`
|
|
202
226
|
|
|
203
|
-
Creates a new client
|
|
227
|
+
Creates a new client and starts background polling. With `block_on_init=True` (default) it performs an
|
|
228
|
+
initial **synchronous** fetch on creation (the client is `ready` on return); with `block_on_init=False`
|
|
229
|
+
the first fetch happens in the background and the constructor returns immediately. See
|
|
230
|
+
[Blocking vs. non-blocking startup](#blocking-vs-non-blocking-startup).
|
|
204
231
|
|
|
205
232
|
### `client.enabled(flag_key, user=None) -> bool`
|
|
206
233
|
|
|
@@ -129,6 +129,7 @@ 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)
|
|
132
|
+
block_on_init=True, # block on the first fetch (default: True)
|
|
132
133
|
)
|
|
133
134
|
```
|
|
134
135
|
|
|
@@ -137,9 +138,32 @@ client = Switchbox(
|
|
|
137
138
|
| `sdk_key` | `str` | — | SDK key from the environment in the dashboard |
|
|
138
139
|
| `poll_interval` | `int` | `30` | Seconds between background config refreshes |
|
|
139
140
|
| `on_error` | `Callable[[Exception], None]` | `None` | Callback invoked when a fetch or parse fails |
|
|
141
|
+
| `timeout` | `int` | `10` | Per-fetch HTTP timeout in seconds |
|
|
142
|
+
| `block_on_init` | `bool` | `True` | Fetch the first config synchronously (see below) |
|
|
140
143
|
|
|
141
144
|
The SDK builds the CDN URL automatically from the SDK key. You can override with `cdn_base_url` if self-hosting.
|
|
142
145
|
|
|
146
|
+
### Blocking vs. non-blocking startup
|
|
147
|
+
|
|
148
|
+
By default (`block_on_init=True`) the constructor performs the **first fetch synchronously**, so
|
|
149
|
+
`client.ready` is `True` the moment `Switchbox(...)` returns — your first flag check already sees live
|
|
150
|
+
config. The trade-off: construction blocks up to `timeout` seconds if the CDN is slow or unreachable,
|
|
151
|
+
which can stall an app's startup path.
|
|
152
|
+
|
|
153
|
+
Set `block_on_init=False` to return immediately and fetch in the background instead. The client starts
|
|
154
|
+
**not ready** (flag checks fall back to your supplied defaults) and becomes ready as soon as the first
|
|
155
|
+
background fetch lands. Poll `client.ready` if you need to know when live config is available:
|
|
156
|
+
|
|
157
|
+
```python
|
|
158
|
+
client = Switchbox(sdk_key="...", block_on_init=False)
|
|
159
|
+
# returns instantly, even if the CDN is down — checks use defaults until ready
|
|
160
|
+
if client.enabled("new_checkout", user={"user_id": "42"}):
|
|
161
|
+
...
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
(The JavaScript SDK makes the same choice explicit at the API surface: `await Switchbox.create(...)`
|
|
165
|
+
blocks on the first fetch, while `new Switchbox(...)` without awaiting `init()` does not.)
|
|
166
|
+
|
|
143
167
|
## How It Works
|
|
144
168
|
|
|
145
169
|
```
|
|
@@ -172,9 +196,12 @@ The API server is only in the write path. All read traffic goes to the CDN.
|
|
|
172
196
|
|
|
173
197
|
## API Reference
|
|
174
198
|
|
|
175
|
-
### `Switchbox(sdk_key, poll_interval=30, on_error=None)`
|
|
199
|
+
### `Switchbox(sdk_key, poll_interval=30, on_error=None, timeout=10, block_on_init=True)`
|
|
176
200
|
|
|
177
|
-
Creates a new client
|
|
201
|
+
Creates a new client and starts background polling. With `block_on_init=True` (default) it performs an
|
|
202
|
+
initial **synchronous** fetch on creation (the client is `ready` on return); with `block_on_init=False`
|
|
203
|
+
the first fetch happens in the background and the constructor returns immediately. See
|
|
204
|
+
[Blocking vs. non-blocking startup](#blocking-vs-non-blocking-startup).
|
|
178
205
|
|
|
179
206
|
### `client.enabled(flag_key, user=None) -> bool`
|
|
180
207
|
|
|
@@ -35,12 +35,17 @@ class Switchbox:
|
|
|
35
35
|
on_error: Callable[[Exception], None] | None = None,
|
|
36
36
|
timeout: int = 10,
|
|
37
37
|
cdn_base_url: str | None = None,
|
|
38
|
+
block_on_init: bool = True,
|
|
38
39
|
) -> None:
|
|
39
40
|
base = cdn_base_url or CDN_BASE_URL
|
|
40
41
|
cdn_url = f"{base}/{sdk_key}/flags.json"
|
|
41
42
|
self._cache = FlagCache()
|
|
42
43
|
self._sync = SyncWorker(cdn_url, self._cache, poll_interval, on_error, timeout=timeout)
|
|
43
|
-
|
|
44
|
+
# block_on_init=True (default): the constructor performs the first fetch
|
|
45
|
+
# synchronously, so the client is `ready` on return. Set False to fetch in
|
|
46
|
+
# the background instead — the constructor returns immediately and never
|
|
47
|
+
# blocks on a slow/unreachable CDN (SEC-9). See `ready`.
|
|
48
|
+
self._sync.start(block=block_on_init)
|
|
44
49
|
|
|
45
50
|
@property
|
|
46
51
|
def ready(self) -> bool:
|
|
@@ -31,12 +31,30 @@ class SyncWorker:
|
|
|
31
31
|
self._stop_event = threading.Event()
|
|
32
32
|
self._thread: threading.Thread | None = None
|
|
33
33
|
|
|
34
|
-
def start(self) -> None:
|
|
35
|
-
"""Fetch
|
|
36
|
-
# Initial synchronous fetch — block until we have configs
|
|
37
|
-
self._poll()
|
|
34
|
+
def start(self, block: bool = True) -> None:
|
|
35
|
+
"""Start polling. Fetch the first config, then poll in the background.
|
|
38
36
|
|
|
39
|
-
|
|
37
|
+
``block=True`` (default): fetch synchronously before returning, so the
|
|
38
|
+
client is ``ready`` the moment construction completes (at the cost of
|
|
39
|
+
blocking up to ``timeout`` if the CDN is slow/unreachable).
|
|
40
|
+
|
|
41
|
+
``block=False`` (SEC-9): return immediately and fetch the first config on
|
|
42
|
+
the background thread, so a slow CDN never stalls app startup. The client
|
|
43
|
+
starts not-ready; poll ``client.ready`` (or rely on flag defaults) until
|
|
44
|
+
the first fetch lands.
|
|
45
|
+
"""
|
|
46
|
+
if block:
|
|
47
|
+
# Initial synchronous fetch — block until we have configs.
|
|
48
|
+
self._poll()
|
|
49
|
+
poll_immediately = False
|
|
50
|
+
else:
|
|
51
|
+
# Defer the first fetch onto the background thread so __init__ returns
|
|
52
|
+
# right away rather than waiting on the network.
|
|
53
|
+
poll_immediately = True
|
|
54
|
+
|
|
55
|
+
self._thread = threading.Thread(
|
|
56
|
+
target=self._run, args=(poll_immediately,), daemon=True
|
|
57
|
+
)
|
|
40
58
|
self._thread.start()
|
|
41
59
|
|
|
42
60
|
def stop(self) -> None:
|
|
@@ -45,8 +63,18 @@ class SyncWorker:
|
|
|
45
63
|
if self._thread is not None:
|
|
46
64
|
self._thread.join(timeout=5)
|
|
47
65
|
|
|
48
|
-
def _run(self) -> None:
|
|
49
|
-
"""Main loop for the background thread.
|
|
66
|
+
def _run(self, poll_immediately: bool = False) -> None:
|
|
67
|
+
"""Main loop for the background thread.
|
|
68
|
+
|
|
69
|
+
``poll_immediately`` does one fetch before the first interval wait — used
|
|
70
|
+
by non-blocking start so the config still arrives ASAP rather than after a
|
|
71
|
+
full poll interval.
|
|
72
|
+
"""
|
|
73
|
+
if poll_immediately:
|
|
74
|
+
try:
|
|
75
|
+
self._poll()
|
|
76
|
+
except Exception as exc:
|
|
77
|
+
logger.warning("Unexpected error in initial sync: %s", exc)
|
|
50
78
|
while not self._stop_event.wait(timeout=self._interval):
|
|
51
79
|
try:
|
|
52
80
|
self._poll()
|
|
@@ -58,7 +86,7 @@ class SyncWorker:
|
|
|
58
86
|
try:
|
|
59
87
|
req = urllib.request.Request(
|
|
60
88
|
self._cdn_url,
|
|
61
|
-
headers={"User-Agent": "switchbox-python/0.
|
|
89
|
+
headers={"User-Agent": "switchbox-python/0.6.0"},
|
|
62
90
|
)
|
|
63
91
|
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
|
|
64
92
|
data = json.loads(resp.read().decode("utf-8"))
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
{
|
|
2
|
+
"flags": {
|
|
3
|
+
"all_operators": {
|
|
4
|
+
"default_value": false,
|
|
5
|
+
"enabled": true,
|
|
6
|
+
"flag_type": "boolean",
|
|
7
|
+
"rollout_pct": 0,
|
|
8
|
+
"rules": [
|
|
9
|
+
{
|
|
10
|
+
"conditions": [
|
|
11
|
+
{
|
|
12
|
+
"attribute": "email",
|
|
13
|
+
"operator": "ends_with",
|
|
14
|
+
"value": "@acme.com"
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"attribute": "plan",
|
|
18
|
+
"operator": "equals",
|
|
19
|
+
"value": "pro"
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"conditions": [
|
|
25
|
+
{
|
|
26
|
+
"attribute": "age",
|
|
27
|
+
"operator": "gt",
|
|
28
|
+
"value": 18
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"attribute": "age",
|
|
32
|
+
"operator": "lt",
|
|
33
|
+
"value": 65
|
|
34
|
+
}
|
|
35
|
+
]
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"conditions": [
|
|
39
|
+
{
|
|
40
|
+
"attribute": "country",
|
|
41
|
+
"operator": "in_list",
|
|
42
|
+
"value": [
|
|
43
|
+
"US",
|
|
44
|
+
"CA",
|
|
45
|
+
"GB"
|
|
46
|
+
]
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"attribute": "name",
|
|
50
|
+
"operator": "contains",
|
|
51
|
+
"value": "a"
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"attribute": "role",
|
|
55
|
+
"operator": "not_equals",
|
|
56
|
+
"value": "guest"
|
|
57
|
+
}
|
|
58
|
+
]
|
|
59
|
+
}
|
|
60
|
+
]
|
|
61
|
+
},
|
|
62
|
+
"bool_off": {
|
|
63
|
+
"default_value": false,
|
|
64
|
+
"enabled": false,
|
|
65
|
+
"flag_type": "boolean",
|
|
66
|
+
"rollout_pct": 100,
|
|
67
|
+
"rules": []
|
|
68
|
+
},
|
|
69
|
+
"bool_on": {
|
|
70
|
+
"default_value": false,
|
|
71
|
+
"enabled": true,
|
|
72
|
+
"flag_type": "boolean",
|
|
73
|
+
"rollout_pct": 100,
|
|
74
|
+
"rules": []
|
|
75
|
+
},
|
|
76
|
+
"json_variant": {
|
|
77
|
+
"default_value": {
|
|
78
|
+
"theme": "light"
|
|
79
|
+
},
|
|
80
|
+
"enabled": true,
|
|
81
|
+
"enabled_value": {
|
|
82
|
+
"theme": "dark"
|
|
83
|
+
},
|
|
84
|
+
"flag_type": "json",
|
|
85
|
+
"rollout_pct": 100,
|
|
86
|
+
"rules": []
|
|
87
|
+
},
|
|
88
|
+
"number_rollout": {
|
|
89
|
+
"default_value": 0,
|
|
90
|
+
"enabled": true,
|
|
91
|
+
"enabled_value": 42,
|
|
92
|
+
"flag_type": "number",
|
|
93
|
+
"rollout_pct": 25,
|
|
94
|
+
"rules": []
|
|
95
|
+
},
|
|
96
|
+
"segment_flag": {
|
|
97
|
+
"default_value": false,
|
|
98
|
+
"enabled": true,
|
|
99
|
+
"flag_type": "boolean",
|
|
100
|
+
"rollout_pct": 0,
|
|
101
|
+
"rules": [
|
|
102
|
+
{
|
|
103
|
+
"conditions": [
|
|
104
|
+
{
|
|
105
|
+
"attribute": "plan",
|
|
106
|
+
"operator": "equals",
|
|
107
|
+
"value": "enterprise"
|
|
108
|
+
}
|
|
109
|
+
]
|
|
110
|
+
}
|
|
111
|
+
]
|
|
112
|
+
},
|
|
113
|
+
"string_ab": {
|
|
114
|
+
"default_value": "control",
|
|
115
|
+
"enabled": true,
|
|
116
|
+
"enabled_value": "treatment",
|
|
117
|
+
"flag_type": "string",
|
|
118
|
+
"rollout_pct": 0,
|
|
119
|
+
"rules": [
|
|
120
|
+
{
|
|
121
|
+
"conditions": [
|
|
122
|
+
{
|
|
123
|
+
"attribute": "country",
|
|
124
|
+
"operator": "equals",
|
|
125
|
+
"value": "US"
|
|
126
|
+
}
|
|
127
|
+
]
|
|
128
|
+
}
|
|
129
|
+
]
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
"version": "2026-01-01T00:00:00+00:00"
|
|
133
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "2026-01-01T00:00:00+00:00",
|
|
3
|
+
"flags": {
|
|
4
|
+
"legacy": {
|
|
5
|
+
"enabled": true,
|
|
6
|
+
"rollout_pct": 100,
|
|
7
|
+
"flag_type": "boolean",
|
|
8
|
+
"default_value": false,
|
|
9
|
+
"rules": [
|
|
10
|
+
{ "attribute": "country", "operator": "equals", "value": "US" }
|
|
11
|
+
]
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "2026-01-01T00:00:00+00:00",
|
|
3
|
+
"future_top_level_field": "must be ignored by every SDK",
|
|
4
|
+
"flags": {
|
|
5
|
+
"fwd_compat": {
|
|
6
|
+
"enabled": true,
|
|
7
|
+
"rollout_pct": 100,
|
|
8
|
+
"flag_type": "boolean",
|
|
9
|
+
"default_value": false,
|
|
10
|
+
"rules": [],
|
|
11
|
+
"some_future_field": { "nested": true },
|
|
12
|
+
"another_unknown": 123
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
}
|
{switchbox_flags-0.5.0/tests → switchbox_flags-0.6.0/tests/fixtures/parity}/parity_vectors.json
RENAMED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"_comment": "SEC-4 cross-SDK parity vectors
|
|
2
|
+
"_comment": "SEC-4 cross-SDK parity vectors (evaluation half of the parity story; the format half is fixtures/cdn-json/). CANONICAL SOURCE: fixtures/parity/parity_vectors.json — distributed into each SDK repo's tests/fixtures/parity/ by `python3 fixtures/sync.py` (do NOT hand-edit a synced copy; edit the canonical and re-sync). Both SDK suites run it (test_parity_vectors.py, parity.test.ts). Sections: rule_match (single-condition coercion/operators), evaluate (full evaluate path incl. rollout-bucket boundaries), rollout_bucket (sha256(user_id:flag_key)%100 — pins the hash itself across languages). 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 (coercion) + ADR-024 (shared-fixture mechanism).",
|
|
3
3
|
"rule_match": [
|
|
4
4
|
{"name": "boolean true matches lowercase 'true'", "rule": {"attribute": "is_beta", "operator": "equals", "value": "true"}, "context": {"is_beta": true}, "expected": true},
|
|
5
5
|
{"name": "boolean false matches lowercase 'false'", "rule": {"attribute": "is_beta", "operator": "equals", "value": "false"}, "context": {"is_beta": false}, "expected": true},
|
|
@@ -40,6 +40,17 @@
|
|
|
40
40
|
{"name": "variation: absent enabled_value falls back to default on match", "flag_key": "f", "flag": {"enabled": true, "rollout_pct": 0, "flag_type": "string", "default_value": "Shop", "rules": [{"conditions": [{"attribute": "plan", "operator": "equals", "value": "pro"}]}]}, "user": {"plan": "pro", "user_id": "u1"}, "expected": "Shop"},
|
|
41
41
|
{"name": "variation: number flag rollout 100 no context serves enabled_value", "flag_key": "f", "flag": {"enabled": true, "rollout_pct": 100, "flag_type": "number", "default_value": 0, "enabled_value": 42, "rules": []}, "user": null, "expected": 42},
|
|
42
42
|
{"name": "variation: disabled non-boolean serves default_value", "flag_key": "f", "flag": {"enabled": false, "rollout_pct": 100, "flag_type": "string", "default_value": "Shop", "enabled_value": "Buy now", "rules": []}, "user": {"user_id": "u1"}, "expected": "Shop"},
|
|
43
|
-
{"name": "variation: boolean flag ignores enabled_value (serves true)", "flag_key": "f", "flag": {"enabled": true, "rollout_pct": 100, "flag_type": "boolean", "default_value": false, "enabled_value": "ignored", "rules": []}, "user": null, "expected": true}
|
|
43
|
+
{"name": "variation: boolean flag ignores enabled_value (serves true)", "flag_key": "f", "flag": {"enabled": true, "rollout_pct": 100, "flag_type": "boolean", "default_value": false, "enabled_value": "ignored", "rules": []}, "user": null, "expected": true},
|
|
44
|
+
{"name": "rollout: user bucket 58 with rollout 59 -> enabled (bucket < pct)", "flag_key": "f", "flag": {"enabled": true, "rollout_pct": 59, "flag_type": "boolean", "default_value": false, "rules": []}, "user": {"user_id": "u_low"}, "expected": true},
|
|
45
|
+
{"name": "rollout: user bucket 58 with rollout 58 -> default (boundary is exclusive)", "flag_key": "f", "flag": {"enabled": true, "rollout_pct": 58, "flag_type": "boolean", "default_value": false, "rules": []}, "user": {"user_id": "u_low"}, "expected": false},
|
|
46
|
+
{"name": "rollout: user bucket 47 with rollout 48 -> enabled", "flag_key": "f", "flag": {"enabled": true, "rollout_pct": 48, "flag_type": "boolean", "default_value": false, "rules": []}, "user": {"user_id": "user-2"}, "expected": true},
|
|
47
|
+
{"name": "rollout: user bucket 47 with rollout 47 -> default", "flag_key": "f", "flag": {"enabled": true, "rollout_pct": 47, "flag_type": "boolean", "default_value": false, "rules": []}, "user": {"user_id": "user-2"}, "expected": false}
|
|
48
|
+
],
|
|
49
|
+
"rollout_bucket": [
|
|
50
|
+
{"name": "42:new_checkout", "user_id": "42", "flag_key": "new_checkout", "expected": 98},
|
|
51
|
+
{"name": "user_100:feature_x", "user_id": "user_100", "flag_key": "feature_x", "expected": 4},
|
|
52
|
+
{"name": "abc:search_version", "user_id": "abc", "flag_key": "search_version", "expected": 10},
|
|
53
|
+
{"name": "u_low:f", "user_id": "u_low", "flag_key": "f", "expected": 58},
|
|
54
|
+
{"name": "user-2:f", "user_id": "user-2", "flag_key": "f", "expected": 47}
|
|
44
55
|
]
|
|
45
56
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import json
|
|
2
|
+
import time
|
|
2
3
|
from unittest.mock import MagicMock, patch
|
|
3
4
|
|
|
4
5
|
from switchbox.client import Switchbox
|
|
@@ -139,3 +140,44 @@ def test_client_close_stops_sync(mock_urlopen):
|
|
|
139
140
|
c = Switchbox(sdk_key=TEST_SDK_KEY, cdn_base_url=TEST_CDN)
|
|
140
141
|
c.close()
|
|
141
142
|
assert c._sync._stop_event.is_set()
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# --- SEC-9: block_on_init ---
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@patch("switchbox.sync.urllib.request.urlopen")
|
|
149
|
+
def test_client_block_on_init_true_ready_immediately(mock_urlopen):
|
|
150
|
+
"""Default: the first fetch is synchronous, so the client is ready on return."""
|
|
151
|
+
mock_urlopen.return_value = _mock_urlopen(SAMPLE_CONFIG)
|
|
152
|
+
client = Switchbox(sdk_key=TEST_SDK_KEY, cdn_base_url=TEST_CDN)
|
|
153
|
+
assert client.ready is True
|
|
154
|
+
client.close()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@patch("switchbox.sync.urllib.request.urlopen")
|
|
158
|
+
def test_client_block_on_init_false_does_not_raise_on_failure(mock_urlopen):
|
|
159
|
+
"""Non-blocking construction never blocks or raises on a down CDN — flag
|
|
160
|
+
checks just fall back to defaults until the background fetch succeeds."""
|
|
161
|
+
mock_urlopen.side_effect = Exception("CDN down")
|
|
162
|
+
client = Switchbox(
|
|
163
|
+
sdk_key=TEST_SDK_KEY, cdn_base_url=TEST_CDN, block_on_init=False
|
|
164
|
+
)
|
|
165
|
+
assert client.enabled("new_dashboard") is False
|
|
166
|
+
assert client.get_value("search_version", default="v1") == "v1"
|
|
167
|
+
client.close()
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@patch("switchbox.sync.urllib.request.urlopen")
|
|
171
|
+
def test_client_block_on_init_false_becomes_ready(mock_urlopen):
|
|
172
|
+
"""With block_on_init=False the config still arrives — on the background
|
|
173
|
+
thread — so the client becomes ready shortly after construction."""
|
|
174
|
+
mock_urlopen.return_value = _mock_urlopen(SAMPLE_CONFIG)
|
|
175
|
+
client = Switchbox(
|
|
176
|
+
sdk_key=TEST_SDK_KEY, cdn_base_url=TEST_CDN, block_on_init=False
|
|
177
|
+
)
|
|
178
|
+
deadline = time.monotonic() + 5
|
|
179
|
+
while not client.ready and time.monotonic() < deadline:
|
|
180
|
+
time.sleep(0.02)
|
|
181
|
+
assert client.ready is True
|
|
182
|
+
assert client.enabled("new_dashboard", user={"user_id": "1"}) is True
|
|
183
|
+
client.close()
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""CDN JSON contract — Python SDK side of the 3-repo handshake (TESTING Phase 2).
|
|
2
|
+
|
|
3
|
+
Parses the canonical fixtures (`tests/fixtures/cdn-json/`, synced from the workspace
|
|
4
|
+
`fixtures/cdn-json/` by `fixtures/sync.py`) and pins how `FlagConfig.from_dict`
|
|
5
|
+
interprets the format the backend publisher emits. A format change that isn't mirrored
|
|
6
|
+
here fails these tests; see the workspace `fixtures/cdn-json/README.md`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
from switchbox.models import FlagConfig
|
|
15
|
+
|
|
16
|
+
FIXTURES = Path(__file__).parent / "fixtures" / "cdn-json"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def load(name: str) -> dict:
|
|
20
|
+
return json.loads((FIXTURES / f"{name}.json").read_text())
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@pytest.mark.parametrize(
|
|
24
|
+
"name",
|
|
25
|
+
["full_config", "defaults", "unknown_fields", "legacy_flat_rules", "empty"],
|
|
26
|
+
)
|
|
27
|
+
def test_every_fixture_parses(name):
|
|
28
|
+
"""Every canonical fixture parses without error."""
|
|
29
|
+
config = FlagConfig.from_dict(load(name))
|
|
30
|
+
assert isinstance(config, FlagConfig)
|
|
31
|
+
assert config.version # all fixtures carry a version string
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_full_config_flag_types_and_values():
|
|
35
|
+
flags = FlagConfig.from_dict(load("full_config")).flags
|
|
36
|
+
|
|
37
|
+
assert flags["bool_on"].flag_type == "boolean"
|
|
38
|
+
assert flags["bool_on"].enabled is True
|
|
39
|
+
assert flags["bool_on"].rollout_pct == 100
|
|
40
|
+
assert flags["bool_on"].rules == []
|
|
41
|
+
|
|
42
|
+
assert flags["bool_off"].enabled is False
|
|
43
|
+
|
|
44
|
+
# Variations carry enabled_value; booleans do not.
|
|
45
|
+
assert flags["string_ab"].flag_type == "string"
|
|
46
|
+
assert flags["string_ab"].default_value == "control"
|
|
47
|
+
assert flags["string_ab"].enabled_value == "treatment"
|
|
48
|
+
assert flags["number_rollout"].enabled_value == 42
|
|
49
|
+
assert flags["number_rollout"].rollout_pct == 25
|
|
50
|
+
assert flags["json_variant"].default_value == {"theme": "light"}
|
|
51
|
+
assert flags["json_variant"].enabled_value == {"theme": "dark"}
|
|
52
|
+
assert flags["bool_on"].enabled_value is None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_full_config_all_seven_operators_present():
|
|
56
|
+
flags = FlagConfig.from_dict(load("full_config")).flags
|
|
57
|
+
groups = flags["all_operators"].rules
|
|
58
|
+
ops = {c.operator for g in groups for c in g.conditions}
|
|
59
|
+
assert ops == {
|
|
60
|
+
"equals",
|
|
61
|
+
"not_equals",
|
|
62
|
+
"contains",
|
|
63
|
+
"ends_with",
|
|
64
|
+
"in_list",
|
|
65
|
+
"gt",
|
|
66
|
+
"lt",
|
|
67
|
+
}
|
|
68
|
+
# in_list value stays a list (both evaluators do membership against a list).
|
|
69
|
+
in_list = next(c for g in groups for c in g.conditions if c.operator == "in_list")
|
|
70
|
+
assert in_list.value == ["US", "CA", "GB"]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_full_config_dnf_structure():
|
|
74
|
+
"""Two-level DNF: a flag is OR of AND-groups."""
|
|
75
|
+
flags = FlagConfig.from_dict(load("full_config")).flags
|
|
76
|
+
groups = flags["all_operators"].rules
|
|
77
|
+
assert len(groups) == 3 # three OR'd groups
|
|
78
|
+
assert [len(g.conditions) for g in groups] == [2, 2, 3]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_segments_are_inlined_not_referenced():
|
|
82
|
+
"""The publisher expands segments to flat conditions — the SDK never sees a
|
|
83
|
+
segment reference."""
|
|
84
|
+
flags = FlagConfig.from_dict(load("full_config")).flags
|
|
85
|
+
seg = flags["segment_flag"]
|
|
86
|
+
assert len(seg.rules) == 1
|
|
87
|
+
cond = seg.rules[0].conditions[0]
|
|
88
|
+
assert (cond.attribute, cond.operator, cond.value) == (
|
|
89
|
+
"plan",
|
|
90
|
+
"equals",
|
|
91
|
+
"enterprise",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_defaults_applied_for_omitted_fields():
|
|
96
|
+
flag = FlagConfig.from_dict(load("defaults")).flags["minimal"]
|
|
97
|
+
assert flag.enabled is True
|
|
98
|
+
assert flag.rollout_pct == 0
|
|
99
|
+
assert flag.flag_type == "boolean"
|
|
100
|
+
assert flag.default_value is None
|
|
101
|
+
assert flag.enabled_value is None
|
|
102
|
+
assert flag.rules == []
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_unknown_fields_are_ignored():
|
|
106
|
+
config = FlagConfig.from_dict(load("unknown_fields"))
|
|
107
|
+
flag = config.flags["fwd_compat"]
|
|
108
|
+
# Known fields parsed; unknown ones simply don't appear on the dataclass.
|
|
109
|
+
assert flag.enabled is True
|
|
110
|
+
assert not hasattr(flag, "some_future_field")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_legacy_flat_rule_becomes_single_condition_group():
|
|
114
|
+
flag = FlagConfig.from_dict(load("legacy_flat_rules")).flags["legacy"]
|
|
115
|
+
assert len(flag.rules) == 1
|
|
116
|
+
assert len(flag.rules[0].conditions) == 1
|
|
117
|
+
cond = flag.rules[0].conditions[0]
|
|
118
|
+
assert (cond.attribute, cond.operator, cond.value) == ("country", "equals", "US")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_empty_flags_object():
|
|
122
|
+
config = FlagConfig.from_dict(load("empty"))
|
|
123
|
+
assert config.flags == {}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
"""Runs the shared cross-SDK parity vectors (SEC-4).
|
|
1
|
+
"""Runs the shared cross-SDK parity vectors (SEC-4 / TESTING Phase 5).
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
The vectors are the canonical `fixtures/parity/parity_vectors.json` (workspace root),
|
|
4
|
+
synced into this repo by `python3 fixtures/sync.py`; the JS suite runs the same bytes.
|
|
5
|
+
Both must stay green so the two SDKs evaluate identical inputs identically. Edit the
|
|
6
|
+
canonical and re-sync — never hand-edit this copy. See the sdk-parity skill and
|
|
7
|
+
DECISIONS.md ADR-013 (coercion) + ADR-024 (shared-fixture mechanism).
|
|
6
8
|
"""
|
|
7
9
|
|
|
8
10
|
import json
|
|
@@ -10,10 +12,19 @@ from pathlib import Path
|
|
|
10
12
|
|
|
11
13
|
import pytest
|
|
12
14
|
|
|
13
|
-
from switchbox.evaluator import _match_rule, evaluate
|
|
15
|
+
from switchbox.evaluator import _check_rollout, _match_rule, evaluate
|
|
14
16
|
from switchbox.models import FlagConfig, Rule
|
|
15
17
|
|
|
16
|
-
_VECTORS = json.loads(
|
|
18
|
+
_VECTORS = json.loads(
|
|
19
|
+
(Path(__file__).parent / "fixtures" / "parity" / "parity_vectors.json").read_text()
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _bucket(user_id: str, flag_key: str) -> int:
|
|
24
|
+
"""Recover the rollout bucket from the real `_check_rollout` code path (which
|
|
25
|
+
the SDK has no public bucket accessor for): it returns `bucket < pct`, so the
|
|
26
|
+
smallest pct that flips it True is `bucket + 1`."""
|
|
27
|
+
return min(p for p in range(101) if _check_rollout(user_id, flag_key, p)) - 1
|
|
17
28
|
|
|
18
29
|
|
|
19
30
|
def _flag_from(case):
|
|
@@ -33,6 +44,13 @@ def test_evaluate_vectors(case):
|
|
|
33
44
|
assert evaluate(_flag_from(case), case["user"]) == case["expected"]
|
|
34
45
|
|
|
35
46
|
|
|
47
|
+
@pytest.mark.parametrize("case", _VECTORS["rollout_bucket"], ids=lambda c: c["name"])
|
|
48
|
+
def test_rollout_bucket_vectors(case):
|
|
49
|
+
"""The rollout hash itself must produce identical buckets across SDKs —
|
|
50
|
+
sha256(f'{user_id}:{flag_key}') % 100. The JS suite asserts the same values."""
|
|
51
|
+
assert _bucket(case["user_id"], case["flag_key"]) == case["expected"]
|
|
52
|
+
|
|
53
|
+
|
|
36
54
|
def test_user_id_resolution_ignores_id_when_user_id_present():
|
|
37
55
|
"""Null-only (`??`) fallback: `user_id` wins over `id`, so two contexts with
|
|
38
56
|
the same user_id but different id must bucket — and therefore resolve —
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|