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