switchbox-flags 0.6.0__tar.gz → 0.7.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 (31) hide show
  1. {switchbox_flags-0.6.0 → switchbox_flags-0.7.0}/PKG-INFO +1 -1
  2. {switchbox_flags-0.6.0 → switchbox_flags-0.7.0}/pyproject.toml +1 -1
  3. switchbox_flags-0.7.0/switchbox/__init__.py +5 -0
  4. switchbox_flags-0.7.0/switchbox/_version.py +15 -0
  5. {switchbox_flags-0.6.0 → switchbox_flags-0.7.0}/switchbox/evaluator.py +32 -6
  6. {switchbox_flags-0.6.0 → switchbox_flags-0.7.0}/switchbox/models.py +22 -11
  7. {switchbox_flags-0.6.0 → switchbox_flags-0.7.0}/switchbox/sync.py +2 -1
  8. {switchbox_flags-0.6.0 → switchbox_flags-0.7.0}/tests/fixtures/parity/parity_vectors.json +26 -4
  9. {switchbox_flags-0.6.0 → switchbox_flags-0.7.0}/tests/test_evaluator.py +53 -0
  10. {switchbox_flags-0.6.0 → switchbox_flags-0.7.0}/tests/test_models.py +54 -3
  11. {switchbox_flags-0.6.0 → switchbox_flags-0.7.0}/tests/test_parity_vectors.py +11 -1
  12. {switchbox_flags-0.6.0 → switchbox_flags-0.7.0}/uv.lock +1 -1
  13. switchbox_flags-0.6.0/switchbox/__init__.py +0 -5
  14. {switchbox_flags-0.6.0 → switchbox_flags-0.7.0}/.coverage +0 -0
  15. {switchbox_flags-0.6.0 → switchbox_flags-0.7.0}/.github/workflows/publish.yml +0 -0
  16. {switchbox_flags-0.6.0 → switchbox_flags-0.7.0}/.github/workflows/test.yml +0 -0
  17. {switchbox_flags-0.6.0 → switchbox_flags-0.7.0}/.gitignore +0 -0
  18. {switchbox_flags-0.6.0 → switchbox_flags-0.7.0}/LICENSE +0 -0
  19. {switchbox_flags-0.6.0 → switchbox_flags-0.7.0}/README.md +0 -0
  20. {switchbox_flags-0.6.0 → switchbox_flags-0.7.0}/switchbox/cache.py +0 -0
  21. {switchbox_flags-0.6.0 → switchbox_flags-0.7.0}/switchbox/client.py +0 -0
  22. {switchbox_flags-0.6.0 → switchbox_flags-0.7.0}/switchbox/exceptions.py +0 -0
  23. {switchbox_flags-0.6.0 → switchbox_flags-0.7.0}/tests/__init__.py +0 -0
  24. {switchbox_flags-0.6.0 → switchbox_flags-0.7.0}/tests/fixtures/cdn-json/defaults.json +0 -0
  25. {switchbox_flags-0.6.0 → switchbox_flags-0.7.0}/tests/fixtures/cdn-json/empty.json +0 -0
  26. {switchbox_flags-0.6.0 → switchbox_flags-0.7.0}/tests/fixtures/cdn-json/full_config.json +0 -0
  27. {switchbox_flags-0.6.0 → switchbox_flags-0.7.0}/tests/fixtures/cdn-json/legacy_flat_rules.json +0 -0
  28. {switchbox_flags-0.6.0 → switchbox_flags-0.7.0}/tests/fixtures/cdn-json/unknown_fields.json +0 -0
  29. {switchbox_flags-0.6.0 → switchbox_flags-0.7.0}/tests/test_cache.py +0 -0
  30. {switchbox_flags-0.6.0 → switchbox_flags-0.7.0}/tests/test_client.py +0 -0
  31. {switchbox_flags-0.6.0 → switchbox_flags-0.7.0}/tests/test_contract_fixtures.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: switchbox-flags
3
- Version: 0.6.0
3
+ Version: 0.7.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.6.0"
7
+ version = "0.7.0"
8
8
  description = "Feature flag SDK with zero dependencies"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -0,0 +1,5 @@
1
+ from switchbox._version import __version__
2
+ from switchbox.client import Switchbox
3
+ from switchbox.exceptions import SwitchboxError
4
+
5
+ __all__ = ["Switchbox", "SwitchboxError", "__version__"]
@@ -0,0 +1,15 @@
1
+ """Single source of the package version (FABLE_IMPROVEMENTS 2.7).
2
+
3
+ Derived from the installed distribution metadata (pyproject.toml's `version`)
4
+ so `__version__`, the User-Agent, and PyPI can never drift — previously
5
+ __init__.py said 0.5.0, pyproject said 0.6.0 and the User-Agent hardcoded a
6
+ third copy. Lives in its own leaf module because both __init__.py and sync.py
7
+ need it (importing from the package root inside sync would be circular).
8
+ """
9
+
10
+ from importlib.metadata import PackageNotFoundError, version
11
+
12
+ try:
13
+ __version__ = version("switchbox-flags")
14
+ except PackageNotFoundError: # running from a raw source tree, not installed
15
+ __version__ = "0.0.0"
@@ -20,25 +20,48 @@ def _js_str(value: Any) -> str:
20
20
  - booleans are lowercase ("true"/"false"), not Python's "True"/"False"
21
21
  - None becomes "null"
22
22
  - an integer-valued float drops its trailing ".0" (JS has no int/float split)
23
+ — but only below 1e21, where JS switches to exponential notation
24
+ - NaN/Infinity render as JS does ("NaN"/"Infinity"), not Python's "nan"/"inf"
25
+ - exponents are not zero-padded ("1e-7", not Python's "1e-07")
26
+
27
+ Residual, documented divergence (deliberately not chased — see
28
+ FABLE_IMPROVEMENTS 2.9): floats in [1e-6, 1e-4) render exponential here but
29
+ fixed in JS (str(1e-5)="1e-05" vs String(1e-5)="0.00001"), and lists/dicts
30
+ stringify Python-style, not JS-style ("[object Object]"). Vectors only pin
31
+ the converged cases.
23
32
  """
24
33
  if isinstance(value, bool):
25
34
  return "true" if value else "false"
26
35
  if value is None:
27
36
  return "null"
28
- if isinstance(value, float) and value.is_integer():
29
- return str(int(value))
37
+ if isinstance(value, float):
38
+ if value != value: # NaN
39
+ return "NaN"
40
+ if value == float("inf"):
41
+ return "Infinity"
42
+ if value == float("-inf"):
43
+ return "-Infinity"
44
+ if value.is_integer() and abs(value) < 1e21:
45
+ return str(int(value))
46
+ return re.sub(r"e([+-])0(\d)$", r"e\1\2", str(value))
30
47
  return str(value)
31
48
 
32
49
 
33
50
  def _to_number(value: Any) -> float | None:
34
51
  """Mimic JS parseFloat(String(value)): parse a leading numeric prefix, or
35
52
  return None (JS NaN) when there isn't one. Booleans are NaN, matching
36
- parseFloat("true")."""
53
+ parseFloat("true"). "Infinity"/"-Infinity" prefixes parse like parseFloat
54
+ does (parseFloat("Infinity") is Infinity, not NaN)."""
37
55
  if isinstance(value, bool):
38
56
  return None
39
57
  if isinstance(value, (int, float)):
40
58
  return float(value)
41
- match = _NUMERIC_PREFIX.match(str(value).lstrip())
59
+ s = str(value).lstrip()
60
+ if s.startswith(("Infinity", "+Infinity")):
61
+ return float("inf")
62
+ if s.startswith("-Infinity"):
63
+ return float("-inf")
64
+ match = _NUMERIC_PREFIX.match(s)
42
65
  return float(match.group(0)) if match else None
43
66
 
44
67
 
@@ -59,8 +82,11 @@ def evaluate(flag: Flag, user_context: dict | None = None) -> bool | str | int |
59
82
  if not flag.enabled:
60
83
  return flag.default_value
61
84
 
62
- # 2. No user context
63
- if not user_context:
85
+ # 2. No user context (None only — an empty dict {} deliberately proceeds
86
+ # to the rules loop and behaves like "no matching attributes", the
87
+ # same path the JS SDK takes; parity-pinned. The end result is
88
+ # identical because step 5 mirrors this branch.)
89
+ if user_context is None:
64
90
  if flag.rollout_pct == 100:
65
91
  return _enabled_value(flag)
66
92
  return flag.default_value
@@ -56,17 +56,28 @@ class FlagConfig:
56
56
 
57
57
  @classmethod
58
58
  def from_dict(cls, data: dict) -> FlagConfig:
59
- """Parse the CDN JSON into a FlagConfig object."""
59
+ """Parse the CDN JSON into a FlagConfig object.
60
+
61
+ **Per-flag tolerant** (FABLE_IMPROVEMENTS 2.3): one malformed flag —
62
+ e.g. a missing ``enabled`` or a null ``rules`` from a future publisher
63
+ bug — is skipped, not fatal. Before this, the sync loop's generic
64
+ handler discarded the *entire* config, freezing every Python client on
65
+ the old version while the (per-flag tolerant) JS SDK kept updating.
66
+ The JS parser skips non-object flag entries the same way
67
+ (``normalizeConfig`` in ``sync.ts``); keep them aligned."""
60
68
  flags = {}
61
69
  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
- )
70
+ try:
71
+ rules = [_parse_rule_group(r) for r in flag_data.get("rules") or []]
72
+ flags[key] = Flag(
73
+ key=key,
74
+ enabled=flag_data["enabled"],
75
+ rollout_pct=flag_data.get("rollout_pct", 0),
76
+ flag_type=flag_data.get("flag_type", "boolean"),
77
+ default_value=flag_data.get("default_value"),
78
+ enabled_value=flag_data.get("enabled_value"),
79
+ rules=rules,
80
+ )
81
+ except Exception:
82
+ continue # skip the bad flag, keep the rest
72
83
  return cls(version=data.get("version", ""), flags=flags)
@@ -5,6 +5,7 @@ import urllib.error
5
5
  import urllib.request
6
6
  from typing import Callable
7
7
 
8
+ from switchbox._version import __version__
8
9
  from switchbox.cache import FlagCache
9
10
  from switchbox.exceptions import ConfigFetchError
10
11
  from switchbox.models import FlagConfig
@@ -86,7 +87,7 @@ class SyncWorker:
86
87
  try:
87
88
  req = urllib.request.Request(
88
89
  self._cdn_url,
89
- headers={"User-Agent": "switchbox-python/0.6.0"},
90
+ headers={"User-Agent": f"switchbox-python/{__version__}"},
90
91
  )
91
92
  with urllib.request.urlopen(req, timeout=self._timeout) as resp:
92
93
  data = json.loads(resp.read().decode("utf-8"))
@@ -1,5 +1,5 @@
1
1
  {
2
- "_comment": "SEC-4 cross-SDK parity vectors (evaluation half of the parity story; the format half is fixtures/cdn-json/). CANONICAL SOURCE: fixtures/parity/parity_vectors.json — distributed into each SDK repo's tests/fixtures/parity/ by `python3 fixtures/sync.py` (do NOT hand-edit a synced copy; edit the canonical and re-sync). Both SDK suites run it (test_parity_vectors.py, parity.test.ts). Sections: rule_match (single-condition coercion/operators), evaluate (full evaluate path incl. rollout-bucket boundaries), rollout_bucket (sha256(user_id:flag_key)%100 — pins the hash itself across languages). Canonical semantics are JS-style: lowercase booleans (String(true)='true'), parseFloat-style prefix numeric parsing, null coerces to 'null', user_id resolved with ??-style null-only fallback, and a user context lacking a usable id serves only 100% rollouts (ADR-008). See DECISIONS.md ADR-013 (coercion) + ADR-024 (shared-fixture mechanism).",
2
+ "_comment": "SEC-4 cross-SDK parity vectors (evaluation half of the parity story; the format half is fixtures/cdn-json/). CANONICAL SOURCE: fixtures/parity/parity_vectors.json — distributed into each SDK repo's tests/fixtures/parity/ by `python3 fixtures/sync.py` (do NOT hand-edit a synced copy; edit the canonical and re-sync). Both SDK suites run it (test_parity_vectors.py, parity.test.ts). Sections: rule_match (single-condition coercion/operators), evaluate (full evaluate path incl. rollout-bucket boundaries), rollout_bucket (sha256(user_id:flag_key)%100 — pins the hash itself across languages). Canonical semantics are JS-style: lowercase booleans (String(true)='true'), parseFloat-style prefix numeric parsing, null coerces to 'null', user_id resolved with ??-style null-only fallback, and a user context lacking a usable id serves only 100% rollouts (ADR-008). The 'hostile:' vectors (FABLE_IMPROVEMENTS 2.8) pin the outside-the-happy-path envelope: evaluation-never-throws containment (ADR-043), {} context = no matching attributes, omitted flag_type/rollout_pct parse defaults (boolean/0), prototype-chain attributes, string-valued in_list substring semantics, Infinity/1e21 coercion, non-ASCII rollout hashing. Runners must exercise their REAL parse path (Python FlagConfig.from_dict, JS normalizeConfig) and compare strictly (no True==1). See DECISIONS.md ADR-013 (coercion) + ADR-024 (shared-fixture mechanism) + ADR-043 (error containment).",
3
3
  "rule_match": [
4
4
  {"name": "boolean true matches lowercase 'true'", "rule": {"attribute": "is_beta", "operator": "equals", "value": "true"}, "context": {"is_beta": true}, "expected": true},
5
5
  {"name": "boolean false matches lowercase 'false'", "rule": {"attribute": "is_beta", "operator": "equals", "value": "false"}, "context": {"is_beta": false}, "expected": true},
@@ -17,7 +17,19 @@
17
17
  {"name": "lt numeric", "rule": {"attribute": "age", "operator": "lt", "value": "18"}, "context": {"age": "5"}, "expected": true},
18
18
  {"name": "in_list string member matches", "rule": {"attribute": "country", "operator": "in_list", "value": ["US", "CA", "UK"]}, "context": {"country": "US"}, "expected": true},
19
19
  {"name": "in_list stringified number does not match raw number item (shared footgun)", "rule": {"attribute": "n", "operator": "in_list", "value": [42]}, "context": {"n": 42}, "expected": false},
20
- {"name": "in_list stringified number matches string item", "rule": {"attribute": "n", "operator": "in_list", "value": ["42"]}, "context": {"n": 42}, "expected": true}
20
+ {"name": "in_list stringified number matches string item", "rule": {"attribute": "n", "operator": "in_list", "value": ["42"]}, "context": {"n": 42}, "expected": true},
21
+ {"name": "hostile: in_list with a STRING rule value does substring matching (pinned coincidence: JS String.includes / Python 'in')", "rule": {"attribute": "country", "operator": "in_list", "value": "US,CA"}, "context": {"country": "US"}, "expected": true},
22
+ {"name": "hostile: in_list string rule value, non-substring does not match", "rule": {"attribute": "country", "operator": "in_list", "value": "US,CA"}, "context": {"country": "FR"}, "expected": false},
23
+ {"name": "hostile: not_equals with an absent attribute does not match (absence is not inequality)", "rule": {"attribute": "plan", "operator": "not_equals", "value": "pro"}, "context": {"other": 1}, "expected": false},
24
+ {"name": "hostile: prototype-chain attribute never matches (constructor)", "rule": {"attribute": "constructor", "operator": "equals", "value": "anything"}, "context": {"other": "x"}, "expected": false},
25
+ {"name": "hostile: prototype-chain attribute never matches (toString)", "rule": {"attribute": "toString", "operator": "not_equals", "value": "x"}, "context": {"other": "x"}, "expected": false},
26
+ {"name": "hostile: gt parses a string 'Infinity' context like parseFloat", "rule": {"attribute": "n", "operator": "gt", "value": "1000000"}, "context": {"n": "Infinity"}, "expected": true},
27
+ {"name": "hostile: lt parses '-Infinity'", "rule": {"attribute": "n", "operator": "lt", "value": "0"}, "context": {"n": "-Infinity"}, "expected": true},
28
+ {"name": "hostile: gt with 'Infinity' RULE value never matches a finite context", "rule": {"attribute": "n", "operator": "gt", "value": "Infinity"}, "context": {"n": "999999999"}, "expected": false},
29
+ {"name": "hostile: equals at 1e21 uses exponential form (String(1e21)='1e+21')", "rule": {"attribute": "n", "operator": "equals", "value": "1e+21"}, "context": {"n": 1e21}, "expected": true},
30
+ {"name": "hostile: gt with tiny float context (1e-5 > 0, numeric path)", "rule": {"attribute": "n", "operator": "gt", "value": "0"}, "context": {"n": 1e-5}, "expected": true},
31
+ {"name": "hostile: gt with huge float context (1e21 > 1e20)", "rule": {"attribute": "n", "operator": "gt", "value": "1e20"}, "context": {"n": 1e21}, "expected": true},
32
+ {"name": "hostile: equals with CJK strings", "rule": {"attribute": "name", "operator": "equals", "value": "用户"}, "context": {"name": "用户"}, "expected": true}
21
33
  ],
22
34
  "evaluate": [
23
35
  {"name": "disabled flag returns default", "flag_key": "f", "flag": {"enabled": false, "rollout_pct": 100, "flag_type": "boolean", "default_value": "off", "rules": []}, "user": {"user_id": "1"}, "expected": "off"},
@@ -44,13 +56,23 @@
44
56
  {"name": "rollout: user bucket 58 with rollout 59 -> enabled (bucket < pct)", "flag_key": "f", "flag": {"enabled": true, "rollout_pct": 59, "flag_type": "boolean", "default_value": false, "rules": []}, "user": {"user_id": "u_low"}, "expected": true},
45
57
  {"name": "rollout: user bucket 58 with rollout 58 -> default (boundary is exclusive)", "flag_key": "f", "flag": {"enabled": true, "rollout_pct": 58, "flag_type": "boolean", "default_value": false, "rules": []}, "user": {"user_id": "u_low"}, "expected": false},
46
58
  {"name": "rollout: user bucket 47 with rollout 48 -> enabled", "flag_key": "f", "flag": {"enabled": true, "rollout_pct": 48, "flag_type": "boolean", "default_value": false, "rules": []}, "user": {"user_id": "user-2"}, "expected": true},
47
- {"name": "rollout: user bucket 47 with rollout 47 -> default", "flag_key": "f", "flag": {"enabled": true, "rollout_pct": 47, "flag_type": "boolean", "default_value": false, "rules": []}, "user": {"user_id": "user-2"}, "expected": false}
59
+ {"name": "rollout: user bucket 47 with rollout 47 -> default", "flag_key": "f", "flag": {"enabled": true, "rollout_pct": 47, "flag_type": "boolean", "default_value": false, "rules": []}, "user": {"user_id": "user-2"}, "expected": false},
60
+ {"name": "hostile: malformed in_list (null value) is contained -> default (evaluation never throws, ADR-043)", "flag_key": "f", "flag": {"enabled": true, "rollout_pct": 0, "flag_type": "boolean", "default_value": false, "rules": [{"conditions": [{"attribute": "x", "operator": "in_list", "value": null}]}]}, "user": {"x": "a", "user_id": "u1"}, "expected": false},
61
+ {"name": "hostile: empty user context {} behaves like no matching attributes, rollout 100 -> enabled", "flag_key": "f", "flag": {"enabled": true, "rollout_pct": 100, "flag_type": "boolean", "default_value": false, "rules": []}, "user": {}, "expected": true},
62
+ {"name": "hostile: empty user context {}, rollout 50 -> default", "flag_key": "f", "flag": {"enabled": true, "rollout_pct": 50, "flag_type": "boolean", "default_value": false, "rules": []}, "user": {}, "expected": false},
63
+ {"name": "hostile: empty user context {} with rules present, rollout 0 -> default", "flag_key": "f", "flag": {"enabled": true, "rollout_pct": 0, "flag_type": "boolean", "default_value": false, "rules": [{"conditions": [{"attribute": "plan", "operator": "equals", "value": "pro"}]}]}, "user": {}, "expected": false},
64
+ {"name": "hostile: omitted flag_type defaults to boolean (serves true)", "flag_key": "f", "flag": {"enabled": true, "rollout_pct": 100, "default_value": false, "rules": []}, "user": null, "expected": true},
65
+ {"name": "hostile: omitted rollout_pct defaults to 0 -> default", "flag_key": "f", "flag": {"enabled": true, "flag_type": "boolean", "default_value": false, "rules": []}, "user": {"user_id": "u1"}, "expected": false},
66
+ {"name": "hostile: non-ASCII user_id evaluates through the rollout hash (bucket 18 < 50)", "flag_key": "new_checkout", "flag": {"enabled": true, "rollout_pct": 50, "flag_type": "boolean", "default_value": false, "rules": []}, "user": {"user_id": "用户42"}, "expected": true}
48
67
  ],
49
68
  "rollout_bucket": [
50
69
  {"name": "42:new_checkout", "user_id": "42", "flag_key": "new_checkout", "expected": 98},
51
70
  {"name": "user_100:feature_x", "user_id": "user_100", "flag_key": "feature_x", "expected": 4},
52
71
  {"name": "abc:search_version", "user_id": "abc", "flag_key": "search_version", "expected": 10},
53
72
  {"name": "u_low:f", "user_id": "u_low", "flag_key": "f", "expected": 58},
54
- {"name": "user-2:f", "user_id": "user-2", "flag_key": "f", "expected": 47}
73
+ {"name": "user-2:f", "user_id": "user-2", "flag_key": "f", "expected": 47},
74
+ {"name": "hostile: CJK user_id (UTF-8 hashing parity)", "user_id": "用户42", "flag_key": "new_checkout", "expected": 18},
75
+ {"name": "hostile: emoji user_id (astral-plane UTF-8)", "user_id": "🚀user", "flag_key": "feature_x", "expected": 59},
76
+ {"name": "hostile: latin diacritics user_id", "user_id": "Ünïcode-Üser", "flag_key": "search_version", "expected": 85}
55
77
  ]
56
78
  }
@@ -243,3 +243,56 @@ def test_rule_attribute_not_in_context_skipped():
243
243
  rules=[Rule(attribute="missing_attr", operator="equals", value="x")],
244
244
  )
245
245
  assert evaluate(flag, {"user_id": "1", "other": "y"}) is False
246
+
247
+
248
+ # --- JS-coercion extremes (FABLE_IMPROVEMENTS 2.9) ---
249
+
250
+
251
+ def test_js_str_extremes_match_js_string():
252
+ """The cheap-to-fix String() divergences, pinned: Infinity/NaN naming,
253
+ exponential form at 1e21+, no zero-padded exponents. The residual
254
+ divergence (fixed-vs-exponential in the 1e-6..1e-4 range, lists/dicts) is
255
+ documented in _js_str's docstring, deliberately not chased."""
256
+ from switchbox.evaluator import _js_str
257
+
258
+ assert _js_str(float("inf")) == "Infinity"
259
+ assert _js_str(float("-inf")) == "-Infinity"
260
+ assert _js_str(float("nan")) == "NaN"
261
+ assert _js_str(1e21) == "1e+21" # JS switches to exponential at 1e21
262
+ assert _js_str(1e22) == "1e+22"
263
+ assert _js_str(1e20) == "100000000000000000000" # still fixed below 1e21
264
+ assert _js_str(1e-7) == "1e-7" # no zero-padded exponent ("1e-07")
265
+ assert _js_str(1.5e-8) == "1.5e-8"
266
+ assert _js_str(42.0) == "42" # unchanged: int-valued float drops .0
267
+
268
+
269
+ def test_to_number_parses_infinity_like_parsefloat():
270
+ from switchbox.evaluator import _to_number
271
+
272
+ assert _to_number("Infinity") == float("inf")
273
+ assert _to_number("+Infinity") == float("inf")
274
+ assert _to_number("-Infinity") == float("-inf")
275
+ assert _to_number("Infinity-and-beyond") == float("inf") # prefix parse
276
+ assert _to_number("Inf") is None # parseFloat("Inf") is NaN
277
+
278
+
279
+ def test_empty_context_takes_the_rules_path_not_the_no_context_branch():
280
+ """{} is a real (empty) context: rules are walked (none can match), then
281
+ the no-usable-id tail applies — the same path JS takes (parity-pinned by
282
+ the hostile vectors)."""
283
+ flag = make_flag(rollout_pct=100)
284
+ assert evaluate(flag, {}) is True
285
+ flag = make_flag(rollout_pct=50)
286
+ assert evaluate(flag, {}) is False
287
+
288
+
289
+ def test_evaluation_error_is_contained_to_default():
290
+ """Evaluation never throws (ADR-043): a malformed in_list value degrades to
291
+ default_value. Same contract as the JS SDK's try/catch."""
292
+ flag = make_flag(
293
+ rollout_pct=0,
294
+ default_value="safe",
295
+ flag_type="string",
296
+ rules=[Rule(attribute="x", operator="in_list", value=None)],
297
+ )
298
+ assert evaluate(flag, {"x": "a", "user_id": "u1"}) == "safe"
@@ -10,9 +10,7 @@ def test_flag_config_from_dict_valid():
10
10
  "rollout_pct": 50,
11
11
  "flag_type": "boolean",
12
12
  "default_value": False,
13
- "rules": [
14
- {"attribute": "country", "operator": "equals", "value": "US"}
15
- ],
13
+ "rules": [{"attribute": "country", "operator": "equals", "value": "US"}],
16
14
  }
17
15
  },
18
16
  }
@@ -105,3 +103,56 @@ def test_rule_stores_fields():
105
103
  assert rule.attribute == "country"
106
104
  assert rule.operator == "equals"
107
105
  assert rule.value == "US"
106
+
107
+
108
+ def test_from_dict_skips_a_malformed_flag_keeps_the_rest():
109
+ """Per-flag tolerance (FABLE_IMPROVEMENTS 2.3): one bad flag from a future
110
+ publisher bug must not discard the whole config — that froze every Python
111
+ client on the old version while the JS SDK kept updating."""
112
+ data = {
113
+ "version": "v1",
114
+ "flags": {
115
+ "good": {
116
+ "enabled": True,
117
+ "rollout_pct": 100,
118
+ "flag_type": "boolean",
119
+ "default_value": False,
120
+ "rules": [],
121
+ },
122
+ "missing_enabled": {
123
+ "rollout_pct": 100,
124
+ "flag_type": "boolean",
125
+ "default_value": False,
126
+ "rules": [],
127
+ },
128
+ "not_a_dict": None,
129
+ "garbage_rules": {
130
+ "enabled": True,
131
+ "rollout_pct": 0,
132
+ "flag_type": "boolean",
133
+ "default_value": False,
134
+ "rules": [{"conditions": [{"nope": 1}]}],
135
+ },
136
+ },
137
+ }
138
+ config = FlagConfig.from_dict(data)
139
+ assert set(config.flags) == {"good"}
140
+ assert config.version == "v1"
141
+
142
+
143
+ def test_from_dict_null_rules_parses_as_no_rules():
144
+ """A null `rules` is tolerated as [] (JS toRuleGroups(null) → []), not fatal."""
145
+ data = {
146
+ "version": "v1",
147
+ "flags": {
148
+ "f": {
149
+ "enabled": True,
150
+ "rollout_pct": 100,
151
+ "flag_type": "boolean",
152
+ "default_value": False,
153
+ "rules": None,
154
+ }
155
+ },
156
+ }
157
+ flag = FlagConfig.from_dict(data).flags["f"]
158
+ assert flag.rules == []
@@ -39,9 +39,19 @@ def test_rule_match_vectors(case):
39
39
  assert _match_rule(rule, case["context"]) == case["expected"]
40
40
 
41
41
 
42
+ def _assert_strict(result, expected):
43
+ """Value AND type must match — Python's `==` alone would let `True == 1`
44
+ pass while the JS runner's `toBe` fails it (runner-asymmetry fix,
45
+ FABLE_IMPROVEMENTS 2.8)."""
46
+ assert result == expected and type(result) is type(expected), (
47
+ f"{result!r} ({type(result).__name__}) != "
48
+ f"{expected!r} ({type(expected).__name__})"
49
+ )
50
+
51
+
42
52
  @pytest.mark.parametrize("case", _VECTORS["evaluate"], ids=lambda c: c["name"])
43
53
  def test_evaluate_vectors(case):
44
- assert evaluate(_flag_from(case), case["user"]) == case["expected"]
54
+ _assert_strict(evaluate(_flag_from(case), case["user"]), case["expected"])
45
55
 
46
56
 
47
57
  @pytest.mark.parametrize("case", _VECTORS["rollout_bucket"], ids=lambda c: c["name"])
@@ -236,7 +236,7 @@ wheels = [
236
236
 
237
237
  [[package]]
238
238
  name = "switchbox-flags"
239
- version = "0.6.0"
239
+ version = "0.7.0"
240
240
  source = { editable = "." }
241
241
 
242
242
  [package.optional-dependencies]
@@ -1,5 +0,0 @@
1
- from switchbox.client import Switchbox
2
- from switchbox.exceptions import SwitchboxError
3
-
4
- __version__ = "0.5.0"
5
- __all__ = ["Switchbox", "SwitchboxError"]
File without changes