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.
- switchbox_flags-0.3.0/.coverage +0 -0
- {switchbox_flags-0.1.0 → switchbox_flags-0.3.0}/.github/workflows/test.yml +3 -3
- switchbox_flags-0.3.0/PKG-INFO +244 -0
- switchbox_flags-0.3.0/README.md +218 -0
- {switchbox_flags-0.1.0 → switchbox_flags-0.3.0}/pyproject.toml +14 -5
- switchbox_flags-0.3.0/switchbox/__init__.py +5 -0
- {switchbox_flags-0.1.0 → switchbox_flags-0.3.0}/switchbox/client.py +32 -15
- switchbox_flags-0.3.0/switchbox/evaluator.py +135 -0
- {switchbox_flags-0.1.0 → switchbox_flags-0.3.0}/switchbox/models.py +2 -0
- {switchbox_flags-0.1.0 → switchbox_flags-0.3.0}/switchbox/sync.py +27 -9
- switchbox_flags-0.3.0/tests/parity_vectors.json +33 -0
- {switchbox_flags-0.1.0 → switchbox_flags-0.3.0}/tests/test_cache.py +6 -0
- switchbox_flags-0.3.0/tests/test_client.py +141 -0
- {switchbox_flags-0.1.0 → switchbox_flags-0.3.0}/tests/test_evaluator.py +101 -0
- switchbox_flags-0.3.0/tests/test_models.py +81 -0
- switchbox_flags-0.3.0/tests/test_parity_vectors.py +55 -0
- switchbox_flags-0.3.0/uv.lock +332 -0
- switchbox_flags-0.1.0/PKG-INFO +0 -90
- switchbox_flags-0.1.0/README.md +0 -67
- switchbox_flags-0.1.0/switchbox/__init__.py +0 -5
- switchbox_flags-0.1.0/switchbox/evaluator.py +0 -98
- switchbox_flags-0.1.0/tests/test_client.py +0 -64
- {switchbox_flags-0.1.0 → switchbox_flags-0.3.0}/.github/workflows/publish.yml +0 -0
- {switchbox_flags-0.1.0 → switchbox_flags-0.3.0}/.gitignore +0 -0
- {switchbox_flags-0.1.0 → switchbox_flags-0.3.0}/LICENSE +0 -0
- {switchbox_flags-0.1.0 → switchbox_flags-0.3.0}/switchbox/cache.py +0 -0
- {switchbox_flags-0.1.0 → switchbox_flags-0.3.0}/switchbox/exceptions.py +0 -0
- {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
|
+
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
|
+
[](https://pypi.org/project/switchbox-flags/)
|
|
32
|
+
[](https://pypi.org/project/switchbox-flags/)
|
|
33
|
+
[](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
|
+
[](https://pypi.org/project/switchbox-flags/)
|
|
6
|
+
[](https://pypi.org/project/switchbox-flags/)
|
|
7
|
+
[](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.
|
|
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.
|
|
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.
|
|
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 = "
|
|
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"]
|
|
@@ -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
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) ->
|
|
88
|
+
def __enter__(self) -> Switchbox:
|
|
72
89
|
return self
|
|
73
90
|
|
|
74
91
|
def __exit__(self, *args: object) -> None:
|