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.
- switchbox_flags-0.2.0/.coverage +0 -0
- {switchbox_flags-0.1.0 → switchbox_flags-0.2.0}/.github/workflows/test.yml +3 -3
- switchbox_flags-0.2.0/PKG-INFO +244 -0
- switchbox_flags-0.2.0/README.md +218 -0
- {switchbox_flags-0.1.0 → switchbox_flags-0.2.0}/pyproject.toml +14 -5
- {switchbox_flags-0.1.0 → switchbox_flags-0.2.0}/switchbox/client.py +15 -4
- {switchbox_flags-0.1.0 → switchbox_flags-0.2.0}/switchbox/evaluator.py +32 -26
- {switchbox_flags-0.1.0 → switchbox_flags-0.2.0}/switchbox/sync.py +26 -8
- {switchbox_flags-0.1.0 → switchbox_flags-0.2.0}/tests/test_cache.py +6 -0
- switchbox_flags-0.2.0/tests/test_client.py +138 -0
- {switchbox_flags-0.1.0 → switchbox_flags-0.2.0}/tests/test_evaluator.py +101 -0
- switchbox_flags-0.2.0/tests/test_models.py +81 -0
- switchbox_flags-0.2.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/tests/test_client.py +0 -64
- {switchbox_flags-0.1.0 → switchbox_flags-0.2.0}/.github/workflows/publish.yml +0 -0
- {switchbox_flags-0.1.0 → switchbox_flags-0.2.0}/.gitignore +0 -0
- {switchbox_flags-0.1.0 → switchbox_flags-0.2.0}/LICENSE +0 -0
- {switchbox_flags-0.1.0 → switchbox_flags-0.2.0}/switchbox/__init__.py +0 -0
- {switchbox_flags-0.1.0 → switchbox_flags-0.2.0}/switchbox/cache.py +0 -0
- {switchbox_flags-0.1.0 → switchbox_flags-0.2.0}/switchbox/exceptions.py +0 -0
- {switchbox_flags-0.1.0 → switchbox_flags-0.2.0}/switchbox/models.py +0 -0
- {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
|
+
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
|
+
[](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 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
|
+
[](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 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.
|
|
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.
|
|
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"]
|
|
@@ -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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
if
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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":
|