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.
- feat_sdk-0.1.0/.github/workflows/publish.yml +37 -0
- feat_sdk-0.1.0/.gitignore +8 -0
- feat_sdk-0.1.0/LICENSE +21 -0
- feat_sdk-0.1.0/PKG-INFO +73 -0
- feat_sdk-0.1.0/README.md +47 -0
- feat_sdk-0.1.0/pyproject.toml +43 -0
- feat_sdk-0.1.0/src/feat/__init__.py +31 -0
- feat_sdk-0.1.0/src/feat/bucketing.py +41 -0
- feat_sdk-0.1.0/src/feat/client.py +146 -0
- feat_sdk-0.1.0/src/feat/context.py +57 -0
- feat_sdk-0.1.0/src/feat/datafile.py +170 -0
- feat_sdk-0.1.0/src/feat/eval.py +115 -0
- feat_sdk-0.1.0/src/feat/operators.py +226 -0
- feat_sdk-0.1.0/src/feat/segments.py +30 -0
- feat_sdk-0.1.0/src/feat/types.py +23 -0
- feat_sdk-0.1.0/tests/test_eval.py +301 -0
|
@@ -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
|
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.
|
feat_sdk-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
feat_sdk-0.1.0/README.md
ADDED
|
@@ -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"
|