feat-sdk 0.1.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.
@@ -0,0 +1,37 @@
1
+ name: Publish feathq-python-sdk to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags: ["v*"]
6
+ workflow_dispatch:
7
+
8
+ jobs:
9
+ build:
10
+ name: Build sdist + wheel
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+ - uses: actions/setup-python@v5
15
+ with:
16
+ python-version: "3.12"
17
+ - run: pip install --upgrade build
18
+ - run: python -m build
19
+ - uses: actions/upload-artifact@v4
20
+ with:
21
+ name: dist
22
+ path: dist/
23
+
24
+ publish:
25
+ name: Publish to PyPI via OIDC
26
+ needs: build
27
+ runs-on: ubuntu-latest
28
+ environment: pypi-publish
29
+ permissions:
30
+ contents: read
31
+ id-token: write
32
+ steps:
33
+ - uses: actions/download-artifact@v4
34
+ with:
35
+ name: dist
36
+ path: dist/
37
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .pytest_cache/
5
+ .venv/
6
+ dist/
7
+ build/
8
+ .DS_Store
feat_sdk-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 feat HQ
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.
@@ -0,0 +1,73 @@
1
+ Metadata-Version: 2.4
2
+ Name: feat-sdk
3
+ Version: 0.1.0
4
+ Summary: feat feature-flag SDK for Python (server-side, local evaluation)
5
+ Project-URL: Homepage, https://feat.so
6
+ Project-URL: Repository, https://github.com/feathq/python-sdk
7
+ Project-URL: Issues, https://github.com/feathq/python-sdk/issues
8
+ Author-email: feat HQ <engineering@feat.so>
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: feat,feature-flags,feature-management,feature-toggles,openfeature
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
+ Requires-Python: >=3.10
23
+ Provides-Extra: test
24
+ Requires-Dist: pytest>=8.0; extra == 'test'
25
+ Description-Content-Type: text/markdown
26
+
27
+ # feat-sdk
28
+
29
+ Server-side Python SDK for [feat](https://feat.so) feature flags. Local flag evaluation against a polled datafile. Zero runtime dependencies (stdlib only).
30
+
31
+ ## Install
32
+
33
+ ```bash
34
+ pip install feat-sdk
35
+ ```
36
+
37
+ Python 3.10+.
38
+
39
+ ## Usage
40
+
41
+ ```python
42
+ from feat import Client, ClientConfig, EvalContext
43
+
44
+ client = Client(ClientConfig(
45
+ api_key="feat_sdk_...",
46
+ data_plane_url="https://data.feat.so",
47
+ ))
48
+ client.ready()
49
+
50
+ ctx = EvalContext(
51
+ targeting_key="user-123",
52
+ kinds={"user": {"plan": "pro", "email": "alice@example.com"}},
53
+ )
54
+
55
+ if client.get_boolean_value("checkout-v2", False, ctx):
56
+ # ...
57
+ pass
58
+
59
+ client.close()
60
+ ```
61
+
62
+ Use a **server** API key (`feat_sdk_...`).
63
+
64
+ ## How it works
65
+
66
+ - Fetches a per-environment datafile and keeps it in memory.
67
+ - Polls every 30 seconds by default (configurable). ETag-aware via `If-None-Match`.
68
+ - Evaluation runs in-process: no per-flag network call.
69
+ - A background daemon thread handles polling; `close()` stops it cleanly.
70
+
71
+ ## License
72
+
73
+ MIT
@@ -0,0 +1,47 @@
1
+ # feat-sdk
2
+
3
+ Server-side Python SDK for [feat](https://feat.so) feature flags. Local flag evaluation against a polled datafile. Zero runtime dependencies (stdlib only).
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install feat-sdk
9
+ ```
10
+
11
+ Python 3.10+.
12
+
13
+ ## Usage
14
+
15
+ ```python
16
+ from feat import Client, ClientConfig, EvalContext
17
+
18
+ client = Client(ClientConfig(
19
+ api_key="feat_sdk_...",
20
+ data_plane_url="https://data.feat.so",
21
+ ))
22
+ client.ready()
23
+
24
+ ctx = EvalContext(
25
+ targeting_key="user-123",
26
+ kinds={"user": {"plan": "pro", "email": "alice@example.com"}},
27
+ )
28
+
29
+ if client.get_boolean_value("checkout-v2", False, ctx):
30
+ # ...
31
+ pass
32
+
33
+ client.close()
34
+ ```
35
+
36
+ Use a **server** API key (`feat_sdk_...`).
37
+
38
+ ## How it works
39
+
40
+ - Fetches a per-environment datafile and keeps it in memory.
41
+ - Polls every 30 seconds by default (configurable). ETag-aware via `If-None-Match`.
42
+ - Evaluation runs in-process: no per-flag network call.
43
+ - A background daemon thread handles polling; `close()` stops it cleanly.
44
+
45
+ ## License
46
+
47
+ MIT
@@ -0,0 +1,43 @@
1
+ [project]
2
+ name = "feat-sdk"
3
+ version = "0.1.0"
4
+ description = "feat feature-flag SDK for Python (server-side, local evaluation)"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = { text = "MIT" }
8
+ authors = [
9
+ { name = "feat HQ", email = "engineering@feat.so" },
10
+ ]
11
+ keywords = ["feature-flags", "feature-toggles", "feature-management", "feat", "openfeature"]
12
+ classifiers = [
13
+ "Development Status :: 4 - Beta",
14
+ "Intended Audience :: Developers",
15
+ "License :: OSI Approved :: MIT License",
16
+ "Operating System :: OS Independent",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.10",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Programming Language :: Python :: 3.13",
22
+ "Topic :: Software Development :: Libraries :: Python Modules",
23
+ ]
24
+ dependencies = []
25
+
26
+ [project.urls]
27
+ Homepage = "https://feat.so"
28
+ Repository = "https://github.com/feathq/python-sdk"
29
+ Issues = "https://github.com/feathq/python-sdk/issues"
30
+
31
+ [project.optional-dependencies]
32
+ test = ["pytest>=8.0"]
33
+
34
+ [build-system]
35
+ requires = ["hatchling"]
36
+ build-backend = "hatchling.build"
37
+
38
+ [tool.hatch.build.targets.wheel]
39
+ packages = ["src/feat"]
40
+
41
+ [tool.pytest.ini_options]
42
+ testpaths = ["tests"]
43
+ pythonpath = ["src"]
@@ -0,0 +1,31 @@
1
+ """feat feature-flag SDK for Python.
2
+
3
+ Server-side evaluation against a locally-cached datafile.
4
+ """
5
+
6
+ from .client import Client, ClientConfig
7
+ from .datafile import (
8
+ ConditionSpec,
9
+ ContextKindSpec,
10
+ Datafile,
11
+ FlagSpec,
12
+ Rollout,
13
+ SegmentSpec,
14
+ )
15
+ from .eval import EvaluationResult, Reason, evaluate
16
+ from .types import EvalContext
17
+
18
+ __all__ = [
19
+ "Client",
20
+ "ClientConfig",
21
+ "ConditionSpec",
22
+ "ContextKindSpec",
23
+ "Datafile",
24
+ "EvalContext",
25
+ "EvaluationResult",
26
+ "FlagSpec",
27
+ "Reason",
28
+ "Rollout",
29
+ "SegmentSpec",
30
+ "evaluate",
31
+ ]
@@ -0,0 +1,41 @@
1
+ """Deterministic bucketing for percentage rollouts.
2
+
3
+ Algorithm matches @feathq/feat-eval and feat-go-sdk bit-for-bit so the
4
+ same context lands in the same variation regardless of which SDK does
5
+ the evaluation:
6
+
7
+ sha1(salt + "." + flag_key + "." + context_key)
8
+ -> first 8 bytes as big-endian uint64
9
+ -> shift right 4 (drop low bits) for exactly 60 bits
10
+ -> modulo 100_000
11
+
12
+ Uses SHA-1 for the algorithm (not security); shrugs off Python's hash
13
+ randomization since the digest is purely a function of the inputs.
14
+ """
15
+
16
+ import hashlib
17
+
18
+ BUCKET_SCALE = 100_000
19
+
20
+
21
+ def bucket(salt: str, flag_key: str, context_key: str) -> int:
22
+ data = f"{salt}.{flag_key}.{context_key}".encode()
23
+ digest = hashlib.sha1(data, usedforsecurity=False).digest()
24
+ first8 = int.from_bytes(digest[:8], "big", signed=False)
25
+ sixty = first8 >> 4
26
+ return sixty % BUCKET_SCALE
27
+
28
+
29
+ def pick_by_weight(bucket_value: int, variations: list) -> str | None:
30
+ """Walk cumulative weights and return the variation whose range
31
+ contains bucket_value. Falls back to the last variation defensively
32
+ if upstream weights underflow the scale.
33
+ """
34
+ cumulative = 0
35
+ for v in variations:
36
+ cumulative += v.weight
37
+ if bucket_value < cumulative:
38
+ return v.variationId
39
+ if variations:
40
+ return variations[-1].variationId
41
+ return None
@@ -0,0 +1,146 @@
1
+ """Polling HTTP client. Uses stdlib only (urllib + threading) - zero deps."""
2
+
3
+ import json
4
+ import threading
5
+ import urllib.error
6
+ import urllib.parse
7
+ import urllib.request
8
+ from dataclasses import dataclass
9
+ from typing import Any
10
+
11
+ from .datafile import Datafile, from_json
12
+ from .eval import EvaluationResult, Reason, evaluate
13
+ from .types import EvalContext
14
+
15
+ MIN_POLL_INTERVAL_SECONDS = 5.0
16
+ MAX_DATAFILE_BYTES = 10 * 1024 * 1024
17
+
18
+
19
+ @dataclass
20
+ class ClientConfig:
21
+ api_key: str
22
+ data_plane_url: str
23
+ poll_interval_seconds: float = 30.0
24
+ # If True, start() spawns a background daemon thread that polls
25
+ # on the configured cadence. Tests typically set this False and
26
+ # drive refresh() manually.
27
+ poll_in_background: bool = True
28
+
29
+
30
+ def _validate_https(url: str) -> None:
31
+ """Reject non-https URLs (allow http://localhost / 127.0.0.1 for tests)."""
32
+ try:
33
+ parsed = urllib.parse.urlparse(url)
34
+ except ValueError as exc: # pragma: no cover - urlparse rarely raises
35
+ raise ValueError("dataPlaneUrl is not a valid URL") from exc
36
+ if parsed.scheme == "https":
37
+ return
38
+ if parsed.scheme == "http" and parsed.hostname in ("localhost", "127.0.0.1"):
39
+ return
40
+ raise ValueError(
41
+ "data_plane_url must use https:// (http://localhost allowed for tests)"
42
+ )
43
+
44
+
45
+ class Client:
46
+ """In-memory datafile cache with background polling.
47
+
48
+ Construct, call ready() once (blocks for the first fetch), then
49
+ evaluate(). Thread-safe: the datafile pointer is swapped atomically.
50
+ """
51
+
52
+ def __init__(self, config: ClientConfig) -> None:
53
+ _validate_https(config.data_plane_url)
54
+ if config.poll_interval_seconds < MIN_POLL_INTERVAL_SECONDS:
55
+ config.poll_interval_seconds = MIN_POLL_INTERVAL_SECONDS
56
+ self.config = config
57
+ self._datafile: Datafile | None = None
58
+ self._etag: str | None = None
59
+ self._stop = threading.Event()
60
+ self._thread: threading.Thread | None = None
61
+ self._lock = threading.Lock()
62
+
63
+ def ready(self) -> None:
64
+ """Blocking initial fetch + start the background poller (if enabled)."""
65
+ self._fetch_once()
66
+ if self.config.poll_in_background and self._thread is None:
67
+ self._thread = threading.Thread(target=self._poll_loop, daemon=True)
68
+ self._thread.start()
69
+
70
+ def close(self) -> None:
71
+ self._stop.set()
72
+
73
+ def refresh(self) -> bool:
74
+ """Fetch once. Returns True if the datafile changed."""
75
+ return self._fetch_once()
76
+
77
+ def evaluate(
78
+ self,
79
+ flag_key: str,
80
+ default_value: Any,
81
+ context: EvalContext,
82
+ ) -> EvaluationResult:
83
+ df = self._datafile
84
+ if df is None:
85
+ return EvaluationResult(
86
+ value=default_value,
87
+ variation_id=None,
88
+ reason=Reason.ERROR,
89
+ error_message="client not ready: call ready() before evaluate",
90
+ )
91
+ return evaluate(flag_key, default_value, context, df)
92
+
93
+ def get_boolean_value(self, flag_key: str, default: bool, context: EvalContext) -> bool:
94
+ r = self.evaluate(flag_key, default, context)
95
+ return r.value if isinstance(r.value, bool) else default
96
+
97
+ def get_string_value(self, flag_key: str, default: str, context: EvalContext) -> str:
98
+ r = self.evaluate(flag_key, default, context)
99
+ return r.value if isinstance(r.value, str) else default
100
+
101
+ def get_number_value(self, flag_key: str, default: float, context: EvalContext) -> float:
102
+ r = self.evaluate(flag_key, default, context)
103
+ if isinstance(r.value, bool): # bool isinstance of int - exclude
104
+ return default
105
+ return r.value if isinstance(r.value, (int, float)) else default
106
+
107
+ def get_object_value(self, flag_key: str, default: Any, context: EvalContext) -> Any:
108
+ r = self.evaluate(flag_key, default, context)
109
+ return r.value
110
+
111
+ def _poll_loop(self) -> None:
112
+ while not self._stop.wait(self.config.poll_interval_seconds):
113
+ try:
114
+ self._fetch_once()
115
+ except Exception: # noqa: BLE001 - defensive: keep polling on any error
116
+ pass
117
+
118
+ def _fetch_once(self) -> bool:
119
+ url = self.config.data_plane_url.rstrip("/") + "/sdk/v1/datafile"
120
+ req = urllib.request.Request(url)
121
+ req.add_header("Authorization", f"Bearer {self.config.api_key}")
122
+ with self._lock:
123
+ if self._etag:
124
+ req.add_header("If-None-Match", self._etag)
125
+ try:
126
+ with urllib.request.urlopen(req, timeout=10) as resp: # noqa: S310 - trusted endpoint
127
+ status = resp.status
128
+ if status in (304, 404):
129
+ return False
130
+ length_header = resp.headers.get("Content-Length")
131
+ if length_header and int(length_header) > MAX_DATAFILE_BYTES:
132
+ raise RuntimeError("datafile exceeds maximum allowed size")
133
+ body = resp.read(MAX_DATAFILE_BYTES + 1)
134
+ if len(body) > MAX_DATAFILE_BYTES:
135
+ raise RuntimeError("datafile exceeds maximum allowed size")
136
+ data = json.loads(body.decode("utf-8"))
137
+ new_etag = resp.headers.get("ETag")
138
+ except urllib.error.HTTPError as e:
139
+ if e.code in (304, 404):
140
+ return False
141
+ raise
142
+ with self._lock:
143
+ self._datafile = from_json(data)
144
+ if new_etag:
145
+ self._etag = new_etag
146
+ return True
@@ -0,0 +1,57 @@
1
+ """Attribute resolution against an EvalContext."""
2
+
3
+ from typing import Any
4
+
5
+ from .types import EvalContext
6
+
7
+
8
+ def resolve_attribute(ctx: EvalContext, attribute_path: str) -> Any:
9
+ """Walk an attribute path like "user.email" or "user.address.city".
10
+
11
+ Returns None if any segment is missing — operators treat None as a
12
+ non-match rather than throw.
13
+ """
14
+ if not attribute_path:
15
+ return None
16
+ parts = attribute_path.split(".", 1)
17
+ kind_key = parts[0]
18
+ kind_obj = _read_kind(ctx, kind_key)
19
+ if kind_obj is None:
20
+ return None
21
+ if len(parts) == 1:
22
+ return kind_obj.get("key")
23
+
24
+ rest = parts[1]
25
+ cur: Any = kind_obj
26
+ for p in rest.split("."):
27
+ if not isinstance(cur, dict):
28
+ return None
29
+ # Defensive: never traverse dunder / class-internal keys.
30
+ if p.startswith("__") and p.endswith("__"):
31
+ return None
32
+ if p not in cur:
33
+ return None
34
+ cur = cur[p]
35
+ return cur
36
+
37
+
38
+ def read_context_key(ctx: EvalContext, kind_key: str) -> str | None:
39
+ """Pull just the `.key` for a context kind. Falls back to
40
+ targeting_key for "user", matching OpenFeature semantics."""
41
+ obj = _read_kind(ctx, kind_key)
42
+ if obj is None:
43
+ return None
44
+ key = obj.get("key")
45
+ return key if isinstance(key, str) else None
46
+
47
+
48
+ def _read_kind(ctx: EvalContext, kind_key: str) -> dict[str, Any] | None:
49
+ if kind_key == "user":
50
+ obj = ctx.kinds.get("user")
51
+ if isinstance(obj, dict):
52
+ return obj
53
+ if ctx.targeting_key:
54
+ return {"key": ctx.targeting_key}
55
+ return None
56
+ obj = ctx.kinds.get(kind_key)
57
+ return obj if isinstance(obj, dict) else None
@@ -0,0 +1,170 @@
1
+ """Wire-format types. JSON field names mirror @feathq/datafile-schema."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any
5
+
6
+
7
+ @dataclass
8
+ class VariationSpec:
9
+ id: str
10
+ name: str
11
+ value: Any
12
+
13
+
14
+ @dataclass
15
+ class TargetSpec:
16
+ contextKindKey: str
17
+ contextKey: str
18
+ variationId: str
19
+
20
+
21
+ @dataclass
22
+ class ConditionSpec:
23
+ attributePath: str
24
+ operator: str
25
+ values: list[Any]
26
+
27
+
28
+ @dataclass
29
+ class ConditionGroupSpec:
30
+ conditions: list[ConditionSpec]
31
+
32
+
33
+ @dataclass
34
+ class RolloutVariation:
35
+ variationId: str
36
+ weight: int
37
+
38
+
39
+ @dataclass
40
+ class Rollout:
41
+ bucketingContextKindKey: str
42
+ variations: list[RolloutVariation]
43
+
44
+
45
+ @dataclass
46
+ class RuleSpec:
47
+ id: str
48
+ bucketingContextKindKey: str | None
49
+ variationId: str | None
50
+ rollout: Rollout | None
51
+ groups: list[ConditionGroupSpec]
52
+
53
+
54
+ @dataclass
55
+ class FlagSpec:
56
+ id: str
57
+ key: str
58
+ valueType: str
59
+ salt: str
60
+ archived: bool
61
+ isEnabled: bool
62
+ offVariationId: str
63
+ defaultVariationId: str | None
64
+ defaultRollout: Rollout | None
65
+ defaultBucketingContextKindKey: str | None
66
+ variations: list[VariationSpec]
67
+ targets: list[TargetSpec]
68
+ rules: list[RuleSpec]
69
+
70
+
71
+ @dataclass
72
+ class SegmentRuleSpec:
73
+ conditions: list[ConditionSpec]
74
+
75
+
76
+ @dataclass
77
+ class SegmentSpec:
78
+ key: str
79
+ rules: list[SegmentRuleSpec]
80
+
81
+
82
+ @dataclass
83
+ class ContextKindSpec:
84
+ key: str
85
+ availableForRules: bool
86
+ availableForExperiments: bool
87
+
88
+
89
+ @dataclass
90
+ class Datafile:
91
+ schemaVersion: int
92
+ envId: str
93
+ envKey: str
94
+ projectId: str
95
+ version: int
96
+ etag: str
97
+ generatedAt: str
98
+ flags: dict[str, FlagSpec]
99
+ segments: dict[str, SegmentSpec] = field(default_factory=dict)
100
+ contextKinds: dict[str, ContextKindSpec] = field(default_factory=dict)
101
+
102
+
103
+ def from_json(data: dict[str, Any]) -> Datafile:
104
+ """Parse the wire-format dict (typically from response.json()) into a Datafile."""
105
+ return Datafile(
106
+ schemaVersion=data["schemaVersion"],
107
+ envId=data["envId"],
108
+ envKey=data["envKey"],
109
+ projectId=data["projectId"],
110
+ version=data["version"],
111
+ etag=data["etag"],
112
+ generatedAt=data["generatedAt"],
113
+ flags={k: _flag(v) for k, v in data["flags"].items()},
114
+ segments={k: _segment(v) for k, v in data.get("segments", {}).items()},
115
+ contextKinds={
116
+ k: ContextKindSpec(**v) for k, v in data.get("contextKinds", {}).items()
117
+ },
118
+ )
119
+
120
+
121
+ def _flag(d: dict[str, Any]) -> FlagSpec:
122
+ return FlagSpec(
123
+ id=d["id"],
124
+ key=d["key"],
125
+ valueType=d["valueType"],
126
+ salt=d["salt"],
127
+ archived=d["archived"],
128
+ isEnabled=d["isEnabled"],
129
+ offVariationId=d["offVariationId"],
130
+ defaultVariationId=d.get("defaultVariationId"),
131
+ defaultRollout=_rollout(d.get("defaultRollout")),
132
+ defaultBucketingContextKindKey=d.get("defaultBucketingContextKindKey"),
133
+ variations=[VariationSpec(**v) for v in d["variations"]],
134
+ targets=[TargetSpec(**t) for t in d["targets"]],
135
+ rules=[_rule(r) for r in d["rules"]],
136
+ )
137
+
138
+
139
+ def _rule(d: dict[str, Any]) -> RuleSpec:
140
+ return RuleSpec(
141
+ id=d["id"],
142
+ bucketingContextKindKey=d.get("bucketingContextKindKey"),
143
+ variationId=d.get("variationId"),
144
+ rollout=_rollout(d.get("rollout")),
145
+ groups=[
146
+ ConditionGroupSpec(
147
+ conditions=[ConditionSpec(**c) for c in g["conditions"]]
148
+ )
149
+ for g in d["groups"]
150
+ ],
151
+ )
152
+
153
+
154
+ def _rollout(d: dict[str, Any] | None) -> Rollout | None:
155
+ if d is None:
156
+ return None
157
+ return Rollout(
158
+ bucketingContextKindKey=d["bucketingContextKindKey"],
159
+ variations=[RolloutVariation(**v) for v in d["variations"]],
160
+ )
161
+
162
+
163
+ def _segment(d: dict[str, Any]) -> SegmentSpec:
164
+ return SegmentSpec(
165
+ key=d["key"],
166
+ rules=[
167
+ SegmentRuleSpec(conditions=[ConditionSpec(**c) for c in r["conditions"]])
168
+ for r in d["rules"]
169
+ ],
170
+ )
@@ -0,0 +1,115 @@
1
+ """Evaluation precedence pipeline. Mirrors @feathq/feat-eval bit-for-bit."""
2
+
3
+ from dataclasses import dataclass
4
+ from enum import Enum
5
+ from typing import Any
6
+
7
+ from .bucketing import bucket, pick_by_weight
8
+ from .context import read_context_key
9
+ from .datafile import Datafile, FlagSpec, RuleSpec
10
+ from .segments import match_condition
11
+ from .types import EvalContext
12
+
13
+
14
+ class Reason(str, Enum):
15
+ TARGETING_MATCH = "TARGETING_MATCH"
16
+ SPLIT = "SPLIT"
17
+ FALLTHROUGH = "FALLTHROUGH"
18
+ DEFAULT = "DEFAULT"
19
+ DISABLED = "DISABLED"
20
+ ERROR = "ERROR"
21
+ STATIC = "STATIC"
22
+
23
+
24
+ @dataclass
25
+ class EvaluationResult:
26
+ value: Any
27
+ variation_id: str | None
28
+ reason: Reason
29
+ error_message: str | None = None
30
+
31
+
32
+ def evaluate(
33
+ flag_key: str,
34
+ default_value: Any,
35
+ ctx: EvalContext,
36
+ df: Datafile,
37
+ ) -> EvaluationResult:
38
+ """Run the evaluation pipeline:
39
+
40
+ 1. archived flag -> off variation DISABLED
41
+ 2. !isEnabled -> off variation DISABLED
42
+ 3. individual target -> target variation TARGETING_MATCH
43
+ 4. first matching rule -> rule variation/rollout TARGETING_MATCH / SPLIT
44
+ 5. default -> default variation/rollout FALLTHROUGH / SPLIT
45
+ 6. nothing matched -> off variation DEFAULT
46
+
47
+ Errors (missing flag, missing variation) return default_value with
48
+ reason ERROR.
49
+ """
50
+ flag = df.flags.get(flag_key)
51
+ if flag is None:
52
+ return EvaluationResult(
53
+ value=default_value,
54
+ variation_id=None,
55
+ reason=Reason.ERROR,
56
+ error_message="flag could not be evaluated",
57
+ )
58
+
59
+ if flag.archived or not flag.isEnabled:
60
+ return _resolve_variation(flag, flag.offVariationId, Reason.DISABLED, default_value)
61
+
62
+ for target in flag.targets:
63
+ ctx_key = read_context_key(ctx, target.contextKindKey)
64
+ if ctx_key is not None and ctx_key == target.contextKey:
65
+ return _resolve_variation(flag, target.variationId, Reason.TARGETING_MATCH, default_value)
66
+
67
+ for rule in flag.rules:
68
+ if not _match_rule(rule, ctx, df):
69
+ continue
70
+ if rule.variationId is not None:
71
+ return _resolve_variation(flag, rule.variationId, Reason.TARGETING_MATCH, default_value)
72
+ if rule.rollout is not None:
73
+ picked = _pick_rollout(flag, rule.rollout, ctx)
74
+ if picked is not None:
75
+ return _resolve_variation(flag, picked, Reason.SPLIT, default_value)
76
+
77
+ if flag.defaultVariationId is not None:
78
+ return _resolve_variation(flag, flag.defaultVariationId, Reason.FALLTHROUGH, default_value)
79
+ if flag.defaultRollout is not None:
80
+ picked = _pick_rollout(flag, flag.defaultRollout, ctx)
81
+ if picked is not None:
82
+ return _resolve_variation(flag, picked, Reason.SPLIT, default_value)
83
+
84
+ return _resolve_variation(flag, flag.offVariationId, Reason.DEFAULT, default_value)
85
+
86
+
87
+ def _match_rule(rule: RuleSpec, ctx: EvalContext, df: Datafile) -> bool:
88
+ if not rule.groups:
89
+ return False
90
+ return any(
91
+ all(match_condition(cond, ctx, df) for cond in group.conditions)
92
+ and len(group.conditions) > 0
93
+ for group in rule.groups
94
+ )
95
+
96
+
97
+ def _pick_rollout(flag: FlagSpec, rollout, ctx: EvalContext) -> str | None:
98
+ ctx_key = read_context_key(ctx, rollout.bucketingContextKindKey)
99
+ if ctx_key is None:
100
+ return None
101
+ return pick_by_weight(bucket(flag.salt, flag.key, ctx_key), rollout.variations)
102
+
103
+
104
+ def _resolve_variation(
105
+ flag: FlagSpec, variation_id: str, reason: Reason, default_value: Any
106
+ ) -> EvaluationResult:
107
+ for v in flag.variations:
108
+ if v.id == variation_id:
109
+ return EvaluationResult(value=v.value, variation_id=variation_id, reason=reason)
110
+ return EvaluationResult(
111
+ value=default_value,
112
+ variation_id=None,
113
+ reason=Reason.ERROR,
114
+ error_message="flag could not be evaluated",
115
+ )
@@ -0,0 +1,226 @@
1
+ """Per-operator predicates. Defensive: type-mismatch / parse-failure
2
+ returns False rather than raising — matches the JS engine's posture
3
+ against malformed contexts at the edge.
4
+
5
+ segment_match / segment_not_match are dispatched by the rule evaluator
6
+ (they recurse into the datafile's segments map), not here.
7
+ """
8
+
9
+ import re
10
+ from datetime import datetime, timezone
11
+ from typing import Any, Callable
12
+
13
+
14
+ def match_operator(operator: str, lhs: Any, values: list[Any]) -> bool:
15
+ fn = _OPS.get(operator)
16
+ if fn is None:
17
+ return False
18
+ return fn(lhs, values)
19
+
20
+
21
+ def _is_one_of(lhs: Any, values: list[Any]) -> bool:
22
+ return any(_deep_eq(lhs, v) for v in values)
23
+
24
+
25
+ def _is_not_one_of(lhs: Any, values: list[Any]) -> bool:
26
+ return not _is_one_of(lhs, values)
27
+
28
+
29
+ def _is_empty(lhs: Any, _: list[Any]) -> bool:
30
+ return lhs is None or lhs == ""
31
+
32
+
33
+ def _is_not_empty(lhs: Any, _: list[Any]) -> bool:
34
+ return not _is_empty(lhs, _)
35
+
36
+
37
+ def _contains(lhs: Any, values: list[Any]) -> bool:
38
+ if not isinstance(lhs, str):
39
+ return False
40
+ return any(isinstance(v, str) and v in lhs for v in values)
41
+
42
+
43
+ def _does_not_contain(lhs: Any, values: list[Any]) -> bool:
44
+ if not isinstance(lhs, str):
45
+ return True
46
+ return not any(isinstance(v, str) and v in lhs for v in values)
47
+
48
+
49
+ def _starts_with(lhs: Any, values: list[Any]) -> bool:
50
+ if not isinstance(lhs, str):
51
+ return False
52
+ return any(isinstance(v, str) and lhs.startswith(v) for v in values)
53
+
54
+
55
+ def _ends_with(lhs: Any, values: list[Any]) -> bool:
56
+ if not isinstance(lhs, str):
57
+ return False
58
+ return any(isinstance(v, str) and lhs.endswith(v) for v in values)
59
+
60
+
61
+ # ReDoS guard: cap pattern length and reject the most common catastrophic-
62
+ # backtracking shapes (nested unbounded quantifiers, alternation inside a
63
+ # starred group). False positives just turn the rule into a non-match,
64
+ # which is the safe default.
65
+ _REDOS_SHAPES = re.compile(r"\([^)]*[+*][^)]*\)\s*[+*]|\([^)]*\|[^)]*\)\s*[+*]")
66
+
67
+
68
+ def _is_safe_regex(pattern: str) -> bool:
69
+ if len(pattern) > 512:
70
+ return False
71
+ if _REDOS_SHAPES.search(pattern):
72
+ return False
73
+ return True
74
+
75
+
76
+ def _matches_regex(lhs: Any, values: list[Any]) -> bool:
77
+ if not isinstance(lhs, str):
78
+ return False
79
+ for v in values:
80
+ if not isinstance(v, str):
81
+ continue
82
+ if not _is_safe_regex(v):
83
+ continue
84
+ try:
85
+ if re.search(v, lhs) is not None:
86
+ return True
87
+ except re.error:
88
+ continue
89
+ return False
90
+
91
+
92
+ def _deep_eq(a: Any, b: Any) -> bool:
93
+ if a == b:
94
+ return True
95
+ # String/number coercion — matches JS engine.
96
+ if isinstance(a, (int, float)) and isinstance(b, str):
97
+ return str(a) == b or _num_str_eq(a, b)
98
+ if isinstance(a, str) and isinstance(b, (int, float)):
99
+ return a == str(b) or _num_str_eq(b, a)
100
+ return False
101
+
102
+
103
+ def _num_str_eq(num: Any, s: str) -> bool:
104
+ try:
105
+ return float(num) == float(s)
106
+ except (TypeError, ValueError):
107
+ return False
108
+
109
+
110
+ def _to_number(x: Any) -> float | None:
111
+ if isinstance(x, bool):
112
+ return None # bool isinstance of int — exclude explicitly
113
+ if isinstance(x, (int, float)):
114
+ return float(x)
115
+ if isinstance(x, str):
116
+ try:
117
+ return float(x)
118
+ except ValueError:
119
+ return None
120
+ return None
121
+
122
+
123
+ def _numeric_cmp(cmp: Callable[[float, float], bool]) -> Callable[[Any, list[Any]], bool]:
124
+ def fn(lhs: Any, values: list[Any]) -> bool:
125
+ a = _to_number(lhs)
126
+ if a is None:
127
+ return False
128
+ for v in values:
129
+ b = _to_number(v)
130
+ if b is not None and cmp(a, b):
131
+ return True
132
+ return False
133
+ return fn
134
+
135
+
136
+ def _to_datetime(x: Any) -> datetime | None:
137
+ if isinstance(x, str):
138
+ try:
139
+ # Support ISO-8601 with or without "Z" / offset.
140
+ s = x.replace("Z", "+00:00")
141
+ return datetime.fromisoformat(s)
142
+ except ValueError:
143
+ return None
144
+ if isinstance(x, (int, float)) and not isinstance(x, bool):
145
+ return datetime.fromtimestamp(float(x) / 1000.0, tz=timezone.utc)
146
+ return None
147
+
148
+
149
+ def _date_cmp(cmp: Callable[[datetime, datetime], bool]) -> Callable[[Any, list[Any]], bool]:
150
+ def fn(lhs: Any, values: list[Any]) -> bool:
151
+ a = _to_datetime(lhs)
152
+ if a is None:
153
+ return False
154
+ for v in values:
155
+ b = _to_datetime(v)
156
+ if b is not None and cmp(a, b):
157
+ return True
158
+ return False
159
+ return fn
160
+
161
+
162
+ _SEMVER_RE = re.compile(
163
+ r"^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$"
164
+ )
165
+
166
+
167
+ def _parse_semver(x: Any) -> tuple[int, int, int, str | None] | None:
168
+ if not isinstance(x, str):
169
+ return None
170
+ m = _SEMVER_RE.match(x.strip())
171
+ if m is None:
172
+ return None
173
+ return int(m.group(1)), int(m.group(2)), int(m.group(3)), m.group(4)
174
+
175
+
176
+ def _compare_semver(a: tuple[int, int, int, str | None], b: tuple[int, int, int, str | None]) -> int:
177
+ for i in range(3):
178
+ if a[i] != b[i]:
179
+ return a[i] - b[i]
180
+ ap, bp = a[3], b[3]
181
+ if ap == bp:
182
+ return 0
183
+ if ap is None:
184
+ return 1
185
+ if bp is None:
186
+ return -1
187
+ return (ap > bp) - (ap < bp)
188
+
189
+
190
+ def _semver_cmp(pred: Callable[[int], bool]) -> Callable[[Any, list[Any]], bool]:
191
+ def fn(lhs: Any, values: list[Any]) -> bool:
192
+ a = _parse_semver(lhs)
193
+ if a is None:
194
+ return False
195
+ for v in values:
196
+ b = _parse_semver(v)
197
+ if b is not None and pred(_compare_semver(a, b)):
198
+ return True
199
+ return False
200
+ return fn
201
+
202
+
203
+ _OPS: dict[str, Callable[[Any, list[Any]], bool]] = {
204
+ "is_one_of": _is_one_of,
205
+ "is_not_one_of": _is_not_one_of,
206
+ "is_empty": _is_empty,
207
+ "is_not_empty": _is_not_empty,
208
+ "contains": _contains,
209
+ "does_not_contain": _does_not_contain,
210
+ "starts_with": _starts_with,
211
+ "ends_with": _ends_with,
212
+ "matches_regex": _matches_regex,
213
+ "gt": _numeric_cmp(lambda a, b: a > b),
214
+ "gte": _numeric_cmp(lambda a, b: a >= b),
215
+ "lt": _numeric_cmp(lambda a, b: a < b),
216
+ "lte": _numeric_cmp(lambda a, b: a <= b),
217
+ "before": _date_cmp(lambda a, b: a < b),
218
+ "after": _date_cmp(lambda a, b: a > b),
219
+ "semver_eq": _semver_cmp(lambda c: c == 0),
220
+ "semver_gt": _semver_cmp(lambda c: c > 0),
221
+ "semver_gte": _semver_cmp(lambda c: c >= 0),
222
+ "semver_lt": _semver_cmp(lambda c: c < 0),
223
+ "semver_lte": _semver_cmp(lambda c: c <= 0),
224
+ "segment_match": lambda lhs, values: False,
225
+ "segment_not_match": lambda lhs, values: False,
226
+ }
@@ -0,0 +1,30 @@
1
+ """Segment matching with recursion for segment_match / segment_not_match."""
2
+
3
+ from .context import resolve_attribute
4
+ from .datafile import ConditionSpec, Datafile
5
+ from .operators import match_operator
6
+ from .types import EvalContext
7
+
8
+
9
+ def match_segment(segment_key: str, ctx: EvalContext, df: Datafile) -> bool:
10
+ seg = df.segments.get(segment_key)
11
+ if seg is None:
12
+ return False
13
+ return any(_match_segment_rule(rule.conditions, ctx, df) for rule in seg.rules)
14
+
15
+
16
+ def _match_segment_rule(conds: list[ConditionSpec], ctx: EvalContext, df: Datafile) -> bool:
17
+ if not conds:
18
+ return False
19
+ return all(match_condition(c, ctx, df) for c in conds)
20
+
21
+
22
+ def match_condition(cond: ConditionSpec, ctx: EvalContext, df: Datafile) -> bool:
23
+ if cond.operator == "segment_match":
24
+ keys = [v for v in cond.values if isinstance(v, str)]
25
+ return any(match_segment(k, ctx, df) for k in keys)
26
+ if cond.operator == "segment_not_match":
27
+ keys = [v for v in cond.values if isinstance(v, str)]
28
+ return not any(match_segment(k, ctx, df) for k in keys)
29
+ lhs = resolve_attribute(ctx, cond.attributePath)
30
+ return match_operator(cond.operator, lhs, cond.values)
@@ -0,0 +1,23 @@
1
+ """SDK-consumer-facing types.
2
+
3
+ EvalContext mirrors OpenFeature's pattern: a `targeting_key` shorthand
4
+ for `user.key`, and a kinds dict matching the datafile's `contextKinds`
5
+ map. Example:
6
+
7
+ EvalContext(
8
+ targeting_key="user-123",
9
+ kinds={
10
+ "user": {"key": "user-123", "email": "u@example.com"},
11
+ "organization": {"key": "acme", "plan": "pro"},
12
+ },
13
+ )
14
+ """
15
+
16
+ from dataclasses import dataclass, field
17
+ from typing import Any
18
+
19
+
20
+ @dataclass
21
+ class EvalContext:
22
+ targeting_key: str | None = None
23
+ kinds: dict[str, dict[str, Any]] = field(default_factory=dict)
@@ -0,0 +1,301 @@
1
+ """Parity suite — mirrors test/eval.test.ts (JS SDK) and feat/eval_test.go.
2
+
3
+ New cases should land in all three so we keep eval semantics aligned
4
+ across languages. Long-term these should become shared JSON fixtures.
5
+ """
6
+
7
+ from typing import Any
8
+
9
+ from feat import EvalContext, evaluate
10
+ from feat.datafile import (
11
+ ConditionGroupSpec,
12
+ ConditionSpec,
13
+ ContextKindSpec,
14
+ Datafile,
15
+ FlagSpec,
16
+ Rollout,
17
+ RolloutVariation,
18
+ RuleSpec,
19
+ SegmentRuleSpec,
20
+ SegmentSpec,
21
+ TargetSpec,
22
+ VariationSpec,
23
+ )
24
+ from feat.eval import Reason
25
+
26
+ TRUE_VAR = VariationSpec(id="var-true", name="true", value=True)
27
+ FALSE_VAR = VariationSpec(id="var-false", name="false", value=False)
28
+
29
+
30
+ def make_df(flags=None, segments=None) -> Datafile:
31
+ return Datafile(
32
+ schemaVersion=1,
33
+ envId="env-1",
34
+ envKey="staging",
35
+ projectId="proj-1",
36
+ version=1,
37
+ etag="etag",
38
+ generatedAt="2026-05-17T00:00:00Z",
39
+ flags=flags or {},
40
+ segments=segments or {},
41
+ contextKinds={
42
+ "user": ContextKindSpec(
43
+ key="user", availableForRules=True, availableForExperiments=True
44
+ ),
45
+ },
46
+ )
47
+
48
+
49
+ def bool_flag(**overrides: Any) -> FlagSpec:
50
+ defaults = dict(
51
+ id="flag-1",
52
+ key="checkout",
53
+ valueType="boolean",
54
+ salt="abcdef0123456789",
55
+ archived=False,
56
+ isEnabled=True,
57
+ offVariationId=FALSE_VAR.id,
58
+ defaultVariationId=FALSE_VAR.id,
59
+ defaultRollout=None,
60
+ defaultBucketingContextKindKey=None,
61
+ variations=[TRUE_VAR, FALSE_VAR],
62
+ targets=[],
63
+ rules=[],
64
+ )
65
+ defaults.update(overrides)
66
+ return FlagSpec(**defaults)
67
+
68
+
69
+ def user_ctx(key: str, **attrs: Any) -> EvalContext:
70
+ obj: dict[str, Any] = {"key": key, **attrs}
71
+ return EvalContext(kinds={"user": obj})
72
+
73
+
74
+ def test_archived_returns_off():
75
+ df = make_df(flags={"checkout": bool_flag(archived=True)})
76
+ r = evaluate("checkout", False, user_ctx("u1"), df)
77
+ assert r.value is False
78
+ assert r.reason == Reason.DISABLED
79
+
80
+
81
+ def test_disabled_returns_off():
82
+ df = make_df(flags={"checkout": bool_flag(isEnabled=False)})
83
+ r = evaluate("checkout", True, user_ctx("u1"), df)
84
+ assert r.value is False
85
+ assert r.reason == Reason.DISABLED
86
+
87
+
88
+ def test_default_when_no_targeting():
89
+ df = make_df(flags={"checkout": bool_flag()})
90
+ r = evaluate("checkout", True, user_ctx("u1"), df)
91
+ assert r.value is False
92
+ assert r.reason == Reason.FALLTHROUGH
93
+
94
+
95
+ def test_individual_target_beats_rules():
96
+ flag = bool_flag(
97
+ targets=[TargetSpec(contextKindKey="user", contextKey="u-vip", variationId=TRUE_VAR.id)],
98
+ rules=[
99
+ RuleSpec(
100
+ id="r1",
101
+ bucketingContextKindKey=None,
102
+ variationId=FALSE_VAR.id,
103
+ rollout=None,
104
+ groups=[
105
+ ConditionGroupSpec(
106
+ conditions=[
107
+ ConditionSpec(
108
+ attributePath="user.key",
109
+ operator="is_one_of",
110
+ values=["u-vip"],
111
+ )
112
+ ]
113
+ )
114
+ ],
115
+ )
116
+ ],
117
+ )
118
+ df = make_df(flags={"checkout": flag})
119
+ r = evaluate("checkout", False, user_ctx("u-vip"), df)
120
+ assert r.value is True
121
+ assert r.reason == Reason.TARGETING_MATCH
122
+
123
+
124
+ def test_rule_ends_with_email():
125
+ flag = bool_flag(
126
+ rules=[
127
+ RuleSpec(
128
+ id="r1",
129
+ bucketingContextKindKey=None,
130
+ variationId=TRUE_VAR.id,
131
+ rollout=None,
132
+ groups=[
133
+ ConditionGroupSpec(
134
+ conditions=[
135
+ ConditionSpec(
136
+ attributePath="user.email",
137
+ operator="ends_with",
138
+ values=["@example.com"],
139
+ )
140
+ ]
141
+ )
142
+ ],
143
+ )
144
+ ]
145
+ )
146
+ df = make_df(flags={"checkout": flag})
147
+ r = evaluate("checkout", False, user_ctx("u1", email="alice@example.com"), df)
148
+ assert r.value is True
149
+ assert r.reason == Reason.TARGETING_MATCH
150
+
151
+
152
+ def test_rule_or_groups():
153
+ flag = bool_flag(
154
+ rules=[
155
+ RuleSpec(
156
+ id="r1",
157
+ bucketingContextKindKey=None,
158
+ variationId=TRUE_VAR.id,
159
+ rollout=None,
160
+ groups=[
161
+ ConditionGroupSpec(
162
+ conditions=[
163
+ ConditionSpec(
164
+ attributePath="user.email",
165
+ operator="ends_with",
166
+ values=["@nope.com"],
167
+ )
168
+ ]
169
+ ),
170
+ ConditionGroupSpec(
171
+ conditions=[
172
+ ConditionSpec(
173
+ attributePath="user.plan",
174
+ operator="is_one_of",
175
+ values=["pro", "enterprise"],
176
+ )
177
+ ]
178
+ ),
179
+ ],
180
+ )
181
+ ]
182
+ )
183
+ df = make_df(flags={"checkout": flag})
184
+ r = evaluate(
185
+ "checkout", False, user_ctx("u1", email="x@elsewhere.com", plan="pro"), df
186
+ )
187
+ assert r.value is True
188
+
189
+
190
+ def test_rollout_deterministic():
191
+ flag = bool_flag(
192
+ defaultVariationId=None,
193
+ defaultRollout=Rollout(
194
+ bucketingContextKindKey="user",
195
+ variations=[
196
+ RolloutVariation(variationId=TRUE_VAR.id, weight=50_000),
197
+ RolloutVariation(variationId=FALSE_VAR.id, weight=50_000),
198
+ ],
199
+ ),
200
+ )
201
+ df = make_df(flags={"checkout": flag})
202
+ r1 = evaluate("checkout", False, user_ctx("stable-key"), df)
203
+ r2 = evaluate("checkout", False, user_ctx("stable-key"), df)
204
+ assert r1.value == r2.value
205
+ assert r1.reason == Reason.SPLIT
206
+
207
+
208
+ def test_rollout_100_percent():
209
+ flag = bool_flag(
210
+ defaultVariationId=None,
211
+ defaultRollout=Rollout(
212
+ bucketingContextKindKey="user",
213
+ variations=[RolloutVariation(variationId=TRUE_VAR.id, weight=100_000)],
214
+ ),
215
+ )
216
+ df = make_df(flags={"checkout": flag})
217
+ for key in ["u1", "u2", "u3", "u4", "u5"]:
218
+ r = evaluate("checkout", False, user_ctx(key), df)
219
+ assert r.value is True
220
+
221
+
222
+ def test_segment_match():
223
+ flag = bool_flag(
224
+ rules=[
225
+ RuleSpec(
226
+ id="r1",
227
+ bucketingContextKindKey=None,
228
+ variationId=TRUE_VAR.id,
229
+ rollout=None,
230
+ groups=[
231
+ ConditionGroupSpec(
232
+ conditions=[
233
+ ConditionSpec(
234
+ attributePath="",
235
+ operator="segment_match",
236
+ values=["internal-users"],
237
+ )
238
+ ]
239
+ )
240
+ ],
241
+ )
242
+ ]
243
+ )
244
+ segs = {
245
+ "internal-users": SegmentSpec(
246
+ key="internal-users",
247
+ rules=[
248
+ SegmentRuleSpec(
249
+ conditions=[
250
+ ConditionSpec(
251
+ attributePath="user.email",
252
+ operator="ends_with",
253
+ values=["@feathq.com"],
254
+ )
255
+ ]
256
+ )
257
+ ],
258
+ )
259
+ }
260
+ df = make_df(flags={"checkout": flag}, segments=segs)
261
+
262
+ hit = evaluate("checkout", False, user_ctx("u1", email="bob@feathq.com"), df)
263
+ assert hit.value is True
264
+ miss = evaluate("checkout", False, user_ctx("u2", email="bob@other.com"), df)
265
+ assert miss.value is False
266
+
267
+
268
+ def test_semver_gte():
269
+ flag = bool_flag(
270
+ rules=[
271
+ RuleSpec(
272
+ id="r1",
273
+ bucketingContextKindKey=None,
274
+ variationId=TRUE_VAR.id,
275
+ rollout=None,
276
+ groups=[
277
+ ConditionGroupSpec(
278
+ conditions=[
279
+ ConditionSpec(
280
+ attributePath="user.app_version",
281
+ operator="semver_gte",
282
+ values=["1.2.0"],
283
+ )
284
+ ]
285
+ )
286
+ ],
287
+ )
288
+ ]
289
+ )
290
+ df = make_df(flags={"checkout": flag})
291
+ newer = evaluate("checkout", False, user_ctx("u1", app_version="1.5.0"), df)
292
+ assert newer.value is True
293
+ older = evaluate("checkout", False, user_ctx("u2", app_version="1.1.5"), df)
294
+ assert older.value is False
295
+
296
+
297
+ def test_missing_flag_returns_error():
298
+ df = make_df()
299
+ r = evaluate("missing", "fallback", user_ctx("u1"), df)
300
+ assert r.reason == Reason.ERROR
301
+ assert r.value == "fallback"