switchbox-flags 0.5.0__tar.gz → 0.7.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.7.0}/PKG-INFO +30 -3
- {switchbox_flags-0.5.0 → switchbox_flags-0.7.0}/README.md +29 -2
- {switchbox_flags-0.5.0 → switchbox_flags-0.7.0}/pyproject.toml +1 -1
- switchbox_flags-0.7.0/switchbox/__init__.py +5 -0
- switchbox_flags-0.7.0/switchbox/_version.py +15 -0
- {switchbox_flags-0.5.0 → switchbox_flags-0.7.0}/switchbox/client.py +6 -1
- {switchbox_flags-0.5.0 → switchbox_flags-0.7.0}/switchbox/evaluator.py +32 -6
- {switchbox_flags-0.5.0 → switchbox_flags-0.7.0}/switchbox/models.py +22 -11
- {switchbox_flags-0.5.0 → switchbox_flags-0.7.0}/switchbox/sync.py +37 -8
- switchbox_flags-0.7.0/tests/fixtures/cdn-json/defaults.json +8 -0
- switchbox_flags-0.7.0/tests/fixtures/cdn-json/empty.json +4 -0
- switchbox_flags-0.7.0/tests/fixtures/cdn-json/full_config.json +133 -0
- switchbox_flags-0.7.0/tests/fixtures/cdn-json/legacy_flat_rules.json +14 -0
- switchbox_flags-0.7.0/tests/fixtures/cdn-json/unknown_fields.json +15 -0
- {switchbox_flags-0.5.0/tests → switchbox_flags-0.7.0/tests/fixtures/parity}/parity_vectors.json +36 -3
- {switchbox_flags-0.5.0 → switchbox_flags-0.7.0}/tests/test_client.py +42 -0
- switchbox_flags-0.7.0/tests/test_contract_fixtures.py +123 -0
- {switchbox_flags-0.5.0 → switchbox_flags-0.7.0}/tests/test_evaluator.py +53 -0
- {switchbox_flags-0.5.0 → switchbox_flags-0.7.0}/tests/test_models.py +54 -3
- switchbox_flags-0.7.0/tests/test_parity_vectors.py +83 -0
- {switchbox_flags-0.5.0 → switchbox_flags-0.7.0}/uv.lock +1 -1
- switchbox_flags-0.5.0/switchbox/__init__.py +0 -5
- switchbox_flags-0.5.0/tests/test_parity_vectors.py +0 -55
- {switchbox_flags-0.5.0 → switchbox_flags-0.7.0}/.coverage +0 -0
- {switchbox_flags-0.5.0 → switchbox_flags-0.7.0}/.github/workflows/publish.yml +0 -0
- {switchbox_flags-0.5.0 → switchbox_flags-0.7.0}/.github/workflows/test.yml +0 -0
- {switchbox_flags-0.5.0 → switchbox_flags-0.7.0}/.gitignore +0 -0
- {switchbox_flags-0.5.0 → switchbox_flags-0.7.0}/LICENSE +0 -0
- {switchbox_flags-0.5.0 → switchbox_flags-0.7.0}/switchbox/cache.py +0 -0
- {switchbox_flags-0.5.0 → switchbox_flags-0.7.0}/switchbox/exceptions.py +0 -0
- {switchbox_flags-0.5.0 → switchbox_flags-0.7.0}/tests/__init__.py +0 -0
- {switchbox_flags-0.5.0 → switchbox_flags-0.7.0}/tests/test_cache.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: switchbox-flags
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.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
|
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Single source of the package version (FABLE_IMPROVEMENTS 2.7).
|
|
2
|
+
|
|
3
|
+
Derived from the installed distribution metadata (pyproject.toml's `version`)
|
|
4
|
+
so `__version__`, the User-Agent, and PyPI can never drift — previously
|
|
5
|
+
__init__.py said 0.5.0, pyproject said 0.6.0 and the User-Agent hardcoded a
|
|
6
|
+
third copy. Lives in its own leaf module because both __init__.py and sync.py
|
|
7
|
+
need it (importing from the package root inside sync would be circular).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
__version__ = version("switchbox-flags")
|
|
14
|
+
except PackageNotFoundError: # running from a raw source tree, not installed
|
|
15
|
+
__version__ = "0.0.0"
|
|
@@ -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:
|
|
@@ -20,25 +20,48 @@ def _js_str(value: Any) -> str:
|
|
|
20
20
|
- booleans are lowercase ("true"/"false"), not Python's "True"/"False"
|
|
21
21
|
- None becomes "null"
|
|
22
22
|
- an integer-valued float drops its trailing ".0" (JS has no int/float split)
|
|
23
|
+
— but only below 1e21, where JS switches to exponential notation
|
|
24
|
+
- NaN/Infinity render as JS does ("NaN"/"Infinity"), not Python's "nan"/"inf"
|
|
25
|
+
- exponents are not zero-padded ("1e-7", not Python's "1e-07")
|
|
26
|
+
|
|
27
|
+
Residual, documented divergence (deliberately not chased — see
|
|
28
|
+
FABLE_IMPROVEMENTS 2.9): floats in [1e-6, 1e-4) render exponential here but
|
|
29
|
+
fixed in JS (str(1e-5)="1e-05" vs String(1e-5)="0.00001"), and lists/dicts
|
|
30
|
+
stringify Python-style, not JS-style ("[object Object]"). Vectors only pin
|
|
31
|
+
the converged cases.
|
|
23
32
|
"""
|
|
24
33
|
if isinstance(value, bool):
|
|
25
34
|
return "true" if value else "false"
|
|
26
35
|
if value is None:
|
|
27
36
|
return "null"
|
|
28
|
-
if isinstance(value, float)
|
|
29
|
-
|
|
37
|
+
if isinstance(value, float):
|
|
38
|
+
if value != value: # NaN
|
|
39
|
+
return "NaN"
|
|
40
|
+
if value == float("inf"):
|
|
41
|
+
return "Infinity"
|
|
42
|
+
if value == float("-inf"):
|
|
43
|
+
return "-Infinity"
|
|
44
|
+
if value.is_integer() and abs(value) < 1e21:
|
|
45
|
+
return str(int(value))
|
|
46
|
+
return re.sub(r"e([+-])0(\d)$", r"e\1\2", str(value))
|
|
30
47
|
return str(value)
|
|
31
48
|
|
|
32
49
|
|
|
33
50
|
def _to_number(value: Any) -> float | None:
|
|
34
51
|
"""Mimic JS parseFloat(String(value)): parse a leading numeric prefix, or
|
|
35
52
|
return None (JS NaN) when there isn't one. Booleans are NaN, matching
|
|
36
|
-
parseFloat("true")."""
|
|
53
|
+
parseFloat("true"). "Infinity"/"-Infinity" prefixes parse like parseFloat
|
|
54
|
+
does (parseFloat("Infinity") is Infinity, not NaN)."""
|
|
37
55
|
if isinstance(value, bool):
|
|
38
56
|
return None
|
|
39
57
|
if isinstance(value, (int, float)):
|
|
40
58
|
return float(value)
|
|
41
|
-
|
|
59
|
+
s = str(value).lstrip()
|
|
60
|
+
if s.startswith(("Infinity", "+Infinity")):
|
|
61
|
+
return float("inf")
|
|
62
|
+
if s.startswith("-Infinity"):
|
|
63
|
+
return float("-inf")
|
|
64
|
+
match = _NUMERIC_PREFIX.match(s)
|
|
42
65
|
return float(match.group(0)) if match else None
|
|
43
66
|
|
|
44
67
|
|
|
@@ -59,8 +82,11 @@ def evaluate(flag: Flag, user_context: dict | None = None) -> bool | str | int |
|
|
|
59
82
|
if not flag.enabled:
|
|
60
83
|
return flag.default_value
|
|
61
84
|
|
|
62
|
-
# 2. No user context
|
|
63
|
-
|
|
85
|
+
# 2. No user context (None only — an empty dict {} deliberately proceeds
|
|
86
|
+
# to the rules loop and behaves like "no matching attributes", the
|
|
87
|
+
# same path the JS SDK takes; parity-pinned. The end result is
|
|
88
|
+
# identical because step 5 mirrors this branch.)
|
|
89
|
+
if user_context is None:
|
|
64
90
|
if flag.rollout_pct == 100:
|
|
65
91
|
return _enabled_value(flag)
|
|
66
92
|
return flag.default_value
|
|
@@ -56,17 +56,28 @@ class FlagConfig:
|
|
|
56
56
|
|
|
57
57
|
@classmethod
|
|
58
58
|
def from_dict(cls, data: dict) -> FlagConfig:
|
|
59
|
-
"""Parse the CDN JSON into a FlagConfig object.
|
|
59
|
+
"""Parse the CDN JSON into a FlagConfig object.
|
|
60
|
+
|
|
61
|
+
**Per-flag tolerant** (FABLE_IMPROVEMENTS 2.3): one malformed flag —
|
|
62
|
+
e.g. a missing ``enabled`` or a null ``rules`` from a future publisher
|
|
63
|
+
bug — is skipped, not fatal. Before this, the sync loop's generic
|
|
64
|
+
handler discarded the *entire* config, freezing every Python client on
|
|
65
|
+
the old version while the (per-flag tolerant) JS SDK kept updating.
|
|
66
|
+
The JS parser skips non-object flag entries the same way
|
|
67
|
+
(``normalizeConfig`` in ``sync.ts``); keep them aligned."""
|
|
60
68
|
flags = {}
|
|
61
69
|
for key, flag_data in data.get("flags", {}).items():
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
key=
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
70
|
+
try:
|
|
71
|
+
rules = [_parse_rule_group(r) for r in flag_data.get("rules") or []]
|
|
72
|
+
flags[key] = Flag(
|
|
73
|
+
key=key,
|
|
74
|
+
enabled=flag_data["enabled"],
|
|
75
|
+
rollout_pct=flag_data.get("rollout_pct", 0),
|
|
76
|
+
flag_type=flag_data.get("flag_type", "boolean"),
|
|
77
|
+
default_value=flag_data.get("default_value"),
|
|
78
|
+
enabled_value=flag_data.get("enabled_value"),
|
|
79
|
+
rules=rules,
|
|
80
|
+
)
|
|
81
|
+
except Exception:
|
|
82
|
+
continue # skip the bad flag, keep the rest
|
|
72
83
|
return cls(version=data.get("version", ""), flags=flags)
|
|
@@ -5,6 +5,7 @@ import urllib.error
|
|
|
5
5
|
import urllib.request
|
|
6
6
|
from typing import Callable
|
|
7
7
|
|
|
8
|
+
from switchbox._version import __version__
|
|
8
9
|
from switchbox.cache import FlagCache
|
|
9
10
|
from switchbox.exceptions import ConfigFetchError
|
|
10
11
|
from switchbox.models import FlagConfig
|
|
@@ -31,12 +32,30 @@ class SyncWorker:
|
|
|
31
32
|
self._stop_event = threading.Event()
|
|
32
33
|
self._thread: threading.Thread | None = None
|
|
33
34
|
|
|
34
|
-
def start(self) -> None:
|
|
35
|
-
"""Fetch
|
|
36
|
-
# Initial synchronous fetch — block until we have configs
|
|
37
|
-
self._poll()
|
|
35
|
+
def start(self, block: bool = True) -> None:
|
|
36
|
+
"""Start polling. Fetch the first config, then poll in the background.
|
|
38
37
|
|
|
39
|
-
|
|
38
|
+
``block=True`` (default): fetch synchronously before returning, so the
|
|
39
|
+
client is ``ready`` the moment construction completes (at the cost of
|
|
40
|
+
blocking up to ``timeout`` if the CDN is slow/unreachable).
|
|
41
|
+
|
|
42
|
+
``block=False`` (SEC-9): return immediately and fetch the first config on
|
|
43
|
+
the background thread, so a slow CDN never stalls app startup. The client
|
|
44
|
+
starts not-ready; poll ``client.ready`` (or rely on flag defaults) until
|
|
45
|
+
the first fetch lands.
|
|
46
|
+
"""
|
|
47
|
+
if block:
|
|
48
|
+
# Initial synchronous fetch — block until we have configs.
|
|
49
|
+
self._poll()
|
|
50
|
+
poll_immediately = False
|
|
51
|
+
else:
|
|
52
|
+
# Defer the first fetch onto the background thread so __init__ returns
|
|
53
|
+
# right away rather than waiting on the network.
|
|
54
|
+
poll_immediately = True
|
|
55
|
+
|
|
56
|
+
self._thread = threading.Thread(
|
|
57
|
+
target=self._run, args=(poll_immediately,), daemon=True
|
|
58
|
+
)
|
|
40
59
|
self._thread.start()
|
|
41
60
|
|
|
42
61
|
def stop(self) -> None:
|
|
@@ -45,8 +64,18 @@ class SyncWorker:
|
|
|
45
64
|
if self._thread is not None:
|
|
46
65
|
self._thread.join(timeout=5)
|
|
47
66
|
|
|
48
|
-
def _run(self) -> None:
|
|
49
|
-
"""Main loop for the background thread.
|
|
67
|
+
def _run(self, poll_immediately: bool = False) -> None:
|
|
68
|
+
"""Main loop for the background thread.
|
|
69
|
+
|
|
70
|
+
``poll_immediately`` does one fetch before the first interval wait — used
|
|
71
|
+
by non-blocking start so the config still arrives ASAP rather than after a
|
|
72
|
+
full poll interval.
|
|
73
|
+
"""
|
|
74
|
+
if poll_immediately:
|
|
75
|
+
try:
|
|
76
|
+
self._poll()
|
|
77
|
+
except Exception as exc:
|
|
78
|
+
logger.warning("Unexpected error in initial sync: %s", exc)
|
|
50
79
|
while not self._stop_event.wait(timeout=self._interval):
|
|
51
80
|
try:
|
|
52
81
|
self._poll()
|
|
@@ -58,7 +87,7 @@ class SyncWorker:
|
|
|
58
87
|
try:
|
|
59
88
|
req = urllib.request.Request(
|
|
60
89
|
self._cdn_url,
|
|
61
|
-
headers={"User-Agent": "switchbox-python/
|
|
90
|
+
headers={"User-Agent": f"switchbox-python/{__version__}"},
|
|
62
91
|
)
|
|
63
92
|
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
|
|
64
93
|
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.7.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). The 'hostile:' vectors (FABLE_IMPROVEMENTS 2.8) pin the outside-the-happy-path envelope: evaluation-never-throws containment (ADR-043), {} context = no matching attributes, omitted flag_type/rollout_pct parse defaults (boolean/0), prototype-chain attributes, string-valued in_list substring semantics, Infinity/1e21 coercion, non-ASCII rollout hashing. Runners must exercise their REAL parse path (Python FlagConfig.from_dict, JS normalizeConfig) and compare strictly (no True==1). See DECISIONS.md ADR-013 (coercion) + ADR-024 (shared-fixture mechanism) + ADR-043 (error containment).",
|
|
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},
|
|
@@ -17,7 +17,19 @@
|
|
|
17
17
|
{"name": "lt numeric", "rule": {"attribute": "age", "operator": "lt", "value": "18"}, "context": {"age": "5"}, "expected": true},
|
|
18
18
|
{"name": "in_list string member matches", "rule": {"attribute": "country", "operator": "in_list", "value": ["US", "CA", "UK"]}, "context": {"country": "US"}, "expected": true},
|
|
19
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}
|
|
20
|
+
{"name": "in_list stringified number matches string item", "rule": {"attribute": "n", "operator": "in_list", "value": ["42"]}, "context": {"n": 42}, "expected": true},
|
|
21
|
+
{"name": "hostile: in_list with a STRING rule value does substring matching (pinned coincidence: JS String.includes / Python 'in')", "rule": {"attribute": "country", "operator": "in_list", "value": "US,CA"}, "context": {"country": "US"}, "expected": true},
|
|
22
|
+
{"name": "hostile: in_list string rule value, non-substring does not match", "rule": {"attribute": "country", "operator": "in_list", "value": "US,CA"}, "context": {"country": "FR"}, "expected": false},
|
|
23
|
+
{"name": "hostile: not_equals with an absent attribute does not match (absence is not inequality)", "rule": {"attribute": "plan", "operator": "not_equals", "value": "pro"}, "context": {"other": 1}, "expected": false},
|
|
24
|
+
{"name": "hostile: prototype-chain attribute never matches (constructor)", "rule": {"attribute": "constructor", "operator": "equals", "value": "anything"}, "context": {"other": "x"}, "expected": false},
|
|
25
|
+
{"name": "hostile: prototype-chain attribute never matches (toString)", "rule": {"attribute": "toString", "operator": "not_equals", "value": "x"}, "context": {"other": "x"}, "expected": false},
|
|
26
|
+
{"name": "hostile: gt parses a string 'Infinity' context like parseFloat", "rule": {"attribute": "n", "operator": "gt", "value": "1000000"}, "context": {"n": "Infinity"}, "expected": true},
|
|
27
|
+
{"name": "hostile: lt parses '-Infinity'", "rule": {"attribute": "n", "operator": "lt", "value": "0"}, "context": {"n": "-Infinity"}, "expected": true},
|
|
28
|
+
{"name": "hostile: gt with 'Infinity' RULE value never matches a finite context", "rule": {"attribute": "n", "operator": "gt", "value": "Infinity"}, "context": {"n": "999999999"}, "expected": false},
|
|
29
|
+
{"name": "hostile: equals at 1e21 uses exponential form (String(1e21)='1e+21')", "rule": {"attribute": "n", "operator": "equals", "value": "1e+21"}, "context": {"n": 1e21}, "expected": true},
|
|
30
|
+
{"name": "hostile: gt with tiny float context (1e-5 > 0, numeric path)", "rule": {"attribute": "n", "operator": "gt", "value": "0"}, "context": {"n": 1e-5}, "expected": true},
|
|
31
|
+
{"name": "hostile: gt with huge float context (1e21 > 1e20)", "rule": {"attribute": "n", "operator": "gt", "value": "1e20"}, "context": {"n": 1e21}, "expected": true},
|
|
32
|
+
{"name": "hostile: equals with CJK strings", "rule": {"attribute": "name", "operator": "equals", "value": "用户"}, "context": {"name": "用户"}, "expected": true}
|
|
21
33
|
],
|
|
22
34
|
"evaluate": [
|
|
23
35
|
{"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"},
|
|
@@ -40,6 +52,27 @@
|
|
|
40
52
|
{"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
53
|
{"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
54
|
{"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}
|
|
55
|
+
{"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},
|
|
56
|
+
{"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},
|
|
57
|
+
{"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},
|
|
58
|
+
{"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},
|
|
59
|
+
{"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},
|
|
60
|
+
{"name": "hostile: malformed in_list (null value) is contained -> default (evaluation never throws, ADR-043)", "flag_key": "f", "flag": {"enabled": true, "rollout_pct": 0, "flag_type": "boolean", "default_value": false, "rules": [{"conditions": [{"attribute": "x", "operator": "in_list", "value": null}]}]}, "user": {"x": "a", "user_id": "u1"}, "expected": false},
|
|
61
|
+
{"name": "hostile: empty user context {} behaves like no matching attributes, rollout 100 -> enabled", "flag_key": "f", "flag": {"enabled": true, "rollout_pct": 100, "flag_type": "boolean", "default_value": false, "rules": []}, "user": {}, "expected": true},
|
|
62
|
+
{"name": "hostile: empty user context {}, rollout 50 -> default", "flag_key": "f", "flag": {"enabled": true, "rollout_pct": 50, "flag_type": "boolean", "default_value": false, "rules": []}, "user": {}, "expected": false},
|
|
63
|
+
{"name": "hostile: empty user context {} with rules present, rollout 0 -> default", "flag_key": "f", "flag": {"enabled": true, "rollout_pct": 0, "flag_type": "boolean", "default_value": false, "rules": [{"conditions": [{"attribute": "plan", "operator": "equals", "value": "pro"}]}]}, "user": {}, "expected": false},
|
|
64
|
+
{"name": "hostile: omitted flag_type defaults to boolean (serves true)", "flag_key": "f", "flag": {"enabled": true, "rollout_pct": 100, "default_value": false, "rules": []}, "user": null, "expected": true},
|
|
65
|
+
{"name": "hostile: omitted rollout_pct defaults to 0 -> default", "flag_key": "f", "flag": {"enabled": true, "flag_type": "boolean", "default_value": false, "rules": []}, "user": {"user_id": "u1"}, "expected": false},
|
|
66
|
+
{"name": "hostile: non-ASCII user_id evaluates through the rollout hash (bucket 18 < 50)", "flag_key": "new_checkout", "flag": {"enabled": true, "rollout_pct": 50, "flag_type": "boolean", "default_value": false, "rules": []}, "user": {"user_id": "用户42"}, "expected": true}
|
|
67
|
+
],
|
|
68
|
+
"rollout_bucket": [
|
|
69
|
+
{"name": "42:new_checkout", "user_id": "42", "flag_key": "new_checkout", "expected": 98},
|
|
70
|
+
{"name": "user_100:feature_x", "user_id": "user_100", "flag_key": "feature_x", "expected": 4},
|
|
71
|
+
{"name": "abc:search_version", "user_id": "abc", "flag_key": "search_version", "expected": 10},
|
|
72
|
+
{"name": "u_low:f", "user_id": "u_low", "flag_key": "f", "expected": 58},
|
|
73
|
+
{"name": "user-2:f", "user_id": "user-2", "flag_key": "f", "expected": 47},
|
|
74
|
+
{"name": "hostile: CJK user_id (UTF-8 hashing parity)", "user_id": "用户42", "flag_key": "new_checkout", "expected": 18},
|
|
75
|
+
{"name": "hostile: emoji user_id (astral-plane UTF-8)", "user_id": "🚀user", "flag_key": "feature_x", "expected": 59},
|
|
76
|
+
{"name": "hostile: latin diacritics user_id", "user_id": "Ünïcode-Üser", "flag_key": "search_version", "expected": 85}
|
|
44
77
|
]
|
|
45
78
|
}
|
|
@@ -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 == {}
|
|
@@ -243,3 +243,56 @@ def test_rule_attribute_not_in_context_skipped():
|
|
|
243
243
|
rules=[Rule(attribute="missing_attr", operator="equals", value="x")],
|
|
244
244
|
)
|
|
245
245
|
assert evaluate(flag, {"user_id": "1", "other": "y"}) is False
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# --- JS-coercion extremes (FABLE_IMPROVEMENTS 2.9) ---
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def test_js_str_extremes_match_js_string():
|
|
252
|
+
"""The cheap-to-fix String() divergences, pinned: Infinity/NaN naming,
|
|
253
|
+
exponential form at 1e21+, no zero-padded exponents. The residual
|
|
254
|
+
divergence (fixed-vs-exponential in the 1e-6..1e-4 range, lists/dicts) is
|
|
255
|
+
documented in _js_str's docstring, deliberately not chased."""
|
|
256
|
+
from switchbox.evaluator import _js_str
|
|
257
|
+
|
|
258
|
+
assert _js_str(float("inf")) == "Infinity"
|
|
259
|
+
assert _js_str(float("-inf")) == "-Infinity"
|
|
260
|
+
assert _js_str(float("nan")) == "NaN"
|
|
261
|
+
assert _js_str(1e21) == "1e+21" # JS switches to exponential at 1e21
|
|
262
|
+
assert _js_str(1e22) == "1e+22"
|
|
263
|
+
assert _js_str(1e20) == "100000000000000000000" # still fixed below 1e21
|
|
264
|
+
assert _js_str(1e-7) == "1e-7" # no zero-padded exponent ("1e-07")
|
|
265
|
+
assert _js_str(1.5e-8) == "1.5e-8"
|
|
266
|
+
assert _js_str(42.0) == "42" # unchanged: int-valued float drops .0
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def test_to_number_parses_infinity_like_parsefloat():
|
|
270
|
+
from switchbox.evaluator import _to_number
|
|
271
|
+
|
|
272
|
+
assert _to_number("Infinity") == float("inf")
|
|
273
|
+
assert _to_number("+Infinity") == float("inf")
|
|
274
|
+
assert _to_number("-Infinity") == float("-inf")
|
|
275
|
+
assert _to_number("Infinity-and-beyond") == float("inf") # prefix parse
|
|
276
|
+
assert _to_number("Inf") is None # parseFloat("Inf") is NaN
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def test_empty_context_takes_the_rules_path_not_the_no_context_branch():
|
|
280
|
+
"""{} is a real (empty) context: rules are walked (none can match), then
|
|
281
|
+
the no-usable-id tail applies — the same path JS takes (parity-pinned by
|
|
282
|
+
the hostile vectors)."""
|
|
283
|
+
flag = make_flag(rollout_pct=100)
|
|
284
|
+
assert evaluate(flag, {}) is True
|
|
285
|
+
flag = make_flag(rollout_pct=50)
|
|
286
|
+
assert evaluate(flag, {}) is False
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def test_evaluation_error_is_contained_to_default():
|
|
290
|
+
"""Evaluation never throws (ADR-043): a malformed in_list value degrades to
|
|
291
|
+
default_value. Same contract as the JS SDK's try/catch."""
|
|
292
|
+
flag = make_flag(
|
|
293
|
+
rollout_pct=0,
|
|
294
|
+
default_value="safe",
|
|
295
|
+
flag_type="string",
|
|
296
|
+
rules=[Rule(attribute="x", operator="in_list", value=None)],
|
|
297
|
+
)
|
|
298
|
+
assert evaluate(flag, {"x": "a", "user_id": "u1"}) == "safe"
|
|
@@ -10,9 +10,7 @@ def test_flag_config_from_dict_valid():
|
|
|
10
10
|
"rollout_pct": 50,
|
|
11
11
|
"flag_type": "boolean",
|
|
12
12
|
"default_value": False,
|
|
13
|
-
"rules": [
|
|
14
|
-
{"attribute": "country", "operator": "equals", "value": "US"}
|
|
15
|
-
],
|
|
13
|
+
"rules": [{"attribute": "country", "operator": "equals", "value": "US"}],
|
|
16
14
|
}
|
|
17
15
|
},
|
|
18
16
|
}
|
|
@@ -105,3 +103,56 @@ def test_rule_stores_fields():
|
|
|
105
103
|
assert rule.attribute == "country"
|
|
106
104
|
assert rule.operator == "equals"
|
|
107
105
|
assert rule.value == "US"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_from_dict_skips_a_malformed_flag_keeps_the_rest():
|
|
109
|
+
"""Per-flag tolerance (FABLE_IMPROVEMENTS 2.3): one bad flag from a future
|
|
110
|
+
publisher bug must not discard the whole config — that froze every Python
|
|
111
|
+
client on the old version while the JS SDK kept updating."""
|
|
112
|
+
data = {
|
|
113
|
+
"version": "v1",
|
|
114
|
+
"flags": {
|
|
115
|
+
"good": {
|
|
116
|
+
"enabled": True,
|
|
117
|
+
"rollout_pct": 100,
|
|
118
|
+
"flag_type": "boolean",
|
|
119
|
+
"default_value": False,
|
|
120
|
+
"rules": [],
|
|
121
|
+
},
|
|
122
|
+
"missing_enabled": {
|
|
123
|
+
"rollout_pct": 100,
|
|
124
|
+
"flag_type": "boolean",
|
|
125
|
+
"default_value": False,
|
|
126
|
+
"rules": [],
|
|
127
|
+
},
|
|
128
|
+
"not_a_dict": None,
|
|
129
|
+
"garbage_rules": {
|
|
130
|
+
"enabled": True,
|
|
131
|
+
"rollout_pct": 0,
|
|
132
|
+
"flag_type": "boolean",
|
|
133
|
+
"default_value": False,
|
|
134
|
+
"rules": [{"conditions": [{"nope": 1}]}],
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
}
|
|
138
|
+
config = FlagConfig.from_dict(data)
|
|
139
|
+
assert set(config.flags) == {"good"}
|
|
140
|
+
assert config.version == "v1"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_from_dict_null_rules_parses_as_no_rules():
|
|
144
|
+
"""A null `rules` is tolerated as [] (JS toRuleGroups(null) → []), not fatal."""
|
|
145
|
+
data = {
|
|
146
|
+
"version": "v1",
|
|
147
|
+
"flags": {
|
|
148
|
+
"f": {
|
|
149
|
+
"enabled": True,
|
|
150
|
+
"rollout_pct": 100,
|
|
151
|
+
"flag_type": "boolean",
|
|
152
|
+
"default_value": False,
|
|
153
|
+
"rules": None,
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
}
|
|
157
|
+
flag = FlagConfig.from_dict(data).flags["f"]
|
|
158
|
+
assert flag.rules == []
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Runs the shared cross-SDK parity vectors (SEC-4 / TESTING Phase 5).
|
|
2
|
+
|
|
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).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
from switchbox.evaluator import _check_rollout, _match_rule, evaluate
|
|
16
|
+
from switchbox.models import FlagConfig, Rule
|
|
17
|
+
|
|
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
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _flag_from(case):
|
|
31
|
+
"""Build a Flag from a vector's CDN-shaped flag dict (reuses the real parser)."""
|
|
32
|
+
config = FlagConfig.from_dict({"flags": {case["flag_key"]: case["flag"]}})
|
|
33
|
+
return config.flags[case["flag_key"]]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@pytest.mark.parametrize("case", _VECTORS["rule_match"], ids=lambda c: c["name"])
|
|
37
|
+
def test_rule_match_vectors(case):
|
|
38
|
+
rule = Rule(**case["rule"])
|
|
39
|
+
assert _match_rule(rule, case["context"]) == case["expected"]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _assert_strict(result, expected):
|
|
43
|
+
"""Value AND type must match — Python's `==` alone would let `True == 1`
|
|
44
|
+
pass while the JS runner's `toBe` fails it (runner-asymmetry fix,
|
|
45
|
+
FABLE_IMPROVEMENTS 2.8)."""
|
|
46
|
+
assert result == expected and type(result) is type(expected), (
|
|
47
|
+
f"{result!r} ({type(result).__name__}) != "
|
|
48
|
+
f"{expected!r} ({type(expected).__name__})"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@pytest.mark.parametrize("case", _VECTORS["evaluate"], ids=lambda c: c["name"])
|
|
53
|
+
def test_evaluate_vectors(case):
|
|
54
|
+
_assert_strict(evaluate(_flag_from(case), case["user"]), case["expected"])
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@pytest.mark.parametrize("case", _VECTORS["rollout_bucket"], ids=lambda c: c["name"])
|
|
58
|
+
def test_rollout_bucket_vectors(case):
|
|
59
|
+
"""The rollout hash itself must produce identical buckets across SDKs —
|
|
60
|
+
sha256(f'{user_id}:{flag_key}') % 100. The JS suite asserts the same values."""
|
|
61
|
+
assert _bucket(case["user_id"], case["flag_key"]) == case["expected"]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_user_id_resolution_ignores_id_when_user_id_present():
|
|
65
|
+
"""Null-only (`??`) fallback: `user_id` wins over `id`, so two contexts with
|
|
66
|
+
the same user_id but different id must bucket — and therefore resolve —
|
|
67
|
+
identically. (An empty-string user_id is a real id, not falsy.)"""
|
|
68
|
+
flag = FlagConfig.from_dict(
|
|
69
|
+
{
|
|
70
|
+
"flags": {
|
|
71
|
+
"f": {
|
|
72
|
+
"enabled": True,
|
|
73
|
+
"rollout_pct": 50,
|
|
74
|
+
"flag_type": "boolean",
|
|
75
|
+
"default_value": False,
|
|
76
|
+
"rules": [],
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
).flags["f"]
|
|
81
|
+
a = evaluate(flag, {"user_id": "stable", "id": "aaa"})
|
|
82
|
+
b = evaluate(flag, {"user_id": "stable", "id": "bbb"})
|
|
83
|
+
assert a == b
|
|
@@ -1,55 +0,0 @@
|
|
|
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
|
|
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
|