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.
Files changed (24) hide show
  1. {switchbox_flags-0.3.0 → switchbox_flags-0.5.0}/.github/workflows/publish.yml +2 -2
  2. {switchbox_flags-0.3.0 → switchbox_flags-0.5.0}/.github/workflows/test.yml +2 -2
  3. {switchbox_flags-0.3.0 → switchbox_flags-0.5.0}/PKG-INFO +1 -1
  4. {switchbox_flags-0.3.0 → switchbox_flags-0.5.0}/pyproject.toml +1 -1
  5. {switchbox_flags-0.3.0 → switchbox_flags-0.5.0}/switchbox/__init__.py +1 -1
  6. {switchbox_flags-0.3.0 → switchbox_flags-0.5.0}/switchbox/evaluator.py +36 -8
  7. switchbox_flags-0.5.0/switchbox/models.py +72 -0
  8. {switchbox_flags-0.3.0 → switchbox_flags-0.5.0}/tests/parity_vectors.json +13 -1
  9. {switchbox_flags-0.3.0 → switchbox_flags-0.5.0}/tests/test_models.py +27 -1
  10. {switchbox_flags-0.3.0 → switchbox_flags-0.5.0}/uv.lock +1 -1
  11. switchbox_flags-0.3.0/switchbox/models.py +0 -50
  12. {switchbox_flags-0.3.0 → switchbox_flags-0.5.0}/.coverage +0 -0
  13. {switchbox_flags-0.3.0 → switchbox_flags-0.5.0}/.gitignore +0 -0
  14. {switchbox_flags-0.3.0 → switchbox_flags-0.5.0}/LICENSE +0 -0
  15. {switchbox_flags-0.3.0 → switchbox_flags-0.5.0}/README.md +0 -0
  16. {switchbox_flags-0.3.0 → switchbox_flags-0.5.0}/switchbox/cache.py +0 -0
  17. {switchbox_flags-0.3.0 → switchbox_flags-0.5.0}/switchbox/client.py +0 -0
  18. {switchbox_flags-0.3.0 → switchbox_flags-0.5.0}/switchbox/exceptions.py +0 -0
  19. {switchbox_flags-0.3.0 → switchbox_flags-0.5.0}/switchbox/sync.py +0 -0
  20. {switchbox_flags-0.3.0 → switchbox_flags-0.5.0}/tests/__init__.py +0 -0
  21. {switchbox_flags-0.3.0 → switchbox_flags-0.5.0}/tests/test_cache.py +0 -0
  22. {switchbox_flags-0.3.0 → switchbox_flags-0.5.0}/tests/test_client.py +0 -0
  23. {switchbox_flags-0.3.0 → switchbox_flags-0.5.0}/tests/test_evaluator.py +0 -0
  24. {switchbox_flags-0.3.0 → switchbox_flags-0.5.0}/tests/test_parity_vectors.py +0 -0
@@ -13,9 +13,9 @@ jobs:
13
13
  runs-on: ubuntu-latest
14
14
  environment: pypi
15
15
  steps:
16
- - uses: actions/checkout@v4
16
+ - uses: actions/checkout@v6
17
17
 
18
- - uses: actions/setup-python@v5
18
+ - uses: actions/setup-python@v6
19
19
  with:
20
20
  python-version: "3.14"
21
21
 
@@ -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@v4
16
+ - uses: actions/checkout@v6
17
17
 
18
- - uses: actions/setup-python@v5
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.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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "switchbox-flags"
7
- version = "0.3.0"
7
+ version = "0.5.0"
8
8
  description = "Feature flag SDK with zero dependencies"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,5 +1,5 @@
1
1
  from switchbox.client import Switchbox
2
2
  from switchbox.exceptions import SwitchboxError
3
3
 
4
- __version__ = "0.3.0"
4
+ __version__ = "0.5.0"
5
5
  __all__ = ["Switchbox", "SwitchboxError"]
@@ -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 rules (OR logic any match wins)
69
- if flag.rules:
70
- for rule in flag.rules:
71
- if _match_rule(rule, user_context):
72
- return _enabled_value(flag)
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
- """Return the appropriate 'enabled' value based on flag type."""
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 == []
@@ -236,7 +236,7 @@ wheels = [
236
236
 
237
237
  [[package]]
238
238
  name = "switchbox-flags"
239
- version = "0.3.0"
239
+ version = "0.5.0"
240
240
  source = { editable = "." }
241
241
 
242
242
  [package.optional-dependencies]
@@ -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