switchbox-flags 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -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"]
switchbox/cache.py ADDED
@@ -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
switchbox/client.py ADDED
@@ -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()
switchbox/evaluator.py ADDED
@@ -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."""
switchbox/models.py ADDED
@@ -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)
switchbox/sync.py ADDED
@@ -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
@@ -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,11 @@
1
+ switchbox/__init__.py,sha256=euy9_hMMg70MENnhWb5cKNyusiXCGZmc2d08QsMjSAs,146
2
+ switchbox/cache.py,sha256=WmeKkQSiVrOtZh_psou6qXibSL82nQJw7jxNfqTXT5E,835
3
+ switchbox/client.py,sha256=GISyZaYOPNJkxXKVkapn9AwZz0bTMfvyFLvqhebatz0,2202
4
+ switchbox/evaluator.py,sha256=Xuo5ybTfHvytouuIIoor9Q6HVPGbCFIhknaFXnq663k,3194
5
+ switchbox/exceptions.py,sha256=Vv5YMiqh9gdEztXX9gGvvl721xV7f3Gzk2TcHS8WEwY,278
6
+ switchbox/models.py,sha256=xdKvg6fOrtwdh45nGsxm8w9M81W8W4A9klcYeKXNgjM,1397
7
+ switchbox/sync.py,sha256=Vzx8YdMydjDUujL-Gw7--xHOu0p25FqFsh3MshgP6b4,2565
8
+ switchbox_flags-0.1.0.dist-info/METADATA,sha256=913axQyjtRBPJS-0fi1KOod4eobZh19gxa39OH77vmQ,2543
9
+ switchbox_flags-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
10
+ switchbox_flags-0.1.0.dist-info/licenses/LICENSE,sha256=wcGGHspwJcuwpobuEkium65D2JZax2cUwEpT1V7nPCs,1066
11
+ switchbox_flags-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.