switchbox-flags 0.3.0__tar.gz → 0.4.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 (23) hide show
  1. {switchbox_flags-0.3.0 → switchbox_flags-0.4.0}/.github/workflows/publish.yml +2 -2
  2. {switchbox_flags-0.3.0 → switchbox_flags-0.4.0}/.github/workflows/test.yml +2 -2
  3. {switchbox_flags-0.3.0 → switchbox_flags-0.4.0}/PKG-INFO +1 -1
  4. {switchbox_flags-0.3.0 → switchbox_flags-0.4.0}/pyproject.toml +1 -1
  5. {switchbox_flags-0.3.0 → switchbox_flags-0.4.0}/switchbox/__init__.py +1 -1
  6. {switchbox_flags-0.3.0 → switchbox_flags-0.4.0}/switchbox/evaluator.py +28 -6
  7. {switchbox_flags-0.3.0 → switchbox_flags-0.4.0}/switchbox/models.py +26 -9
  8. {switchbox_flags-0.3.0 → switchbox_flags-0.4.0}/tests/parity_vectors.json +7 -1
  9. {switchbox_flags-0.3.0 → switchbox_flags-0.4.0}/tests/test_models.py +27 -1
  10. {switchbox_flags-0.3.0 → switchbox_flags-0.4.0}/uv.lock +1 -1
  11. {switchbox_flags-0.3.0 → switchbox_flags-0.4.0}/.coverage +0 -0
  12. {switchbox_flags-0.3.0 → switchbox_flags-0.4.0}/.gitignore +0 -0
  13. {switchbox_flags-0.3.0 → switchbox_flags-0.4.0}/LICENSE +0 -0
  14. {switchbox_flags-0.3.0 → switchbox_flags-0.4.0}/README.md +0 -0
  15. {switchbox_flags-0.3.0 → switchbox_flags-0.4.0}/switchbox/cache.py +0 -0
  16. {switchbox_flags-0.3.0 → switchbox_flags-0.4.0}/switchbox/client.py +0 -0
  17. {switchbox_flags-0.3.0 → switchbox_flags-0.4.0}/switchbox/exceptions.py +0 -0
  18. {switchbox_flags-0.3.0 → switchbox_flags-0.4.0}/switchbox/sync.py +0 -0
  19. {switchbox_flags-0.3.0 → switchbox_flags-0.4.0}/tests/__init__.py +0 -0
  20. {switchbox_flags-0.3.0 → switchbox_flags-0.4.0}/tests/test_cache.py +0 -0
  21. {switchbox_flags-0.3.0 → switchbox_flags-0.4.0}/tests/test_client.py +0 -0
  22. {switchbox_flags-0.3.0 → switchbox_flags-0.4.0}/tests/test_evaluator.py +0 -0
  23. {switchbox_flags-0.3.0 → switchbox_flags-0.4.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.4.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.4.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.4.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.
@@ -94,6 +94,28 @@ def _enabled_value(flag: Flag) -> bool | str | int | Any:
94
94
  return flag.default_value
95
95
 
96
96
 
97
+ def _to_groups(rules: list) -> list[RuleGroup]:
98
+ """Normalise a flag's rules into RuleGroups. ``from_dict`` already yields
99
+ RuleGroups; this also tolerates a flat list of ``Rule`` (legacy/direct
100
+ construction) — each becomes its own single-condition group, preserving the
101
+ old OR semantics. Idempotent."""
102
+ groups: list[RuleGroup] = []
103
+ for r in rules:
104
+ if isinstance(r, RuleGroup):
105
+ groups.append(r)
106
+ elif isinstance(r, Rule):
107
+ groups.append(RuleGroup(conditions=[r]))
108
+ return groups
109
+
110
+
111
+ def _match_group(group: RuleGroup, user_context: dict) -> bool:
112
+ """A group matches when ALL its conditions match (AND). An empty group
113
+ matches no one (never 'match all')."""
114
+ return bool(group.conditions) and all(
115
+ _match_rule(c, user_context) for c in group.conditions
116
+ )
117
+
118
+
97
119
  def _match_rule(rule: Rule, user_context: dict) -> bool:
98
120
  """Check if a single rule matches the user context.
99
121
 
@@ -6,11 +6,22 @@ from typing import Any
6
6
 
7
7
  @dataclass
8
8
  class Rule:
9
+ """A single condition. The atom of targeting."""
10
+
9
11
  attribute: str
10
12
  operator: str # equals | not_equals | contains | ends_with | in_list | gt | lt
11
13
  value: Any
12
14
 
13
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
+
14
25
  @dataclass
15
26
  class Flag:
16
27
  key: str
@@ -18,7 +29,20 @@ class Flag:
18
29
  rollout_pct: int
19
30
  flag_type: str # boolean | string | number | json
20
31
  default_value: Any
21
- rules: list[Rule] = field(default_factory=list)
32
+ rules: list[RuleGroup] = field(default_factory=list)
33
+
34
+
35
+ def _parse_condition(d: dict) -> Rule:
36
+ return Rule(attribute=d["attribute"], operator=d["operator"], value=d["value"])
37
+
38
+
39
+ def _parse_rule_group(entry: dict) -> RuleGroup:
40
+ """Parse one rules entry, accepting the two-level shape
41
+ ``{"conditions": [...]}`` and the legacy flat ``{attribute, operator, value}``
42
+ (a pre-DNF config) — the latter becomes a single-condition group."""
43
+ if "conditions" in entry:
44
+ return RuleGroup(conditions=[_parse_condition(c) for c in entry["conditions"]])
45
+ return RuleGroup(conditions=[_parse_condition(entry)])
22
46
 
23
47
 
24
48
  @dataclass
@@ -31,14 +55,7 @@ class FlagConfig:
31
55
  """Parse the CDN JSON into a FlagConfig object."""
32
56
  flags = {}
33
57
  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
- ]
58
+ rules = [_parse_rule_group(r) for r in flag_data.get("rules", [])]
42
59
  flags[key] = Flag(
43
60
  key=key,
44
61
  enabled=flag_data["enabled"],
@@ -28,6 +28,12 @@
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}
32
38
  ]
33
39
  }
@@ -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.4.0"
240
240
  source = { editable = "." }
241
241
 
242
242
  [package.optional-dependencies]
File without changes