Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ jobs:
python-version: ['3.10', '3.14']

steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7

- uses: actions/setup-python@v6
with:
Expand All @@ -41,7 +41,7 @@ jobs:
timeout-minutes: 10

steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7

- uses: actions/setup-python@v6
with:
Expand Down Expand Up @@ -93,14 +93,14 @@ jobs:
timeout-minutes: 15

steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7

- uses: actions/setup-python@v6
with:
python-version: '3.14'
cache: pip

- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version-file: '.nvmrc'

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ jobs:

steps:
- name: Check out
uses: actions/checkout@v6
uses: actions/checkout@v7

- name: Set up Python
uses: actions/setup-python@v6
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ f: Featurevisor = create_featurevisor({

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.

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.

## Initialization

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

## Child instance

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.

```python
child = f.spawn({"country": "de"})
child.is_enabled("my_feature")
child.evaluate_flag("my_feature")
child.evaluate_variation("my_feature")
child.evaluate_variable("my_feature", "my_variable")
```

## Close
Expand Down
175 changes: 172 additions & 3 deletions conformance/sdk-v3.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"version": 1,
"version": 2,
"description": "Featurevisor v3 cross SDK compatibility contracts",
"bucketing": {
"minimum": 0,
Expand All @@ -26,7 +26,47 @@
"pattern": "chrome",
"flags": "g",
"values": ["chrome", "chrome", "firefox", "chrome"],
"matches": [true, true, false, true]
"matches": [true, true, false, true],
"portableCases": [
{
"pattern": "^chrome$",
"flags": "",
"value": "chrome",
"expected": true
},
{
"pattern": "^(chrome|firefox)$",
"flags": "i",
"value": "Firefox",
"expected": true
},
{
"pattern": "^second$",
"flags": "m",
"value": "first\nsecond",
"expected": true
},
{
"pattern": "first.*second",
"flags": "s",
"value": "first\nsecond",
"expected": true
},
{
"pattern": "\\(literal\\)",
"flags": "g",
"value": "(literal)",
"expected": true
}
],
"rejectedSyntax": [
"foo(?=bar)",
"(?<=foo)bar",
"(?:foo|bar)",
"(?<name>foo)",
"(foo)\\1",
"foo++"
]
},
"typedVariables": [
{ "type": "integer", "value": 1, "valid": true },
Expand All @@ -44,6 +84,135 @@
"diagnostics": {
"requiredFields": ["level", "code", "message", "details"],
"detailsType": "object",
"emptyDetailsJson": "{}"
"emptyDetailsJson": "{}",
"evaluationDetailFields": ["featureKey", "variableKey", "reason", "evaluation"],
"moduleEnvelopeFields": ["module", "moduleName", "originalError"],
"errorEventLevels": ["error"]
},
"numericBucketKeys": [
{ "value": 1.2345678901234567, "expected": "1.2345678901234567" },
{ "value": 0.30000000000000004, "expected": "0.30000000000000004" },
{ "value": 0.000001, "expected": "0.000001" },
{ "value": 1e-7, "expected": "1e-7" },
{ "value": 100000000000000000000, "expected": "100000000000000000000" },
{ "value": 1e21, "expected": "1e+21" }
],
"portableConditions": {
"regexFlags": ["g", "i", "m", "s"],
"rejectedRegexFlags": ["d", "u", "v", "y"],
"dateFormat": "ISO 8601 with an explicit timezone",
"dates": [
"2024-01-01T00:00:00Z",
"2024-01-01T01:00:00+01:00",
"2024-01-01T00:00:00.250Z",
"2024-01-01T01:00:00.250+01:00"
],
"semanticVersions": [
"1.2.3",
"1.2.3-beta.1",
"1.2.3+build.5"
],
"invalidSemanticVersion": "invalid",
"invalidSemanticVersionDiagnosticCode": "condition_match_error"
},
"conditionCases": [
{
"name": "strict primitive equality",
"condition": {
"attribute": "value",
"operator": "equals",
"value": 1
},
"context": { "value": "1" },
"expected": false
},
{
"name": "not negates implicit and",
"condition": {
"not": [
{ "attribute": "country", "operator": "equals", "value": "us" },
{ "attribute": "device", "operator": "equals", "value": "mobile" }
]
},
"context": { "country": "us", "device": "desktop" },
"expected": true
},
{
"name": "not with nested or means none match",
"condition": {
"not": [
{
"or": [
{ "attribute": "country", "operator": "equals", "value": "us" },
{ "attribute": "country", "operator": "equals", "value": "nl" }
]
}
]
},
"context": { "country": "de" },
"expected": true
},
{
"name": "empty not fails defensively",
"condition": { "not": [] },
"context": {},
"expected": false
},
{
"name": "fractional ISO date with offset",
"condition": {
"attribute": "date",
"operator": "before",
"value": "2024-01-01T00:00:00.500Z"
},
"context": { "date": "2024-01-01T01:00:00.250+01:00" },
"expected": true
}
],
"childInstances": {
"contextModel": "snapshot existing parent keys at spawn, inherit newly introduced parent keys, child keys win",
"closeRemovesLocalAndDelegatedSubscriptions": true,
"detailedEvaluationMethods": ["flag", "variation", "variable"],
"contextCase": {
"parentAtSpawn": { "country": "nl", "plan": "free" },
"child": { "country": "de" },
"parentAfterSpawn": { "country": "us", "plan": "pro", "region": "eu" },
"expected": { "country": "de", "plan": "free", "region": "eu" }
}
},
"defaults": {
"presenceBased": true,
"values": ["", 0, false, null],
"aggregateEvaluationPreservesEmptyVariation": true,
"aggregateCase": {
"datafile": {
"schemaVersion": "2",
"revision": "defaults",
"segments": {},
"features": {
"experiment": {
"key": "experiment",
"bucketBy": "userId",
"variations": [{ "value": "control" }],
"traffic": []
}
}
},
"defaultVariationValue": "",
"expected": {
"enabled": false,
"variation": ""
}
}
},
"diagnosticCase": {
"featureKey": "missing",
"expectedLevel": "warn",
"expectedCode": "feature_not_found",
"detailsMustBeObject": true
},
"nativeContexts": {
"numericTypesUseOneComparisonContract": true,
"primitiveNativeSlicesSupportIncludes": true
}
}
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "featurevisor"
version = "1.1.0"
version = "2.0.0"
description = "Featurevisor Python SDK"
readme = "README.md"
requires-python = ">=3.10"
Expand Down
41 changes: 38 additions & 3 deletions src/featurevisor/bucketer.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
from __future__ import annotations

import math
from decimal import Decimal

from .conditions import MISSING, get_value_from_context
from .murmurhash import murmurhash_v3
from .types import Context
Expand All @@ -10,13 +13,45 @@
DEFAULT_BUCKET_KEY_SEPARATOR = "."


def _to_javascript_string(value: object) -> str:
if value is None:
return ""
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, float):
if math.isnan(value):
return "NaN"
if math.isinf(value):
return "Infinity" if value > 0 else "-Infinity"
if value == 0:
return "0"

text = repr(value)
absolute = abs(value)
if "e" in text.lower() and 1e-6 <= absolute < 1e21:
return format(Decimal(text), "f")
if "e" in text.lower():
coefficient, exponent = text.lower().split("e")
if coefficient.endswith(".0"):
coefficient = coefficient[:-2]
parsed_exponent = int(exponent)
sign = "+" if parsed_exponent >= 0 else ""
return f"{coefficient}e{sign}{parsed_exponent}"
return text[:-2] if text.endswith(".0") else text
if isinstance(value, list):
return ",".join(_to_javascript_string(item) for item in value)
if isinstance(value, dict):
return "[object Object]"
return str(value)


def get_bucketed_number(bucket_key: str) -> int:
hash_value = murmurhash_v3(bucket_key, HASH_SEED)
ratio = hash_value / MAX_HASH_VALUE
return int(ratio * MAX_BUCKETED_NUMBER)


def get_bucket_key(*, featureKey: str, bucketBy, context: Context, logger) -> str:
def get_bucket_key(*, featureKey: str, bucketBy, context: Context, diagnostics) -> str:
if isinstance(bucketBy, str):
bucket_type = "plain"
attribute_keys = [bucketBy]
Expand All @@ -27,7 +62,7 @@ def get_bucket_key(*, featureKey: str, bucketBy, context: Context, logger) -> st
bucket_type = "or"
attribute_keys = bucketBy["or"]
else:
logger.error("invalid bucketBy", {"featureKey": featureKey, "bucketBy": bucketBy})
diagnostics.error("invalid bucketBy", {"featureKey": featureKey, "bucketBy": bucketBy})
raise ValueError("invalid bucketBy")

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