switchbox-flags 0.3.0__tar.gz → 0.5.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {switchbox_flags-0.3.0 → switchbox_flags-0.5.0}/.github/workflows/publish.yml +2 -2
- {switchbox_flags-0.3.0 → switchbox_flags-0.5.0}/.github/workflows/test.yml +2 -2
- {switchbox_flags-0.3.0 → switchbox_flags-0.5.0}/PKG-INFO +1 -1
- {switchbox_flags-0.3.0 → switchbox_flags-0.5.0}/pyproject.toml +1 -1
- {switchbox_flags-0.3.0 → switchbox_flags-0.5.0}/switchbox/__init__.py +1 -1
- {switchbox_flags-0.3.0 → switchbox_flags-0.5.0}/switchbox/evaluator.py +36 -8
- switchbox_flags-0.5.0/switchbox/models.py +72 -0
- {switchbox_flags-0.3.0 → switchbox_flags-0.5.0}/tests/parity_vectors.json +13 -1
- {switchbox_flags-0.3.0 → switchbox_flags-0.5.0}/tests/test_models.py +27 -1
- {switchbox_flags-0.3.0 → switchbox_flags-0.5.0}/uv.lock +1 -1
- switchbox_flags-0.3.0/switchbox/models.py +0 -50
- {switchbox_flags-0.3.0 → switchbox_flags-0.5.0}/.coverage +0 -0
- {switchbox_flags-0.3.0 → switchbox_flags-0.5.0}/.gitignore +0 -0
- {switchbox_flags-0.3.0 → switchbox_flags-0.5.0}/LICENSE +0 -0
- {switchbox_flags-0.3.0 → switchbox_flags-0.5.0}/README.md +0 -0
- {switchbox_flags-0.3.0 → switchbox_flags-0.5.0}/switchbox/cache.py +0 -0
- {switchbox_flags-0.3.0 → switchbox_flags-0.5.0}/switchbox/client.py +0 -0
- {switchbox_flags-0.3.0 → switchbox_flags-0.5.0}/switchbox/exceptions.py +0 -0
- {switchbox_flags-0.3.0 → switchbox_flags-0.5.0}/switchbox/sync.py +0 -0
- {switchbox_flags-0.3.0 → switchbox_flags-0.5.0}/tests/__init__.py +0 -0
- {switchbox_flags-0.3.0 → switchbox_flags-0.5.0}/tests/test_cache.py +0 -0
- {switchbox_flags-0.3.0 → switchbox_flags-0.5.0}/tests/test_client.py +0 -0
- {switchbox_flags-0.3.0 → switchbox_flags-0.5.0}/tests/test_evaluator.py +0 -0
- {switchbox_flags-0.3.0 → switchbox_flags-0.5.0}/tests/test_parity_vectors.py +0 -0
|
@@ -13,9 +13,9 @@ jobs:
|
|
|
13
13
|
matrix:
|
|
14
14
|
python-version: ["3.10", "3.11", "3.12"]
|
|
15
15
|
steps:
|
|
16
|
-
- uses: actions/checkout@
|
|
16
|
+
- uses: actions/checkout@v6
|
|
17
17
|
|
|
18
|
-
- uses: actions/setup-python@
|
|
18
|
+
- uses: actions/setup-python@v6
|
|
19
19
|
with:
|
|
20
20
|
python-version: ${{ matrix.python-version }}
|
|
21
21
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: switchbox-flags
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Feature flag SDK with zero dependencies
|
|
5
5
|
Project-URL: Homepage, https://github.com/ignat14/switchbox-sdk-python
|
|
6
6
|
Project-URL: Repository, https://github.com/ignat14/switchbox-sdk-python
|
|
@@ -7,7 +7,7 @@ import hashlib
|
|
|
7
7
|
import re
|
|
8
8
|
from typing import Any
|
|
9
9
|
|
|
10
|
-
from switchbox.models import Flag, Rule
|
|
10
|
+
from switchbox.models import Flag, Rule, RuleGroup
|
|
11
11
|
|
|
12
12
|
# Leading numeric prefix, matching JS parseFloat (e.g. "25px" -> 25, "1e3" -> 1000).
|
|
13
13
|
_NUMERIC_PREFIX = re.compile(r"[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?")
|
|
@@ -65,11 +65,11 @@ def evaluate(flag: Flag, user_context: dict | None = None) -> bool | str | int |
|
|
|
65
65
|
return _enabled_value(flag)
|
|
66
66
|
return flag.default_value
|
|
67
67
|
|
|
68
|
-
# 3. Check
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
68
|
+
# 3. Check rule groups: OR across groups, AND within a group (two-level
|
|
69
|
+
# DNF). Any matching group wins.
|
|
70
|
+
for group in _to_groups(flag.rules):
|
|
71
|
+
if _match_group(group, user_context):
|
|
72
|
+
return _enabled_value(flag)
|
|
73
73
|
|
|
74
74
|
# 4. Rollout. Resolve the id with a null-only fallback (JS `??`, not
|
|
75
75
|
# `or`): an empty-string user_id is a real id, not falsy.
|
|
@@ -88,10 +88,38 @@ def evaluate(flag: Flag, user_context: dict | None = None) -> bool | str | int |
|
|
|
88
88
|
|
|
89
89
|
|
|
90
90
|
def _enabled_value(flag: Flag) -> bool | str | int | Any:
|
|
91
|
-
"""
|
|
91
|
+
"""The value served to matched / in-rollout users.
|
|
92
|
+
|
|
93
|
+
Boolean flags serve ``True``. Non-boolean flags serve ``enabled_value``
|
|
94
|
+
(variations, ADR-017), falling back to ``default_value`` when it's unset —
|
|
95
|
+
so a config without ``enabled_value`` behaves exactly as before. Fallback is
|
|
96
|
+
at evaluation time (not parse time) so directly-constructed Flags work too,
|
|
97
|
+
matching the JS SDK's ``?? default_value``."""
|
|
92
98
|
if flag.flag_type == "boolean":
|
|
93
99
|
return True
|
|
94
|
-
return flag.default_value
|
|
100
|
+
return flag.enabled_value if flag.enabled_value is not None else flag.default_value
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _to_groups(rules: list) -> list[RuleGroup]:
|
|
104
|
+
"""Normalise a flag's rules into RuleGroups. ``from_dict`` already yields
|
|
105
|
+
RuleGroups; this also tolerates a flat list of ``Rule`` (legacy/direct
|
|
106
|
+
construction) — each becomes its own single-condition group, preserving the
|
|
107
|
+
old OR semantics. Idempotent."""
|
|
108
|
+
groups: list[RuleGroup] = []
|
|
109
|
+
for r in rules:
|
|
110
|
+
if isinstance(r, RuleGroup):
|
|
111
|
+
groups.append(r)
|
|
112
|
+
elif isinstance(r, Rule):
|
|
113
|
+
groups.append(RuleGroup(conditions=[r]))
|
|
114
|
+
return groups
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _match_group(group: RuleGroup, user_context: dict) -> bool:
|
|
118
|
+
"""A group matches when ALL its conditions match (AND). An empty group
|
|
119
|
+
matches no one (never 'match all')."""
|
|
120
|
+
return bool(group.conditions) and all(
|
|
121
|
+
_match_rule(c, user_context) for c in group.conditions
|
|
122
|
+
)
|
|
95
123
|
|
|
96
124
|
|
|
97
125
|
def _match_rule(rule: Rule, user_context: dict) -> bool:
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class Rule:
|
|
9
|
+
"""A single condition. The atom of targeting."""
|
|
10
|
+
|
|
11
|
+
attribute: str
|
|
12
|
+
operator: str # equals | not_equals | contains | ends_with | in_list | gt | lt
|
|
13
|
+
value: Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class RuleGroup:
|
|
18
|
+
"""An AND-group of conditions. Groups are OR'd by the evaluator — two-level
|
|
19
|
+
DNF (ADR-015): a flag matches if any group matches; a group matches if all
|
|
20
|
+
its conditions match."""
|
|
21
|
+
|
|
22
|
+
conditions: list[Rule] = field(default_factory=list)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class Flag:
|
|
27
|
+
key: str
|
|
28
|
+
enabled: bool
|
|
29
|
+
rollout_pct: int
|
|
30
|
+
flag_type: str # boolean | string | number | json
|
|
31
|
+
default_value: Any
|
|
32
|
+
# The "on"/matched value for non-boolean flags (variations, ADR-017). None
|
|
33
|
+
# means "unset" — the evaluator falls back to default_value, so configs
|
|
34
|
+
# without this key behave exactly as before. Boolean flags ignore it.
|
|
35
|
+
enabled_value: Any = None
|
|
36
|
+
rules: list[RuleGroup] = field(default_factory=list)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _parse_condition(d: dict) -> Rule:
|
|
40
|
+
return Rule(attribute=d["attribute"], operator=d["operator"], value=d["value"])
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _parse_rule_group(entry: dict) -> RuleGroup:
|
|
44
|
+
"""Parse one rules entry, accepting the two-level shape
|
|
45
|
+
``{"conditions": [...]}`` and the legacy flat ``{attribute, operator, value}``
|
|
46
|
+
(a pre-DNF config) — the latter becomes a single-condition group."""
|
|
47
|
+
if "conditions" in entry:
|
|
48
|
+
return RuleGroup(conditions=[_parse_condition(c) for c in entry["conditions"]])
|
|
49
|
+
return RuleGroup(conditions=[_parse_condition(entry)])
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class FlagConfig:
|
|
54
|
+
version: str # ISO timestamp
|
|
55
|
+
flags: dict[str, Flag] = field(default_factory=dict)
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def from_dict(cls, data: dict) -> FlagConfig:
|
|
59
|
+
"""Parse the CDN JSON into a FlagConfig object."""
|
|
60
|
+
flags = {}
|
|
61
|
+
for key, flag_data in data.get("flags", {}).items():
|
|
62
|
+
rules = [_parse_rule_group(r) for r in flag_data.get("rules", [])]
|
|
63
|
+
flags[key] = Flag(
|
|
64
|
+
key=key,
|
|
65
|
+
enabled=flag_data["enabled"],
|
|
66
|
+
rollout_pct=flag_data.get("rollout_pct", 0),
|
|
67
|
+
flag_type=flag_data.get("flag_type", "boolean"),
|
|
68
|
+
default_value=flag_data.get("default_value"),
|
|
69
|
+
enabled_value=flag_data.get("enabled_value"),
|
|
70
|
+
rules=rules,
|
|
71
|
+
)
|
|
72
|
+
return cls(version=data.get("version", ""), flags=flags)
|
|
@@ -28,6 +28,18 @@
|
|
|
28
28
|
{"name": "has user_id, rollout 0, no match -> default", "flag_key": "f", "flag": {"enabled": true, "rollout_pct": 0, "flag_type": "boolean", "default_value": false, "rules": []}, "user": {"user_id": "x"}, "expected": false},
|
|
29
29
|
{"name": "user context without id, rollout 100 -> enabled (ADR-008)", "flag_key": "f", "flag": {"enabled": true, "rollout_pct": 100, "flag_type": "boolean", "default_value": false, "rules": []}, "user": {"plan": "pro"}, "expected": true},
|
|
30
30
|
{"name": "user context without id, rollout 50 -> default", "flag_key": "f", "flag": {"enabled": true, "rollout_pct": 50, "flag_type": "boolean", "default_value": false, "rules": []}, "user": {"plan": "pro"}, "expected": false},
|
|
31
|
-
{"name": "empty-string user_id is treated as present (rollout 100)", "flag_key": "f", "flag": {"enabled": true, "rollout_pct": 100, "flag_type": "boolean", "default_value": false, "rules": []}, "user": {"user_id": "", "id": "ignored"}, "expected": true}
|
|
31
|
+
{"name": "empty-string user_id is treated as present (rollout 100)", "flag_key": "f", "flag": {"enabled": true, "rollout_pct": 100, "flag_type": "boolean", "default_value": false, "rules": []}, "user": {"user_id": "", "id": "ignored"}, "expected": true},
|
|
32
|
+
{"name": "DNF: AND-group with all conditions matching -> enabled", "flag_key": "f", "flag": {"enabled": true, "rollout_pct": 0, "flag_type": "boolean", "default_value": false, "rules": [{"conditions": [{"attribute": "country", "operator": "equals", "value": "US"}, {"attribute": "device", "operator": "equals", "value": "mobile"}]}]}, "user": {"country": "US", "device": "mobile"}, "expected": true},
|
|
33
|
+
{"name": "DNF: AND-group with one failing condition -> default", "flag_key": "f", "flag": {"enabled": true, "rollout_pct": 0, "flag_type": "boolean", "default_value": false, "rules": [{"conditions": [{"attribute": "country", "operator": "equals", "value": "US"}, {"attribute": "device", "operator": "equals", "value": "mobile"}]}]}, "user": {"country": "US", "device": "desktop"}, "expected": false},
|
|
34
|
+
{"name": "DNF: second group matches (OR across groups) -> enabled", "flag_key": "f", "flag": {"enabled": true, "rollout_pct": 0, "flag_type": "boolean", "default_value": false, "rules": [{"conditions": [{"attribute": "country", "operator": "equals", "value": "US"}]}, {"conditions": [{"attribute": "plan", "operator": "equals", "value": "ent"}]}]}, "user": {"plan": "ent"}, "expected": true},
|
|
35
|
+
{"name": "DNF: no group matches (OR across groups) -> default", "flag_key": "f", "flag": {"enabled": true, "rollout_pct": 0, "flag_type": "boolean", "default_value": false, "rules": [{"conditions": [{"attribute": "country", "operator": "equals", "value": "US"}]}, {"conditions": [{"attribute": "plan", "operator": "equals", "value": "ent"}]}]}, "user": {"plan": "free", "country": "EU"}, "expected": false},
|
|
36
|
+
{"name": "DNF: empty-conditions group never matches -> default", "flag_key": "f", "flag": {"enabled": true, "rollout_pct": 0, "flag_type": "boolean", "default_value": false, "rules": [{"conditions": []}]}, "user": {"any": "thing"}, "expected": false},
|
|
37
|
+
{"name": "DNF back-compat: legacy flat rule still matches -> enabled", "flag_key": "f", "flag": {"enabled": true, "rollout_pct": 0, "flag_type": "boolean", "default_value": false, "rules": [{"attribute": "plan", "operator": "equals", "value": "pro"}]}, "user": {"plan": "pro"}, "expected": true},
|
|
38
|
+
{"name": "variation: string flag rule match serves enabled_value", "flag_key": "f", "flag": {"enabled": true, "rollout_pct": 0, "flag_type": "string", "default_value": "Shop", "enabled_value": "Buy now", "rules": [{"conditions": [{"attribute": "plan", "operator": "equals", "value": "pro"}]}]}, "user": {"plan": "pro", "user_id": "u1"}, "expected": "Buy now"},
|
|
39
|
+
{"name": "variation: string flag no match serves default_value", "flag_key": "f", "flag": {"enabled": true, "rollout_pct": 0, "flag_type": "string", "default_value": "Shop", "enabled_value": "Buy now", "rules": [{"conditions": [{"attribute": "plan", "operator": "equals", "value": "pro"}]}]}, "user": {"plan": "free", "user_id": "u1"}, "expected": "Shop"},
|
|
40
|
+
{"name": "variation: absent enabled_value falls back to default on match", "flag_key": "f", "flag": {"enabled": true, "rollout_pct": 0, "flag_type": "string", "default_value": "Shop", "rules": [{"conditions": [{"attribute": "plan", "operator": "equals", "value": "pro"}]}]}, "user": {"plan": "pro", "user_id": "u1"}, "expected": "Shop"},
|
|
41
|
+
{"name": "variation: number flag rollout 100 no context serves enabled_value", "flag_key": "f", "flag": {"enabled": true, "rollout_pct": 100, "flag_type": "number", "default_value": 0, "enabled_value": 42, "rules": []}, "user": null, "expected": 42},
|
|
42
|
+
{"name": "variation: disabled non-boolean serves default_value", "flag_key": "f", "flag": {"enabled": false, "rollout_pct": 100, "flag_type": "string", "default_value": "Shop", "enabled_value": "Buy now", "rules": []}, "user": {"user_id": "u1"}, "expected": "Shop"},
|
|
43
|
+
{"name": "variation: boolean flag ignores enabled_value (serves true)", "flag_key": "f", "flag": {"enabled": true, "rollout_pct": 100, "flag_type": "boolean", "default_value": false, "enabled_value": "ignored", "rules": []}, "user": null, "expected": true}
|
|
32
44
|
]
|
|
33
45
|
}
|
|
@@ -22,8 +22,9 @@ def test_flag_config_from_dict_valid():
|
|
|
22
22
|
flag = config.flags["feature_a"]
|
|
23
23
|
assert flag.enabled is True
|
|
24
24
|
assert flag.rollout_pct == 50
|
|
25
|
+
# Legacy flat rule is parsed into a single-condition group (back-compat).
|
|
25
26
|
assert len(flag.rules) == 1
|
|
26
|
-
assert flag.rules[0].attribute == "country"
|
|
27
|
+
assert flag.rules[0].conditions[0].attribute == "country"
|
|
27
28
|
|
|
28
29
|
|
|
29
30
|
def test_flag_config_from_dict_missing_fields():
|
|
@@ -69,6 +70,31 @@ def test_flag_config_from_dict_with_rules():
|
|
|
69
70
|
assert len(config.flags["f"].rules) == 2
|
|
70
71
|
|
|
71
72
|
|
|
73
|
+
def test_flag_config_from_dict_with_dnf_groups():
|
|
74
|
+
"""The two-level shape: a group with multiple AND'd conditions."""
|
|
75
|
+
data = {
|
|
76
|
+
"version": "v1",
|
|
77
|
+
"flags": {
|
|
78
|
+
"f": {
|
|
79
|
+
"enabled": True,
|
|
80
|
+
"rules": [
|
|
81
|
+
{
|
|
82
|
+
"conditions": [
|
|
83
|
+
{"attribute": "country", "operator": "equals", "value": "US"},
|
|
84
|
+
{"attribute": "device", "operator": "equals", "value": "mobile"},
|
|
85
|
+
]
|
|
86
|
+
},
|
|
87
|
+
{"conditions": [{"attribute": "plan", "operator": "equals", "value": "ent"}]},
|
|
88
|
+
],
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
}
|
|
92
|
+
flag = FlagConfig.from_dict(data).flags["f"]
|
|
93
|
+
assert len(flag.rules) == 2 # two groups (OR)
|
|
94
|
+
assert len(flag.rules[0].conditions) == 2 # AND within the first group
|
|
95
|
+
assert flag.rules[1].conditions[0].attribute == "plan"
|
|
96
|
+
|
|
97
|
+
|
|
72
98
|
def test_flag_defaults():
|
|
73
99
|
flag = Flag(key="f", enabled=True, rollout_pct=100, flag_type="boolean", default_value=False)
|
|
74
100
|
assert flag.rules == []
|
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from dataclasses import dataclass, field
|
|
4
|
-
from typing import Any
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
@dataclass
|
|
8
|
-
class Rule:
|
|
9
|
-
attribute: str
|
|
10
|
-
operator: str # equals | not_equals | contains | ends_with | in_list | gt | lt
|
|
11
|
-
value: Any
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
@dataclass
|
|
15
|
-
class Flag:
|
|
16
|
-
key: str
|
|
17
|
-
enabled: bool
|
|
18
|
-
rollout_pct: int
|
|
19
|
-
flag_type: str # boolean | string | number | json
|
|
20
|
-
default_value: Any
|
|
21
|
-
rules: list[Rule] = field(default_factory=list)
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
@dataclass
|
|
25
|
-
class FlagConfig:
|
|
26
|
-
version: str # ISO timestamp
|
|
27
|
-
flags: dict[str, Flag] = field(default_factory=dict)
|
|
28
|
-
|
|
29
|
-
@classmethod
|
|
30
|
-
def from_dict(cls, data: dict) -> FlagConfig:
|
|
31
|
-
"""Parse the CDN JSON into a FlagConfig object."""
|
|
32
|
-
flags = {}
|
|
33
|
-
for key, flag_data in data.get("flags", {}).items():
|
|
34
|
-
rules = [
|
|
35
|
-
Rule(
|
|
36
|
-
attribute=r["attribute"],
|
|
37
|
-
operator=r["operator"],
|
|
38
|
-
value=r["value"],
|
|
39
|
-
)
|
|
40
|
-
for r in flag_data.get("rules", [])
|
|
41
|
-
]
|
|
42
|
-
flags[key] = Flag(
|
|
43
|
-
key=key,
|
|
44
|
-
enabled=flag_data["enabled"],
|
|
45
|
-
rollout_pct=flag_data.get("rollout_pct", 0),
|
|
46
|
-
flag_type=flag_data.get("flag_type", "boolean"),
|
|
47
|
-
default_value=flag_data.get("default_value"),
|
|
48
|
-
rules=rules,
|
|
49
|
-
)
|
|
50
|
-
return cls(version=data.get("version", ""), flags=flags)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|