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.
@@ -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,15 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *$py.class
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .eggs/
8
+ *.egg
9
+ .pytest_cache/
10
+ .ruff_cache/
11
+ .venv/
12
+ venv/
13
+ .env
14
+ *.so
15
+ .DS_Store
@@ -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,5 @@
1
+ from switchbox.client import Client
2
+ from switchbox.exceptions import SwitchboxError
3
+
4
+ __version__ = "0.1.0"
5
+ __all__ = ["Client", "SwitchboxError"]
@@ -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