switchbox-flags 0.1.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.1.0/.github/workflows/publish.yml +29 -0
- switchbox_flags-0.1.0/.github/workflows/test.yml +29 -0
- switchbox_flags-0.1.0/.gitignore +15 -0
- switchbox_flags-0.1.0/LICENSE +21 -0
- switchbox_flags-0.1.0/PKG-INFO +90 -0
- switchbox_flags-0.1.0/README.md +67 -0
- switchbox_flags-0.1.0/pyproject.toml +42 -0
- switchbox_flags-0.1.0/switchbox/__init__.py +5 -0
- switchbox_flags-0.1.0/switchbox/cache.py +31 -0
- switchbox_flags-0.1.0/switchbox/client.py +75 -0
- switchbox_flags-0.1.0/switchbox/evaluator.py +98 -0
- switchbox_flags-0.1.0/switchbox/exceptions.py +10 -0
- switchbox_flags-0.1.0/switchbox/models.py +48 -0
- switchbox_flags-0.1.0/switchbox/sync.py +76 -0
- switchbox_flags-0.1.0/tests/__init__.py +0 -0
- switchbox_flags-0.1.0/tests/test_cache.py +81 -0
- switchbox_flags-0.1.0/tests/test_client.py +64 -0
- switchbox_flags-0.1.0/tests/test_evaluator.py +144 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*.*.*"
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
id-token: write
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
publish:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
environment: pypi
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
|
|
18
|
+
- uses: actions/setup-python@v5
|
|
19
|
+
with:
|
|
20
|
+
python-version: "3.14"
|
|
21
|
+
|
|
22
|
+
- name: Install build tools
|
|
23
|
+
run: pip install build
|
|
24
|
+
|
|
25
|
+
- name: Build package
|
|
26
|
+
run: python -m build
|
|
27
|
+
|
|
28
|
+
- name: Publish to PyPI
|
|
29
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
name: Tests
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: ["**"]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
strategy:
|
|
13
|
+
matrix:
|
|
14
|
+
python-version: ["3.14"]
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
|
|
18
|
+
- uses: actions/setup-python@v5
|
|
19
|
+
with:
|
|
20
|
+
python-version: ${{ matrix.python-version }}
|
|
21
|
+
|
|
22
|
+
- name: Install dependencies
|
|
23
|
+
run: pip install -e ".[dev]"
|
|
24
|
+
|
|
25
|
+
- name: Lint
|
|
26
|
+
run: ruff check .
|
|
27
|
+
|
|
28
|
+
- name: Test
|
|
29
|
+
run: pytest -v
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Switchbox
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: switchbox-flags
|
|
3
|
+
Version: 0.1.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.14
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Python: >=3.14
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
21
|
+
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# Switchbox
|
|
25
|
+
|
|
26
|
+
Feature flag SDK for Python. Zero dependencies. Reads configs from a CDN.
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
pip install switchbox-flags
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Quick start
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from switchbox import Client
|
|
38
|
+
|
|
39
|
+
client = Client(cdn_url="https://your-cdn.r2.dev/project_id/production/flags.json")
|
|
40
|
+
|
|
41
|
+
# Boolean flag
|
|
42
|
+
if client.enabled("new_checkout", user={"user_id": "42", "email": "a@b.com"}):
|
|
43
|
+
show_new_checkout()
|
|
44
|
+
|
|
45
|
+
# String flag
|
|
46
|
+
version = client.get_value("search_version", user={"user_id": "42"}, default="v1")
|
|
47
|
+
|
|
48
|
+
# All flags at once
|
|
49
|
+
flags = client.get_all_flags(user={"user_id": "42"})
|
|
50
|
+
|
|
51
|
+
# Cleanup
|
|
52
|
+
client.close()
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Or use as a context manager:
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
with Client(cdn_url="https://your-cdn.r2.dev/project_id/production/flags.json") as client:
|
|
59
|
+
if client.enabled("new_checkout", user={"user_id": "42"}):
|
|
60
|
+
show_new_checkout()
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## How it works
|
|
64
|
+
|
|
65
|
+
- Fetches flag configs from CDN (static JSON, no server in the loop)
|
|
66
|
+
- Evaluates rules locally (sub-millisecond)
|
|
67
|
+
- Polls for updates every 30 seconds (configurable)
|
|
68
|
+
- Works offline — keeps using cached configs if CDN is unreachable
|
|
69
|
+
- Zero runtime dependencies — only Python stdlib
|
|
70
|
+
|
|
71
|
+
## Configuration
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
client = Client(
|
|
75
|
+
cdn_url="https://your-cdn.r2.dev/project_id/production/flags.json",
|
|
76
|
+
poll_interval=60, # poll every 60 seconds (default: 30)
|
|
77
|
+
on_error=lambda e: print(e), # optional error callback
|
|
78
|
+
)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Evaluation logic
|
|
82
|
+
|
|
83
|
+
1. **Disabled flag** — returns `default_value`
|
|
84
|
+
2. **Rules** — if any rule matches the user context (OR logic), the flag is on
|
|
85
|
+
3. **Rollout** — deterministic percentage based on `sha256(user_id:flag_key)`
|
|
86
|
+
4. **Fallback** — returns `default_value`
|
|
87
|
+
|
|
88
|
+
## License
|
|
89
|
+
|
|
90
|
+
MIT
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# Switchbox
|
|
2
|
+
|
|
3
|
+
Feature flag SDK for Python. Zero dependencies. Reads configs from a CDN.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
pip install switchbox-flags
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick start
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from switchbox import Client
|
|
15
|
+
|
|
16
|
+
client = Client(cdn_url="https://your-cdn.r2.dev/project_id/production/flags.json")
|
|
17
|
+
|
|
18
|
+
# Boolean flag
|
|
19
|
+
if client.enabled("new_checkout", user={"user_id": "42", "email": "a@b.com"}):
|
|
20
|
+
show_new_checkout()
|
|
21
|
+
|
|
22
|
+
# String flag
|
|
23
|
+
version = client.get_value("search_version", user={"user_id": "42"}, default="v1")
|
|
24
|
+
|
|
25
|
+
# All flags at once
|
|
26
|
+
flags = client.get_all_flags(user={"user_id": "42"})
|
|
27
|
+
|
|
28
|
+
# Cleanup
|
|
29
|
+
client.close()
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Or use as a context manager:
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
with Client(cdn_url="https://your-cdn.r2.dev/project_id/production/flags.json") as client:
|
|
36
|
+
if client.enabled("new_checkout", user={"user_id": "42"}):
|
|
37
|
+
show_new_checkout()
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## How it works
|
|
41
|
+
|
|
42
|
+
- Fetches flag configs from CDN (static JSON, no server in the loop)
|
|
43
|
+
- Evaluates rules locally (sub-millisecond)
|
|
44
|
+
- Polls for updates every 30 seconds (configurable)
|
|
45
|
+
- Works offline — keeps using cached configs if CDN is unreachable
|
|
46
|
+
- Zero runtime dependencies — only Python stdlib
|
|
47
|
+
|
|
48
|
+
## Configuration
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
client = Client(
|
|
52
|
+
cdn_url="https://your-cdn.r2.dev/project_id/production/flags.json",
|
|
53
|
+
poll_interval=60, # poll every 60 seconds (default: 30)
|
|
54
|
+
on_error=lambda e: print(e), # optional error callback
|
|
55
|
+
)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Evaluation logic
|
|
59
|
+
|
|
60
|
+
1. **Disabled flag** — returns `default_value`
|
|
61
|
+
2. **Rules** — if any rule matches the user context (OR logic), the flag is on
|
|
62
|
+
3. **Rollout** — deterministic percentage based on `sha256(user_id:flag_key)`
|
|
63
|
+
4. **Fallback** — returns `default_value`
|
|
64
|
+
|
|
65
|
+
## License
|
|
66
|
+
|
|
67
|
+
MIT
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "switchbox-flags"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Feature flag SDK with zero dependencies"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.14"
|
|
12
|
+
authors = [{ name = "Switchbox" }]
|
|
13
|
+
keywords = ["feature-flags", "feature-toggles", "sdk"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.14",
|
|
20
|
+
"Typing :: Typed",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://github.com/ignat14/switchbox-sdk-python"
|
|
25
|
+
Repository = "https://github.com/ignat14/switchbox-sdk-python"
|
|
26
|
+
Issues = "https://github.com/ignat14/switchbox-sdk-python/issues"
|
|
27
|
+
|
|
28
|
+
[tool.hatch.build.targets.wheel]
|
|
29
|
+
packages = ["switchbox"]
|
|
30
|
+
|
|
31
|
+
[tool.pytest.ini_options]
|
|
32
|
+
testpaths = ["tests"]
|
|
33
|
+
|
|
34
|
+
[tool.ruff]
|
|
35
|
+
target-version = "py314"
|
|
36
|
+
line-length = 100
|
|
37
|
+
|
|
38
|
+
[tool.ruff.lint]
|
|
39
|
+
select = ["E", "F", "I", "W"]
|
|
40
|
+
|
|
41
|
+
[project.optional-dependencies]
|
|
42
|
+
dev = ["pytest>=7", "ruff>=0.4"]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
|
|
3
|
+
from switchbox.models import Flag, FlagConfig
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class FlagCache:
|
|
7
|
+
"""Thread-safe in-memory store for flag configs."""
|
|
8
|
+
|
|
9
|
+
def __init__(self) -> None:
|
|
10
|
+
self._config: FlagConfig | None = None
|
|
11
|
+
self._lock = threading.Lock()
|
|
12
|
+
|
|
13
|
+
def get_config(self) -> FlagConfig | None:
|
|
14
|
+
with self._lock:
|
|
15
|
+
return self._config
|
|
16
|
+
|
|
17
|
+
def set_config(self, config: FlagConfig) -> None:
|
|
18
|
+
with self._lock:
|
|
19
|
+
self._config = config
|
|
20
|
+
|
|
21
|
+
def get_flag(self, key: str) -> Flag | None:
|
|
22
|
+
with self._lock:
|
|
23
|
+
if self._config is None:
|
|
24
|
+
return None
|
|
25
|
+
return self._config.flags.get(key)
|
|
26
|
+
|
|
27
|
+
def get_version(self) -> str | None:
|
|
28
|
+
with self._lock:
|
|
29
|
+
if self._config is None:
|
|
30
|
+
return None
|
|
31
|
+
return self._config.version
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from typing import Any, Callable
|
|
2
|
+
|
|
3
|
+
from switchbox.cache import FlagCache
|
|
4
|
+
from switchbox.evaluator import evaluate
|
|
5
|
+
from switchbox.sync import SyncWorker
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Client:
|
|
9
|
+
"""Switchbox feature flag client.
|
|
10
|
+
|
|
11
|
+
Fetches flag configs from a CDN and evaluates them locally.
|
|
12
|
+
|
|
13
|
+
Usage::
|
|
14
|
+
|
|
15
|
+
client = Client(cdn_url="https://cdn.example.com/proj/production/flags.json")
|
|
16
|
+
if client.enabled("new_feature", user={"user_id": "42"}):
|
|
17
|
+
...
|
|
18
|
+
client.close()
|
|
19
|
+
|
|
20
|
+
Or as a context manager::
|
|
21
|
+
|
|
22
|
+
with Client(cdn_url="...") as client:
|
|
23
|
+
if client.enabled("new_feature"):
|
|
24
|
+
...
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
cdn_url: str,
|
|
30
|
+
poll_interval: int = 30,
|
|
31
|
+
on_error: Callable[[Exception], None] | None = None,
|
|
32
|
+
) -> None:
|
|
33
|
+
self._cache = FlagCache()
|
|
34
|
+
self._sync = SyncWorker(cdn_url, self._cache, poll_interval, on_error)
|
|
35
|
+
self._sync.start()
|
|
36
|
+
|
|
37
|
+
def enabled(self, flag_key: str, user: dict | None = None) -> bool:
|
|
38
|
+
"""Check if a boolean flag is enabled for a user.
|
|
39
|
+
|
|
40
|
+
Returns False if the flag doesn't exist (safe default).
|
|
41
|
+
"""
|
|
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)
|
|
47
|
+
|
|
48
|
+
def get_value(
|
|
49
|
+
self, flag_key: str, user: dict | None = None, default: Any = None
|
|
50
|
+
) -> Any:
|
|
51
|
+
"""Get the resolved value of any flag type.
|
|
52
|
+
|
|
53
|
+
Returns *default* if the flag doesn't exist.
|
|
54
|
+
"""
|
|
55
|
+
flag = self._cache.get_flag(flag_key)
|
|
56
|
+
if flag is None:
|
|
57
|
+
return default
|
|
58
|
+
return evaluate(flag, user)
|
|
59
|
+
|
|
60
|
+
def get_all_flags(self, user: dict | None = None) -> dict[str, Any]:
|
|
61
|
+
"""Get all flag values resolved for a user."""
|
|
62
|
+
config = self._cache.get_config()
|
|
63
|
+
if config is None:
|
|
64
|
+
return {}
|
|
65
|
+
return {key: evaluate(flag, user) for key, flag in config.flags.items()}
|
|
66
|
+
|
|
67
|
+
def close(self) -> None:
|
|
68
|
+
"""Stop the background sync. Call on shutdown."""
|
|
69
|
+
self._sync.stop()
|
|
70
|
+
|
|
71
|
+
def __enter__(self) -> Client:
|
|
72
|
+
return self
|
|
73
|
+
|
|
74
|
+
def __exit__(self, *args: object) -> None:
|
|
75
|
+
self.close()
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Pure evaluation engine for Switchbox feature flags.
|
|
2
|
+
|
|
3
|
+
Zero external dependencies — only Python stdlib.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import hashlib
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from switchbox.models import Flag, Rule
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def evaluate(flag: Flag, user_context: dict | None = None) -> bool | str | int | Any:
|
|
13
|
+
"""Evaluate a flag for a given user context.
|
|
14
|
+
|
|
15
|
+
Returns the flag's resolved value.
|
|
16
|
+
|
|
17
|
+
Evaluation order:
|
|
18
|
+
1. Flag disabled → default_value
|
|
19
|
+
2. No user context → enabled value if rollout == 100, else default_value
|
|
20
|
+
3. Rules match (OR logic) → enabled value
|
|
21
|
+
4. Rollout percentage check → enabled value or default_value
|
|
22
|
+
5. Nothing matched → default_value
|
|
23
|
+
"""
|
|
24
|
+
# 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):
|
|
38
|
+
return _enabled_value(flag)
|
|
39
|
+
|
|
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)
|
|
49
|
+
return flag.default_value
|
|
50
|
+
|
|
51
|
+
# 5. Nothing matched
|
|
52
|
+
return flag.default_value
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _enabled_value(flag: Flag) -> bool | str | int | Any:
|
|
56
|
+
"""Return the appropriate 'enabled' value based on flag type."""
|
|
57
|
+
if flag.flag_type == "boolean":
|
|
58
|
+
return True
|
|
59
|
+
return flag.default_value
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _match_rule(rule: Rule, user_context: dict) -> bool:
|
|
63
|
+
"""Check if a single rule matches the user context."""
|
|
64
|
+
if rule.attribute not in user_context:
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
context_value = user_context[rule.attribute]
|
|
68
|
+
|
|
69
|
+
if rule.operator == "equals":
|
|
70
|
+
return str(context_value) == str(rule.value)
|
|
71
|
+
elif rule.operator == "not_equals":
|
|
72
|
+
return str(context_value) != str(rule.value)
|
|
73
|
+
elif rule.operator == "contains":
|
|
74
|
+
return str(rule.value) in str(context_value)
|
|
75
|
+
elif rule.operator == "ends_with":
|
|
76
|
+
return str(context_value).endswith(str(rule.value))
|
|
77
|
+
elif rule.operator == "in_list":
|
|
78
|
+
return str(context_value) in rule.value
|
|
79
|
+
elif rule.operator == "gt":
|
|
80
|
+
try:
|
|
81
|
+
return float(context_value) > float(rule.value)
|
|
82
|
+
except (ValueError, TypeError):
|
|
83
|
+
return False
|
|
84
|
+
elif rule.operator == "lt":
|
|
85
|
+
try:
|
|
86
|
+
return float(context_value) < float(rule.value)
|
|
87
|
+
except (ValueError, TypeError):
|
|
88
|
+
return False
|
|
89
|
+
|
|
90
|
+
return False
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _check_rollout(user_id: str, flag_key: str, rollout_pct: int) -> bool:
|
|
94
|
+
"""Deterministic percentage rollout using consistent hashing."""
|
|
95
|
+
hash_input = f"{user_id}:{flag_key}"
|
|
96
|
+
hash_value = int(hashlib.sha256(hash_input.encode()).hexdigest(), 16)
|
|
97
|
+
bucket = hash_value % 100
|
|
98
|
+
return bucket < rollout_pct
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
class SwitchboxError(Exception):
|
|
2
|
+
"""Base exception for Switchbox SDK."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ConfigFetchError(SwitchboxError):
|
|
6
|
+
"""Raised when fetching flag config from CDN fails."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class EvaluationError(SwitchboxError):
|
|
10
|
+
"""Raised when flag evaluation encounters an error."""
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass
|
|
6
|
+
class Rule:
|
|
7
|
+
attribute: str
|
|
8
|
+
operator: str # equals | not_equals | contains | ends_with | in_list | gt | lt
|
|
9
|
+
value: Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class Flag:
|
|
14
|
+
key: str
|
|
15
|
+
enabled: bool
|
|
16
|
+
rollout_pct: int
|
|
17
|
+
flag_type: str # boolean | string | number | json
|
|
18
|
+
default_value: Any
|
|
19
|
+
rules: list[Rule] = field(default_factory=list)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class FlagConfig:
|
|
24
|
+
version: str # ISO timestamp
|
|
25
|
+
flags: dict[str, Flag] = field(default_factory=dict)
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
def from_dict(cls, data: dict) -> FlagConfig:
|
|
29
|
+
"""Parse the CDN JSON into a FlagConfig object."""
|
|
30
|
+
flags = {}
|
|
31
|
+
for key, flag_data in data.get("flags", {}).items():
|
|
32
|
+
rules = [
|
|
33
|
+
Rule(
|
|
34
|
+
attribute=r["attribute"],
|
|
35
|
+
operator=r["operator"],
|
|
36
|
+
value=r["value"],
|
|
37
|
+
)
|
|
38
|
+
for r in flag_data.get("rules", [])
|
|
39
|
+
]
|
|
40
|
+
flags[key] = Flag(
|
|
41
|
+
key=key,
|
|
42
|
+
enabled=flag_data["enabled"],
|
|
43
|
+
rollout_pct=flag_data.get("rollout_pct", 0),
|
|
44
|
+
flag_type=flag_data.get("flag_type", "boolean"),
|
|
45
|
+
default_value=flag_data.get("default_value"),
|
|
46
|
+
rules=rules,
|
|
47
|
+
)
|
|
48
|
+
return cls(version=data.get("version", ""), flags=flags)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import threading
|
|
4
|
+
import urllib.request
|
|
5
|
+
from typing import Callable
|
|
6
|
+
|
|
7
|
+
from switchbox.cache import FlagCache
|
|
8
|
+
from switchbox.exceptions import ConfigFetchError
|
|
9
|
+
from switchbox.models import FlagConfig
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger("switchbox")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SyncWorker:
|
|
15
|
+
"""Background thread that polls the CDN for updated flag configs."""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
cdn_url: str,
|
|
20
|
+
cache: FlagCache,
|
|
21
|
+
interval: int = 30,
|
|
22
|
+
on_error: Callable[[Exception], None] | None = None,
|
|
23
|
+
) -> None:
|
|
24
|
+
self._cdn_url = cdn_url
|
|
25
|
+
self._cache = cache
|
|
26
|
+
self._interval = interval
|
|
27
|
+
self._on_error = on_error
|
|
28
|
+
self._stop_event = threading.Event()
|
|
29
|
+
self._thread: threading.Thread | None = None
|
|
30
|
+
|
|
31
|
+
def start(self) -> None:
|
|
32
|
+
"""Fetch configs synchronously first, then start background polling."""
|
|
33
|
+
# Initial synchronous fetch — block until we have configs
|
|
34
|
+
self._poll()
|
|
35
|
+
|
|
36
|
+
self._thread = threading.Thread(target=self._run, daemon=True)
|
|
37
|
+
self._thread.start()
|
|
38
|
+
|
|
39
|
+
def stop(self) -> None:
|
|
40
|
+
"""Stop the background polling thread gracefully."""
|
|
41
|
+
self._stop_event.set()
|
|
42
|
+
if self._thread is not None:
|
|
43
|
+
self._thread.join(timeout=5)
|
|
44
|
+
|
|
45
|
+
def _run(self) -> None:
|
|
46
|
+
"""Main loop for the background thread."""
|
|
47
|
+
while not self._stop_event.wait(timeout=self._interval):
|
|
48
|
+
self._poll()
|
|
49
|
+
|
|
50
|
+
def _poll(self) -> None:
|
|
51
|
+
"""Fetch config from CDN, parse, and update cache if changed."""
|
|
52
|
+
try:
|
|
53
|
+
req = urllib.request.Request(
|
|
54
|
+
self._cdn_url,
|
|
55
|
+
headers={"User-Agent": "switchbox-python/0.1.0"},
|
|
56
|
+
)
|
|
57
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
58
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
59
|
+
|
|
60
|
+
# Skip parsing if version hasn't changed
|
|
61
|
+
new_version = data.get("version", "")
|
|
62
|
+
current_version = self._cache.get_version()
|
|
63
|
+
if current_version and new_version == current_version:
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
config = FlagConfig.from_dict(data)
|
|
67
|
+
self._cache.set_config(config)
|
|
68
|
+
logger.debug("Updated flag config to version %s", config.version)
|
|
69
|
+
|
|
70
|
+
except Exception as exc:
|
|
71
|
+
logger.warning("Failed to fetch flag config from %s: %s", self._cdn_url, exc)
|
|
72
|
+
if self._on_error is not None:
|
|
73
|
+
try:
|
|
74
|
+
self._on_error(ConfigFetchError(str(exc)))
|
|
75
|
+
except Exception:
|
|
76
|
+
pass
|
|
File without changes
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
|
|
3
|
+
from switchbox.cache import FlagCache
|
|
4
|
+
from switchbox.models import Flag, FlagConfig
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _make_config(version="v1"):
|
|
8
|
+
return FlagConfig(
|
|
9
|
+
version=version,
|
|
10
|
+
flags={
|
|
11
|
+
"flag_a": Flag(
|
|
12
|
+
key="flag_a",
|
|
13
|
+
enabled=True,
|
|
14
|
+
rollout_pct=100,
|
|
15
|
+
flag_type="boolean",
|
|
16
|
+
default_value=False,
|
|
17
|
+
rules=[],
|
|
18
|
+
),
|
|
19
|
+
},
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_cache_starts_empty():
|
|
24
|
+
cache = FlagCache()
|
|
25
|
+
assert cache.get_config() is None
|
|
26
|
+
assert cache.get_version() is None
|
|
27
|
+
assert cache.get_flag("anything") is None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_set_and_get_config():
|
|
31
|
+
cache = FlagCache()
|
|
32
|
+
config = _make_config()
|
|
33
|
+
cache.set_config(config)
|
|
34
|
+
assert cache.get_config() is config
|
|
35
|
+
assert cache.get_version() == "v1"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_get_flag_returns_correct_flag():
|
|
39
|
+
cache = FlagCache()
|
|
40
|
+
cache.set_config(_make_config())
|
|
41
|
+
flag = cache.get_flag("flag_a")
|
|
42
|
+
assert flag is not None
|
|
43
|
+
assert flag.key == "flag_a"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_get_flag_returns_none_for_missing_key():
|
|
47
|
+
cache = FlagCache()
|
|
48
|
+
cache.set_config(_make_config())
|
|
49
|
+
assert cache.get_flag("nonexistent") is None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_thread_safety():
|
|
53
|
+
"""Concurrent reads and writes should not raise or corrupt data."""
|
|
54
|
+
cache = FlagCache()
|
|
55
|
+
errors = []
|
|
56
|
+
|
|
57
|
+
def writer():
|
|
58
|
+
try:
|
|
59
|
+
for i in range(200):
|
|
60
|
+
cache.set_config(_make_config(version=f"v{i}"))
|
|
61
|
+
except Exception as e:
|
|
62
|
+
errors.append(e)
|
|
63
|
+
|
|
64
|
+
def reader():
|
|
65
|
+
try:
|
|
66
|
+
for _ in range(200):
|
|
67
|
+
cache.get_config()
|
|
68
|
+
cache.get_flag("flag_a")
|
|
69
|
+
cache.get_version()
|
|
70
|
+
except Exception as e:
|
|
71
|
+
errors.append(e)
|
|
72
|
+
|
|
73
|
+
threads = [threading.Thread(target=writer) for _ in range(4)]
|
|
74
|
+
threads += [threading.Thread(target=reader) for _ in range(4)]
|
|
75
|
+
|
|
76
|
+
for t in threads:
|
|
77
|
+
t.start()
|
|
78
|
+
for t in threads:
|
|
79
|
+
t.join()
|
|
80
|
+
|
|
81
|
+
assert errors == []
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from unittest.mock import MagicMock, patch
|
|
3
|
+
|
|
4
|
+
from switchbox.client import Client
|
|
5
|
+
|
|
6
|
+
SAMPLE_CONFIG = {
|
|
7
|
+
"version": "2026-04-07T12:00:00Z",
|
|
8
|
+
"flags": {
|
|
9
|
+
"new_dashboard": {
|
|
10
|
+
"enabled": True,
|
|
11
|
+
"rollout_pct": 100,
|
|
12
|
+
"flag_type": "boolean",
|
|
13
|
+
"default_value": False,
|
|
14
|
+
"rules": [],
|
|
15
|
+
},
|
|
16
|
+
"search_version": {
|
|
17
|
+
"enabled": True,
|
|
18
|
+
"rollout_pct": 100,
|
|
19
|
+
"flag_type": "string",
|
|
20
|
+
"default_value": "v1",
|
|
21
|
+
"rules": [],
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _mock_urlopen(data):
|
|
28
|
+
"""Create a mock that mimics urllib.request.urlopen response."""
|
|
29
|
+
resp = MagicMock()
|
|
30
|
+
resp.read.return_value = json.dumps(data).encode("utf-8")
|
|
31
|
+
resp.__enter__ = lambda s: s
|
|
32
|
+
resp.__exit__ = MagicMock(return_value=False)
|
|
33
|
+
return resp
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@patch("switchbox.sync.urllib.request.urlopen")
|
|
37
|
+
def test_client_returns_false_for_nonexistent_flag(mock_urlopen):
|
|
38
|
+
mock_urlopen.return_value = _mock_urlopen(SAMPLE_CONFIG)
|
|
39
|
+
with Client(cdn_url="https://example.com/flags.json") as client:
|
|
40
|
+
assert client.enabled("nonexistent") is False
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@patch("switchbox.sync.urllib.request.urlopen")
|
|
44
|
+
def test_client_get_value_returns_default_for_nonexistent_flag(mock_urlopen):
|
|
45
|
+
mock_urlopen.return_value = _mock_urlopen(SAMPLE_CONFIG)
|
|
46
|
+
with Client(cdn_url="https://example.com/flags.json") as client:
|
|
47
|
+
assert client.get_value("nonexistent", default="fallback") == "fallback"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@patch("switchbox.sync.urllib.request.urlopen")
|
|
51
|
+
def test_client_works_with_mock_cdn(mock_urlopen):
|
|
52
|
+
mock_urlopen.return_value = _mock_urlopen(SAMPLE_CONFIG)
|
|
53
|
+
with Client(cdn_url="https://example.com/flags.json") as client:
|
|
54
|
+
assert client.enabled("new_dashboard", user={"user_id": "1"}) is True
|
|
55
|
+
assert client.get_value("search_version", user={"user_id": "1"}) == "v1"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@patch("switchbox.sync.urllib.request.urlopen")
|
|
59
|
+
def test_client_handles_cdn_failure_gracefully(mock_urlopen):
|
|
60
|
+
mock_urlopen.side_effect = Exception("Network error")
|
|
61
|
+
with Client(cdn_url="https://example.com/flags.json") as client:
|
|
62
|
+
# Should return safe defaults, not crash
|
|
63
|
+
assert client.enabled("new_dashboard") is False
|
|
64
|
+
assert client.get_value("search_version", default="v1") == "v1"
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
from switchbox.evaluator import _check_rollout, _match_rule, evaluate
|
|
2
|
+
from switchbox.models import Flag, Rule
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def make_flag(
|
|
6
|
+
key="test_flag",
|
|
7
|
+
enabled=True,
|
|
8
|
+
rollout_pct=100,
|
|
9
|
+
flag_type="boolean",
|
|
10
|
+
default_value=False,
|
|
11
|
+
rules=None,
|
|
12
|
+
):
|
|
13
|
+
return Flag(
|
|
14
|
+
key=key,
|
|
15
|
+
enabled=enabled,
|
|
16
|
+
rollout_pct=rollout_pct,
|
|
17
|
+
flag_type=flag_type,
|
|
18
|
+
default_value=default_value,
|
|
19
|
+
rules=rules or [],
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# --- Basic evaluation ---
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_disabled_flag_returns_default():
|
|
27
|
+
flag = make_flag(enabled=False, default_value="off")
|
|
28
|
+
assert evaluate(flag, {"user_id": "1"}) == "off"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_enabled_flag_100_rollout_returns_true():
|
|
32
|
+
flag = make_flag(enabled=True, rollout_pct=100)
|
|
33
|
+
assert evaluate(flag, {"user_id": "1"}) is True
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_enabled_flag_0_rollout_returns_default():
|
|
37
|
+
flag = make_flag(enabled=True, rollout_pct=0, default_value=False)
|
|
38
|
+
assert evaluate(flag, {"user_id": "1"}) is False
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# --- Rule matching operators ---
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_rule_equals():
|
|
45
|
+
rule = Rule(attribute="country", operator="equals", value="US")
|
|
46
|
+
assert _match_rule(rule, {"country": "US"}) is True
|
|
47
|
+
assert _match_rule(rule, {"country": "UK"}) is False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_rule_not_equals():
|
|
51
|
+
rule = Rule(attribute="country", operator="not_equals", value="US")
|
|
52
|
+
assert _match_rule(rule, {"country": "UK"}) is True
|
|
53
|
+
assert _match_rule(rule, {"country": "US"}) is False
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_rule_contains():
|
|
57
|
+
rule = Rule(attribute="email", operator="contains", value="@company")
|
|
58
|
+
assert _match_rule(rule, {"email": "alice@company.com"}) is True
|
|
59
|
+
assert _match_rule(rule, {"email": "alice@other.com"}) is False
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_rule_ends_with():
|
|
63
|
+
rule = Rule(attribute="email", operator="ends_with", value="@company.com")
|
|
64
|
+
assert _match_rule(rule, {"email": "alice@company.com"}) is True
|
|
65
|
+
assert _match_rule(rule, {"email": "alice@other.com"}) is False
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_rule_in_list():
|
|
69
|
+
rule = Rule(attribute="tier", operator="in_list", value=["gold", "platinum"])
|
|
70
|
+
assert _match_rule(rule, {"tier": "gold"}) is True
|
|
71
|
+
assert _match_rule(rule, {"tier": "silver"}) is False
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_rule_gt():
|
|
75
|
+
rule = Rule(attribute="age", operator="gt", value="18")
|
|
76
|
+
assert _match_rule(rule, {"age": 21}) is True
|
|
77
|
+
assert _match_rule(rule, {"age": 16}) is False
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_rule_lt():
|
|
81
|
+
rule = Rule(attribute="age", operator="lt", value="18")
|
|
82
|
+
assert _match_rule(rule, {"age": 16}) is True
|
|
83
|
+
assert _match_rule(rule, {"age": 21}) is False
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_rule_missing_attribute_does_not_match():
|
|
87
|
+
rule = Rule(attribute="country", operator="equals", value="US")
|
|
88
|
+
assert _match_rule(rule, {"email": "a@b.com"}) is False
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# --- Rules in evaluation (OR logic) ---
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_any_rule_match_returns_enabled():
|
|
95
|
+
flag = make_flag(
|
|
96
|
+
rollout_pct=0,
|
|
97
|
+
rules=[
|
|
98
|
+
Rule(attribute="country", operator="equals", value="US"),
|
|
99
|
+
Rule(attribute="email", operator="ends_with", value="@company.com"),
|
|
100
|
+
],
|
|
101
|
+
)
|
|
102
|
+
# Second rule matches
|
|
103
|
+
assert evaluate(flag, {"user_id": "1", "email": "a@company.com"}) is True
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# --- Rollout ---
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_rollout_deterministic():
|
|
110
|
+
"""Same user + flag always yields the same result."""
|
|
111
|
+
results = [_check_rollout("user42", "flag_a", 50) for _ in range(100)]
|
|
112
|
+
assert len(set(results)) == 1
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_rollout_distribution():
|
|
116
|
+
"""Over 10k users, ~30% should be in a 30% rollout (within tolerance)."""
|
|
117
|
+
in_rollout = sum(_check_rollout(str(i), "flag_b", 30) for i in range(10_000))
|
|
118
|
+
assert 2500 < in_rollout < 3500
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# --- No user context ---
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_no_user_context_100_rollout():
|
|
125
|
+
flag = make_flag(rollout_pct=100)
|
|
126
|
+
assert evaluate(flag, None) is True
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_no_user_context_partial_rollout_returns_default():
|
|
130
|
+
flag = make_flag(rollout_pct=50, default_value=False)
|
|
131
|
+
assert evaluate(flag, None) is False
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# --- Non-boolean flag types ---
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def test_string_flag_returns_string_value():
|
|
138
|
+
flag = make_flag(flag_type="string", default_value="v1", rollout_pct=100)
|
|
139
|
+
assert evaluate(flag, {"user_id": "1"}) == "v1"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_number_flag_returns_number_value():
|
|
143
|
+
flag = make_flag(flag_type="number", default_value=42, rollout_pct=100)
|
|
144
|
+
assert evaluate(flag, {"user_id": "1"}) == 42
|