diff --git a/README.md b/README.md index faa069b..de02609 100644 --- a/README.md +++ b/README.md @@ -19,10 +19,11 @@ This SDK is compatible with Featurevisor v3 projects and v2 datafiles. - [Manually passing context](#manually-passing-context) - [Check if enabled](#check-if-enabled) - [Getting variation](#getting-variation) -- [Getting variables](#getting-variables) - - [Type specific methods](#type-specific-methods) -- [Getting all evaluations](#getting-all-evaluations) -- [Sticky](#sticky) +- [Getting feature variables](#getting-feature-variables) +- [Getting global variables](#getting-global-variables) +- [Type specific methods](#type-specific-methods) +- [Getting evaluation snapshots](#getting-evaluation-snapshots) +- [Sticky features and variables](#sticky-features-and-variables) - [Initialize with sticky](#initialize-with-sticky) - [Set sticky afterwards](#set-sticky-afterwards) - [Setting datafile](#setting-datafile) @@ -37,7 +38,7 @@ This SDK is compatible with Featurevisor v3 projects and v2 datafiles. - [Events](#events) - [`datafile_set`](#datafile_set) - [`context_set`](#context_set) - - [`sticky_set`](#sticky_set) + - [`sticky_features_set` and `sticky_variables_set`](#sticky_features_set-and-sticky_variables_set) - [`error`](#error) - [Modules](#modules) - [Defining a module](#defining-a-module) @@ -82,7 +83,7 @@ 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. +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_features`, `set_sticky_variables`, `add_module`, `remove_module`, and `close`. Module, event, and diagnostic callbacks must synchronize mutable state that they capture. ## Initialization @@ -106,11 +107,12 @@ f = create_featurevisor({ ## Evaluation types -We can evaluate 3 types of values against a particular [feature](https://featurevisor.com/docs/features/): +Featurevisor evaluates flags, variations, feature variables, and global variables: - [**Flag**](#check-if-enabled) (`bool`): whether the feature is enabled or not - [**Variation**](#getting-variation) (`string`): the variation of the feature (if any) -- [**Variables**](#getting-variables): variable values of the feature (if any) +- [**Feature variables**](#getting-feature-variables): variable values owned by a feature +- [**Global variables**](#getting-global-variables): independently evaluated project values ## Context @@ -169,7 +171,8 @@ You can also pass additional per-evaluation context: ```python is_enabled = f.is_enabled("my_feature", {"country": "nl"}) variation = f.get_variation("my_feature", {"country": "nl"}) -variable_value = f.get_variable("my_feature", "my_variable", {"country": "nl"}) +feature_variable = f.get_variable("my_feature", "my_variable", {"country": "nl"}) +global_variable = f.get_variable("supportEmail", {"country": "nl"}) ``` ## Check if enabled @@ -188,13 +191,24 @@ if variation == "treatment": pass ``` -## Getting variables +## Getting feature variables ```python bg_color = f.get_variable("my_feature", "bgColor") ``` -### Type specific methods +## Getting global variables + +Global variables use the same methods without a feature key: + +```python +support_email = f.get_variable("supportEmail", {"country": "nl"}) +evaluation = f.evaluate_variable("supportEmail", {"country": "nl"}) +``` + +Global variables honour sticky values, required features, and the first matching override. When an override contains both conditions and segments, both must match. + +## Type specific methods Typed convenience methods are also available: @@ -208,33 +222,44 @@ f.get_variable_object(feature_key, variable_key, context={}) f.get_variable_json(feature_key, variable_key, context={}) ``` +The same helpers evaluate global variables when the feature key is omitted: + +```python +f.get_variable_string("supportEmail", context={}) +f.get_variable_object("checkoutSettings", context={}) +``` + Type specific methods do not coerce values. `get_variable_integer()` returns `None` for the string `"1"`, and boolean getters return `None` for non-boolean values. -## Getting all evaluations +## Getting evaluation snapshots ```python -all_evaluations = f.get_all_evaluations() +features = f.get_feature_evaluations() +variables = f.get_variable_evaluations() ``` -## Sticky +## Sticky features and variables ### Initialize with sticky You can pin feature evaluations with sticky values: -Sticky values belong to an SDK or child instance. Evaluation options do not accept sticky overrides; use `spawn(context, {"sticky": ...})` when a child needs its own sticky state. +Sticky values belong to an SDK or child instance. Evaluation options do not accept sticky overrides. Pass `stickyFeatures` or `stickyVariables` when a child needs its own state. ```python f = create_featurevisor({ - "sticky": { + "stickyFeatures": { "myFeatureKey": { "enabled": True, "variation": "treatment", "variables": { "myVariableKey": "myVariableValue", }, - } - } + }, + }, + "stickyVariables": { + "supportEmail": "sticky@example.com", + }, }) ``` @@ -243,11 +268,13 @@ f = create_featurevisor({ Or update them later: ```python -f.set_sticky({ +f.set_sticky_features({ "myFeatureKey": { "enabled": False, } }) + +f.set_sticky_variables({"supportEmail": "new@example.com"}) ``` ## Setting datafile @@ -389,10 +416,13 @@ unsubscribe = f.on("context_set", lambda event: print(event["context"])) unsubscribe() ``` -### `sticky_set` +### `sticky_features_set` and `sticky_variables_set` ```python -unsubscribe = f.on("sticky_set", lambda event: print(event["features"])) +unsubscribe = f.on("sticky_features_set", lambda event: print(event["features"])) +unsubscribe() + +unsubscribe = f.on("sticky_variables_set", lambda event: print(event["variables"])) unsubscribe() ``` @@ -437,6 +467,8 @@ And optionally these properties depending on whether you are evaluating a featur - `variableValue`: the variable value - `variableSchema`: the variable schema - `variableOverrideIndex`: index of matched variable override when applicable +- `variableOverrideKey`: key of the matched variable override when available +- `variableOverridePath`: nested authoring path of the matched global variable override ## Modules @@ -447,6 +479,8 @@ Modules can intercept evaluation and participate in SDK lifecycle: - `bucketKey` - `bucketValue` - `after` +- `beforeEvaluation` +- `afterEvaluation` - `close` ### Defining a module @@ -456,13 +490,17 @@ my_module = { "name": "my-module", "setup": lambda api: api["onDiagnostic"](lambda diagnostic: print(diagnostic)), "before": lambda options: {**options, "context": {**options["context"], "country": "nl"}}, + "beforeEvaluation": lambda options: options, "bucketKey": lambda options: options["bucketKey"], "bucketValue": lambda options: options["bucketValue"], "after": lambda evaluation, options: evaluation, + "afterEvaluation": lambda evaluation, options: evaluation, "close": lambda: None, } ``` +For feature evaluations, all `before` callbacks run in registration order, followed by all `beforeEvaluation` callbacks. After evaluation and caller defaults, all `afterEvaluation` callbacks run, followed by all `after` callbacks. Global variable evaluations use only `beforeEvaluation` and `afterEvaluation`. Required feature checks run through the complete module pipeline, and transformed defaults are preserved. + The module API passed to `setup` exposes `getRevision`, `onDiagnostic`, and `reportDiagnostic`. If `setup` raises an exception, the module is not registered. Featurevisor removes subscriptions created during setup, reports `module_setup_error`, and calls `close` when present. @@ -493,6 +531,7 @@ child.is_enabled("my_feature") child.evaluate_flag("my_feature") child.evaluate_variation("my_feature") child.evaluate_variable("my_feature", "my_variable") +child.evaluate_variable("supportEmail") ``` ## Close @@ -571,6 +610,16 @@ python -m featurevisor benchmark \ --context='{"userId":"123"}' ``` +For a global variable, omit `--feature`: + +```bash +python -m featurevisor benchmark \ + --projectDirectoryPath=/path/to/featurevisor-project \ + --environment=production \ + --variable=supportEmail \ + --context='{"country":"nl"}' +``` + ### Assess distribution Inspect enabled/disabled and variation distribution over repeated evaluations: @@ -634,6 +683,7 @@ Call `api.shutdown()` during application shutdown. This closes a Featurevisor in | `checkout` | Boolean flag for `checkout` | | `checkout:variation` | Variation value for `checkout` | | `checkout:title` | Variable `title` for `checkout` | +| `variable:supportEmail` | Global variable `supportEmail` | Boolean variables use the boolean resolver. Integer and double variables use their matching numeric resolvers. Arrays, objects, and JSON variables use the object resolver. @@ -644,10 +694,11 @@ provider = FeaturevisorOpenFeatureProvider( {"datafile": datafile_content}, key_separator="/", variation_key="$variation", + global_variable_prefix="$variable", ) ``` -This makes `checkout/$variation` the variation key and `checkout/title` a variable key. +This makes `checkout/$variation` the variation key, `checkout/title` a feature variable key, and `$variable/supportEmail` a global variable key. The global prefix defaults to `variable` and cannot contain the separator. ### Context mapping @@ -668,9 +719,9 @@ The provider maps Featurevisor evaluation results to OpenFeature details: | Featurevisor result | OpenFeature result | | --- | --- | -| Required, forced, sticky, or rule match | `TARGETING_MATCH` | +| Required feature rule, forced, sticky, or rule match | `TARGETING_MATCH` | | Traffic allocation | `SPLIT` | -| Disabled variation or variable | `DISABLED` | +| Unmet global variable requirements, disabled variation, or disabled variable | `DISABLED` | | No match or variable default | `DEFAULT` | | Missing feature, variable, or variations | `ERROR` with `FLAG_NOT_FOUND` | | Wrong resolver type | `ERROR` with `TYPE_MISMATCH` | @@ -679,7 +730,7 @@ The provider maps Featurevisor evaluation results to OpenFeature details: Errors return the default value supplied to OpenFeature. A malformed datafile uses the stable message `Could not parse datafile`. A later successful `set_datafile` call clears the parse error. -Resolution metadata can include `featureKey`, `variableKey`, `featurevisorReason`, `revision`, `schemaVersion`, `ruleKey`, `bucketKey`, `bucketValue`, `forceIndex`, and `variableOverrideIndex`. The selected variation is exposed as the OpenFeature variant when available. +Resolution metadata can include `featureKey`, `variableKey`, `featurevisorReason`, `revision`, `schemaVersion`, `ruleKey`, `bucketKey`, `bucketValue`, `forceIndex`, `variableOverrideIndex`, and `variableOverrideKey`. The selected variation is exposed as the OpenFeature variant when available. ### Tracking diff --git a/conformance/sdk-v3.json b/conformance/sdk-v3.json index 49396ce..d5870a5 100644 --- a/conformance/sdk-v3.json +++ b/conformance/sdk-v3.json @@ -1,5 +1,5 @@ { - "version": 2, + "version": 6, "description": "Featurevisor v3 cross SDK compatibility contracts", "bucketing": { "minimum": 0, @@ -81,6 +81,594 @@ "schemaVersionIsInformational": true, "schemaVersionType": "string" }, + "globalVariables": { + "datafile": { + "schemaVersion": "2", + "revision": "global-variables", + "segments": { + "netherlands": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "enabledFeature": { + "bucketBy": "userId", + "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }] + }, + "disabledFeature": { + "bucketBy": "userId", + "traffic": [] + }, + "variationFeature": { + "bucketBy": "userId", + "variations": [{ "value": "control" }, { "value": "treatment" }], + "force": [{ "segments": "*", "enabled": true, "variation": "treatment" }], + "traffic": [] + }, + "shared": { + "bucketBy": "userId", + "variablesSchema": { + "owned": { "type": "string", "defaultValue": "feature-value" } + }, + "force": [{ "segments": "*", "enabled": true }], + "traffic": [] + } + }, + "variables": { + "shared": { "type": "string", "defaultValue": "global-value" }, + "stringValue": { "type": "string", "defaultValue": "hello" }, + "integerValue": { "type": "integer", "defaultValue": 1 }, + "doubleValue": { "type": "double", "defaultValue": 1.5 }, + "booleanValue": { "type": "boolean", "defaultValue": true }, + "arrayValue": { "type": "array", "defaultValue": ["one", "two"] }, + "objectValue": { "type": "object", "defaultValue": { "enabled": true } }, + "jsonValue": { "type": "json", "defaultValue": "{\"enabled\":true}" }, + "requiredDisabled": { + "type": "string", + "defaultValue": "default", + "disabledValue": "disabled", + "requiredFeatures": ["disabledFeature"] + }, + "requiredMissingValue": { + "type": "string", + "defaultValue": "default", + "requiredFeatures": ["disabledFeature"] + }, + "requiredUsesDefault": { + "type": "string", + "defaultValue": "default", + "disabledValue": "disabled", + "useDefaultWhenDisabled": true, + "requiredFeatures": ["disabledFeature"] + }, + "requiredVariation": { + "type": "string", + "defaultValue": "matched", + "disabledValue": "disabled", + "requiredFeatures": [{ "feature": "variationFeature", "variation": "treatment" }] + }, + "overrideRequirement": { + "type": "string", + "defaultValue": "default", + "overrides": [ + { + "key": "blocked", + "segments": "*", + "requiredFeatures": ["disabledFeature"], + "value": "blocked" + } + ] + }, + "orderedOverrides": { + "type": "string", + "defaultValue": "default", + "overrides": [ + { + "key": "blocked", + "segments": "*", + "requiredFeatures": ["disabledFeature"], + "value": "blocked" + }, + { + "key": "nl-pro", + "keyPath": ["europe", "netherlands", "pro"], + "segments": "netherlands", + "conditions": { + "attribute": "plan", + "operator": "equals", + "value": "pro" + }, + "requiredFeatures": ["enabledFeature"], + "value": "matched" + }, + { "key": "catch-all", "segments": "*", "value": "fallback" } + ] + } + } + }, + "cases": [ + { + "name": "string default", + "key": "stringValue", + "expectedValue": "hello", + "expectedReason": "variable_default" + }, + { + "name": "integer default", + "key": "integerValue", + "expectedValue": 1, + "expectedReason": "variable_default" + }, + { + "name": "double default", + "key": "doubleValue", + "expectedValue": 1.5, + "expectedReason": "variable_default" + }, + { + "name": "boolean default", + "key": "booleanValue", + "expectedValue": true, + "expectedReason": "variable_default" + }, + { + "name": "array default", + "key": "arrayValue", + "expectedValue": ["one", "two"], + "expectedReason": "variable_default" + }, + { + "name": "object default", + "key": "objectValue", + "expectedValue": { "enabled": true }, + "expectedReason": "variable_default" + }, + { + "name": "json default", + "key": "jsonValue", + "expectedValue": "{\"enabled\":true}", + "expectedReason": "variable_default" + }, + { + "name": "required unmet with disabled value", + "key": "requiredDisabled", + "expectedValue": "disabled", + "expectedReason": "required_features_unmet" + }, + { + "name": "required unmet without value", + "key": "requiredMissingValue", + "expectedReason": "required_features_unmet" + }, + { + "name": "required unmet with caller default", + "key": "requiredMissingValue", + "defaultVariableValue": "caller", + "expectedValue": "caller", + "expectedReason": "required_features_unmet" + }, + { + "name": "required unmet using variable default", + "key": "requiredUsesDefault", + "expectedValue": "default", + "expectedReason": "required_features_unmet" + }, + { + "name": "required variation matched", + "key": "requiredVariation", + "expectedValue": "matched", + "expectedReason": "variable_default" + }, + { + "name": "unmet override requirement falls through", + "key": "overrideRequirement", + "expectedValue": "default", + "expectedReason": "variable_default" + }, + { + "name": "segment and condition override", + "key": "orderedOverrides", + "context": { "userId": "1", "country": "nl", "plan": "pro" }, + "expectedValue": "matched", + "expectedReason": "variable_override_rule", + "expectedOverrideIndex": 1, + "expectedOverrideKey": "nl-pro", + "expectedOverridePath": ["europe", "netherlands", "pro"] + }, + { + "name": "catch all override", + "key": "orderedOverrides", + "context": { "userId": "1", "country": "de", "plan": "pro" }, + "expectedValue": "fallback", + "expectedReason": "variable_override_rule", + "expectedOverrideIndex": 2, + "expectedOverrideKey": "catch-all" + }, + { + "name": "sticky precedence without definition", + "key": "absent", + "stickyVariables": { "absent": "sticky" }, + "expectedValue": "sticky", + "expectedReason": "sticky" + } + ], + "overloadCase": { + "sharedKey": "shared", + "featureVariableKey": "owned", + "expectedGlobalValue": "global-value", + "expectedFeatureValue": "feature-value" + }, + "datafileUpdateCase": { + "initial": { + "schemaVersion": "2", + "revision": "initial", + "segments": {}, + "features": { + "retained": { "hash": "feature-retained", "bucketBy": "userId", "traffic": [] }, + "changed": { "hash": "feature-old", "bucketBy": "userId", "traffic": [] } + }, + "variables": { + "retained": { "hash": "variable-retained", "type": "string", "defaultValue": "retained" }, + "changed": { "hash": "variable-old", "type": "string", "defaultValue": "old" } + } + }, + "merge": { + "schemaVersion": "2", + "revision": "merged", + "segments": {}, + "features": { + "changed": { "hash": "feature-new", "bucketBy": "userId", "traffic": [] }, + "added": { "hash": "feature-added", "bucketBy": "userId", "traffic": [] } + }, + "variables": { + "changed": { "hash": "variable-new", "type": "string", "defaultValue": "new" }, + "added": { "hash": "variable-added", "type": "string", "defaultValue": "added" } + } + }, + "expectedAfterMerge": { + "features": ["added", "changed", "retained"], + "variables": ["added", "changed", "retained"], + "changedFeatures": ["changed", "added"], + "changedVariables": ["changed", "added"] + }, + "replacement": { + "schemaVersion": "2", + "revision": "replaced", + "segments": {}, + "features": { + "added": { "hash": "feature-added", "bucketBy": "userId", "traffic": [] } + }, + "variables": { + "added": { "hash": "variable-added", "type": "string", "defaultValue": "added" } + } + }, + "expectedAfterReplacement": { + "features": ["added"], + "variables": ["added"], + "changedFeatures": ["retained", "changed"], + "changedVariables": ["retained", "changed"] + } + }, + "dependencyUpdateCase": { + "modes": [ + { "name": "merge", "replace": false }, + { "name": "replacement", "replace": true } + ], + "initial": { + "schemaVersion": "2", + "revision": "dependencies-initial", + "segments": { + "audience": { + "conditions": { "attribute": "country", "operator": "equals", "value": "nl" } + } + }, + "features": { + "segmentFeature": { + "hash": "segment-feature", + "bucketBy": "userId", + "traffic": [{ "key": "audience", "segments": "audience", "percentage": 100000 }] + }, + "segmentDependent": { + "hash": "segment-dependent", + "bucketBy": "userId", + "requiredFeatures": ["segmentFeature"], + "traffic": [] + }, + "prerequisite": { + "hash": "prerequisite-old", + "bucketBy": "userId", + "traffic": [] + }, + "requiredDependent": { + "hash": "required-dependent", + "bucketBy": "userId", + "requiredFeatures": ["prerequisite"], + "traffic": [] + } + }, + "variables": { + "bySegment": { + "hash": "by-segment", + "type": "string", + "defaultValue": "default", + "overrides": [{ "key": "audience", "segments": "audience", "value": "matched" }] + }, + "bySegmentFeature": { + "hash": "by-segment-feature", + "type": "string", + "defaultValue": "default", + "requiredFeatures": ["segmentDependent"] + }, + "byRequiredFeature": { + "hash": "by-required-feature", + "type": "string", + "defaultValue": "default", + "requiredFeatures": ["requiredDependent"] + } + } + }, + "updated": { + "schemaVersion": "2", + "revision": "dependencies-updated", + "segments": { + "audience": { + "conditions": { "attribute": "country", "operator": "equals", "value": "de" } + } + }, + "features": { + "segmentFeature": { + "hash": "segment-feature", + "bucketBy": "userId", + "traffic": [{ "key": "audience", "segments": "audience", "percentage": 100000 }] + }, + "segmentDependent": { + "hash": "segment-dependent", + "bucketBy": "userId", + "requiredFeatures": ["segmentFeature"], + "traffic": [] + }, + "prerequisite": { + "hash": "prerequisite-new", + "bucketBy": "userId", + "traffic": [] + }, + "requiredDependent": { + "hash": "required-dependent", + "bucketBy": "userId", + "requiredFeatures": ["prerequisite"], + "traffic": [] + } + }, + "variables": { + "bySegment": { + "hash": "by-segment", + "type": "string", + "defaultValue": "default", + "overrides": [{ "key": "audience", "segments": "audience", "value": "matched" }] + }, + "bySegmentFeature": { + "hash": "by-segment-feature", + "type": "string", + "defaultValue": "default", + "requiredFeatures": ["segmentDependent"] + }, + "byRequiredFeature": { + "hash": "by-required-feature", + "type": "string", + "defaultValue": "default", + "requiredFeatures": ["requiredDependent"] + } + } + }, + "withoutSegment": { + "schemaVersion": "2", + "revision": "dependencies-without-segment", + "segments": {}, + "features": { + "segmentFeature": { + "hash": "segment-feature", + "bucketBy": "userId", + "traffic": [{ "key": "audience", "segments": "audience", "percentage": 100000 }] + }, + "segmentDependent": { + "hash": "segment-dependent", + "bucketBy": "userId", + "requiredFeatures": ["segmentFeature"], + "traffic": [] + }, + "prerequisite": { + "hash": "prerequisite-old", + "bucketBy": "userId", + "traffic": [] + }, + "requiredDependent": { + "hash": "required-dependent", + "bucketBy": "userId", + "requiredFeatures": ["prerequisite"], + "traffic": [] + } + }, + "variables": { + "bySegment": { + "hash": "by-segment", + "type": "string", + "defaultValue": "default", + "overrides": [{ "key": "audience", "segments": "audience", "value": "matched" }] + }, + "bySegmentFeature": { + "hash": "by-segment-feature", + "type": "string", + "defaultValue": "default", + "requiredFeatures": ["segmentDependent"] + }, + "byRequiredFeature": { + "hash": "by-required-feature", + "type": "string", + "defaultValue": "default", + "requiredFeatures": ["requiredDependent"] + } + } + }, + "expectedChangedFeatures": [ + "prerequisite", + "requiredDependent", + "segmentDependent", + "segmentFeature" + ], + "expectedChangedVariables": ["byRequiredFeature", "bySegment", "bySegmentFeature"], + "expectedRemovedSegmentFeatures": ["segmentDependent", "segmentFeature"], + "expectedRemovedSegmentVariables": ["bySegment", "bySegmentFeature"] + } + }, + "requiredFeatures": { + "datafile": { + "schemaVersion": "2", + "revision": "required-features", + "segments": {}, + "features": { + "enabledFeature": { + "bucketBy": "userId", + "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }] + }, + "disabledFeature": { "bucketBy": "userId", "traffic": [] }, + "disabledVariationFeature": { + "bucketBy": "userId", + "disabledVariationValue": "treatment", + "variations": [{ "value": "control" }, { "value": "treatment" }], + "traffic": [] + }, + "stringRequirement": { + "bucketBy": "userId", + "requiredFeatures": ["enabledFeature"], + "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }] + }, + "explicitEnabledRequirement": { + "bucketBy": "userId", + "requiredFeatures": [{ "feature": "enabledFeature", "enabled": true }], + "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }] + }, + "disabledRequirement": { + "bucketBy": "userId", + "requiredFeatures": [{ "feature": "disabledFeature", "enabled": false }], + "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }] + }, + "missingDisabledRequirement": { + "bucketBy": "userId", + "requiredFeatures": [{ "feature": "missingFeature", "enabled": false }], + "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }] + }, + "enabledAndVariationRequirement": { + "bucketBy": "userId", + "requiredFeatures": [ + { + "feature": "disabledVariationFeature", + "enabled": false, + "variation": "treatment" + } + ], + "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }] + }, + "multipleRequirements": { + "bucketBy": "userId", + "requiredFeatures": [ + "enabledFeature", + { "feature": "disabledFeature", "enabled": false } + ], + "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }] + }, + "unmetMultipleRequirements": { + "bucketBy": "userId", + "requiredFeatures": ["enabledFeature", { "feature": "disabledFeature", "enabled": true }], + "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }] + }, + "canonicalPrecedence": { + "bucketBy": "userId", + "required": ["disabledFeature"], + "requiredFeatures": ["enabledFeature"], + "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }] + }, + "featureVariableOverride": { + "bucketBy": "userId", + "variablesSchema": { + "message": { "type": "string", "defaultValue": "default" } + }, + "traffic": [ + { + "key": "all", + "segments": "*", + "percentage": 100000, + "variableOverrides": { + "message": [ + { + "key": "blocked", + "requiredFeatures": ["disabledFeature"], + "value": "blocked" + }, + { + "key": "matched", + "requiredFeatures": ["enabledFeature"], + "value": "matched" + } + ] + } + } + ] + } + } + }, + "cases": [ + { + "name": "string requirement defaults to enabled", + "feature": "stringRequirement", + "expectedEnabled": true + }, + { + "name": "explicit enabled true", + "feature": "explicitEnabledRequirement", + "expectedEnabled": true + }, + { + "name": "disabled feature satisfies enabled false", + "feature": "disabledRequirement", + "expectedEnabled": true + }, + { + "name": "missing feature satisfies enabled false", + "feature": "missingDisabledRequirement", + "expectedEnabled": true + }, + { + "name": "enabled and variation both match", + "feature": "enabledAndVariationRequirement", + "expectedEnabled": true + }, + { + "name": "multiple requirements use AND", + "feature": "multipleRequirements", + "expectedEnabled": true + }, + { + "name": "one unmet requirement disables feature", + "feature": "unmetMultipleRequirements", + "expectedEnabled": false + }, + { + "name": "requiredFeatures takes precedence over required", + "feature": "canonicalPrecedence", + "expectedEnabled": true + } + ], + "featureVariableCase": { + "feature": "featureVariableOverride", + "variable": "message", + "expectedValue": "matched", + "expectedOverrideKey": "matched" + } + }, "diagnostics": { "requiredFields": ["level", "code", "message", "details"], "detailsType": "object", @@ -107,11 +695,7 @@ "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" - ], + "semanticVersions": ["1.2.3", "1.2.3-beta.1", "1.2.3+build.5"], "invalidSemanticVersion": "invalid", "invalidSemanticVersionDiagnosticCode": "condition_match_error" }, @@ -171,6 +755,7 @@ ], "childInstances": { "contextModel": "snapshot existing parent keys at spawn, inherit newly introduced parent keys, child keys win", + "stickyStateModel": "child sticky features and variables replace parent sticky state; omitted child sticky options mean empty sticky state", "closeRemovesLocalAndDelegatedSubscriptions": true, "detailedEvaluationMethods": ["flag", "variation", "variable"], "contextCase": { @@ -178,11 +763,52 @@ "child": { "country": "de" }, "parentAfterSpawn": { "country": "us", "plan": "pro", "region": "eu" }, "expected": { "country": "de", "plan": "free", "region": "eu" } + }, + "stickyCase": { + "datafile": { + "schemaVersion": "2", + "revision": "child-sticky", + "segments": {}, + "features": { + "flag": { + "key": "flag", + "bucketBy": "userId", + "traffic": [] + } + }, + "variables": { + "setting": { + "type": "string", + "defaultValue": "datafile" + } + } + }, + "parentStickyFeatures": { "flag": { "enabled": true } }, + "parentStickyVariables": { "setting": "parent-sticky" }, + "expectedParent": { "flag": true, "setting": "parent-sticky" }, + "expectedChildWithoutStickyOptions": { "flag": false, "setting": "datafile" } + }, + "globalJsonCase": { + "datafile": { + "schemaVersion": "2", + "revision": "child-global-json", + "segments": {}, + "features": {}, + "variables": { + "settings": { + "type": "json", + "defaultValue": "{\"enabled\":true}" + } + } + }, + "variableKey": "settings", + "expected": { "enabled": true } } }, "defaults": { "presenceBased": true, "values": ["", 0, false, null], + "explicitNullBeatsCallerDefault": true, "aggregateEvaluationPreservesEmptyVariation": true, "aggregateCase": { "datafile": { @@ -205,6 +831,38 @@ } } }, + "modulePipeline": { + "featureOrder": [ + "before:first", + "before:second", + "beforeEvaluation:first", + "beforeEvaluation:second", + "afterEvaluation:first", + "afterEvaluation:second", + "after:first", + "after:second" + ], + "globalOrder": [ + "beforeEvaluation:first", + "beforeEvaluation:second", + "afterEvaluation:first", + "afterEvaluation:second" + ], + "requiredFeaturesUseModules": true, + "transformedDefaultsAreApplied": true + }, + "lifecycle": { + "stickyFeatureEvent": "sticky_features_set", + "stickyFeatureDiagnostic": "sticky_features_set", + "stickyVariableEvent": "sticky_variables_set", + "stickyVariableDiagnostic": "sticky_variables_set", + "diagnosticBeforeEvent": true + }, + "openFeature": { + "reasonMappings": { + "required_features_unmet": "DISABLED" + } + }, "diagnosticCase": { "featureKey": "missing", "expectedLevel": "warn", diff --git a/pyproject.toml b/pyproject.toml index 5fa7f7f..a42bec2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "featurevisor" -version = "2.0.0" +version = "3.0.0" description = "Featurevisor Python SDK" readme = "README.md" requires-python = ">=3.10" diff --git a/src/featurevisor/child.py b/src/featurevisor/child.py index 50180ba..2ee18ec 100644 --- a/src/featurevisor/child.py +++ b/src/featurevisor/child.py @@ -3,22 +3,23 @@ from typing import TYPE_CHECKING, Any from .emitter import Emitter -from .events import get_params_for_sticky_set_event +from .events import get_params_for_sticky_features_set_event, get_params_for_sticky_variables_set_event if TYPE_CHECKING: from .instance import Featurevisor class FeaturevisorChildInstance: - def __init__(self, *, parent: "Featurevisor", context: dict[str, Any], sticky: dict[str, Any] | None = None) -> None: + def __init__(self, *, parent: "Featurevisor", context: dict[str, Any], sticky_features: dict[str, Any] | None = None, sticky_variables: dict[str, Any] | None = None) -> None: self.parent = parent self.context = context - self.sticky = sticky or {} + self.sticky_features = sticky_features or {} + self.sticky_variables = sticky_variables or {} self.emitter = Emitter() self._parent_unsubscribers: list[Any] = [] def on(self, event_name, callback): - if event_name in {"context_set", "sticky_set"}: + if event_name in {"context_set", "sticky_features_set", "sticky_variables_set"}: return self.emitter.on(event_name, callback) parent_unsubscribe = self.parent.on(event_name, callback) active = True @@ -48,10 +49,16 @@ def set_context(self, context: dict[str, Any], replace: bool = False) -> None: def get_context(self, context: dict[str, Any] | None = None) -> dict[str, Any]: return self.parent.get_context({**self.context, **(context or {})}) - def set_sticky(self, sticky: dict[str, Any], replace: bool = False) -> None: - previous = self.sticky or {} - self.sticky = dict(sticky) if replace else {**self.sticky, **sticky} - self.emitter.trigger("sticky_set", get_params_for_sticky_set_event(previous, self.sticky, replace)) + def set_sticky_features(self, sticky: dict[str, Any], replace: bool = False) -> None: + previous = self.sticky_features + self.sticky_features = dict(sticky) if replace else {**self.sticky_features, **sticky} + params = get_params_for_sticky_features_set_event(previous, self.sticky_features, replace) + self.emitter.trigger("sticky_features_set", params) + + def set_sticky_variables(self, sticky: dict[str, Any], replace: bool = False) -> None: + previous = self.sticky_variables + self.sticky_variables = dict(sticky) if replace else {**self.sticky_variables, **sticky} + self.emitter.trigger("sticky_variables_set", get_params_for_sticky_variables_set_event(previous, self.sticky_variables, replace)) def _merge_context(self, context: dict[str, Any] | None) -> dict[str, Any]: return {**self.context, **(context or {})} @@ -60,7 +67,11 @@ def _merge_options(self, options: dict[str, Any] | None) -> dict[str, Any]: # Sticky assignments belong to an instance. This private value carries # this child instance's state to its parent without exposing a # per-evaluation sticky override in the public options API. - return {**(options or {}), "__featurevisor_child_sticky": self.sticky} + return { + **(options or {}), + "__featurevisor_child_sticky_features": self.sticky_features, + "__featurevisor_child_sticky_variables": self.sticky_variables, + } def is_enabled(self, feature_key: str, context: dict[str, Any] | None = None, options: dict[str, Any] | None = None) -> bool: return self.parent.is_enabled(feature_key, self._merge_context(context), self._merge_options(options)) @@ -74,36 +85,55 @@ def get_variation(self, feature_key: str, context: dict[str, Any] | None = None, def evaluate_variation(self, feature_key: str, context: dict[str, Any] | None = None, options: dict[str, Any] | None = None): return self.parent.evaluate_variation(feature_key, self._merge_context(context), self._merge_options(options)) - def get_variable(self, feature_key: str, variable_key: str, context: dict[str, Any] | None = None, options: dict[str, Any] | None = None): - return self.parent.get_variable(feature_key, variable_key, self._merge_context(context), self._merge_options(options)) + def _variable_args(self, feature_or_variable_key, variable_key_or_context=None, context_or_options=None, options=None): + if isinstance(variable_key_or_context, str): + return ( + feature_or_variable_key, variable_key_or_context, + self._merge_context(context_or_options), self._merge_options(options), + ) + return ( + feature_or_variable_key, + self._merge_context(variable_key_or_context), + self._merge_options(context_or_options), + ) + + def get_variable(self, *args): + return self.parent.get_variable(*self._variable_args(*args)) + + def evaluate_variable(self, *args): + return self.parent.evaluate_variable(*self._variable_args(*args)) + + def get_variable_boolean(self, *args): + return self.parent.get_variable_boolean(*self._variable_args(*args)) - def evaluate_variable(self, feature_key: str, variable_key: str, context: dict[str, Any] | None = None, options: dict[str, Any] | None = None): - return self.parent.evaluate_variable(feature_key, variable_key, self._merge_context(context), self._merge_options(options)) + def get_variable_string(self, *args): + return self.parent.get_variable_string(*self._variable_args(*args)) - def get_variable_boolean(self, feature_key: str, variable_key: str, context: dict[str, Any] | None = None, options: dict[str, Any] | None = None): - return self.parent.get_variable_boolean(feature_key, variable_key, self._merge_context(context), self._merge_options(options)) + def get_variable_integer(self, *args): + return self.parent.get_variable_integer(*self._variable_args(*args)) - def get_variable_string(self, feature_key: str, variable_key: str, context: dict[str, Any] | None = None, options: dict[str, Any] | None = None): - return self.parent.get_variable_string(feature_key, variable_key, self._merge_context(context), self._merge_options(options)) + def get_variable_double(self, *args): + return self.parent.get_variable_double(*self._variable_args(*args)) - def get_variable_integer(self, feature_key: str, variable_key: str, context: dict[str, Any] | None = None, options: dict[str, Any] | None = None): - return self.parent.get_variable_integer(feature_key, variable_key, self._merge_context(context), self._merge_options(options)) + def get_variable_array(self, *args): + return self.parent.get_variable_array(*self._variable_args(*args)) - def get_variable_double(self, feature_key: str, variable_key: str, context: dict[str, Any] | None = None, options: dict[str, Any] | None = None): - return self.parent.get_variable_double(feature_key, variable_key, self._merge_context(context), self._merge_options(options)) + def get_variable_object(self, *args): + return self.parent.get_variable_object(*self._variable_args(*args)) - def get_variable_array(self, feature_key: str, variable_key: str, context: dict[str, Any] | None = None, options: dict[str, Any] | None = None): - return self.parent.get_variable_array(feature_key, variable_key, self._merge_context(context), self._merge_options(options)) + def get_variable_json(self, *args): + return self.parent.get_variable_json(*self._variable_args(*args)) - def get_variable_object(self, feature_key: str, variable_key: str, context: dict[str, Any] | None = None, options: dict[str, Any] | None = None): - return self.parent.get_variable_object(feature_key, variable_key, self._merge_context(context), self._merge_options(options)) + def get_feature_evaluations(self, context=None, feature_keys=None, options=None): + return self.parent.get_feature_evaluations(self._merge_context(context), feature_keys, self._merge_options(options)) - def get_variable_json(self, feature_key: str, variable_key: str, context: dict[str, Any] | None = None, options: dict[str, Any] | None = None): - return self.parent.get_variable_json(feature_key, variable_key, self._merge_context(context), self._merge_options(options)) + def get_variable_evaluations(self, context=None, variable_keys=None, options=None): + return self.parent.get_variable_evaluations(self._merge_context(context), variable_keys, self._merge_options(options)) setContext = set_context getContext = get_context - setSticky = set_sticky + setStickyFeatures = set_sticky_features + setStickyVariables = set_sticky_variables isEnabled = is_enabled evaluateFlag = evaluate_flag getVariation = get_variation @@ -117,3 +147,5 @@ def get_variable_json(self, feature_key: str, variable_key: str, context: dict[s getVariableArray = get_variable_array getVariableObject = get_variable_object getVariableJSON = get_variable_json + getFeatureEvaluations = get_feature_evaluations + getVariableEvaluations = get_variable_evaluations diff --git a/src/featurevisor/cli.py b/src/featurevisor/cli.py index 0dfd0b9..fffc1ef 100644 --- a/src/featurevisor/cli.py +++ b/src/featurevisor/cli.py @@ -58,8 +58,8 @@ def main(argv: list[str] | None = None) -> int: ) return 0 if ok else 1 if args.command == "benchmark": - if not args.environment or not args.feature: - parser.error("benchmark requires --environment and --feature") + if not args.environment or (not args.feature and not args.variable) or (args.variation and not args.feature): + parser.error("benchmark requires --environment and either --feature or --variable") return run_benchmark( args.projectDirectoryPath, environment=args.environment, diff --git a/src/featurevisor/evaluate.py b/src/featurevisor/evaluate.py index 8fcdf25..18e5121 100644 --- a/src/featurevisor/evaluate.py +++ b/src/featurevisor/evaluate.py @@ -11,6 +11,7 @@ class EvaluationReason(str, Enum): FEATURE_NOT_FOUND = "feature_not_found" DISABLED = "disabled" REQUIRED = "required" + REQUIRED_FEATURES_UNMET = "required_features_unmet" OUT_OF_RANGE = "out_of_range" NO_VARIATIONS = "no_variations" VARIATION_DISABLED = "variation_disabled" @@ -34,6 +35,7 @@ def evaluate_with_modules(options: dict[str, Any]) -> dict[str, Any]: try: modules_manager = options["modulesManager"] current_options = modules_manager.run_before_modules(options) + current_options = modules_manager.run_before_evaluation_modules(current_options) evaluation = evaluate(current_options) if ( "defaultVariationValue" in current_options @@ -48,6 +50,7 @@ def evaluate_with_modules(options: dict[str, Any]) -> dict[str, Any]: and "variableValue" not in evaluation ): evaluation["variableValue"] = current_options["defaultVariableValue"] + evaluation = modules_manager.run_after_evaluation_modules(evaluation, current_options) evaluation = modules_manager.run_after_modules(evaluation, current_options) return evaluation except Exception as exc: @@ -62,17 +65,59 @@ def evaluate_with_modules(options: dict[str, Any]) -> dict[str, Any]: return evaluation -def _find_override_index(overrides: list[dict[str, Any]], context: dict[str, Any], datafile) -> int: +def _normalise_requirements(requirements: Any) -> list[Any]: + if requirements is None: + return [] + return requirements if isinstance(requirements, list) else [requirements] + + +def _required_features_are_matched(requirements: Any, options: dict[str, Any]) -> bool: + for required in _normalise_requirements(requirements): + if isinstance(required, str): + feature_key, expected_enabled, expected_variation = required, True, None + elif "feature" in required: + feature_key = required["feature"] + expected_enabled = required.get("enabled", True) + expected_variation = required.get("variation") + else: + feature_key = required["key"] + expected_enabled = True + expected_variation = required.get("variation") + + nested = { + key: value + for key, value in options.items() + if key not in {"type", "featureKey", "variableKey", "defaultVariationValue", "defaultVariableValue"} + } + flag = evaluate_with_modules({**nested, "type": "flag", "featureKey": feature_key}) + if (flag.get("enabled") is True) != expected_enabled: + return False + if expected_variation is not None: + variation = evaluate_with_modules({**nested, "type": "variation", "featureKey": feature_key}) + actual = variation.get("variationValue") + if actual is None and variation.get("variation"): + actual = variation["variation"].get("value") + if actual != expected_variation: + return False + return True + + +def _find_override_index(overrides: list[dict[str, Any]], context: dict[str, Any], datafile, options: dict[str, Any]) -> int: for index, override in enumerate(overrides): + if not _required_features_are_matched(override.get("requiredFeatures"), options): + continue + conditions_match = True + segments_match = True if override.get("conditions"): conditions = override["conditions"] if isinstance(conditions, str) and conditions != "*": conditions = json.loads(conditions) - if datafile.all_conditions_are_matched(conditions, context): - return index - if override.get("segments") and datafile.all_segments_are_matched( - datafile.parse_segments_if_stringified(override["segments"]), context - ): + conditions_match = datafile.all_conditions_are_matched(conditions, context) + if override.get("segments"): + segments_match = datafile.all_segments_are_matched( + datafile.parse_segments_if_stringified(override["segments"]), context + ) + if conditions_match and segments_match: return index return -1 @@ -85,7 +130,7 @@ def evaluate(options: dict[str, Any]) -> dict[str, Any]: context = options["context"] diagnostics = options["diagnostics"] datafile = options["datafile"] - sticky = options.get("sticky") + sticky = options.get("stickyFeatures") modules_manager = options["modulesManager"] try: @@ -199,29 +244,15 @@ def evaluate(options: dict[str, Any]) -> dict[str, Any]: diagnostics.debug("forced variable", evaluation) return evaluation - if type_ == "flag" and feature.get("required"): - required_enabled = True - for required in feature["required"]: - required_key = required if isinstance(required, str) else required["key"] - required_variation = None if isinstance(required, str) else required.get("variation") - required_eval = evaluate({**options, "type": "flag", "featureKey": required_key}) - if not required_eval.get("enabled"): - required_enabled = False - break - if required_variation is not None: - required_variation_eval = evaluate({**options, "type": "variation", "featureKey": required_key}) - value = required_variation_eval.get("variationValue") - if value is None and required_variation_eval.get("variation"): - value = required_variation_eval["variation"]["value"] - if value != required_variation: - required_enabled = False - break - if not required_enabled: + required_features = feature.get("requiredFeatures") or feature.get("required") + if type_ == "flag" and required_features: + if not _required_features_are_matched(required_features, options): evaluation = { "type": type_, "featureKey": feature_key, "reason": EvaluationReason.REQUIRED, - "required": feature["required"], + "required": feature.get("required"), + "requiredFeatures": feature.get("requiredFeatures"), "enabled": False, } diagnostics.debug("required features not enabled", evaluation) @@ -341,7 +372,7 @@ def evaluate(options: dict[str, Any]) -> dict[str, Any]: if matched_traffic: overrides = matched_traffic.get("variableOverrides", {}).get(variable_key) if overrides: - override_index = _find_override_index(overrides, context, datafile) + override_index = _find_override_index(overrides, context, datafile, options) if override_index != -1: override = overrides[override_index] evaluation = { @@ -356,6 +387,8 @@ def evaluate(options: dict[str, Any]) -> dict[str, Any]: "variableSchema": variable_schema, "variableValue": override["value"], "variableOverrideIndex": override_index, + "variableOverrideKey": override.get("key"), + "variableOverridePath": override.get("keyPath"), } diagnostics.debug("variable override from rule", evaluation) return evaluation @@ -386,7 +419,7 @@ def evaluate(options: dict[str, Any]) -> dict[str, Any]: variation = next((item for item in feature["variations"] if item["value"] == variation_value), None) if variation and variation.get("variableOverrides", {}).get(variable_key): overrides = variation["variableOverrides"][variable_key] - override_index = _find_override_index(overrides, context, datafile) + override_index = _find_override_index(overrides, context, datafile, options) if override_index != -1: override = overrides[override_index] evaluation = { @@ -401,6 +434,8 @@ def evaluate(options: dict[str, Any]) -> dict[str, Any]: "variableSchema": variable_schema, "variableValue": override["value"], "variableOverrideIndex": override_index, + "variableOverrideKey": override.get("key"), + "variableOverridePath": override.get("keyPath"), } diagnostics.debug("variable override from variation", evaluation) return evaluation diff --git a/src/featurevisor/evaluation_data_provider.py b/src/featurevisor/evaluation_data_provider.py index c6ad04c..42c2cf7 100644 --- a/src/featurevisor/evaluation_data_provider.py +++ b/src/featurevisor/evaluation_data_provider.py @@ -17,6 +17,7 @@ def __init__(self, *, datafile: DatafileContent, diagnostics: _EvaluationDiagnos self.featurevisor_version = datafile.get("featurevisorVersion") self.segments = datafile.get("segments", {}) self.features = datafile.get("features", {}) + self.variables = datafile.get("variables", {}) self.regex_cache: dict[str, re.Pattern[str]] = {} def get_revision(self) -> str: @@ -32,6 +33,7 @@ def get_datafile(self) -> dict[str, Any]: "featurevisorVersion": self.featurevisor_version, "segments": self.segments, "features": self.features, + "variables": self.variables, } return {key: value for key, value in datafile.items() if value is not None} @@ -48,10 +50,18 @@ def get_feature_keys(self) -> list[str]: def get_feature(self, feature_key: str) -> Feature | None: return self.features.get(feature_key) - def get_variable_keys(self, feature_key: str) -> list[str]: + def get_variable_keys(self, feature_key: str | None = None) -> list[str]: + if feature_key is None: + return list(self.variables.keys()) feature = self.get_feature(feature_key) return list((feature or {}).get("variablesSchema", {}).keys()) + def get_global_variable(self, variable_key: str): + return self.variables.get(variable_key) + + def get_segment_keys(self) -> list[str]: + return list(self.segments.keys()) + def has_variations(self, feature_key: str) -> bool: feature = self.get_feature(feature_key) return bool(feature and feature.get("variations")) @@ -168,6 +178,8 @@ def parse_segments_if_stringified(self, segments: Any) -> Any: getFeatureKeys = get_feature_keys getFeature = get_feature getVariableKeys = get_variable_keys + getGlobalVariable = get_global_variable + getSegmentKeys = get_segment_keys hasVariations = has_variations getRegex = get_regex allConditionsAreMatched = all_conditions_are_matched diff --git a/src/featurevisor/events.py b/src/featurevisor/events.py index 5c12d30..c778783 100644 --- a/src/featurevisor/events.py +++ b/src/featurevisor/events.py @@ -1,9 +1,11 @@ from __future__ import annotations +import json + from .evaluation_data_provider import _InstanceEvaluationDataProvider -def get_params_for_sticky_set_event(previous_sticky_features: dict | None = None, new_sticky_features: dict | None = None, replace: bool = False) -> dict: +def get_params_for_sticky_features_set_event(previous_sticky_features: dict | None = None, new_sticky_features: dict | None = None, replace: bool = False) -> dict: previous_sticky_features = previous_sticky_features or {} new_sticky_features = new_sticky_features or {} all_keys = list(previous_sticky_features.keys()) + list(new_sticky_features.keys()) @@ -14,6 +16,11 @@ def get_params_for_sticky_set_event(previous_sticky_features: dict | None = None return {"features": features, "replaced": replace} +def get_params_for_sticky_variables_set_event(previous: dict | None = None, new: dict | None = None, replace: bool = False) -> dict: + keys = list((previous or {}).keys()) + list((new or {}).keys()) + return {"variables": list(dict.fromkeys(keys)), "replaced": replace} + + def get_params_for_datafile_set_event(previous_datafile: _InstanceEvaluationDataProvider, new_datafile: _InstanceEvaluationDataProvider, replace: bool = False) -> dict: previous_revision = previous_datafile.get_revision() previous_feature_keys = previous_datafile.get_feature_keys() @@ -24,17 +31,120 @@ def get_params_for_datafile_set_event(previous_datafile: _InstanceEvaluationData key for key in previous_feature_keys if key in new_feature_keys - and (previous_datafile.get_feature(key) or {}).get("hash") != (new_datafile.get_feature(key) or {}).get("hash") + and ( + not (previous_datafile.get_feature(key) or {}).get("hash") + or not (new_datafile.get_feature(key) or {}).get("hash") + or (previous_datafile.get_feature(key) or {}).get("hash") != (new_datafile.get_feature(key) or {}).get("hash") + ) ] added_features = [key for key in new_feature_keys if key not in previous_feature_keys] features = [] for key in removed_features + changed_features + added_features: if key not in features: features.append(key) + previous_variable_keys = previous_datafile.get_variable_keys() + new_variable_keys = new_datafile.get_variable_keys() + variables = [ + key for key in dict.fromkeys(previous_variable_keys + new_variable_keys) + if _entity_changed(previous_datafile.get_global_variable(key), new_datafile.get_global_variable(key)) + ] + changed_segments = [ + key for key in dict.fromkeys(previous_datafile.get_segment_keys() + new_datafile.get_segment_keys()) + if previous_datafile.get_segment(key) != new_datafile.get_segment(key) + ] + + feature_keys = list(dict.fromkeys(previous_feature_keys + new_feature_keys)) + while True: + before = len(features) + for key in feature_keys: + if key in features: + continue + candidates = [value for value in (previous_datafile.get_feature(key), new_datafile.get_feature(key)) if value] + for feature in candidates: + segments, required = _feature_dependencies(feature) + if set(segments) & set(changed_segments) or set(required) & set(features): + features.append(key) + break + if len(features) == before: + break + + for key in dict.fromkeys(previous_variable_keys + new_variable_keys): + if key in variables: + continue + candidates = [value for value in (previous_datafile.get_global_variable(key), new_datafile.get_global_variable(key)) if value] + for variable in candidates: + segments, required = _global_variable_dependencies(variable) + if set(segments) & set(changed_segments) or set(required) & set(features): + variables.append(key) + break return { "revision": new_revision, "previousRevision": previous_revision, "revisionChanged": previous_revision != new_revision, "features": features, + "variables": variables, "replaced": replace, } + + +def _entity_changed(previous, current) -> bool: + if previous is None or current is None: + return True + return not previous.get("hash") or not current.get("hash") or previous.get("hash") != current.get("hash") + + +def _required_feature_keys(values) -> list[str]: + values = values if isinstance(values, list) else ([values] if values is not None else []) + return [value if isinstance(value, str) else value.get("feature") or value.get("key") for value in values] + + +def _segment_keys(value) -> list[str]: + if value is None or value == "*": + return [] + if isinstance(value, str): + if value[:1] in {"{", "["}: + try: + return _segment_keys(json.loads(value)) + except ValueError: + return [] + return [value] + if isinstance(value, list): + return list(dict.fromkeys(key for item in value for key in _segment_keys(item))) + if isinstance(value, dict): + return list(dict.fromkeys(key for operator in ("and", "or", "not") for key in _segment_keys(value.get(operator)))) + return [] + + +def _override_dependencies(groups) -> tuple[list[str], list[str]]: + overrides = [override for values in (groups or {}).values() for override in values] + return ( + list(dict.fromkeys(key for override in overrides for key in _segment_keys(override.get("segments")))), + list(dict.fromkeys(key for override in overrides for key in _required_feature_keys(override.get("requiredFeatures")))), + ) + + +def _feature_dependencies(feature) -> tuple[list[str], list[str]]: + requirements = feature.get("requiredFeatures") if "requiredFeatures" in feature else feature.get("required") + segments: list[str] = [] + required = _required_feature_keys(requirements) + for traffic in feature.get("traffic", []): + segments.extend(_segment_keys(traffic.get("segments"))) + nested_segments, nested_required = _override_dependencies(traffic.get("variableOverrides")) + segments.extend(nested_segments) + required.extend(nested_required) + for force in feature.get("force", []): + segments.extend(_segment_keys(force.get("segments"))) + for variation in feature.get("variations", []): + nested_segments, nested_required = _override_dependencies(variation.get("variableOverrides")) + segments.extend(nested_segments) + required.extend(nested_required) + return list(dict.fromkeys(segments)), list(dict.fromkeys(required)) + + +def _global_variable_dependencies(variable) -> tuple[list[str], list[str]]: + segments: list[str] = [] + required = _required_feature_keys(variable.get("requiredFeatures")) + for override in variable.get("overrides", []): + segments.extend(_segment_keys(override.get("segments"))) + required.extend(_required_feature_keys(override.get("requiredFeatures"))) + return list(dict.fromkeys(segments)), list(dict.fromkeys(required)) diff --git a/src/featurevisor/instance.py b/src/featurevisor/instance.py index b69145e..a6cfcdd 100644 --- a/src/featurevisor/instance.py +++ b/src/featurevisor/instance.py @@ -8,13 +8,13 @@ from .evaluation_data_provider import _InstanceEvaluationDataProvider from .emitter import Emitter from .evaluate import evaluate_with_modules -from .events import get_params_for_datafile_set_event, get_params_for_sticky_set_event +from .events import get_params_for_datafile_set_event, get_params_for_sticky_features_set_event, get_params_for_sticky_variables_set_event from .helpers import get_value_by_type from .diagnostics import DEFAULT_LOG_LEVEL, LOG_LEVELS, _EvaluationDiagnostics, should_report, write_diagnostic_to_console from .modules import FeaturevisorModule, ModulesManager from .types import DatafileContent, LogLevel -empty_datafile: DatafileContent = {"schemaVersion": "2", "revision": "unknown", "segments": {}, "features": {}} +empty_datafile: DatafileContent = {"schemaVersion": "2", "revision": "unknown", "segments": {}, "features": {}, "variables": {}} class Featurevisor: @@ -24,7 +24,8 @@ def __init__(self, options: dict[str, Any] | None = None) -> None: self.log_level = cast(LogLevel, options.get("logLevel") or DEFAULT_LOG_LEVEL) self.on_diagnostic = options.get("onDiagnostic") or options.get("on_diagnostic") self.emitter = Emitter() - self.sticky = options.get("sticky") + self.sticky_features = options.get("stickyFeatures") + self.sticky_variables = options.get("stickyVariables") self.closed = False self.module_diagnostic_subscriptions: list[dict[str, Any]] = [] self.evaluation_diagnostics = _EvaluationDiagnostics(self.report_diagnostic) @@ -64,6 +65,7 @@ def set_datafile(self, datafile, replace: bool = False) -> None: and isinstance(parsed.get("revision"), str) and isinstance(parsed.get("segments"), dict) and isinstance(parsed.get("features"), dict) + and (parsed.get("variables") is None or isinstance(parsed.get("variables"), dict)) ): raise ValueError("Invalid datafile") next_datafile = parsed if replace else self._merge_datafiles(self.datafile.get_datafile(), parsed) @@ -75,14 +77,23 @@ def set_datafile(self, datafile, replace: bool = False) -> None: except Exception as exc: self.report_diagnostic({"level": "error", "code": "invalid_datafile", "message": "Could not parse datafile", "originalError": exc}) - def set_sticky(self, sticky: dict[str, Any], replace: bool = False) -> None: + def set_sticky_features(self, sticky: dict[str, Any], replace: bool = False) -> None: if self.closed: return - previous = self.sticky or {} - self.sticky = dict(sticky) if replace else {**(self.sticky or {}), **sticky} - params = get_params_for_sticky_set_event(previous, self.sticky, replace) - self.report_diagnostic({"level": "info", "code": "sticky_set", "message": "Sticky features set", "details": params}) - self.emitter.trigger("sticky_set", params) + previous = self.sticky_features or {} + self.sticky_features = dict(sticky) if replace else {**(self.sticky_features or {}), **sticky} + params = get_params_for_sticky_features_set_event(previous, self.sticky_features, replace) + self.report_diagnostic({"level": "info", "code": "sticky_features_set", "message": "Sticky features set", "details": params}) + self.emitter.trigger("sticky_features_set", params) + + def set_sticky_variables(self, sticky: dict[str, Any], replace: bool = False) -> None: + if self.closed: + return + previous = self.sticky_variables or {} + self.sticky_variables = dict(sticky) if replace else {**(self.sticky_variables or {}), **sticky} + params = get_params_for_sticky_variables_set_event(previous, self.sticky_variables, replace) + self.report_diagnostic({"level": "info", "code": "sticky_variables_set", "message": "Sticky variables set", "details": params}) + self.emitter.trigger("sticky_variables_set", params) def get_revision(self) -> str: return self.datafile.get_revision() @@ -96,7 +107,7 @@ def get_segment(self, segment_key: str): def get_feature_keys(self) -> list[str]: return self.datafile.get_feature_keys() - def get_variable_keys(self, feature_key: str) -> list[str]: + def get_variable_keys(self, feature_key: str | None = None) -> list[str]: return self.datafile.get_variable_keys(feature_key) def has_variations(self, feature_key: str) -> bool: @@ -145,7 +156,12 @@ def get_context(self, context: dict[str, Any] | None = None) -> dict[str, Any]: def spawn(self, context: dict[str, Any] | None = None, options: dict[str, Any] | None = None) -> FeaturevisorChildInstance: options = options or {} - return FeaturevisorChildInstance(parent=self, context=self.get_context(context or {}), sticky=options.get("sticky")) + return FeaturevisorChildInstance( + parent=self, + context=self.get_context(context or {}), + sticky_features=options.get("stickyFeatures"), + sticky_variables=options.get("stickyVariables"), + ) def _get_evaluation_dependencies(self, context: dict[str, Any], options: dict[str, Any] | None = None) -> dict[str, Any]: options = options or {} @@ -155,10 +171,10 @@ def _get_evaluation_dependencies(self, context: dict[str, Any], options: dict[st "reportDiagnostic": self.report_diagnostic, "modulesManager": self.modules_manager, "datafile": self.datafile, - "sticky": ( - options["__featurevisor_child_sticky"] - if "__featurevisor_child_sticky" in options - else self.sticky + "stickyFeatures": ( + options["__featurevisor_child_sticky_features"] + if "__featurevisor_child_sticky_features" in options + else self.sticky_features ), } if "defaultVariationValue" in options: @@ -192,44 +208,146 @@ def get_variation(self, feature_key: str, context: dict[str, Any] | None = None, self.report_diagnostic({"level": "error", "code": "evaluation_error", "message": "getVariation failed", "originalError": exc, "details": {"featureKey": feature_key}}) return None - def evaluate_variable(self, feature_key: str, variable_key: str, context: dict[str, Any] | None = None, options: dict[str, Any] | None = None): - return evaluate_with_modules({**self._get_evaluation_dependencies(context or {}, options), "type": "variable", "featureKey": feature_key, "variableKey": variable_key}) + def evaluate_variable(self, feature_or_variable_key: str, variable_key_or_context=None, context_or_options=None, options=None): + if isinstance(variable_key_or_context, str): + return evaluate_with_modules({ + **self._get_evaluation_dependencies(context_or_options or {}, options), + "type": "variable", "featureKey": feature_or_variable_key, "variableKey": variable_key_or_context, + }) + return self._evaluate_global_variable( + feature_or_variable_key, + variable_key_or_context or {}, + context_or_options or {}, + ) - def get_variable(self, feature_key: str, variable_key: str, context: dict[str, Any] | None = None, options: dict[str, Any] | None = None): + def get_variable(self, feature_or_variable_key: str, variable_key_or_context=None, context_or_options=None, options=None): try: - evaluation = self.evaluate_variable(feature_key, variable_key, context or {}, options) + evaluation = self.evaluate_variable(feature_or_variable_key, variable_key_or_context, context_or_options, options) if "variableValue" in evaluation: value = evaluation.get("variableValue") - if evaluation.get("variableSchema", {}).get("type") == "json" and isinstance(value, str): + variable_type = evaluation.get("variableSchema", {}).get("type") or evaluation.get("variable", {}).get("type") + if variable_type == "json" and isinstance(value, str): return json.loads(value) return value return None except Exception as exc: - self.report_diagnostic({"level": "error", "code": "evaluation_error", "message": "getVariable failed", "originalError": exc, "details": {"featureKey": feature_key, "variableKey": variable_key}}) + self.report_diagnostic({"level": "error", "code": "evaluation_error", "message": "getVariable failed", "originalError": exc, "details": {"variableKey": feature_or_variable_key}}) return None - def get_variable_boolean(self, feature_key: str, variable_key: str, context: dict[str, Any] | None = None, options: dict[str, Any] | None = None): - return get_value_by_type(self.get_variable(feature_key, variable_key, context or {}, options), "boolean") + def get_variable_boolean(self, *args): + return get_value_by_type(self.get_variable(*args), "boolean") + + def get_variable_string(self, *args): + return get_value_by_type(self.get_variable(*args), "string") - def get_variable_string(self, feature_key: str, variable_key: str, context: dict[str, Any] | None = None, options: dict[str, Any] | None = None): - return get_value_by_type(self.get_variable(feature_key, variable_key, context or {}, options), "string") + def get_variable_integer(self, *args): + return get_value_by_type(self.get_variable(*args), "integer") - def get_variable_integer(self, feature_key: str, variable_key: str, context: dict[str, Any] | None = None, options: dict[str, Any] | None = None): - return get_value_by_type(self.get_variable(feature_key, variable_key, context or {}, options), "integer") + def get_variable_double(self, *args): + return get_value_by_type(self.get_variable(*args), "double") - def get_variable_double(self, feature_key: str, variable_key: str, context: dict[str, Any] | None = None, options: dict[str, Any] | None = None): - return get_value_by_type(self.get_variable(feature_key, variable_key, context or {}, options), "double") + def get_variable_array(self, *args): + return get_value_by_type(self.get_variable(*args), "array") - def get_variable_array(self, feature_key: str, variable_key: str, context: dict[str, Any] | None = None, options: dict[str, Any] | None = None): - return get_value_by_type(self.get_variable(feature_key, variable_key, context or {}, options), "array") + def get_variable_object(self, *args): + return get_value_by_type(self.get_variable(*args), "object") - def get_variable_object(self, feature_key: str, variable_key: str, context: dict[str, Any] | None = None, options: dict[str, Any] | None = None): - return get_value_by_type(self.get_variable(feature_key, variable_key, context or {}, options), "object") + def get_variable_json(self, *args): + return get_value_by_type(self.get_variable(*args), "json") - def get_variable_json(self, feature_key: str, variable_key: str, context: dict[str, Any] | None = None, options: dict[str, Any] | None = None): - return get_value_by_type(self.get_variable(feature_key, variable_key, context or {}, options), "json") + def _required_features_are_matched(self, requirements, context, options) -> bool: + items = requirements if isinstance(requirements, list) else ([requirements] if requirements is not None else []) + clean_options = { + key: value + for key, value in (options or {}).items() + if key not in {"defaultVariationValue", "defaultVariableValue"} + } + for required in items: + if isinstance(required, str): + feature_key, expected_enabled, expected_variation = required, True, None + else: + feature_key = required["feature"] + expected_enabled = required.get("enabled", True) + expected_variation = required.get("variation") + if self.is_enabled(feature_key, context, clean_options) != expected_enabled: + return False + if expected_variation is not None and self.get_variation(feature_key, context, clean_options) != expected_variation: + return False + return True + + def _evaluate_global_variable(self, variable_key: str, context: dict[str, Any], options: dict[str, Any]): + evaluation_options: dict[str, Any] = { + "type": "variable", + "variableKey": variable_key, + "context": self.get_context(context), + } + if "defaultVariableValue" in options: + evaluation_options["defaultVariableValue"] = options["defaultVariableValue"] + try: + evaluation_options = self.modules_manager.run_before_evaluation_modules(evaluation_options) + resolved_key = evaluation_options["variableKey"] + resolved_context = evaluation_options["context"] + variable = self.datafile.get_global_variable(resolved_key) + evaluation: dict[str, Any] = { + "type": "variable", "variableKey": resolved_key, "reason": "variable_not_found" + } + sticky = options.get("__featurevisor_child_sticky_variables", self.sticky_variables) or {} + + if resolved_key in sticky: + evaluation.update(reason="sticky", variable=variable, variableValue=sticky[resolved_key]) + elif variable: + if not self._required_features_are_matched(variable.get("requiredFeatures"), resolved_context, options): + evaluation.update(reason="required_features_unmet", variable=variable) + value_key = "defaultValue" if variable.get("useDefaultWhenDisabled") else "disabledValue" + if value_key in variable: + evaluation["variableValue"] = variable[value_key] + else: + for index, override in enumerate(variable.get("overrides", [])): + if not self._required_features_are_matched(override.get("requiredFeatures"), resolved_context, options): + continue + conditions_match = not override.get("conditions") or self.datafile.all_conditions_are_matched( + self.datafile.parse_conditions_if_stringified(override["conditions"]), resolved_context + ) + segments_match = not override.get("segments") or self.datafile.all_segments_are_matched( + self.datafile.parse_segments_if_stringified(override["segments"]), resolved_context + ) + if conditions_match and segments_match: + evaluation.update( + reason="variable_override_rule", + variable=variable, + variableValue=override.get("value"), + variableOverrideIndex=index, + ) + if override.get("key") is not None: + evaluation["variableOverrideKey"] = override["key"] + if override.get("keyPath") is not None: + evaluation["variableOverridePath"] = override["keyPath"] + break + if evaluation["reason"] == "variable_not_found": + evaluation.update(reason="variable_default", variable=variable) + if "defaultValue" in variable: + evaluation["variableValue"] = variable["defaultValue"] + if variable.get("deprecated"): + self.report_diagnostic({ + "level": "warn", "code": "variable_deprecated", + "message": f'Variable "{resolved_key}" is deprecated', + "details": {"variableKey": resolved_key, "evaluation": evaluation}, + }) + + if "variableValue" not in evaluation and "defaultVariableValue" in options: + evaluation["variableValue"] = options["defaultVariableValue"] + evaluation = self.modules_manager.run_after_evaluation_modules(evaluation, evaluation_options) + self.report_diagnostic({ + "level": "debug", "code": str(evaluation["reason"]), + "message": "Global variable evaluated", "details": dict(evaluation), + }) + return evaluation + except Exception as exc: + evaluation = {"type": "variable", "variableKey": variable_key, "reason": "error", "error": exc} + self.report_diagnostic({"level": "error", "code": "evaluation_error", "message": "Global variable evaluation failed", "originalError": exc, "details": evaluation}) + return evaluation - def get_all_evaluations(self, context: dict[str, Any] | None = None, feature_keys: list[str] | None = None, options: dict[str, Any] | None = None) -> dict[str, Any]: + def get_feature_evaluations(self, context: dict[str, Any] | None = None, feature_keys: list[str] | None = None, options: dict[str, Any] | None = None) -> dict[str, Any]: result: dict[str, Any] = {} keys = feature_keys or self.datafile.get_feature_keys() for feature_key in keys: @@ -247,6 +365,10 @@ def get_all_evaluations(self, context: dict[str, Any] | None = None, feature_key result[feature_key] = evaluated return result + def get_variable_evaluations(self, context: dict[str, Any] | None = None, variable_keys: list[str] | None = None, options: dict[str, Any] | None = None) -> dict[str, Any]: + keys = variable_keys or self.datafile.get_variable_keys() + return {key: self.get_variable(key, context or {}, options or {}) for key in keys} + def create_module_api(self, module: FeaturevisorModule) -> dict[str, Any]: def on_diagnostic(handler, options: dict[str, Any] | None = None): options = options or {} @@ -323,11 +445,13 @@ def _merge_datafiles(self, previous: dict[str, Any], incoming: dict[str, Any]) - "featurevisorVersion": incoming.get("featurevisorVersion"), "segments": {**(previous.get("segments") or {}), **(incoming.get("segments") or {})}, "features": {**(previous.get("features") or {}), **(incoming.get("features") or {})}, + "variables": {**(previous.get("variables") or {}), **(incoming.get("variables") or {})}, } setLogLevel = set_log_level setDatafile = set_datafile - setSticky = set_sticky + setStickyFeatures = set_sticky_features + setStickyVariables = set_sticky_variables getRevision = get_revision getSchemaVersion = get_schema_version getSegment = get_segment @@ -352,7 +476,8 @@ def _merge_datafiles(self, previous: dict[str, Any], incoming: dict[str, Any]) - getVariableArray = get_variable_array getVariableObject = get_variable_object getVariableJSON = get_variable_json - getAllEvaluations = get_all_evaluations + getFeatureEvaluations = get_feature_evaluations + getVariableEvaluations = get_variable_evaluations def create_featurevisor(options: dict[str, Any] | None = None) -> Featurevisor: diff --git a/src/featurevisor/modules.py b/src/featurevisor/modules.py index 59ea3d6..e9966f3 100644 --- a/src/featurevisor/modules.py +++ b/src/featurevisor/modules.py @@ -16,6 +16,8 @@ def __init__(self, options: dict[str, Any] | None = None) -> None: self.bucket_key = options.get("bucketKey") or options.get("bucket_key") self.bucket_value = options.get("bucketValue") or options.get("bucket_value") self.after = options.get("after") + self.before_evaluation = options.get("beforeEvaluation") or options.get("before_evaluation") + self.after_evaluation = options.get("afterEvaluation") or options.get("after_evaluation") self.close = options.get("close") def call_setup(self, api: dict[str, Any]) -> None: @@ -42,6 +44,16 @@ def call_after(self, evaluation: dict[str, Any], options: dict[str, Any]) -> dic return evaluation return self.after(evaluation, options) + def call_before_evaluation(self, options: dict[str, Any]) -> dict[str, Any]: + if not self.before_evaluation: + return options + return self.before_evaluation(options) + + def call_after_evaluation(self, evaluation: dict[str, Any], options: dict[str, Any]) -> dict[str, Any]: + if not self.after_evaluation: + return evaluation + return self.after_evaluation(evaluation, options) + def call_close(self) -> None: if self.close: self.close() @@ -132,6 +144,12 @@ def run_before_modules(self, options: dict[str, Any]) -> dict[str, Any]: current = module.call_before(current) return current + def run_before_evaluation_modules(self, options: dict[str, Any]) -> dict[str, Any]: + current = options + for module in self.modules: + current = module.call_before_evaluation(current) + return current + def run_bucket_key_modules(self, options: dict[str, Any]) -> str: bucket_key = options["bucketKey"] for module in self.modules: @@ -150,6 +168,12 @@ def run_after_modules(self, evaluation: dict[str, Any], options: dict[str, Any]) current = module.call_after(current, options) return current + def run_after_evaluation_modules(self, evaluation: dict[str, Any], options: dict[str, Any]) -> dict[str, Any]: + current = evaluation + for module in self.modules: + current = module.call_after_evaluation(current, options) + return current + def close_all(self) -> None: for module in self.modules: if self.clear_module_diagnostic_subscriptions: @@ -178,7 +202,9 @@ def _report(self, diagnostic: dict[str, Any], module: FeaturevisorModule | None getAll = get_all runBeforeModules = run_before_modules + runBeforeEvaluationModules = run_before_evaluation_modules runBucketKeyModules = run_bucket_key_modules runBucketValueModules = run_bucket_value_modules runAfterModules = run_after_modules + runAfterEvaluationModules = run_after_evaluation_modules closeAll = close_all diff --git a/src/featurevisor/openfeature.py b/src/featurevisor/openfeature.py index df25e47..587b6b6 100644 --- a/src/featurevisor/openfeature.py +++ b/src/featurevisor/openfeature.py @@ -35,12 +35,16 @@ def __init__( targeting_key_field: str = "userId", key_separator: str = ":", variation_key: str = "variation", + global_variable_prefix: str = "variable", on_track: Callable[[str, EvaluationContext | None, TrackingEventDetails | None], None] | None = None, ) -> None: super().__init__() self.targeting_key_field = targeting_key_field or "userId" self.key_separator = key_separator or ":" self.variation_key = variation_key or "variation" + self.global_variable_prefix = global_variable_prefix or "variable" + if self.key_separator in self.global_variable_prefix: + raise ValueError("global_variable_prefix cannot contain key_separator") self.on_track = on_track self.datafile_error: str | None = None self._owns_featurevisor = featurevisor is None @@ -108,7 +112,16 @@ def _resolve(self, flag_key: str, default_value: Any, evaluation_context: Evalua selector: str | None = parsed_selector if separator else None context = self._context(evaluation_context) - if not selector: + if feature_key == self.global_variable_prefix and selector: + evaluation = self.featurevisor.evaluate_variable(selector, context) + value = evaluation.get("variableValue") + variable = evaluation.get("variable") or {} + if variable.get("type") == "json" and isinstance(value, str): + try: + value = json.loads(value) + except (TypeError, ValueError): + pass + elif not selector: if expected_type != "boolean": return self._type_mismatch(flag_key, default_value, expected_type) evaluation = self.featurevisor.evaluate_flag(feature_key, context) @@ -151,14 +164,15 @@ def _context(self, context: EvaluationContext | None) -> dict[str, Any]: def _metadata(self, evaluation: dict[str, Any]) -> dict[str, bool | int | float | str]: metadata: dict[str, bool | int | float | str] = { - "featureKey": evaluation["featureKey"], "featurevisorReason": evaluation["reason"], "schemaVersion": self.featurevisor.get_schema_version(), } + if evaluation.get("featureKey") is not None: + metadata["featureKey"] = evaluation["featureKey"] revision = self.featurevisor.get_revision() if revision: metadata["revision"] = revision - for key in ("variableKey", "ruleKey", "bucketKey", "bucketValue", "forceIndex", "variableOverrideIndex"): + for key in ("variableKey", "ruleKey", "bucketKey", "bucketValue", "forceIndex", "variableOverrideIndex", "variableOverrideKey"): if evaluation.get(key) is not None: metadata[key] = evaluation[key] return metadata @@ -171,7 +185,7 @@ def _reason(reason: str | None) -> Reason: return Reason.TARGETING_MATCH if reason == "allocated": return Reason.SPLIT - if reason in {"disabled", "variation_disabled", "variable_disabled"}: + if reason in {"disabled", "required_features_unmet", "variation_disabled", "variable_disabled"}: return Reason.DISABLED return Reason.DEFAULT @@ -191,7 +205,9 @@ def _error_message(evaluation: dict[str, Any]) -> str: if evaluation.get("reason") == "feature_not_found": return f'Feature "{evaluation["featureKey"]}" was not found' if evaluation.get("reason") == "variable_not_found": - return f'Variable "{evaluation.get("variableKey")}" was not found for feature "{evaluation["featureKey"]}"' + if evaluation.get("featureKey"): + return f'Variable "{evaluation.get("variableKey")}" was not found for feature "{evaluation["featureKey"]}"' + return f'Global variable "{evaluation.get("variableKey")}" was not found' if evaluation.get("reason") == "no_variations": return f'Feature "{evaluation["featureKey"]}" has no variations' return "Featurevisor evaluation failed" diff --git a/src/featurevisor/tester.py b/src/featurevisor/tester.py index acf569e..a27a5f9 100644 --- a/src/featurevisor/tester.py +++ b/src/featurevisor/tester.py @@ -158,6 +158,24 @@ def _assert_feature(sdk, feature_key: str, assertion: dict[str, Any], datafile: assertion_result["errors"].append({"type": "evaluation", "expected": expected, "actual": actual, "details": {**details_base, "evaluationType": "variable", "evaluationKey": variable_key}}) +def _assert_global_variable(sdk, variable_key: str, assertion: dict[str, Any], result: dict[str, Any], assertion_result: dict[str, Any]) -> None: + context = assertion.get("context", {}) + options = {} + if "defaultVariableValue" in assertion: + options["defaultVariableValue"] = assertion["defaultVariableValue"] + if "expectedValue" in assertion: + actual = sdk.get_variable(variable_key, context, options) + if not _compare_jsonish(assertion["expectedValue"], actual): + result["passed"] = assertion_result["passed"] = False + assertion_result["errors"].append({"type": "variable", "expected": assertion["expectedValue"], "actual": actual, "details": {"variableKey": variable_key}}) + for key, expected in assertion.get("expectedEvaluation", {}).items(): + evaluation = sdk.evaluate_variable(variable_key, context, options) + actual = _get_evaluation_value(evaluation, key) + if not _compare_jsonish(expected, actual): + result["passed"] = assertion_result["passed"] = False + assertion_result["errors"].append({"type": "evaluation", "expected": expected, "actual": actual, "details": {"variableKey": variable_key, "evaluationKey": key}}) + + def test_segment(segment: dict[str, Any], assertion_options: dict[str, Any] | None = None) -> dict[str, Any]: options = assertion_options or {} diagnostics = _create_evaluation_diagnostics() @@ -217,7 +235,34 @@ def run_test_project(project_directory_path: str, *, key_pattern: str | None = N passed = True for test in tests: - if test.get("feature"): + if test.get("variable"): + assertions = [ + assertion for assertion in test["assertions"] + if not selected_targets or not assertion.get("target") or assertion.get("target") in selected_targets + ] + if not assertions: + continue + variable_key = test["variable"] + result = {"type": "variable", "key": variable_key, "notFound": False, "passed": True, "duration": 0, "assertions": []} + variable_start = time.perf_counter() + for assertion in assertions: + assertion_start = time.perf_counter() + datafile = _get_datafile_for_assertion(assertion, datafile_cache) + if variable_key not in datafile.get("variables", {}): + result["notFound"] = True + result["passed"] = False + continue + sdk = create_featurevisor({ + "datafile": datafile, + "stickyVariables": assertion.get("stickyVariables", {}), + "logLevel": _log_level(verbose, quiet), + }) + assertion_result = {"description": assertion.get("description", ""), "duration": 0, "passed": True, "errors": []} + _assert_global_variable(sdk, variable_key, assertion, result, assertion_result) + assertion_result["duration"] = int((time.perf_counter() - assertion_start) * 1000) + result["assertions"].append(assertion_result) + result["duration"] = int((time.perf_counter() - variable_start) * 1000) + elif test.get("feature"): assertions = [ assertion for assertion in test["assertions"] if not selected_targets or not assertion.get("target") or assertion.get("target") in selected_targets @@ -237,7 +282,8 @@ def run_test_project(project_directory_path: str, *, key_pattern: str | None = N print(json.dumps(datafile, indent=2)) sdk = create_featurevisor({ "datafile": datafile, - "sticky": assertion.get("sticky", {}), + "stickyFeatures": assertion.get("stickyFeatures") or assertion.get("sticky", {}), + "stickyVariables": assertion.get("stickyVariables", {}), "modules": [ { "name": "tester", @@ -252,7 +298,10 @@ def run_test_project(project_directory_path: str, *, key_pattern: str | None = N assertion_result = {"description": assertion.get("description", ""), "duration": 0, "passed": True, "errors": []} _assert_feature(sdk, feature_key, {**assertion, "context": context}, datafile, result, assertion_result) for index, child in enumerate(assertion.get("children", [])): - child_instance = sdk.spawn(child.get("context", {}), {"sticky": child.get("sticky") or assertion.get("sticky")}) + child_instance = sdk.spawn(child.get("context", {}), { + "stickyFeatures": child.get("stickyFeatures") or child.get("sticky") or assertion.get("stickyFeatures") or assertion.get("sticky"), + "stickyVariables": child.get("stickyVariables") or assertion.get("stickyVariables"), + }) _assert_feature(child_instance, feature_key, child, datafile, result, assertion_result, child_index=index) assertion_result["duration"] = int((time.perf_counter() - assertion_start) * 1000) result["assertions"].append(assertion_result) @@ -285,7 +334,7 @@ def run_test_project(project_directory_path: str, *, key_pattern: str | None = N return passed -def run_benchmark(project_directory_path: str, *, environment: str, feature: str, context: dict[str, Any] | None = None, n: int = 1000, variation: bool = False, variable: str | None = None, schema_version: str | None = None, inflate: int = 0, verbose: bool = False, quiet: bool = False, targets: list[str] | None = None) -> int: +def run_benchmark(project_directory_path: str, *, environment: str, feature: str | None = None, context: dict[str, Any] | None = None, n: int = 1000, variation: bool = False, variable: str | None = None, schema_version: str | None = None, inflate: int = 0, verbose: bool = False, quiet: bool = False, targets: list[str] | None = None) -> int: project = FeaturevisorProject(project_directory_path) selected_targets = _resolve_targets(project, targets) entries: list[str | None] = [] @@ -298,7 +347,7 @@ def run_benchmark(project_directory_path: str, *, environment: str, feature: str return 0 -def _run_benchmark_datafile(datafile: dict[str, Any], build_duration: float, *, environment: str, target: str | None, feature: str, context: dict[str, Any] | None, n: int, variation: bool, variable: str | None, verbose: bool, quiet: bool) -> None: +def _run_benchmark_datafile(datafile: dict[str, Any], build_duration: float, *, environment: str, target: str | None, feature: str | None, context: dict[str, Any] | None, n: int, variation: bool, variable: str | None, verbose: bool, quiet: bool) -> None: level = _log_level(verbose, quiet) instance = create_featurevisor({"datafile": datafile, "logLevel": level}) context = context or {} @@ -308,12 +357,16 @@ def _run_benchmark_datafile(datafile: dict[str, Any], build_duration: float, *, value = None for _ in range(n): evaluation_start = time.perf_counter_ns() - if variation: - value = instance.get_variation(feature, context) - elif variable: - value = instance.get_variable(feature, variable, context) + if variable and not feature: + value = instance.get_variable(variable, context) else: - value = instance.is_enabled(feature, context) + assert feature is not None + if variation: + value = instance.get_variation(feature, context) + elif variable: + value = instance.get_variable(feature, variable, context) + else: + value = instance.is_enabled(feature, context) evaluation_duration_ns = time.perf_counter_ns() - evaluation_start total_duration_ns += evaluation_duration_ns min_duration_ns = evaluation_duration_ns if min_duration_ns is None else min(min_duration_ns, evaluation_duration_ns) @@ -321,8 +374,11 @@ def _run_benchmark_datafile(datafile: dict[str, Any], build_duration: float, *, duration = total_duration_ns / 1_000_000_000 average_duration_ns = total_duration_ns / n if n else 0 print("") - print("Benchmark Featurevisor feature") - print(f" Feature: {feature}") + print("Benchmark Featurevisor global variable" if variable and not feature else "Benchmark Featurevisor feature") + if feature: + print(f" Feature: {feature}") + if variable: + print(f" Variable: {variable}") print(f" Environment: {environment}") if target: print(f" Target: {target}") diff --git a/src/featurevisor/types.py b/src/featurevisor/types.py index 916618c..a9a8aef 100644 --- a/src/featurevisor/types.py +++ b/src/featurevisor/types.py @@ -20,7 +20,10 @@ BucketValue = int LogLevel = Literal["fatal", "error", "warn", "info", "debug"] -EventName = Literal["datafile_set", "context_set", "sticky_set", "error"] +EventName = Literal[ + "datafile_set", "context_set", "sticky_features_set", + "sticky_variables_set", "error" +] class EvaluatedFeature(TypedDict, total=False): @@ -30,12 +33,23 @@ class EvaluatedFeature(TypedDict, total=False): StickyFeatures = dict[FeatureKey, EvaluatedFeature] +StickyVariables = dict[VariableKey, VariableValue] EvaluatedFeatures = dict[FeatureKey, EvaluatedFeature] +EvaluatedVariables = dict[VariableKey, VariableValue] + + +class RequiredFeature(TypedDict, total=False): + feature: FeatureKey + enabled: bool + variation: VariationValue class VariableOverride(TypedDict, total=False): + key: str + keyPath: list[str] conditions: Any segments: Any + requiredFeatures: str | RequiredFeature | list[str | RequiredFeature] value: VariableValue @@ -59,6 +73,13 @@ class VariableSchema(TypedDict, total=False): deprecated: bool +class GlobalVariable(VariableSchema, total=False): + key: VariableKey + hash: str + requiredFeatures: str | RequiredFeature | list[str | RequiredFeature] + overrides: list[VariableOverride] + + class Allocation(TypedDict): variation: VariationValue range: list[int] @@ -89,6 +110,7 @@ class Feature(TypedDict, total=False): hash: str deprecated: bool required: list[str | RequiredFeatureRef] + requiredFeatures: str | RequiredFeature | list[str | RequiredFeature] variablesSchema: dict[VariableKey, VariableSchema] disabledVariationValue: VariationValue variations: list[Variation] @@ -110,6 +132,7 @@ class DatafileContent(TypedDict): featurevisorVersion: NotRequired[str] segments: dict[SegmentKey, Segment] features: dict[FeatureKey, Feature] + variables: NotRequired[dict[VariableKey, GlobalVariable]] class Evaluation(TypedDict, total=False): @@ -125,13 +148,17 @@ class Evaluation(TypedDict, total=False): forceIndex: int force: Force required: list[str | RequiredFeatureRef] + requiredFeatures: str | RequiredFeature | list[str | RequiredFeature] sticky: EvaluatedFeature variation: Variation variationValue: VariationValue variableKey: VariableKey variableValue: VariableValue variableSchema: VariableSchema + variable: GlobalVariable variableOverrideIndex: int + variableOverrideKey: str + variableOverridePath: list[str] class FeaturevisorDiagnostic(TypedDict, total=False): @@ -150,6 +177,8 @@ class FeaturevisorModule(TypedDict, total=False): bucketKey: Callable[[dict[str, Any]], BucketKey] bucketValue: Callable[[dict[str, Any]], BucketValue] after: Callable[[Evaluation, dict[str, Any]], Evaluation] + beforeEvaluation: Callable[[dict[str, Any]], dict[str, Any]] + afterEvaluation: Callable[[Evaluation, dict[str, Any]], Evaluation] setup: Callable[[dict[str, Any]], None] close: Callable[[], None] diff --git a/tests/test_conformance.py b/tests/test_conformance.py index c00d9dd..83c9780 100644 --- a/tests/test_conformance.py +++ b/tests/test_conformance.py @@ -24,7 +24,7 @@ def test_evaluation_reason_is_python_310_compatible_and_string_like(self) -> Non self.assertEqual(json.dumps({"reason": reason}), '{"reason": "feature_not_found"}') def test_allocation_and_typed_value_contracts(self) -> None: - self.assertEqual(self.fixture["version"], 2) + self.assertEqual(self.fixture["version"], 6) reader = _InstanceEvaluationDataProvider( datafile={"schemaVersion": "2", "revision": "conformance", "segments": {}, "features": {}}, diagnostics=_create_evaluation_diagnostics(), @@ -74,7 +74,7 @@ def test_allocation_and_typed_value_contracts(self) -> None: aggregate_case = self.fixture["defaults"]["aggregateCase"] featurevisor = create_featurevisor({"datafile": aggregate_case["datafile"]}) - evaluated = featurevisor.get_all_evaluations( + evaluated = featurevisor.get_feature_evaluations( {}, [], {"defaultVariationValue": aggregate_case["defaultVariationValue"]}, @@ -82,6 +82,129 @@ def test_allocation_and_typed_value_contracts(self) -> None: self.assertEqual(evaluated["enabled"], aggregate_case["expected"]["enabled"]) self.assertEqual(evaluated["variation"], aggregate_case["expected"]["variation"]) + def test_global_variables_and_overloaded_api(self) -> None: + fixture = self.fixture["globalVariables"] + for case in fixture["cases"]: + f = create_featurevisor({ + "datafile": fixture["datafile"], + "stickyVariables": case.get("stickyVariables", {}), + "logLevel": "fatal", + }) + options = {} + if "defaultVariableValue" in case: + options["defaultVariableValue"] = case["defaultVariableValue"] + evaluation = f.evaluate_variable(case["key"], case.get("context", {}), options) + self.assertEqual(evaluation.get("variableValue"), case.get("expectedValue"), case["name"]) + self.assertEqual(str(evaluation["reason"]), case["expectedReason"], case["name"]) + self.assertEqual(evaluation.get("variableOverrideIndex"), case.get("expectedOverrideIndex"), case["name"]) + self.assertEqual(evaluation.get("variableOverrideKey"), case.get("expectedOverrideKey"), case["name"]) + self.assertEqual(evaluation.get("variableOverridePath"), case.get("expectedOverridePath"), case["name"]) + + boundary = fixture["overloadCase"] + f = create_featurevisor({"datafile": fixture["datafile"], "logLevel": "fatal"}) + self.assertEqual(f.get_variable(boundary["sharedKey"]), boundary["expectedGlobalValue"]) + self.assertEqual( + f.get_variable(boundary["sharedKey"], boundary["featureVariableKey"]), + boundary["expectedFeatureValue"], + ) + self.assertIn(boundary["sharedKey"], f.get_variable_keys()) + + def test_canonical_required_features(self) -> None: + fixture = self.fixture["requiredFeatures"] + f = create_featurevisor({"datafile": fixture["datafile"], "logLevel": "fatal"}) + for case in fixture["cases"]: + self.assertEqual(f.is_enabled(case["feature"]), case["expectedEnabled"], case["name"]) + case = fixture["featureVariableCase"] + evaluation = f.evaluate_variable(case["feature"], case["variable"]) + self.assertEqual(evaluation.get("variableValue"), case["expectedValue"]) + self.assertEqual(evaluation.get("variableOverrideKey"), case["expectedOverrideKey"]) + + def test_datafile_dependency_events(self) -> None: + fixture = self.fixture["globalVariables"] + update = fixture["datafileUpdateCase"] + f = create_featurevisor({"datafile": update["initial"], "logLevel": "fatal"}) + events = [] + f.on("datafile_set", events.append) + f.set_datafile(update["merge"]) + self.assertEqual(sorted(f.get_feature_keys()), update["expectedAfterMerge"]["features"]) + self.assertEqual(sorted(f.get_variable_keys()), update["expectedAfterMerge"]["variables"]) + self.assertEqual(sorted(events[-1]["features"]), sorted(update["expectedAfterMerge"]["changedFeatures"])) + self.assertEqual(sorted(events[-1]["variables"]), sorted(update["expectedAfterMerge"]["changedVariables"])) + f.set_datafile(update["replacement"], True) + self.assertEqual(sorted(events[-1]["features"]), sorted(update["expectedAfterReplacement"]["changedFeatures"])) + self.assertEqual(sorted(events[-1]["variables"]), sorted(update["expectedAfterReplacement"]["changedVariables"])) + + dependencies = fixture["dependencyUpdateCase"] + for mode in dependencies["modes"]: + f = create_featurevisor({"datafile": dependencies["initial"], "logLevel": "fatal"}) + events = [] + f.on("datafile_set", events.append) + f.set_datafile(dependencies["updated"], mode["replace"]) + self.assertEqual(sorted(events[-1]["features"]), dependencies["expectedChangedFeatures"], mode["name"]) + self.assertEqual(sorted(events[-1]["variables"]), dependencies["expectedChangedVariables"], mode["name"]) + + f = create_featurevisor({"datafile": dependencies["initial"], "logLevel": "fatal"}) + events = [] + f.on("datafile_set", events.append) + f.set_datafile(dependencies["withoutSegment"], True) + self.assertEqual(sorted(events[-1]["features"]), dependencies["expectedRemovedSegmentFeatures"]) + self.assertEqual(sorted(events[-1]["variables"]), dependencies["expectedRemovedSegmentVariables"]) + + def test_global_modules_sticky_children_and_aggregates(self) -> None: + fixture = self.fixture["globalVariables"] + observed = [] + f = create_featurevisor({ + "datafile": fixture["datafile"], + "stickyVariables": {"stringValue": "parent-sticky"}, + "modules": [{ + "name": "unified", + "beforeEvaluation": lambda options: observed.append(("before", options["variableKey"])) or options, + "afterEvaluation": lambda evaluation, options: observed.append(("after", evaluation["reason"])) or evaluation, + }], + "logLevel": "fatal", + }) + self.assertEqual(f.get_variable("stringValue"), "parent-sticky") + self.assertEqual(observed, [("before", "stringValue"), ("after", "sticky")]) + self.assertIn("integerValue", f.get_variable_evaluations({}, ["integerValue"])) + + child = f.spawn({}, {"stickyVariables": {"stringValue": "child-sticky"}}) + self.assertEqual(child.get_variable("stringValue"), "child-sticky") + child.set_sticky_variables({"integerValue": 99}) + self.assertEqual(child.get_variable("integerValue"), 99) + self.assertEqual(f.get_variable("integerValue"), 1) + + def test_required_features_use_modules_and_module_phases_are_ordered(self) -> None: + order = [] + modules = [] + for name in ("first", "second"): + modules.append({ + "name": name, + "before": lambda options, name=name: order.append(f"before:{name}") or options, + "beforeEvaluation": lambda options, name=name: order.append(f"beforeEvaluation:{name}") or options, + "afterEvaluation": lambda evaluation, _options, name=name: order.append(f"afterEvaluation:{name}") or evaluation, + "after": lambda evaluation, _options, name=name: order.append(f"after:{name}") or evaluation, + }) + f = create_featurevisor({ + "datafile": self.fixture["requiredFeatures"]["datafile"], + "modules": modules, + "logLevel": "fatal", + }) + self.assertTrue(f.is_enabled("enabledFeature")) + self.assertEqual(order, self.fixture["modulePipeline"]["featureOrder"]) + + f = create_featurevisor({ + "datafile": self.fixture["requiredFeatures"]["datafile"], + "modules": [{ + "name": "redirect-required-feature", + "beforeEvaluation": lambda options: { + **options, + "featureKey": "disabledFeature" if options.get("featureKey") == "enabledFeature" else options.get("featureKey"), + }, + }], + "logLevel": "fatal", + }) + self.assertFalse(f.is_enabled("stringRequirement")) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_instance_parity.py b/tests/test_instance_parity.py index 211e0dd..d4f6251 100644 --- a/tests/test_instance_parity.py +++ b/tests/test_instance_parity.py @@ -99,13 +99,13 @@ def test_should_initialize_with_sticky_features(self) -> None: }, "segments": {}, } - sdk = create_featurevisor({"sticky": {"test": {"enabled": True, "variation": "control", "variables": {"color": "red"}}}, "logLevel": "fatal"}) + sdk = create_featurevisor({"stickyFeatures": {"test": {"enabled": True, "variation": "control", "variables": {"color": "red"}}}, "logLevel": "fatal"}) self.assertEqual(sdk.getVariation("test", {"userId": "123"}), "control") self.assertEqual(sdk.getVariable("test", "color", {"userId": "123"}), "red") sdk.setDatafile(datafile) time.sleep(0.08) self.assertEqual(sdk.getVariation("test", {"userId": "123"}), "control") - sdk.setSticky({}, True) + sdk.setStickyFeatures({}, True) self.assertEqual(sdk.getVariation("test", {"userId": "123"}), "treatment") def test_required_deprecated_and_rule_override_cases(self) -> None: @@ -192,7 +192,7 @@ def test_mutually_exclusive_variation_and_variable_cases(self) -> None: self.assertEqual(sdk.getVariableJSON("test", "nestedConfig", {"userId": "123"}), {"key": {"nested": "value"}}) self.assertIsNone(sdk.getVariable("test", "nonExisting", {"userId": "123"})) self.assertIsNone(sdk.getVariable("test", "color", {"userId": "user-gb"})) - all_evaluations = sdk.getAllEvaluations({"userId": "123"}) + all_evaluations = sdk.getFeatureEvaluations({"userId": "123"}) self.assertEqual(all_evaluations["test"]["variation"], "treatment") def test_variables_without_variations_rule_overrides_arrays_objects_and_individual_segments(self) -> None: @@ -238,7 +238,7 @@ def test_variables_without_variations_rule_overrides_arrays_objects_and_individu self.assertEqual(sdk.getVariableArray("withArray", "objectArray", {"userId": "user-1"}), [{"color": "red", "opacity": 100}, {"color": "blue", "opacity": 90}, {"color": "green", "opacity": 95}]) self.assertEqual(sdk.getVariableObject("withObject", "themeConfig", {"userId": "user-1"}), {"theme": "light", "darkMode": False}) self.assertIsNone(sdk.getVariableArray("withArray", "nonExisting", {"userId": "user-1"})) - all_evaluations = sdk.getAllEvaluations({"userId": "user-1"}) + all_evaluations = sdk.getFeatureEvaluations({"userId": "user-1"}) self.assertEqual(all_evaluations["withObject"]["variables"]["mixedConfig"], {"name": "mixed", "enabled": True, "meta": {"score": 0.95, "items": ["a", "b"]}}) self.assertFalse(sdk.isEnabled("flagTest")) self.assertFalse(sdk.isEnabled("flagTest", {"userId": "123"})) diff --git a/tests/test_sdk.py b/tests/test_sdk.py index 4948d5c..d0a7172 100644 --- a/tests/test_sdk.py +++ b/tests/test_sdk.py @@ -12,7 +12,7 @@ from featurevisor.compare_versions import compare_versions from featurevisor.conditions import condition_is_matched from featurevisor.evaluation_data_provider import _InstanceEvaluationDataProvider -from featurevisor.events import get_params_for_datafile_set_event, get_params_for_sticky_set_event +from featurevisor.events import get_params_for_datafile_set_event, get_params_for_sticky_features_set_event from featurevisor.emitter import Emitter from featurevisor.diagnostics import _create_evaluation_diagnostics @@ -82,7 +82,7 @@ def test_instance_basic_flow(self) -> None: instance = create_featurevisor({"datafile": datafile, "context": {"userId": "123"}}) self.assertTrue(instance.is_enabled("my_feature")) self.assertEqual(instance.get_variable("my_feature", "title"), "Hello") - self.assertEqual(instance.get_all_evaluations()["my_feature"]["variables"]["title"], "Hello") + self.assertEqual(instance.get_feature_evaluations()["my_feature"]["variables"]["title"], "Hello") def test_set_datafile_merges_by_default_and_replace_opt_in(self) -> None: instance = create_featurevisor( @@ -142,12 +142,12 @@ def test_lifecycle_mutations_report_diagnostics(self) -> None: instance = create_featurevisor({"logLevel": "debug", "onDiagnostic": lambda diagnostic: diagnostics.append(diagnostic)}) instance.set_datafile({"schemaVersion": "2", "revision": "1", "segments": {}, "features": {}}) - instance.set_sticky({"test": {"enabled": True}}) + instance.set_sticky_features({"test": {"enabled": True}}) instance.set_context({"country": "nl"}) codes = [diagnostic["code"] for diagnostic in diagnostics] self.assertIn("datafile_set", codes) - self.assertIn("sticky_set", codes) + self.assertIn("sticky_features_set", codes) self.assertIn("context_set", codes) def test_module_lifecycle_duplicate_diagnostics_and_close(self) -> None: @@ -337,7 +337,7 @@ def test_evaluation_data_provider_parses_stringified_conditions(self) -> None: def test_events_helpers(self) -> None: self.assertEqual( - get_params_for_sticky_set_event({"feature1": {"enabled": True}}, {"feature2": {"enabled": True}}, True), + get_params_for_sticky_features_set_event({"feature1": {"enabled": True}}, {"feature2": {"enabled": True}}, True), {"features": ["feature1", "feature2"], "replaced": True}, ) diagnostics = _create_evaluation_diagnostics() @@ -345,7 +345,7 @@ def test_events_helpers(self) -> None: current = _InstanceEvaluationDataProvider(datafile={"schemaVersion": "2", "revision": "2", "segments": {}, "features": {"feature1": {"bucketBy": "userId", "hash": "hash2", "traffic": []}, "feature2": {"bucketBy": "userId", "hash": "hash3", "traffic": []}}}, diagnostics=diagnostics) self.assertEqual( get_params_for_datafile_set_event(previous, current), - {"revision": "2", "previousRevision": "1", "revisionChanged": True, "features": ["feature1", "feature2"], "replaced": False}, + {"revision": "2", "previousRevision": "1", "revisionChanged": True, "features": ["feature1", "feature2"], "variables": [], "replaced": False}, ) def test_emitter_subscribe_unsubscribe(self) -> None: @@ -353,7 +353,7 @@ def test_emitter_subscribe_unsubscribe(self) -> None: handled = [] unsubscribe = emitter.on("datafile_set", lambda details: handled.append(details)) emitter.trigger("datafile_set", {"key": "value"}) - emitter.trigger("sticky_set", {"key": "value2"}) + emitter.trigger("sticky_features_set", {"key": "value2"}) self.assertEqual(handled, [{"key": "value"}]) unsubscribe() self.assertEqual(len(emitter.listeners["datafile_set"]), 0) @@ -372,11 +372,11 @@ def first(_details: dict) -> None: def second(_details: dict) -> None: calls.append("second") - emitter.on("sticky_set", first) - unsubscribe_second = emitter.on("sticky_set", second) + emitter.on("sticky_features_set", first) + unsubscribe_second = emitter.on("sticky_features_set", second) - emitter.trigger("sticky_set") - emitter.trigger("sticky_set") + emitter.trigger("sticky_features_set") + emitter.trigger("sticky_features_set") self.assertEqual(calls, ["first", "second", "first"]) diff --git a/tests_openfeature/test_provider.py b/tests_openfeature/test_provider.py index 0faa887..495f5ec 100644 --- a/tests_openfeature/test_provider.py +++ b/tests_openfeature/test_provider.py @@ -69,6 +69,17 @@ def datafile(): ), "emptyVariation": feature(variations=[]), }, + "variables": { + "supportEmail": { + "type": "string", "defaultValue": "support@example.com", + "overrides": [{ + "key": "netherlands", "keyPath": ["europe", "netherlands"], + "conditions": {"attribute": "country", "operator": "equals", "value": "nl"}, + "value": "nl@example.com", + }], + }, + "limits": {"type": "object", "defaultValue": {"requests": 10}}, + }, } @@ -144,6 +155,29 @@ def test_supports_custom_key_separator_and_variation_selector(self): self.assertEqual(provider.resolve_string_details("checkout/$variation", "fallback").value, "on") self.assertEqual(provider.resolve_string_details("checkout/title", "fallback").value, "Hello") + def test_resolves_global_variables_and_custom_prefix(self): + provider = self.provider() + result = provider.resolve_string_details( + "variable:supportEmail", "fallback", EvaluationContext(attributes={"country": "nl"}) + ) + self.assertEqual(result.value, "nl@example.com") + self.assertEqual(result.reason, Reason.TARGETING_MATCH) + self.assertEqual(result.flag_metadata["variableKey"], "supportEmail") + self.assertEqual(result.flag_metadata["variableOverrideKey"], "netherlands") + self.assertEqual(provider.resolve_object_details("variable:limits", {}).value, {"requests": 10}) + + custom = self.provider(key_separator="/", global_variable_prefix="$variable") + self.assertEqual(custom.resolve_string_details("$variable/supportEmail", "fallback").value, "support@example.com") + + def test_rejects_global_prefix_containing_separator(self): + with self.assertRaisesRegex(ValueError, "global_variable_prefix cannot contain key_separator"): + self.provider(global_variable_prefix="global:variable") + + def test_missing_global_variable_uses_standard_not_found_error(self): + result = self.provider().resolve_string_details("variable:missing", "fallback") + self.assertEqual(result.value, "fallback") + self.assertEqual(result.error_code, ErrorCode.FLAG_NOT_FOUND) + def test_returns_defaults_and_standard_errors_for_missing_entities_and_malformed_datafiles(self): provider = self.provider() @@ -228,6 +262,7 @@ def test_maps_disabled_evaluations(self): def test_maps_all_featurevisor_reasons(self): mappings = { "required": Reason.TARGETING_MATCH, + "required_features_unmet": Reason.DISABLED, "forced": Reason.TARGETING_MATCH, "sticky": Reason.TARGETING_MATCH, "rule": Reason.TARGETING_MATCH,