switchbox-flags 0.1.0__tar.gz → 0.2.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 (24) hide show
  1. switchbox_flags-0.2.0/.coverage +0 -0
  2. {switchbox_flags-0.1.0 → switchbox_flags-0.2.0}/.github/workflows/test.yml +3 -3
  3. switchbox_flags-0.2.0/PKG-INFO +244 -0
  4. switchbox_flags-0.2.0/README.md +218 -0
  5. {switchbox_flags-0.1.0 → switchbox_flags-0.2.0}/pyproject.toml +14 -5
  6. {switchbox_flags-0.1.0 → switchbox_flags-0.2.0}/switchbox/client.py +15 -4
  7. {switchbox_flags-0.1.0 → switchbox_flags-0.2.0}/switchbox/evaluator.py +32 -26
  8. {switchbox_flags-0.1.0 → switchbox_flags-0.2.0}/switchbox/sync.py +26 -8
  9. {switchbox_flags-0.1.0 → switchbox_flags-0.2.0}/tests/test_cache.py +6 -0
  10. switchbox_flags-0.2.0/tests/test_client.py +138 -0
  11. {switchbox_flags-0.1.0 → switchbox_flags-0.2.0}/tests/test_evaluator.py +101 -0
  12. switchbox_flags-0.2.0/tests/test_models.py +81 -0
  13. switchbox_flags-0.2.0/uv.lock +332 -0
  14. switchbox_flags-0.1.0/PKG-INFO +0 -90
  15. switchbox_flags-0.1.0/README.md +0 -67
  16. switchbox_flags-0.1.0/tests/test_client.py +0 -64
  17. {switchbox_flags-0.1.0 → switchbox_flags-0.2.0}/.github/workflows/publish.yml +0 -0
  18. {switchbox_flags-0.1.0 → switchbox_flags-0.2.0}/.gitignore +0 -0
  19. {switchbox_flags-0.1.0 → switchbox_flags-0.2.0}/LICENSE +0 -0
  20. {switchbox_flags-0.1.0 → switchbox_flags-0.2.0}/switchbox/__init__.py +0 -0
  21. {switchbox_flags-0.1.0 → switchbox_flags-0.2.0}/switchbox/cache.py +0 -0
  22. {switchbox_flags-0.1.0 → switchbox_flags-0.2.0}/switchbox/exceptions.py +0 -0
  23. {switchbox_flags-0.1.0 → switchbox_flags-0.2.0}/switchbox/models.py +0 -0
  24. {switchbox_flags-0.1.0 → switchbox_flags-0.2.0}/tests/__init__.py +0 -0
Binary file
@@ -11,7 +11,7 @@ jobs:
11
11
  runs-on: ubuntu-latest
12
12
  strategy:
13
13
  matrix:
14
- python-version: ["3.14"]
14
+ python-version: ["3.10", "3.11", "3.12"]
15
15
  steps:
16
16
  - uses: actions/checkout@v4
17
17
 
@@ -25,5 +25,5 @@ jobs:
25
25
  - name: Lint
26
26
  run: ruff check .
27
27
 
28
- - name: Test
29
- run: pytest -v
28
+ - name: Test with coverage
29
+ run: pytest -v --cov=switchbox --cov-report=term-missing --cov-fail-under=80
@@ -0,0 +1,244 @@
1
+ Metadata-Version: 2.4
2
+ Name: switchbox-flags
3
+ Version: 0.2.0
4
+ Summary: Feature flag SDK with zero dependencies
5
+ Project-URL: Homepage, https://github.com/ignat14/switchbox-sdk-python
6
+ Project-URL: Repository, https://github.com/ignat14/switchbox-sdk-python
7
+ Project-URL: Issues, https://github.com/ignat14/switchbox-sdk-python/issues
8
+ Author: Switchbox
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: feature-flags,feature-toggles,sdk
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.10
21
+ Provides-Extra: dev
22
+ Requires-Dist: pytest-cov>=6.0; extra == 'dev'
23
+ Requires-Dist: pytest>=7; extra == 'dev'
24
+ Requires-Dist: ruff>=0.4; extra == 'dev'
25
+ Description-Content-Type: text/markdown
26
+
27
+ # Switchbox
28
+
29
+ Feature flags served from a CDN. Zero dependencies. Sub-millisecond evaluation.
30
+
31
+ [![PyPI](https://img.shields.io/pypi/v/switchbox-flags)](https://pypi.org/project/switchbox-flags/)
32
+ [![Python](https://img.shields.io/pypi/pyversions/switchbox-flags)](https://pypi.org/project/switchbox-flags/)
33
+ [![License](https://img.shields.io/pypi/l/switchbox-flags)](https://github.com/ignat14/switchbox-sdk-python/blob/main/LICENSE)
34
+
35
+ ## What is this?
36
+
37
+ Switchbox is a feature flag SDK that reads configs from a CDN instead of an API server. Flag configs are static JSON files on the edge — your app fetches them directly. Rules and rollouts are evaluated locally in the SDK, not on a server.
38
+
39
+ ## Install
40
+
41
+ ```
42
+ pip install switchbox-flags
43
+ ```
44
+
45
+ ## Quick Start
46
+
47
+ ```python
48
+ from switchbox import Client
49
+
50
+ client = Client(sdk_key="your-sdk-key-from-dashboard")
51
+
52
+ if client.enabled("new_checkout", user={"user_id": "42"}):
53
+ show_new_checkout()
54
+
55
+ client.close()
56
+ ```
57
+
58
+ ## Features
59
+
60
+ - **CDN-first** — fetches flag configs from static JSON on a CDN, no server in the read path
61
+ - **Zero dependencies** — Python stdlib only, nothing to install beyond the package
62
+ - **Sub-millisecond evaluation** — rules and rollouts evaluated locally in-process
63
+ - **Background polling** — syncs configs every 30 seconds (configurable)
64
+ - **Offline resilient** — keeps working on cached configs if the CDN is unreachable
65
+ - **Thread-safe** — safe to use from multiple threads
66
+ - **Context manager** — supports `with Client(...) as client:` for automatic cleanup
67
+
68
+ ## Usage
69
+
70
+ ### Boolean Flags
71
+
72
+ ```python
73
+ from switchbox import Client
74
+
75
+ client = Client(sdk_key="your-sdk-key-from-dashboard")
76
+
77
+ if client.enabled("dark_mode"):
78
+ enable_dark_mode()
79
+
80
+ client.close()
81
+ ```
82
+
83
+ ### String / Number Flags
84
+
85
+ ```python
86
+ version = client.get_value("search_algorithm", user={"user_id": "42"}, default="v1")
87
+ print(f"Using search {version}")
88
+
89
+ max_results = client.get_value("max_search_results", user={"user_id": "42"}, default=10)
90
+ ```
91
+
92
+ ### All Flags at Once
93
+
94
+ ```python
95
+ flags = client.get_all_flags(user={"user_id": "42"})
96
+ # {"dark_mode": True, "search_algorithm": "v2", "max_search_results": 50}
97
+ ```
98
+
99
+ ### Targeting Rules
100
+
101
+ Pass a `user` dict with attributes you want to target on. Rules are configured in the dashboard.
102
+
103
+ ```python
104
+ user = {
105
+ "user_id": "42",
106
+ "email": "alice@company.com",
107
+ "plan": "enterprise",
108
+ "age": "30",
109
+ }
110
+
111
+ # Flag with rule: email ends_with "@company.com"
112
+ client.enabled("internal_tools", user=user) # True
113
+
114
+ # Flag with rule: plan equals "enterprise"
115
+ client.enabled("advanced_analytics", user=user) # True
116
+
117
+ # Flag with rule: plan in_list ["pro", "enterprise"]
118
+ client.enabled("export_csv", user=user) # True
119
+ ```
120
+
121
+ Supported operators: `equals`, `not_equals`, `contains`, `ends_with`, `in_list`, `gt`, `lt`.
122
+
123
+ Rules use OR logic — if any rule matches, the flag is on for that user.
124
+
125
+ ### Percentage Rollouts
126
+
127
+ Rollouts use deterministic hashing (`sha256(user_id:flag_key) % 100`). The same user always gets the same result for a given flag — no flickering between requests.
128
+
129
+ ```python
130
+ # Flag with rollout_pct=25 — 25% of users get this flag
131
+ client.enabled("new_onboarding", user={"user_id": "42"}) # deterministic True/False
132
+ ```
133
+
134
+ A `user_id` (or `id`) key is required in the user dict for percentage rollouts.
135
+
136
+ ### Offline / Fail-safe Behavior
137
+
138
+ If the CDN is unreachable, the SDK keeps using the last successfully fetched config. Your flags keep working.
139
+
140
+ If the SDK has never successfully fetched a config (e.g., CDN is down on first startup), `enabled()` returns `False` and `get_value()` returns the `default` you pass in. No exceptions are raised.
141
+
142
+ ### Context Manager
143
+
144
+ ```python
145
+ with Client(sdk_key="your-sdk-key-from-dashboard") as client:
146
+ if client.enabled("new_checkout", user={"user_id": "42"}):
147
+ show_new_checkout()
148
+ # client.close() is called automatically
149
+ ```
150
+
151
+ ## Configuration
152
+
153
+ ```python
154
+ client = Client(
155
+ sdk_key="your-sdk-key-from-dashboard", # required — get from Environments tab
156
+ poll_interval=60, # seconds between polls (default: 30)
157
+ on_error=lambda e: logger.warning(e), # called on fetch errors (default: None)
158
+ )
159
+ ```
160
+
161
+ | Parameter | Type | Default | Description |
162
+ |-----------------|--------------------------------|---------|------------------------------------------------|
163
+ | `sdk_key` | `str` | — | SDK key from the environment in the dashboard |
164
+ | `poll_interval` | `int` | `30` | Seconds between background config refreshes |
165
+ | `on_error` | `Callable[[Exception], None]` | `None` | Callback invoked when a fetch or parse fails |
166
+
167
+ The SDK builds the CDN URL automatically from the SDK key. You can override with `cdn_base_url` if self-hosting.
168
+
169
+ ## How It Works
170
+
171
+ ```
172
+ ┌──────────┐ ┌──────────┐ ┌─────────────┐
173
+ │Dashboard │──────>│ API │──────>│ Postgres │
174
+ │ │ HTTP │ (Fly.io) │ SQL │ (Neon) │
175
+ └──────────┘ └────┬─────┘ └─────────────┘
176
+
177
+ │ publish on every change
178
+ v
179
+ ┌─────────────┐ ┌──────────────┐
180
+ │CDN Publisher│──────>│Cloudflare R2 │
181
+ │ │ PUT │(static JSON) │
182
+ └─────────────┘ └──────┬───────┘
183
+
184
+ │ HTTP GET (SDK polls)
185
+ v
186
+ ┌──────────────┐
187
+ │ Your App │
188
+ │ (this SDK) │
189
+ └──────────────┘
190
+ ```
191
+
192
+ 1. You create and toggle flags in the dashboard or API
193
+ 2. On every change, the API generates a static JSON file and uploads it to Cloudflare R2
194
+ 3. This SDK polls that JSON file from the CDN every 30 seconds
195
+ 4. Flag evaluation (rules, rollouts) happens locally — no network call per flag check
196
+
197
+ The API server is only in the write path. All read traffic goes to the CDN.
198
+
199
+ ## API Reference
200
+
201
+ ### `Client(sdk_key, poll_interval=30, on_error=None)`
202
+
203
+ Creates a new client. Performs an initial synchronous fetch on creation, then starts background polling.
204
+
205
+ ### `client.enabled(flag_key, user=None) -> bool`
206
+
207
+ Check if a boolean flag is enabled. Returns `False` if the flag doesn't exist.
208
+
209
+ | Parameter | Type | Description |
210
+ |------------|----------------|--------------------------------------|
211
+ | `flag_key` | `str` | The flag key to check |
212
+ | `user` | `dict \| None` | User context for targeting/rollouts |
213
+
214
+ ### `client.get_value(flag_key, user=None, default=None) -> Any`
215
+
216
+ Get the resolved value of any flag type (string, number, JSON). Returns `default` if the flag doesn't exist.
217
+
218
+ | Parameter | Type | Description |
219
+ |------------|----------------|--------------------------------------|
220
+ | `flag_key` | `str` | The flag key to check |
221
+ | `user` | `dict \| None` | User context for targeting/rollouts |
222
+ | `default` | `Any` | Value returned if flag doesn't exist |
223
+
224
+ ### `client.get_all_flags(user=None) -> dict[str, Any]`
225
+
226
+ Get all flag values resolved for a user. Returns an empty dict if no config is available.
227
+
228
+ ### `client.close() -> None`
229
+
230
+ Stop background polling. Call this on application shutdown.
231
+
232
+ ## Contributing
233
+
234
+ ```sh
235
+ git clone https://github.com/ignat14/switchbox-sdk-python.git
236
+ cd switchbox-sdk-python
237
+ pip install -e ".[dev]"
238
+ pytest
239
+ ruff check .
240
+ ```
241
+
242
+ ## License
243
+
244
+ MIT
@@ -0,0 +1,218 @@
1
+ # Switchbox
2
+
3
+ Feature flags served from a CDN. Zero dependencies. Sub-millisecond evaluation.
4
+
5
+ [![PyPI](https://img.shields.io/pypi/v/switchbox-flags)](https://pypi.org/project/switchbox-flags/)
6
+ [![Python](https://img.shields.io/pypi/pyversions/switchbox-flags)](https://pypi.org/project/switchbox-flags/)
7
+ [![License](https://img.shields.io/pypi/l/switchbox-flags)](https://github.com/ignat14/switchbox-sdk-python/blob/main/LICENSE)
8
+
9
+ ## What is this?
10
+
11
+ Switchbox is a feature flag SDK that reads configs from a CDN instead of an API server. Flag configs are static JSON files on the edge — your app fetches them directly. Rules and rollouts are evaluated locally in the SDK, not on a server.
12
+
13
+ ## Install
14
+
15
+ ```
16
+ pip install switchbox-flags
17
+ ```
18
+
19
+ ## Quick Start
20
+
21
+ ```python
22
+ from switchbox import Client
23
+
24
+ client = Client(sdk_key="your-sdk-key-from-dashboard")
25
+
26
+ if client.enabled("new_checkout", user={"user_id": "42"}):
27
+ show_new_checkout()
28
+
29
+ client.close()
30
+ ```
31
+
32
+ ## Features
33
+
34
+ - **CDN-first** — fetches flag configs from static JSON on a CDN, no server in the read path
35
+ - **Zero dependencies** — Python stdlib only, nothing to install beyond the package
36
+ - **Sub-millisecond evaluation** — rules and rollouts evaluated locally in-process
37
+ - **Background polling** — syncs configs every 30 seconds (configurable)
38
+ - **Offline resilient** — keeps working on cached configs if the CDN is unreachable
39
+ - **Thread-safe** — safe to use from multiple threads
40
+ - **Context manager** — supports `with Client(...) as client:` for automatic cleanup
41
+
42
+ ## Usage
43
+
44
+ ### Boolean Flags
45
+
46
+ ```python
47
+ from switchbox import Client
48
+
49
+ client = Client(sdk_key="your-sdk-key-from-dashboard")
50
+
51
+ if client.enabled("dark_mode"):
52
+ enable_dark_mode()
53
+
54
+ client.close()
55
+ ```
56
+
57
+ ### String / Number Flags
58
+
59
+ ```python
60
+ version = client.get_value("search_algorithm", user={"user_id": "42"}, default="v1")
61
+ print(f"Using search {version}")
62
+
63
+ max_results = client.get_value("max_search_results", user={"user_id": "42"}, default=10)
64
+ ```
65
+
66
+ ### All Flags at Once
67
+
68
+ ```python
69
+ flags = client.get_all_flags(user={"user_id": "42"})
70
+ # {"dark_mode": True, "search_algorithm": "v2", "max_search_results": 50}
71
+ ```
72
+
73
+ ### Targeting Rules
74
+
75
+ Pass a `user` dict with attributes you want to target on. Rules are configured in the dashboard.
76
+
77
+ ```python
78
+ user = {
79
+ "user_id": "42",
80
+ "email": "alice@company.com",
81
+ "plan": "enterprise",
82
+ "age": "30",
83
+ }
84
+
85
+ # Flag with rule: email ends_with "@company.com"
86
+ client.enabled("internal_tools", user=user) # True
87
+
88
+ # Flag with rule: plan equals "enterprise"
89
+ client.enabled("advanced_analytics", user=user) # True
90
+
91
+ # Flag with rule: plan in_list ["pro", "enterprise"]
92
+ client.enabled("export_csv", user=user) # True
93
+ ```
94
+
95
+ Supported operators: `equals`, `not_equals`, `contains`, `ends_with`, `in_list`, `gt`, `lt`.
96
+
97
+ Rules use OR logic — if any rule matches, the flag is on for that user.
98
+
99
+ ### Percentage Rollouts
100
+
101
+ Rollouts use deterministic hashing (`sha256(user_id:flag_key) % 100`). The same user always gets the same result for a given flag — no flickering between requests.
102
+
103
+ ```python
104
+ # Flag with rollout_pct=25 — 25% of users get this flag
105
+ client.enabled("new_onboarding", user={"user_id": "42"}) # deterministic True/False
106
+ ```
107
+
108
+ A `user_id` (or `id`) key is required in the user dict for percentage rollouts.
109
+
110
+ ### Offline / Fail-safe Behavior
111
+
112
+ If the CDN is unreachable, the SDK keeps using the last successfully fetched config. Your flags keep working.
113
+
114
+ If the SDK has never successfully fetched a config (e.g., CDN is down on first startup), `enabled()` returns `False` and `get_value()` returns the `default` you pass in. No exceptions are raised.
115
+
116
+ ### Context Manager
117
+
118
+ ```python
119
+ with Client(sdk_key="your-sdk-key-from-dashboard") as client:
120
+ if client.enabled("new_checkout", user={"user_id": "42"}):
121
+ show_new_checkout()
122
+ # client.close() is called automatically
123
+ ```
124
+
125
+ ## Configuration
126
+
127
+ ```python
128
+ client = Client(
129
+ sdk_key="your-sdk-key-from-dashboard", # required — get from Environments tab
130
+ poll_interval=60, # seconds between polls (default: 30)
131
+ on_error=lambda e: logger.warning(e), # called on fetch errors (default: None)
132
+ )
133
+ ```
134
+
135
+ | Parameter | Type | Default | Description |
136
+ |-----------------|--------------------------------|---------|------------------------------------------------|
137
+ | `sdk_key` | `str` | — | SDK key from the environment in the dashboard |
138
+ | `poll_interval` | `int` | `30` | Seconds between background config refreshes |
139
+ | `on_error` | `Callable[[Exception], None]` | `None` | Callback invoked when a fetch or parse fails |
140
+
141
+ The SDK builds the CDN URL automatically from the SDK key. You can override with `cdn_base_url` if self-hosting.
142
+
143
+ ## How It Works
144
+
145
+ ```
146
+ ┌──────────┐ ┌──────────┐ ┌─────────────┐
147
+ │Dashboard │──────>│ API │──────>│ Postgres │
148
+ │ │ HTTP │ (Fly.io) │ SQL │ (Neon) │
149
+ └──────────┘ └────┬─────┘ └─────────────┘
150
+
151
+ │ publish on every change
152
+ v
153
+ ┌─────────────┐ ┌──────────────┐
154
+ │CDN Publisher│──────>│Cloudflare R2 │
155
+ │ │ PUT │(static JSON) │
156
+ └─────────────┘ └──────┬───────┘
157
+
158
+ │ HTTP GET (SDK polls)
159
+ v
160
+ ┌──────────────┐
161
+ │ Your App │
162
+ │ (this SDK) │
163
+ └──────────────┘
164
+ ```
165
+
166
+ 1. You create and toggle flags in the dashboard or API
167
+ 2. On every change, the API generates a static JSON file and uploads it to Cloudflare R2
168
+ 3. This SDK polls that JSON file from the CDN every 30 seconds
169
+ 4. Flag evaluation (rules, rollouts) happens locally — no network call per flag check
170
+
171
+ The API server is only in the write path. All read traffic goes to the CDN.
172
+
173
+ ## API Reference
174
+
175
+ ### `Client(sdk_key, poll_interval=30, on_error=None)`
176
+
177
+ Creates a new client. Performs an initial synchronous fetch on creation, then starts background polling.
178
+
179
+ ### `client.enabled(flag_key, user=None) -> bool`
180
+
181
+ Check if a boolean flag is enabled. Returns `False` if the flag doesn't exist.
182
+
183
+ | Parameter | Type | Description |
184
+ |------------|----------------|--------------------------------------|
185
+ | `flag_key` | `str` | The flag key to check |
186
+ | `user` | `dict \| None` | User context for targeting/rollouts |
187
+
188
+ ### `client.get_value(flag_key, user=None, default=None) -> Any`
189
+
190
+ Get the resolved value of any flag type (string, number, JSON). Returns `default` if the flag doesn't exist.
191
+
192
+ | Parameter | Type | Description |
193
+ |------------|----------------|--------------------------------------|
194
+ | `flag_key` | `str` | The flag key to check |
195
+ | `user` | `dict \| None` | User context for targeting/rollouts |
196
+ | `default` | `Any` | Value returned if flag doesn't exist |
197
+
198
+ ### `client.get_all_flags(user=None) -> dict[str, Any]`
199
+
200
+ Get all flag values resolved for a user. Returns an empty dict if no config is available.
201
+
202
+ ### `client.close() -> None`
203
+
204
+ Stop background polling. Call this on application shutdown.
205
+
206
+ ## Contributing
207
+
208
+ ```sh
209
+ git clone https://github.com/ignat14/switchbox-sdk-python.git
210
+ cd switchbox-sdk-python
211
+ pip install -e ".[dev]"
212
+ pytest
213
+ ruff check .
214
+ ```
215
+
216
+ ## License
217
+
218
+ MIT
@@ -4,11 +4,11 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "switchbox-flags"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "Feature flag SDK with zero dependencies"
9
9
  readme = "README.md"
10
10
  license = "MIT"
11
- requires-python = ">=3.14"
11
+ requires-python = ">=3.10"
12
12
  authors = [{ name = "Switchbox" }]
13
13
  keywords = ["feature-flags", "feature-toggles", "sdk"]
14
14
  classifiers = [
@@ -16,7 +16,9 @@ classifiers = [
16
16
  "Intended Audience :: Developers",
17
17
  "License :: OSI Approved :: MIT License",
18
18
  "Programming Language :: Python :: 3",
19
- "Programming Language :: Python :: 3.14",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
20
22
  "Typing :: Typed",
21
23
  ]
22
24
 
@@ -32,11 +34,18 @@ packages = ["switchbox"]
32
34
  testpaths = ["tests"]
33
35
 
34
36
  [tool.ruff]
35
- target-version = "py314"
37
+ target-version = "py310"
36
38
  line-length = 100
37
39
 
38
40
  [tool.ruff.lint]
39
41
  select = ["E", "F", "I", "W"]
40
42
 
43
+ [dependency-groups]
44
+ dev = [
45
+ "pytest>=9.0.2",
46
+ "pytest-cov>=7.0.0",
47
+ "ruff>=0.15.5",
48
+ ]
49
+
41
50
  [project.optional-dependencies]
42
- dev = ["pytest>=7", "ruff>=0.4"]
51
+ dev = ["pytest>=7", "ruff>=0.4", "pytest-cov>=6.0"]
@@ -4,6 +4,8 @@ from switchbox.cache import FlagCache
4
4
  from switchbox.evaluator import evaluate
5
5
  from switchbox.sync import SyncWorker
6
6
 
7
+ CDN_BASE_URL = "https://cdn.switchbox.dev"
8
+
7
9
 
8
10
  class Client:
9
11
  """Switchbox feature flag client.
@@ -12,28 +14,37 @@ class Client:
12
14
 
13
15
  Usage::
14
16
 
15
- client = Client(cdn_url="https://cdn.example.com/proj/production/flags.json")
17
+ client = Client(sdk_key="your-sdk-key")
16
18
  if client.enabled("new_feature", user={"user_id": "42"}):
17
19
  ...
18
20
  client.close()
19
21
 
20
22
  Or as a context manager::
21
23
 
22
- with Client(cdn_url="...") as client:
24
+ with Client(sdk_key="your-sdk-key") as client:
23
25
  if client.enabled("new_feature"):
24
26
  ...
25
27
  """
26
28
 
27
29
  def __init__(
28
30
  self,
29
- cdn_url: str,
31
+ sdk_key: str,
30
32
  poll_interval: int = 30,
31
33
  on_error: Callable[[Exception], None] | None = None,
34
+ timeout: int = 10,
35
+ cdn_base_url: str | None = None,
32
36
  ) -> None:
37
+ base = cdn_base_url or CDN_BASE_URL
38
+ cdn_url = f"{base}/{sdk_key}/flags.json"
33
39
  self._cache = FlagCache()
34
- self._sync = SyncWorker(cdn_url, self._cache, poll_interval, on_error)
40
+ self._sync = SyncWorker(cdn_url, self._cache, poll_interval, on_error, timeout=timeout)
35
41
  self._sync.start()
36
42
 
43
+ @property
44
+ def ready(self) -> bool:
45
+ """Return True when configs have been loaded at least once."""
46
+ return self._cache.get_config() is not None
47
+
37
48
  def enabled(self, flag_key: str, user: dict | None = None) -> bool:
38
49
  """Check if a boolean flag is enabled for a user.
39
50
 
@@ -21,35 +21,38 @@ def evaluate(flag: Flag, user_context: dict | None = None) -> bool | str | int |
21
21
  4. Rollout percentage check → enabled value or default_value
22
22
  5. Nothing matched → default_value
23
23
  """
24
- # 1. Disabled flag always returns default
25
- if not flag.enabled:
26
- return flag.default_value
27
-
28
- # 2. No user context
29
- if not user_context:
30
- if flag.rollout_pct == 100:
31
- return _enabled_value(flag)
32
- return flag.default_value
33
-
34
- # 3. Check rules (OR logic — any match wins)
35
- if flag.rules:
36
- for rule in flag.rules:
37
- if _match_rule(rule, user_context):
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:
38
49
  return _enabled_value(flag)
50
+ return flag.default_value
39
51
 
40
- # 4. Rollout percentage
41
- user_id = user_context.get("user_id") or user_context.get("id")
42
- if user_id is not None:
43
- if _check_rollout(str(user_id), flag.key, flag.rollout_pct):
44
- return _enabled_value(flag)
45
- else:
46
- # No user ID for hashing — can only serve 100% rollouts
47
- if flag.rollout_pct == 100:
48
- return _enabled_value(flag)
52
+ # 5. Nothing matched
53
+ return flag.default_value
54
+ except Exception:
49
55
  return flag.default_value
50
-
51
- # 5. Nothing matched
52
- return flag.default_value
53
56
 
54
57
 
55
58
  def _enabled_value(flag: Flag) -> bool | str | int | Any:
@@ -66,6 +69,9 @@ def _match_rule(rule: Rule, user_context: dict) -> bool:
66
69
 
67
70
  context_value = user_context[rule.attribute]
68
71
 
72
+ if context_value is None:
73
+ return False
74
+
69
75
  if rule.operator == "equals":
70
76
  return str(context_value) == str(rule.value)
71
77
  elif rule.operator == "not_equals":