switchbox-flags 0.4.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.
Files changed (29) hide show
  1. {switchbox_flags-0.4.0 → switchbox_flags-0.6.0}/PKG-INFO +30 -3
  2. {switchbox_flags-0.4.0 → switchbox_flags-0.6.0}/README.md +29 -2
  3. {switchbox_flags-0.4.0 → switchbox_flags-0.6.0}/pyproject.toml +1 -1
  4. {switchbox_flags-0.4.0 → switchbox_flags-0.6.0}/switchbox/__init__.py +1 -1
  5. {switchbox_flags-0.4.0 → switchbox_flags-0.6.0}/switchbox/client.py +6 -1
  6. {switchbox_flags-0.4.0 → switchbox_flags-0.6.0}/switchbox/evaluator.py +8 -2
  7. {switchbox_flags-0.4.0 → switchbox_flags-0.6.0}/switchbox/models.py +5 -0
  8. {switchbox_flags-0.4.0 → switchbox_flags-0.6.0}/switchbox/sync.py +36 -8
  9. switchbox_flags-0.6.0/tests/fixtures/cdn-json/defaults.json +8 -0
  10. switchbox_flags-0.6.0/tests/fixtures/cdn-json/empty.json +4 -0
  11. switchbox_flags-0.6.0/tests/fixtures/cdn-json/full_config.json +133 -0
  12. switchbox_flags-0.6.0/tests/fixtures/cdn-json/legacy_flat_rules.json +14 -0
  13. switchbox_flags-0.6.0/tests/fixtures/cdn-json/unknown_fields.json +15 -0
  14. {switchbox_flags-0.4.0/tests → switchbox_flags-0.6.0/tests/fixtures/parity}/parity_vectors.json +19 -2
  15. {switchbox_flags-0.4.0 → switchbox_flags-0.6.0}/tests/test_client.py +42 -0
  16. switchbox_flags-0.6.0/tests/test_contract_fixtures.py +123 -0
  17. {switchbox_flags-0.4.0 → switchbox_flags-0.6.0}/tests/test_parity_vectors.py +24 -6
  18. {switchbox_flags-0.4.0 → switchbox_flags-0.6.0}/uv.lock +1 -1
  19. {switchbox_flags-0.4.0 → switchbox_flags-0.6.0}/.coverage +0 -0
  20. {switchbox_flags-0.4.0 → switchbox_flags-0.6.0}/.github/workflows/publish.yml +0 -0
  21. {switchbox_flags-0.4.0 → switchbox_flags-0.6.0}/.github/workflows/test.yml +0 -0
  22. {switchbox_flags-0.4.0 → switchbox_flags-0.6.0}/.gitignore +0 -0
  23. {switchbox_flags-0.4.0 → switchbox_flags-0.6.0}/LICENSE +0 -0
  24. {switchbox_flags-0.4.0 → switchbox_flags-0.6.0}/switchbox/cache.py +0 -0
  25. {switchbox_flags-0.4.0 → switchbox_flags-0.6.0}/switchbox/exceptions.py +0 -0
  26. {switchbox_flags-0.4.0 → switchbox_flags-0.6.0}/tests/__init__.py +0 -0
  27. {switchbox_flags-0.4.0 → switchbox_flags-0.6.0}/tests/test_cache.py +0 -0
  28. {switchbox_flags-0.4.0 → switchbox_flags-0.6.0}/tests/test_evaluator.py +0 -0
  29. {switchbox_flags-0.4.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.4.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. Performs an initial synchronous fetch on creation, then starts background polling.
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. Performs an initial synchronous fetch on creation, then starts background polling.
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
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "switchbox-flags"
7
- version = "0.4.0"
7
+ version = "0.6.0"
8
8
  description = "Feature flag SDK with zero dependencies"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,5 +1,5 @@
1
1
  from switchbox.client import Switchbox
2
2
  from switchbox.exceptions import SwitchboxError
3
3
 
4
- __version__ = "0.4.0"
4
+ __version__ = "0.5.0"
5
5
  __all__ = ["Switchbox", "SwitchboxError"]
@@ -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
- self._sync.start()
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:
@@ -88,10 +88,16 @@ def evaluate(flag: Flag, user_context: dict | None = None) -> bool | str | int |
88
88
 
89
89
 
90
90
  def _enabled_value(flag: Flag) -> bool | str | int | Any:
91
- """Return the appropriate 'enabled' value based on flag type."""
91
+ """The value served to matched / in-rollout users.
92
+
93
+ Boolean flags serve ``True``. Non-boolean flags serve ``enabled_value``
94
+ (variations, ADR-017), falling back to ``default_value`` when it's unset —
95
+ so a config without ``enabled_value`` behaves exactly as before. Fallback is
96
+ at evaluation time (not parse time) so directly-constructed Flags work too,
97
+ matching the JS SDK's ``?? default_value``."""
92
98
  if flag.flag_type == "boolean":
93
99
  return True
94
- return flag.default_value
100
+ return flag.enabled_value if flag.enabled_value is not None else flag.default_value
95
101
 
96
102
 
97
103
  def _to_groups(rules: list) -> list[RuleGroup]:
@@ -29,6 +29,10 @@ class Flag:
29
29
  rollout_pct: int
30
30
  flag_type: str # boolean | string | number | json
31
31
  default_value: Any
32
+ # The "on"/matched value for non-boolean flags (variations, ADR-017). None
33
+ # means "unset" — the evaluator falls back to default_value, so configs
34
+ # without this key behave exactly as before. Boolean flags ignore it.
35
+ enabled_value: Any = None
32
36
  rules: list[RuleGroup] = field(default_factory=list)
33
37
 
34
38
 
@@ -62,6 +66,7 @@ class FlagConfig:
62
66
  rollout_pct=flag_data.get("rollout_pct", 0),
63
67
  flag_type=flag_data.get("flag_type", "boolean"),
64
68
  default_value=flag_data.get("default_value"),
69
+ enabled_value=flag_data.get("enabled_value"),
65
70
  rules=rules,
66
71
  )
67
72
  return cls(version=data.get("version", ""), flags=flags)
@@ -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 configs synchronously first, then start background polling."""
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
- self._thread = threading.Thread(target=self._run, daemon=True)
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.3.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,8 @@
1
+ {
2
+ "version": "2026-01-01T00:00:00+00:00",
3
+ "flags": {
4
+ "minimal": {
5
+ "enabled": true
6
+ }
7
+ }
8
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "version": "2026-01-01T00:00:00+00:00",
3
+ "flags": {}
4
+ }
@@ -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
+ }
@@ -1,5 +1,5 @@
1
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.",
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},
@@ -34,6 +34,23 @@
34
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
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
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}
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
+ {"name": "variation: string flag rule match serves enabled_value", "flag_key": "f", "flag": {"enabled": true, "rollout_pct": 0, "flag_type": "string", "default_value": "Shop", "enabled_value": "Buy now", "rules": [{"conditions": [{"attribute": "plan", "operator": "equals", "value": "pro"}]}]}, "user": {"plan": "pro", "user_id": "u1"}, "expected": "Buy now"},
39
+ {"name": "variation: string flag no match serves default_value", "flag_key": "f", "flag": {"enabled": true, "rollout_pct": 0, "flag_type": "string", "default_value": "Shop", "enabled_value": "Buy now", "rules": [{"conditions": [{"attribute": "plan", "operator": "equals", "value": "pro"}]}]}, "user": {"plan": "free", "user_id": "u1"}, "expected": "Shop"},
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
+ {"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
+ {"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},
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}
38
55
  ]
39
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
- `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.
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((Path(__file__).parent / "parity_vectors.json").read_text())
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 —
@@ -236,7 +236,7 @@ wheels = [
236
236
 
237
237
  [[package]]
238
238
  name = "switchbox-flags"
239
- version = "0.4.0"
239
+ version = "0.6.0"
240
240
  source = { editable = "." }
241
241
 
242
242
  [package.optional-dependencies]
File without changes