switchbox-flags 0.1.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 (28) hide show
  1. switchbox_flags-0.3.0/.coverage +0 -0
  2. {switchbox_flags-0.1.0 → switchbox_flags-0.3.0}/.github/workflows/test.yml +3 -3
  3. switchbox_flags-0.3.0/PKG-INFO +244 -0
  4. switchbox_flags-0.3.0/README.md +218 -0
  5. {switchbox_flags-0.1.0 → switchbox_flags-0.3.0}/pyproject.toml +14 -5
  6. switchbox_flags-0.3.0/switchbox/__init__.py +5 -0
  7. {switchbox_flags-0.1.0 → switchbox_flags-0.3.0}/switchbox/client.py +32 -15
  8. switchbox_flags-0.3.0/switchbox/evaluator.py +135 -0
  9. {switchbox_flags-0.1.0 → switchbox_flags-0.3.0}/switchbox/models.py +2 -0
  10. {switchbox_flags-0.1.0 → switchbox_flags-0.3.0}/switchbox/sync.py +27 -9
  11. switchbox_flags-0.3.0/tests/parity_vectors.json +33 -0
  12. {switchbox_flags-0.1.0 → switchbox_flags-0.3.0}/tests/test_cache.py +6 -0
  13. switchbox_flags-0.3.0/tests/test_client.py +141 -0
  14. {switchbox_flags-0.1.0 → switchbox_flags-0.3.0}/tests/test_evaluator.py +101 -0
  15. switchbox_flags-0.3.0/tests/test_models.py +81 -0
  16. switchbox_flags-0.3.0/tests/test_parity_vectors.py +55 -0
  17. switchbox_flags-0.3.0/uv.lock +332 -0
  18. switchbox_flags-0.1.0/PKG-INFO +0 -90
  19. switchbox_flags-0.1.0/README.md +0 -67
  20. switchbox_flags-0.1.0/switchbox/__init__.py +0 -5
  21. switchbox_flags-0.1.0/switchbox/evaluator.py +0 -98
  22. switchbox_flags-0.1.0/tests/test_client.py +0 -64
  23. {switchbox_flags-0.1.0 → switchbox_flags-0.3.0}/.github/workflows/publish.yml +0 -0
  24. {switchbox_flags-0.1.0 → switchbox_flags-0.3.0}/.gitignore +0 -0
  25. {switchbox_flags-0.1.0 → switchbox_flags-0.3.0}/LICENSE +0 -0
  26. {switchbox_flags-0.1.0 → switchbox_flags-0.3.0}/switchbox/cache.py +0 -0
  27. {switchbox_flags-0.1.0 → switchbox_flags-0.3.0}/switchbox/exceptions.py +0 -0
  28. {switchbox_flags-0.1.0 → switchbox_flags-0.3.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.3.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 Switchbox
49
+
50
+ client = Switchbox(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 Switchbox(...) as client:` for automatic cleanup
67
+
68
+ ## Usage
69
+
70
+ ### Boolean Flags
71
+
72
+ ```python
73
+ from switchbox import Switchbox
74
+
75
+ client = Switchbox(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 Switchbox(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 = Switchbox(
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
+ ### `Switchbox(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 Switchbox
23
+
24
+ client = Switchbox(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 Switchbox(...) as client:` for automatic cleanup
41
+
42
+ ## Usage
43
+
44
+ ### Boolean Flags
45
+
46
+ ```python
47
+ from switchbox import Switchbox
48
+
49
+ client = Switchbox(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 Switchbox(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 = Switchbox(
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
+ ### `Switchbox(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.3.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"]
@@ -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,49 +1,69 @@
1
+ from __future__ import annotations
2
+
1
3
  from typing import Any, Callable
2
4
 
3
5
  from switchbox.cache import FlagCache
4
6
  from switchbox.evaluator import evaluate
5
7
  from switchbox.sync import SyncWorker
6
8
 
9
+ CDN_BASE_URL = "https://cdn.switchbox.dev"
10
+
7
11
 
8
- class Client:
12
+ class Switchbox:
9
13
  """Switchbox feature flag client.
10
14
 
11
15
  Fetches flag configs from a CDN and evaluates them locally.
12
16
 
13
17
  Usage::
14
18
 
15
- client = Client(cdn_url="https://cdn.example.com/proj/production/flags.json")
19
+ client = Switchbox(sdk_key="your-sdk-key")
16
20
  if client.enabled("new_feature", user={"user_id": "42"}):
17
21
  ...
18
22
  client.close()
19
23
 
20
24
  Or as a context manager::
21
25
 
22
- with Client(cdn_url="...") as client:
26
+ with Switchbox(sdk_key="your-sdk-key") as client:
23
27
  if client.enabled("new_feature"):
24
28
  ...
25
29
  """
26
30
 
27
31
  def __init__(
28
32
  self,
29
- cdn_url: str,
33
+ sdk_key: str,
30
34
  poll_interval: int = 30,
31
35
  on_error: Callable[[Exception], None] | None = None,
36
+ timeout: int = 10,
37
+ cdn_base_url: str | None = None,
32
38
  ) -> None:
39
+ base = cdn_base_url or CDN_BASE_URL
40
+ cdn_url = f"{base}/{sdk_key}/flags.json"
33
41
  self._cache = FlagCache()
34
- self._sync = SyncWorker(cdn_url, self._cache, poll_interval, on_error)
42
+ self._sync = SyncWorker(cdn_url, self._cache, poll_interval, on_error, timeout=timeout)
35
43
  self._sync.start()
36
44
 
45
+ @property
46
+ def ready(self) -> bool:
47
+ """Return True when configs have been loaded at least once."""
48
+ return self._cache.get_config() is not None
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
+
37
61
  def enabled(self, flag_key: str, user: dict | None = None) -> bool:
38
62
  """Check if a boolean flag is enabled for a user.
39
63
 
40
64
  Returns False if the flag doesn't exist (safe default).
41
65
  """
42
- flag = self._cache.get_flag(flag_key)
43
- if flag is None:
44
- return False
45
- result = evaluate(flag, user)
46
- return bool(result)
66
+ return bool(self._eval_flag(flag_key, user, False))
47
67
 
48
68
  def get_value(
49
69
  self, flag_key: str, user: dict | None = None, default: Any = None
@@ -52,10 +72,7 @@ class Client:
52
72
 
53
73
  Returns *default* if the flag doesn't exist.
54
74
  """
55
- flag = self._cache.get_flag(flag_key)
56
- if flag is None:
57
- return default
58
- return evaluate(flag, user)
75
+ return self._eval_flag(flag_key, user, default)
59
76
 
60
77
  def get_all_flags(self, user: dict | None = None) -> dict[str, Any]:
61
78
  """Get all flag values resolved for a user."""
@@ -68,7 +85,7 @@ class Client:
68
85
  """Stop the background sync. Call on shutdown."""
69
86
  self._sync.stop()
70
87
 
71
- def __enter__(self) -> Client:
88
+ def __enter__(self) -> Switchbox:
72
89
  return self
73
90
 
74
91
  def __exit__(self, *args: object) -> None: