Skip to content

Commit 899bb27

Browse files
authored
feat: v3 alignments (#3)
1 parent fc24b6f commit 899bb27

19 files changed

Lines changed: 656 additions & 268 deletions

.github/workflows/checks.yml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ jobs:
2323
python-version: ['3.10', '3.14']
2424

2525
steps:
26-
- uses: actions/checkout@v6
26+
- uses: actions/checkout@v7
2727

2828
- uses: actions/setup-python@v6
2929
with:
@@ -41,7 +41,7 @@ jobs:
4141
timeout-minutes: 10
4242

4343
steps:
44-
- uses: actions/checkout@v6
44+
- uses: actions/checkout@v7
4545

4646
- uses: actions/setup-python@v6
4747
with:
@@ -93,14 +93,14 @@ jobs:
9393
timeout-minutes: 15
9494

9595
steps:
96-
- uses: actions/checkout@v6
96+
- uses: actions/checkout@v7
9797

9898
- uses: actions/setup-python@v6
9999
with:
100100
python-version: '3.14'
101101
cache: pip
102102

103-
- uses: actions/setup-node@v6
103+
- uses: actions/setup-node@v7
104104
with:
105105
node-version-file: '.nvmrc'
106106

.github/workflows/publish.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ jobs:
1616

1717
steps:
1818
- name: Check out
19-
uses: actions/checkout@v6
19+
uses: actions/checkout@v7
2020

2121
- name: Set up Python
2222
uses: actions/setup-python@v6

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@ f: Featurevisor = create_featurevisor({
8282

8383
Most applications only need `create_featurevisor` and the `Featurevisor` instance type. Public extension and observability APIs include `FeaturevisorModule`, diagnostics, events, and the datafile dictionaries accepted by the factory.
8484

85+
Concurrent evaluations are safe after an instance is configured. Do not mutate or close the same instance concurrently with evaluations. Serialize calls to `set_datafile`, `set_context`, `set_sticky`, `add_module`, `remove_module`, and `close`. Module, event, and diagnostic callbacks must synchronize mutable state that they capture.
86+
8587
## Initialization
8688

8789
Initialize the SDK with Featurevisor datafile content:
@@ -483,9 +485,14 @@ f.remove_module("my-module")
483485

484486
## Child instance
485487

488+
A child snapshots the parent keys that exist when it is spawned. Child values win for those keys. Parent keys introduced later are still inherited. Calling `close()` removes both child-owned listeners and subscriptions delegated to the parent.
489+
486490
```python
487491
child = f.spawn({"country": "de"})
488492
child.is_enabled("my_feature")
493+
child.evaluate_flag("my_feature")
494+
child.evaluate_variation("my_feature")
495+
child.evaluate_variable("my_feature", "my_variable")
489496
```
490497

491498
## Close

conformance/sdk-v3.json

Lines changed: 172 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"version": 1,
2+
"version": 2,
33
"description": "Featurevisor v3 cross SDK compatibility contracts",
44
"bucketing": {
55
"minimum": 0,
@@ -26,7 +26,47 @@
2626
"pattern": "chrome",
2727
"flags": "g",
2828
"values": ["chrome", "chrome", "firefox", "chrome"],
29-
"matches": [true, true, false, true]
29+
"matches": [true, true, false, true],
30+
"portableCases": [
31+
{
32+
"pattern": "^chrome$",
33+
"flags": "",
34+
"value": "chrome",
35+
"expected": true
36+
},
37+
{
38+
"pattern": "^(chrome|firefox)$",
39+
"flags": "i",
40+
"value": "Firefox",
41+
"expected": true
42+
},
43+
{
44+
"pattern": "^second$",
45+
"flags": "m",
46+
"value": "first\nsecond",
47+
"expected": true
48+
},
49+
{
50+
"pattern": "first.*second",
51+
"flags": "s",
52+
"value": "first\nsecond",
53+
"expected": true
54+
},
55+
{
56+
"pattern": "\\(literal\\)",
57+
"flags": "g",
58+
"value": "(literal)",
59+
"expected": true
60+
}
61+
],
62+
"rejectedSyntax": [
63+
"foo(?=bar)",
64+
"(?<=foo)bar",
65+
"(?:foo|bar)",
66+
"(?<name>foo)",
67+
"(foo)\\1",
68+
"foo++"
69+
]
3070
},
3171
"typedVariables": [
3272
{ "type": "integer", "value": 1, "valid": true },
@@ -44,6 +84,135 @@
4484
"diagnostics": {
4585
"requiredFields": ["level", "code", "message", "details"],
4686
"detailsType": "object",
47-
"emptyDetailsJson": "{}"
87+
"emptyDetailsJson": "{}",
88+
"evaluationDetailFields": ["featureKey", "variableKey", "reason", "evaluation"],
89+
"moduleEnvelopeFields": ["module", "moduleName", "originalError"],
90+
"errorEventLevels": ["error"]
91+
},
92+
"numericBucketKeys": [
93+
{ "value": 1.2345678901234567, "expected": "1.2345678901234567" },
94+
{ "value": 0.30000000000000004, "expected": "0.30000000000000004" },
95+
{ "value": 0.000001, "expected": "0.000001" },
96+
{ "value": 1e-7, "expected": "1e-7" },
97+
{ "value": 100000000000000000000, "expected": "100000000000000000000" },
98+
{ "value": 1e21, "expected": "1e+21" }
99+
],
100+
"portableConditions": {
101+
"regexFlags": ["g", "i", "m", "s"],
102+
"rejectedRegexFlags": ["d", "u", "v", "y"],
103+
"dateFormat": "ISO 8601 with an explicit timezone",
104+
"dates": [
105+
"2024-01-01T00:00:00Z",
106+
"2024-01-01T01:00:00+01:00",
107+
"2024-01-01T00:00:00.250Z",
108+
"2024-01-01T01:00:00.250+01:00"
109+
],
110+
"semanticVersions": [
111+
"1.2.3",
112+
"1.2.3-beta.1",
113+
"1.2.3+build.5"
114+
],
115+
"invalidSemanticVersion": "invalid",
116+
"invalidSemanticVersionDiagnosticCode": "condition_match_error"
117+
},
118+
"conditionCases": [
119+
{
120+
"name": "strict primitive equality",
121+
"condition": {
122+
"attribute": "value",
123+
"operator": "equals",
124+
"value": 1
125+
},
126+
"context": { "value": "1" },
127+
"expected": false
128+
},
129+
{
130+
"name": "not negates implicit and",
131+
"condition": {
132+
"not": [
133+
{ "attribute": "country", "operator": "equals", "value": "us" },
134+
{ "attribute": "device", "operator": "equals", "value": "mobile" }
135+
]
136+
},
137+
"context": { "country": "us", "device": "desktop" },
138+
"expected": true
139+
},
140+
{
141+
"name": "not with nested or means none match",
142+
"condition": {
143+
"not": [
144+
{
145+
"or": [
146+
{ "attribute": "country", "operator": "equals", "value": "us" },
147+
{ "attribute": "country", "operator": "equals", "value": "nl" }
148+
]
149+
}
150+
]
151+
},
152+
"context": { "country": "de" },
153+
"expected": true
154+
},
155+
{
156+
"name": "empty not fails defensively",
157+
"condition": { "not": [] },
158+
"context": {},
159+
"expected": false
160+
},
161+
{
162+
"name": "fractional ISO date with offset",
163+
"condition": {
164+
"attribute": "date",
165+
"operator": "before",
166+
"value": "2024-01-01T00:00:00.500Z"
167+
},
168+
"context": { "date": "2024-01-01T01:00:00.250+01:00" },
169+
"expected": true
170+
}
171+
],
172+
"childInstances": {
173+
"contextModel": "snapshot existing parent keys at spawn, inherit newly introduced parent keys, child keys win",
174+
"closeRemovesLocalAndDelegatedSubscriptions": true,
175+
"detailedEvaluationMethods": ["flag", "variation", "variable"],
176+
"contextCase": {
177+
"parentAtSpawn": { "country": "nl", "plan": "free" },
178+
"child": { "country": "de" },
179+
"parentAfterSpawn": { "country": "us", "plan": "pro", "region": "eu" },
180+
"expected": { "country": "de", "plan": "free", "region": "eu" }
181+
}
182+
},
183+
"defaults": {
184+
"presenceBased": true,
185+
"values": ["", 0, false, null],
186+
"aggregateEvaluationPreservesEmptyVariation": true,
187+
"aggregateCase": {
188+
"datafile": {
189+
"schemaVersion": "2",
190+
"revision": "defaults",
191+
"segments": {},
192+
"features": {
193+
"experiment": {
194+
"key": "experiment",
195+
"bucketBy": "userId",
196+
"variations": [{ "value": "control" }],
197+
"traffic": []
198+
}
199+
}
200+
},
201+
"defaultVariationValue": "",
202+
"expected": {
203+
"enabled": false,
204+
"variation": ""
205+
}
206+
}
207+
},
208+
"diagnosticCase": {
209+
"featureKey": "missing",
210+
"expectedLevel": "warn",
211+
"expectedCode": "feature_not_found",
212+
"detailsMustBeObject": true
213+
},
214+
"nativeContexts": {
215+
"numericTypesUseOneComparisonContract": true,
216+
"primitiveNativeSlicesSupportIncludes": true
48217
}
49218
}

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "featurevisor"
7-
version = "1.1.0"
7+
version = "2.0.0"
88
description = "Featurevisor Python SDK"
99
readme = "README.md"
1010
requires-python = ">=3.10"

src/featurevisor/bucketer.py

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
from __future__ import annotations
22

3+
import math
4+
from decimal import Decimal
5+
36
from .conditions import MISSING, get_value_from_context
47
from .murmurhash import murmurhash_v3
58
from .types import Context
@@ -10,13 +13,45 @@
1013
DEFAULT_BUCKET_KEY_SEPARATOR = "."
1114

1215

16+
def _to_javascript_string(value: object) -> str:
17+
if value is None:
18+
return ""
19+
if isinstance(value, bool):
20+
return "true" if value else "false"
21+
if isinstance(value, float):
22+
if math.isnan(value):
23+
return "NaN"
24+
if math.isinf(value):
25+
return "Infinity" if value > 0 else "-Infinity"
26+
if value == 0:
27+
return "0"
28+
29+
text = repr(value)
30+
absolute = abs(value)
31+
if "e" in text.lower() and 1e-6 <= absolute < 1e21:
32+
return format(Decimal(text), "f")
33+
if "e" in text.lower():
34+
coefficient, exponent = text.lower().split("e")
35+
if coefficient.endswith(".0"):
36+
coefficient = coefficient[:-2]
37+
parsed_exponent = int(exponent)
38+
sign = "+" if parsed_exponent >= 0 else ""
39+
return f"{coefficient}e{sign}{parsed_exponent}"
40+
return text[:-2] if text.endswith(".0") else text
41+
if isinstance(value, list):
42+
return ",".join(_to_javascript_string(item) for item in value)
43+
if isinstance(value, dict):
44+
return "[object Object]"
45+
return str(value)
46+
47+
1348
def get_bucketed_number(bucket_key: str) -> int:
1449
hash_value = murmurhash_v3(bucket_key, HASH_SEED)
1550
ratio = hash_value / MAX_HASH_VALUE
1651
return int(ratio * MAX_BUCKETED_NUMBER)
1752

1853

19-
def get_bucket_key(*, featureKey: str, bucketBy, context: Context, logger) -> str:
54+
def get_bucket_key(*, featureKey: str, bucketBy, context: Context, diagnostics) -> str:
2055
if isinstance(bucketBy, str):
2156
bucket_type = "plain"
2257
attribute_keys = [bucketBy]
@@ -27,7 +62,7 @@ def get_bucket_key(*, featureKey: str, bucketBy, context: Context, logger) -> st
2762
bucket_type = "or"
2863
attribute_keys = bucketBy["or"]
2964
else:
30-
logger.error("invalid bucketBy", {"featureKey": featureKey, "bucketBy": bucketBy})
65+
diagnostics.error("invalid bucketBy", {"featureKey": featureKey, "bucketBy": bucketBy})
3166
raise ValueError("invalid bucketBy")
3267

3368
bucket_key: list[object] = []
@@ -40,4 +75,4 @@ def get_bucket_key(*, featureKey: str, bucketBy, context: Context, logger) -> st
4075
elif not bucket_key:
4176
bucket_key.append(attribute_value)
4277
bucket_key.append(featureKey)
43-
return DEFAULT_BUCKET_KEY_SEPARATOR.join(str(part) for part in bucket_key)
78+
return DEFAULT_BUCKET_KEY_SEPARATOR.join(_to_javascript_string(part) for part in bucket_key)

0 commit comments

Comments
 (0)