From 4e97d221b42d67a3821406637c17e9b9f0c0a220 Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Thu, 23 Jul 2026 23:09:42 +0200 Subject: [PATCH 1/2] feat: v3 alignments --- .github/workflows/publish.yml | 2 +- README.md | 7 + conformance/sdk-v3.json | 175 +++++++++++++++++- pyproject.toml | 2 +- src/featurevisor/bucketer.py | 41 +++- src/featurevisor/child.py | 43 +++-- src/featurevisor/conditions.py | 48 +++-- src/featurevisor/diagnostics.py | 68 +++++++ src/featurevisor/evaluate.py | 118 ++++++------ ..._reader.py => evaluation_data_provider.py} | 27 ++- src/featurevisor/events.py | 14 +- src/featurevisor/instance.py | 115 +++++------- src/featurevisor/logger.py | 49 ----- src/featurevisor/tester.py | 12 +- tests/test_conditions_parity.py | 19 +- tests/test_conformance.py | 54 +++++- ...ty.py => test_evaluation_data_provider.py} | 14 +- tests/test_sdk.py | 108 ++++++++--- 18 files changed, 652 insertions(+), 264 deletions(-) create mode 100644 src/featurevisor/diagnostics.py rename src/featurevisor/{datafile_reader.py => evaluation_data_provider.py} (87%) delete mode 100644 src/featurevisor/logger.py rename tests/{test_datafile_reader_parity.py => test_evaluation_data_provider.py} (91%) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b6e436b..b8cd983 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -16,7 +16,7 @@ jobs: steps: - name: Check out - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Set up Python uses: actions/setup-python@v6 diff --git a/README.md b/README.md index ae60fd7..faa069b 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,8 @@ f: Featurevisor = create_featurevisor({ Most applications only need `create_featurevisor` and the `Featurevisor` instance type. Public extension and observability APIs include `FeaturevisorModule`, diagnostics, events, and the datafile dictionaries accepted by the factory. +Concurrent evaluations are safe after an instance is configured. Do not mutate or close the same instance concurrently with evaluations. Serialize calls to `set_datafile`, `set_context`, `set_sticky`, `add_module`, `remove_module`, and `close`. Module, event, and diagnostic callbacks must synchronize mutable state that they capture. + ## Initialization Initialize the SDK with Featurevisor datafile content: @@ -483,9 +485,14 @@ f.remove_module("my-module") ## Child instance +A child snapshots the parent keys that exist when it is spawned. Child values win for those keys. Parent keys introduced later are still inherited. Calling `close()` removes both child-owned listeners and subscriptions delegated to the parent. + ```python child = f.spawn({"country": "de"}) child.is_enabled("my_feature") +child.evaluate_flag("my_feature") +child.evaluate_variation("my_feature") +child.evaluate_variable("my_feature", "my_variable") ``` ## Close diff --git a/conformance/sdk-v3.json b/conformance/sdk-v3.json index ceae692..49396ce 100644 --- a/conformance/sdk-v3.json +++ b/conformance/sdk-v3.json @@ -1,5 +1,5 @@ { - "version": 1, + "version": 2, "description": "Featurevisor v3 cross SDK compatibility contracts", "bucketing": { "minimum": 0, @@ -26,7 +26,47 @@ "pattern": "chrome", "flags": "g", "values": ["chrome", "chrome", "firefox", "chrome"], - "matches": [true, true, false, true] + "matches": [true, true, false, true], + "portableCases": [ + { + "pattern": "^chrome$", + "flags": "", + "value": "chrome", + "expected": true + }, + { + "pattern": "^(chrome|firefox)$", + "flags": "i", + "value": "Firefox", + "expected": true + }, + { + "pattern": "^second$", + "flags": "m", + "value": "first\nsecond", + "expected": true + }, + { + "pattern": "first.*second", + "flags": "s", + "value": "first\nsecond", + "expected": true + }, + { + "pattern": "\\(literal\\)", + "flags": "g", + "value": "(literal)", + "expected": true + } + ], + "rejectedSyntax": [ + "foo(?=bar)", + "(?<=foo)bar", + "(?:foo|bar)", + "(?foo)", + "(foo)\\1", + "foo++" + ] }, "typedVariables": [ { "type": "integer", "value": 1, "valid": true }, @@ -44,6 +84,135 @@ "diagnostics": { "requiredFields": ["level", "code", "message", "details"], "detailsType": "object", - "emptyDetailsJson": "{}" + "emptyDetailsJson": "{}", + "evaluationDetailFields": ["featureKey", "variableKey", "reason", "evaluation"], + "moduleEnvelopeFields": ["module", "moduleName", "originalError"], + "errorEventLevels": ["error"] + }, + "numericBucketKeys": [ + { "value": 1.2345678901234567, "expected": "1.2345678901234567" }, + { "value": 0.30000000000000004, "expected": "0.30000000000000004" }, + { "value": 0.000001, "expected": "0.000001" }, + { "value": 1e-7, "expected": "1e-7" }, + { "value": 100000000000000000000, "expected": "100000000000000000000" }, + { "value": 1e21, "expected": "1e+21" } + ], + "portableConditions": { + "regexFlags": ["g", "i", "m", "s"], + "rejectedRegexFlags": ["d", "u", "v", "y"], + "dateFormat": "ISO 8601 with an explicit timezone", + "dates": [ + "2024-01-01T00:00:00Z", + "2024-01-01T01:00:00+01:00", + "2024-01-01T00:00:00.250Z", + "2024-01-01T01:00:00.250+01:00" + ], + "semanticVersions": [ + "1.2.3", + "1.2.3-beta.1", + "1.2.3+build.5" + ], + "invalidSemanticVersion": "invalid", + "invalidSemanticVersionDiagnosticCode": "condition_match_error" + }, + "conditionCases": [ + { + "name": "strict primitive equality", + "condition": { + "attribute": "value", + "operator": "equals", + "value": 1 + }, + "context": { "value": "1" }, + "expected": false + }, + { + "name": "not negates implicit and", + "condition": { + "not": [ + { "attribute": "country", "operator": "equals", "value": "us" }, + { "attribute": "device", "operator": "equals", "value": "mobile" } + ] + }, + "context": { "country": "us", "device": "desktop" }, + "expected": true + }, + { + "name": "not with nested or means none match", + "condition": { + "not": [ + { + "or": [ + { "attribute": "country", "operator": "equals", "value": "us" }, + { "attribute": "country", "operator": "equals", "value": "nl" } + ] + } + ] + }, + "context": { "country": "de" }, + "expected": true + }, + { + "name": "empty not fails defensively", + "condition": { "not": [] }, + "context": {}, + "expected": false + }, + { + "name": "fractional ISO date with offset", + "condition": { + "attribute": "date", + "operator": "before", + "value": "2024-01-01T00:00:00.500Z" + }, + "context": { "date": "2024-01-01T01:00:00.250+01:00" }, + "expected": true + } + ], + "childInstances": { + "contextModel": "snapshot existing parent keys at spawn, inherit newly introduced parent keys, child keys win", + "closeRemovesLocalAndDelegatedSubscriptions": true, + "detailedEvaluationMethods": ["flag", "variation", "variable"], + "contextCase": { + "parentAtSpawn": { "country": "nl", "plan": "free" }, + "child": { "country": "de" }, + "parentAfterSpawn": { "country": "us", "plan": "pro", "region": "eu" }, + "expected": { "country": "de", "plan": "free", "region": "eu" } + } + }, + "defaults": { + "presenceBased": true, + "values": ["", 0, false, null], + "aggregateEvaluationPreservesEmptyVariation": true, + "aggregateCase": { + "datafile": { + "schemaVersion": "2", + "revision": "defaults", + "segments": {}, + "features": { + "experiment": { + "key": "experiment", + "bucketBy": "userId", + "variations": [{ "value": "control" }], + "traffic": [] + } + } + }, + "defaultVariationValue": "", + "expected": { + "enabled": false, + "variation": "" + } + } + }, + "diagnosticCase": { + "featureKey": "missing", + "expectedLevel": "warn", + "expectedCode": "feature_not_found", + "detailsMustBeObject": true + }, + "nativeContexts": { + "numericTypesUseOneComparisonContract": true, + "primitiveNativeSlicesSupportIncludes": true } } diff --git a/pyproject.toml b/pyproject.toml index 7439d7b..5fa7f7f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "featurevisor" -version = "1.1.0" +version = "2.0.0" description = "Featurevisor Python SDK" readme = "README.md" requires-python = ">=3.10" diff --git a/src/featurevisor/bucketer.py b/src/featurevisor/bucketer.py index cda8ab6..6acf217 100644 --- a/src/featurevisor/bucketer.py +++ b/src/featurevisor/bucketer.py @@ -1,5 +1,8 @@ from __future__ import annotations +import math +from decimal import Decimal + from .conditions import MISSING, get_value_from_context from .murmurhash import murmurhash_v3 from .types import Context @@ -10,13 +13,45 @@ DEFAULT_BUCKET_KEY_SEPARATOR = "." +def _to_javascript_string(value: object) -> str: + if value is None: + return "" + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, float): + if math.isnan(value): + return "NaN" + if math.isinf(value): + return "Infinity" if value > 0 else "-Infinity" + if value == 0: + return "0" + + text = repr(value) + absolute = abs(value) + if "e" in text.lower() and 1e-6 <= absolute < 1e21: + return format(Decimal(text), "f") + if "e" in text.lower(): + coefficient, exponent = text.lower().split("e") + if coefficient.endswith(".0"): + coefficient = coefficient[:-2] + parsed_exponent = int(exponent) + sign = "+" if parsed_exponent >= 0 else "" + return f"{coefficient}e{sign}{parsed_exponent}" + return text[:-2] if text.endswith(".0") else text + if isinstance(value, list): + return ",".join(_to_javascript_string(item) for item in value) + if isinstance(value, dict): + return "[object Object]" + return str(value) + + def get_bucketed_number(bucket_key: str) -> int: hash_value = murmurhash_v3(bucket_key, HASH_SEED) ratio = hash_value / MAX_HASH_VALUE return int(ratio * MAX_BUCKETED_NUMBER) -def get_bucket_key(*, featureKey: str, bucketBy, context: Context, logger) -> str: +def get_bucket_key(*, featureKey: str, bucketBy, context: Context, diagnostics) -> str: if isinstance(bucketBy, str): bucket_type = "plain" attribute_keys = [bucketBy] @@ -27,7 +62,7 @@ def get_bucket_key(*, featureKey: str, bucketBy, context: Context, logger) -> st bucket_type = "or" attribute_keys = bucketBy["or"] else: - logger.error("invalid bucketBy", {"featureKey": featureKey, "bucketBy": bucketBy}) + diagnostics.error("invalid bucketBy", {"featureKey": featureKey, "bucketBy": bucketBy}) raise ValueError("invalid bucketBy") bucket_key: list[object] = [] @@ -40,4 +75,4 @@ def get_bucket_key(*, featureKey: str, bucketBy, context: Context, logger) -> st elif not bucket_key: bucket_key.append(attribute_value) bucket_key.append(featureKey) - return DEFAULT_BUCKET_KEY_SEPARATOR.join(str(part) for part in bucket_key) + return DEFAULT_BUCKET_KEY_SEPARATOR.join(_to_javascript_string(part) for part in bucket_key) diff --git a/src/featurevisor/child.py b/src/featurevisor/child.py index 9652e72..50180ba 100644 --- a/src/featurevisor/child.py +++ b/src/featurevisor/child.py @@ -15,13 +15,30 @@ def __init__(self, *, parent: "Featurevisor", context: dict[str, Any], sticky: d self.context = context self.sticky = sticky or {} self.emitter = Emitter() + self._parent_unsubscribers: list[Any] = [] def on(self, event_name, callback): if event_name in {"context_set", "sticky_set"}: return self.emitter.on(event_name, callback) - return self.parent.on(event_name, callback) + parent_unsubscribe = self.parent.on(event_name, callback) + active = True + + def unsubscribe(): + nonlocal active + if not active: + return + active = False + parent_unsubscribe() + if unsubscribe in self._parent_unsubscribers: + self._parent_unsubscribers.remove(unsubscribe) + + self._parent_unsubscribers.append(unsubscribe) + return unsubscribe def close(self) -> None: + for unsubscribe in list(self._parent_unsubscribers): + unsubscribe() + self._parent_unsubscribers.clear() self.emitter.clear_all() def set_context(self, context: dict[str, Any], replace: bool = False) -> None: @@ -48,12 +65,21 @@ def _merge_options(self, options: dict[str, Any] | None) -> dict[str, Any]: 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)) + def evaluate_flag(self, feature_key: str, context: dict[str, Any] | None = None, options: dict[str, Any] | None = None): + return self.parent.evaluate_flag(feature_key, self._merge_context(context), self._merge_options(options)) + def get_variation(self, feature_key: str, context: dict[str, Any] | None = None, options: dict[str, Any] | None = None): return self.parent.get_variation(feature_key, self._merge_context(context), self._merge_options(options)) + 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 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_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)) @@ -75,21 +101,15 @@ def get_variable_object(self, feature_key: str, variable_key: str, context: dict 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 evaluate_flag(self, feature_key: str, context: dict[str, Any] | None = None, options: dict[str, Any] | None = None): - return self.parent.evaluate_flag(feature_key, self._merge_context(context), self._merge_options(options)) - - 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 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)) - setContext = set_context getContext = get_context setSticky = set_sticky isEnabled = is_enabled + evaluateFlag = evaluate_flag getVariation = get_variation + evaluateVariation = evaluate_variation getVariable = get_variable + evaluateVariable = evaluate_variable getVariableBoolean = get_variable_boolean getVariableString = get_variable_string getVariableInteger = get_variable_integer @@ -97,6 +117,3 @@ def evaluate_variable(self, feature_key: str, variable_key: str, context: dict[s getVariableArray = get_variable_array getVariableObject = get_variable_object getVariableJSON = get_variable_json - evaluateFlag = evaluate_flag - evaluateVariation = evaluate_variation - evaluateVariable = evaluate_variable diff --git a/src/featurevisor/conditions.py b/src/featurevisor/conditions.py index 9972d9a..d2eafb4 100644 --- a/src/featurevisor/conditions.py +++ b/src/featurevisor/conditions.py @@ -10,6 +10,18 @@ MISSING = object() +def _strict_equal(left: Any, right: Any) -> bool: + if isinstance(left, bool) or isinstance(right, bool): + return isinstance(left, bool) and isinstance(right, bool) and left == right + if isinstance(left, (int, float)) and isinstance(right, (int, float)): + return left == right + if left is None or right is None: + return left is None and right is None + if isinstance(left, str) and isinstance(right, str): + return left == right + return False + + def get_value_from_context(obj: Context, path: str) -> AttributeValue: if "." not in path: return obj.get(path, MISSING) @@ -23,12 +35,18 @@ def get_value_from_context(obj: Context, path: str) -> AttributeValue: return current -def _to_datetime(value: Any) -> dt.datetime: +def _to_datetime(value: Any) -> dt.datetime | None: if isinstance(value, dt.datetime): - return value + return value if value.tzinfo is not None else None + if not isinstance(value, str): + return None if isinstance(value, str) and value.endswith("Z"): value = value.replace("Z", "+00:00") - return dt.datetime.fromisoformat(str(value)) + try: + parsed = dt.datetime.fromisoformat(value) + except ValueError: + return None + return parsed if parsed.tzinfo is not None else None def condition_is_matched(condition: dict[str, Any], context: Context, get_regex: Callable[[str, str], re.Pattern[str]]) -> bool: @@ -41,18 +59,24 @@ def condition_is_matched(condition: dict[str, Any], context: Context, get_regex: context_value = get_value_from_context(context, attribute) if operator == "equals": - return context_value == value + return _strict_equal(context_value, value) if operator == "notEquals": - return context_value != value + return not _strict_equal(context_value, value) if operator in {"before", "after"}: date_in_context = _to_datetime(context_value) date_in_condition = _to_datetime(value) + if date_in_context is None or date_in_condition is None: + return False return date_in_context < date_in_condition if operator == "before" else date_in_context > date_in_condition - if isinstance(value, list) and (isinstance(context_value, (str, int, float)) or context_value is None): + if isinstance(value, list) and ( + isinstance(context_value, str) + or (isinstance(context_value, (int, float)) and not isinstance(context_value, bool)) + or context_value is None + ): if operator == "in": - return context_value in value + return any(_strict_equal(context_value, candidate) for candidate in value) if operator == "notIn": - return context_value not in value + return not any(_strict_equal(context_value, candidate) for candidate in value) if isinstance(context_value, str) and isinstance(value, str): if operator == "contains": return value in context_value @@ -78,7 +102,7 @@ def condition_is_matched(condition: dict[str, Any], context: Context, get_regex: return bool(get_regex(value, regex_flags).search(context_value)) if operator == "notMatches": return not get_regex(value, regex_flags).search(context_value) - if isinstance(context_value, (int, float)) and isinstance(value, (int, float)): + if not isinstance(context_value, bool) and not isinstance(value, bool) and isinstance(context_value, (int, float)) and isinstance(value, (int, float)): if operator == "greaterThan": return context_value > value if operator == "greaterThanOrEquals": @@ -91,9 +115,9 @@ def condition_is_matched(condition: dict[str, Any], context: Context, get_regex: return context_value is not MISSING if operator == "notExists": return context_value is MISSING - if isinstance(context_value, list) and isinstance(value, str): + if isinstance(context_value, list) and (isinstance(value, (str, int, float, bool)) or value is None): if operator == "includes": - return value in context_value + return any(_strict_equal(value, candidate) for candidate in context_value) if operator == "notIncludes": - return value not in context_value + return not any(_strict_equal(value, candidate) for candidate in context_value) return False diff --git a/src/featurevisor/diagnostics.py b/src/featurevisor/diagnostics.py new file mode 100644 index 0000000..6d9cd23 --- /dev/null +++ b/src/featurevisor/diagnostics.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from typing import Any, Callable + +LOG_LEVELS = ["fatal", "error", "warn", "info", "debug"] +DEFAULT_LOG_LEVEL = "info" + + +def should_report(configured_level: str, diagnostic_level: str) -> bool: + try: + return LOG_LEVELS.index(configured_level) >= LOG_LEVELS.index(diagnostic_level) + except ValueError: + return False + + +def write_diagnostic_to_console(diagnostic: dict[str, Any]) -> None: + print("[Featurevisor]", diagnostic.get("message") or diagnostic.get("code") or "diagnostic", diagnostic) + + +class _EvaluationDiagnostics: + """Evaluator adapter that emits structured diagnostics directly.""" + + def __init__(self, report: Callable[[dict[str, Any]], None]) -> None: + self._report = report + + def _emit(self, level: str, message: str, details: dict[str, Any] | None = None) -> None: + details = dict(details or {}) + explicit_code = details.pop("code", None) + original_error = details.pop("originalError", details.pop("error", None)) + code_by_message = { + "feature is deprecated": "deprecated_feature", + "variable is deprecated": "deprecated_variable", + "feature not found": "feature_not_found", + "variable schema not found": "variable_not_found", + "no variations": "no_variations", + "invalid bucketBy": "invalid_bucket_by", + "Error during evaluation": "evaluation_error", + "Error parsing conditions": "conditions_parse_error", + } + code = str(explicit_code or code_by_message.get(message, details.get("reason") or message)) + nested_details = details.pop("details", None) + if isinstance(nested_details, dict): + details.update(nested_details) + evaluation = dict(details) if "featureKey" in details and "reason" in details else None + if evaluation is not None: + details = { + "featureKey": evaluation.get("featureKey"), + "variableKey": evaluation.get("variableKey"), + "reason": evaluation.get("reason"), + "evaluation": evaluation, + } + diagnostic: dict[str, Any] = {"level": level, "code": code, "message": message, "details": details} + if original_error is not None: + diagnostic["originalError"] = original_error + self._report(diagnostic) + + def debug(self, message: str, details: dict[str, Any] | None = None) -> None: + self._emit("debug", message, details) + + def warn(self, message: str, details: dict[str, Any] | None = None) -> None: + self._emit("warn", message, details) + + def error(self, message: str, details: dict[str, Any] | None = None) -> None: + self._emit("error", message, details) + + +def _create_evaluation_diagnostics() -> _EvaluationDiagnostics: + return _EvaluationDiagnostics(lambda diagnostic: None) diff --git a/src/featurevisor/evaluate.py b/src/featurevisor/evaluate.py index b7c52a7..8fcdf25 100644 --- a/src/featurevisor/evaluate.py +++ b/src/featurevisor/evaluate.py @@ -36,16 +36,16 @@ def evaluate_with_modules(options: dict[str, Any]) -> dict[str, Any]: current_options = modules_manager.run_before_modules(options) evaluation = evaluate(current_options) if ( - current_options.get("defaultVariationValue") is not None + "defaultVariationValue" in current_options and evaluation["type"] == "variation" - and evaluation.get("variationValue") is None - and evaluation.get("variation") is None + and "variationValue" not in evaluation + and "variation" not in evaluation ): evaluation["variationValue"] = current_options["defaultVariationValue"] if ( - current_options.get("defaultVariableValue") is not None + "defaultVariableValue" in current_options and evaluation["type"] == "variable" - and evaluation.get("variableValue") is None + and "variableValue" not in evaluation ): evaluation["variableValue"] = current_options["defaultVariableValue"] evaluation = modules_manager.run_after_modules(evaluation, current_options) @@ -58,20 +58,20 @@ def evaluate_with_modules(options: dict[str, Any]) -> dict[str, Any]: "reason": EvaluationReason.ERROR, "error": exc, } - options["logger"].error("error during evaluation", evaluation) + options["diagnostics"].error("Error during evaluation", evaluation) return evaluation -def _find_override_index(overrides: list[dict[str, Any]], context: dict[str, Any], datafile_reader) -> int: +def _find_override_index(overrides: list[dict[str, Any]], context: dict[str, Any], datafile) -> int: for index, override in enumerate(overrides): if override.get("conditions"): conditions = override["conditions"] if isinstance(conditions, str) and conditions != "*": conditions = json.loads(conditions) - if datafile_reader.all_conditions_are_matched(conditions, context): + if datafile.all_conditions_are_matched(conditions, context): return index - if override.get("segments") and datafile_reader.all_segments_are_matched( - datafile_reader.parse_segments_if_stringified(override["segments"]), context + if override.get("segments") and datafile.all_segments_are_matched( + datafile.parse_segments_if_stringified(override["segments"]), context ): return index return -1 @@ -83,8 +83,8 @@ def evaluate(options: dict[str, Any]) -> dict[str, Any]: feature_key = options["featureKey"] variable_key = options.get("variableKey") context = options["context"] - logger = options["logger"] - datafile_reader = options["datafileReader"] + diagnostics = options["diagnostics"] + datafile = options["datafile"] sticky = options.get("sticky") modules_manager = options["modulesManager"] @@ -93,10 +93,10 @@ def evaluate(options: dict[str, Any]) -> dict[str, Any]: flag = evaluate({**options, "type": "flag"}) if flag.get("enabled") is False: evaluation = {"type": type_, "featureKey": feature_key, "reason": EvaluationReason.DISABLED} - feature = datafile_reader.get_feature(feature_key) + feature = datafile.get_feature(feature_key) if type_ == "variable" and feature and variable_key and feature.get("variablesSchema", {}).get(variable_key): variable_schema = feature["variablesSchema"][variable_key] - if variable_schema.get("disabledValue") is not None: + if "disabledValue" in variable_schema: evaluation = { "type": type_, "featureKey": feature_key, @@ -116,7 +116,7 @@ def evaluate(options: dict[str, Any]) -> dict[str, Any]: "variableSchema": variable_schema, "enabled": False, } - if type_ == "variation" and feature and feature.get("disabledVariationValue") is not None: + if type_ == "variation" and feature and "disabledVariationValue" in feature: evaluation = { "type": type_, "featureKey": feature_key, @@ -124,20 +124,20 @@ def evaluate(options: dict[str, Any]) -> dict[str, Any]: "variationValue": feature["disabledVariationValue"], "enabled": False, } - logger.debug("feature is disabled", evaluation) + diagnostics.debug("feature is disabled", evaluation) return evaluation if sticky and sticky.get(feature_key): item = sticky[feature_key] - if type_ == "flag" and item.get("enabled") is not None: + if type_ == "flag" and "enabled" in item: evaluation = {"type": type_, "featureKey": feature_key, "reason": EvaluationReason.STICKY, "sticky": item, "enabled": item["enabled"]} - logger.debug("using sticky enabled", evaluation) + diagnostics.debug("using sticky enabled", evaluation) return evaluation - if type_ == "variation" and item.get("variation") is not None: + if type_ == "variation" and "variation" in item: evaluation = {"type": type_, "featureKey": feature_key, "reason": EvaluationReason.STICKY, "variationValue": item["variation"]} - logger.debug("using sticky variation", evaluation) + diagnostics.debug("using sticky variation", evaluation) return evaluation - if variable_key and item.get("variables", {}).get(variable_key) is not None: + if variable_key and variable_key in item.get("variables", {}): evaluation = { "type": type_, "featureKey": feature_key, @@ -145,47 +145,47 @@ def evaluate(options: dict[str, Any]) -> dict[str, Any]: "variableKey": variable_key, "variableValue": item["variables"][variable_key], } - logger.debug("using sticky variable", evaluation) + diagnostics.debug("using sticky variable", evaluation) return evaluation - feature = datafile_reader.get_feature(feature_key) + feature = datafile.get_feature(feature_key) if not feature: evaluation = {"type": type_, "featureKey": feature_key, "reason": EvaluationReason.FEATURE_NOT_FOUND} - logger.warn("feature not found", evaluation) + diagnostics.warn("feature not found", evaluation) return evaluation if type_ == "flag" and feature.get("deprecated"): - logger.warn("feature is deprecated", {"featureKey": feature_key}) + diagnostics.warn("feature is deprecated", {"featureKey": feature_key}) variable_schema = None if variable_key: variable_schema = feature.get("variablesSchema", {}).get(variable_key) if not variable_schema: evaluation = {"type": type_, "featureKey": feature_key, "reason": EvaluationReason.VARIABLE_NOT_FOUND, "variableKey": variable_key} - logger.warn("variable schema not found", evaluation) + diagnostics.warn("variable schema not found", evaluation) return evaluation if variable_schema.get("deprecated"): - logger.warn("variable is deprecated", {"featureKey": feature_key, "variableKey": variable_key}) + diagnostics.warn("variable is deprecated", {"featureKey": feature_key, "variableKey": variable_key}) if type_ == "variation" and not feature.get("variations"): evaluation = {"type": type_, "featureKey": feature_key, "reason": EvaluationReason.NO_VARIATIONS} - logger.warn("no variations", evaluation) + diagnostics.warn("no variations", evaluation) return evaluation - force_result = datafile_reader.get_matched_force(feature, context) + force_result = datafile.get_matched_force(feature, context) force = force_result.get("force") force_index = force_result.get("forceIndex") if force: - if type_ == "flag" and force.get("enabled") is not None: + if type_ == "flag" and "enabled" in force: evaluation = {"type": type_, "featureKey": feature_key, "reason": EvaluationReason.FORCED, "forceIndex": force_index, "force": force, "enabled": force["enabled"]} - logger.debug("forced enabled found", evaluation) + diagnostics.debug("forced enabled found", evaluation) return evaluation if type_ == "variation" and force.get("variation") is not None and feature.get("variations"): variation = next((item for item in feature["variations"] if item["value"] == force["variation"]), None) if variation: evaluation = {"type": type_, "featureKey": feature_key, "reason": EvaluationReason.FORCED, "forceIndex": force_index, "force": force, "variation": variation} - logger.debug("forced variation found", evaluation) + diagnostics.debug("forced variation found", evaluation) return evaluation - if variable_key and force.get("variables", {}).get(variable_key) is not None: + if variable_key and variable_key in force.get("variables", {}): evaluation = { "type": type_, "featureKey": feature_key, @@ -196,7 +196,7 @@ def evaluate(options: dict[str, Any]) -> dict[str, Any]: "variableSchema": variable_schema, "variableValue": force["variables"][variable_key], } - logger.debug("forced variable", evaluation) + diagnostics.debug("forced variable", evaluation) return evaluation if type_ == "flag" and feature.get("required"): @@ -224,10 +224,10 @@ def evaluate(options: dict[str, Any]) -> dict[str, Any]: "required": feature["required"], "enabled": False, } - logger.debug("required features not enabled", evaluation) + diagnostics.debug("required features not enabled", evaluation) return evaluation - bucket_key = get_bucket_key(featureKey=feature_key, bucketBy=feature["bucketBy"], context=context, logger=logger) + bucket_key = get_bucket_key(featureKey=feature_key, bucketBy=feature["bucketBy"], context=context, diagnostics=diagnostics) bucket_key = modules_manager.run_bucket_key_modules( {"featureKey": feature_key, "context": context, "bucketBy": feature["bucketBy"], "bucketKey": bucket_key} ) @@ -236,8 +236,8 @@ def evaluate(options: dict[str, Any]) -> dict[str, Any]: {"featureKey": feature_key, "bucketKey": bucket_key, "context": context, "bucketValue": bucket_value} ) - matched_traffic = datafile_reader.get_matched_traffic(feature["traffic"], context) - matched_allocation = datafile_reader.get_matched_allocation(matched_traffic, bucket_value) if type_ != "flag" and matched_traffic else None + matched_traffic = datafile.get_matched_traffic(feature["traffic"], context) + matched_allocation = datafile.get_matched_allocation(matched_traffic, bucket_value) if type_ != "flag" and matched_traffic else None if matched_traffic: if matched_traffic["percentage"] == 0: @@ -251,7 +251,7 @@ def evaluate(options: dict[str, Any]) -> dict[str, Any]: "traffic": matched_traffic, "enabled": False, } - logger.debug("matched rule with 0 percentage", evaluation) + diagnostics.debug("matched rule with 0 percentage", evaluation) return evaluation if type_ == "flag": if feature.get("ranges"): @@ -267,7 +267,7 @@ def evaluate(options: dict[str, Any]) -> dict[str, Any]: "traffic": matched_traffic, "enabled": matched_traffic.get("enabled", True), } - logger.debug("matched", evaluation) + diagnostics.debug("matched", evaluation) return evaluation evaluation = { "type": type_, @@ -277,9 +277,9 @@ def evaluate(options: dict[str, Any]) -> dict[str, Any]: "bucketValue": bucket_value, "enabled": False, } - logger.debug("not matched", evaluation) + diagnostics.debug("not matched", evaluation) return evaluation - if matched_traffic.get("enabled") is not None: + if "enabled" in matched_traffic: evaluation = { "type": type_, "featureKey": feature_key, @@ -290,7 +290,7 @@ def evaluate(options: dict[str, Any]) -> dict[str, Any]: "traffic": matched_traffic, "enabled": matched_traffic["enabled"], } - logger.debug("override from rule", evaluation) + diagnostics.debug("override from rule", evaluation) return evaluation if bucket_value <= matched_traffic["percentage"]: evaluation = { @@ -303,7 +303,7 @@ def evaluate(options: dict[str, Any]) -> dict[str, Any]: "traffic": matched_traffic, "enabled": True, } - logger.debug("matched traffic", evaluation) + diagnostics.debug("matched traffic", evaluation) return evaluation if type_ == "variation" and feature.get("variations"): if matched_traffic.get("variation") is not None: @@ -319,7 +319,7 @@ def evaluate(options: dict[str, Any]) -> dict[str, Any]: "traffic": matched_traffic, "variation": variation, } - logger.debug("override from rule", evaluation) + diagnostics.debug("override from rule", evaluation) return evaluation if matched_allocation and matched_allocation.get("variation") is not None: variation = next((item for item in feature["variations"] if item["value"] == matched_allocation["variation"]), None) @@ -334,14 +334,14 @@ def evaluate(options: dict[str, Any]) -> dict[str, Any]: "traffic": matched_traffic, "variation": variation, } - logger.debug("allocated variation", evaluation) + diagnostics.debug("allocated variation", evaluation) return evaluation if type_ == "variable" and variable_key: if matched_traffic: overrides = matched_traffic.get("variableOverrides", {}).get(variable_key) if overrides: - override_index = _find_override_index(overrides, context, datafile_reader) + override_index = _find_override_index(overrides, context, datafile) if override_index != -1: override = overrides[override_index] evaluation = { @@ -357,9 +357,9 @@ def evaluate(options: dict[str, Any]) -> dict[str, Any]: "variableValue": override["value"], "variableOverrideIndex": override_index, } - logger.debug("variable override from rule", evaluation) + diagnostics.debug("variable override from rule", evaluation) return evaluation - if matched_traffic.get("variables", {}).get(variable_key) is not None: + if variable_key in matched_traffic.get("variables", {}): evaluation = { "type": type_, "featureKey": feature_key, @@ -372,7 +372,7 @@ def evaluate(options: dict[str, Any]) -> dict[str, Any]: "variableSchema": variable_schema, "variableValue": matched_traffic["variables"][variable_key], } - logger.debug("override from rule", evaluation) + diagnostics.debug("override from rule", evaluation) return evaluation variation_value = None @@ -386,7 +386,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_reader) + override_index = _find_override_index(overrides, context, datafile) if override_index != -1: override = overrides[override_index] evaluation = { @@ -402,9 +402,9 @@ def evaluate(options: dict[str, Any]) -> dict[str, Any]: "variableValue": override["value"], "variableOverrideIndex": override_index, } - logger.debug("variable override from variation", evaluation) + diagnostics.debug("variable override from variation", evaluation) return evaluation - if variation and variation.get("variables", {}).get(variable_key) is not None: + if variation and variable_key in variation.get("variables", {}): evaluation = { "type": type_, "featureKey": feature_key, @@ -417,12 +417,12 @@ def evaluate(options: dict[str, Any]) -> dict[str, Any]: "variableSchema": variable_schema, "variableValue": variation["variables"][variable_key], } - logger.debug("allocated variable", evaluation) + diagnostics.debug("allocated variable", evaluation) return evaluation if type_ == "variation": evaluation = {"type": type_, "featureKey": feature_key, "reason": EvaluationReason.NO_MATCH, "bucketKey": bucket_key, "bucketValue": bucket_value} - logger.debug("no matched variation", evaluation) + diagnostics.debug("no matched variation", evaluation) return evaluation if type_ == "variable": if variable_schema: @@ -436,15 +436,15 @@ def evaluate(options: dict[str, Any]) -> dict[str, Any]: "variableSchema": variable_schema, "variableValue": variable_schema.get("defaultValue"), } - logger.debug("using default value", evaluation) + diagnostics.debug("using default value", evaluation) return evaluation evaluation = {"type": type_, "featureKey": feature_key, "reason": EvaluationReason.VARIABLE_NOT_FOUND, "variableKey": variable_key, "bucketKey": bucket_key, "bucketValue": bucket_value} - logger.debug("variable not found", evaluation) + diagnostics.debug("variable not found", evaluation) return evaluation evaluation = {"type": type_, "featureKey": feature_key, "reason": EvaluationReason.NO_MATCH, "bucketKey": bucket_key, "bucketValue": bucket_value, "enabled": False} - logger.debug("nothing matched", evaluation) + diagnostics.debug("nothing matched", evaluation) return evaluation except Exception as exc: evaluation = {"type": type_, "featureKey": feature_key, "variableKey": variable_key, "reason": EvaluationReason.ERROR, "error": exc} - logger.error("error", evaluation) + diagnostics.error("Error during evaluation", evaluation) return evaluation diff --git a/src/featurevisor/datafile_reader.py b/src/featurevisor/evaluation_data_provider.py similarity index 87% rename from src/featurevisor/datafile_reader.py rename to src/featurevisor/evaluation_data_provider.py index 99b6764..c6ad04c 100644 --- a/src/featurevisor/datafile_reader.py +++ b/src/featurevisor/evaluation_data_provider.py @@ -5,13 +5,13 @@ from typing import Any from .conditions import condition_is_matched -from .logger import _Logger +from .diagnostics import _EvaluationDiagnostics from .types import Context, DatafileContent, Feature, Force, Segment, Traffic -class _DatafileReader: - def __init__(self, *, datafile: DatafileContent, logger: _Logger) -> None: - self.logger = logger +class _InstanceEvaluationDataProvider: + def __init__(self, *, datafile: DatafileContent, diagnostics: _EvaluationDiagnostics) -> None: + self.diagnostics = diagnostics self.schema_version = datafile["schemaVersion"] self.revision = datafile["revision"] self.featurevisor_version = datafile.get("featurevisorVersion") @@ -59,9 +59,16 @@ def has_variations(self, feature_key: str) -> bool: def get_regex(self, regex_string: str, regex_flags: str = "") -> re.Pattern[str]: key = f"{regex_string}-{regex_flags}" if key not in self.regex_cache: + invalid_flags = set(regex_flags) - set("gimsuy") + if invalid_flags: + raise ValueError(f"invalid regular expression flags: {''.join(sorted(invalid_flags))}") flags = 0 if "i" in regex_flags: flags |= re.IGNORECASE + if "m" in regex_flags: + flags |= re.MULTILINE + if "s" in regex_flags: + flags |= re.DOTALL self.regex_cache[key] = re.compile(regex_string, flags) return self.regex_cache[key] @@ -73,7 +80,11 @@ def all_conditions_are_matched(self, conditions: Any, context: Context) -> bool: try: return condition_is_matched(conditions, context, get_regex) except Exception as exc: - self.logger.warn(str(exc), {"error": exc, "details": {"condition": conditions, "context": context}}) + self.diagnostics.warn(str(exc), { + "code": "condition_match_error", + "originalError": exc, + "details": {"condition": conditions, "context": context}, + }) return False if isinstance(conditions, dict) and isinstance(conditions.get("and"), list): return all(self.all_conditions_are_matched(item, context) for item in conditions["and"]) @@ -138,7 +149,11 @@ def parse_conditions_if_stringified(self, conditions: Any) -> Any: try: return json.loads(conditions) except Exception as exc: - self.logger.error("Error parsing conditions", {"error": exc, "details": {"conditions": conditions}}) + self.diagnostics.error("Error parsing conditions", { + "code": "conditions_parse_error", + "originalError": exc, + "details": {"conditions": conditions}, + }) return conditions def parse_segments_if_stringified(self, segments: Any) -> Any: diff --git a/src/featurevisor/events.py b/src/featurevisor/events.py index fde7cf8..5c12d30 100644 --- a/src/featurevisor/events.py +++ b/src/featurevisor/events.py @@ -1,6 +1,6 @@ from __future__ import annotations -from .datafile_reader import _DatafileReader +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: @@ -14,17 +14,17 @@ def get_params_for_sticky_set_event(previous_sticky_features: dict | None = None return {"features": features, "replaced": replace} -def get_params_for_datafile_set_event(previous_datafile_reader: _DatafileReader, new_datafile_reader: _DatafileReader, replace: bool = False) -> dict: - previous_revision = previous_datafile_reader.get_revision() - previous_feature_keys = previous_datafile_reader.get_feature_keys() - new_revision = new_datafile_reader.get_revision() - new_feature_keys = new_datafile_reader.get_feature_keys() +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() + new_revision = new_datafile.get_revision() + new_feature_keys = new_datafile.get_feature_keys() removed_features = [key for key in previous_feature_keys if key not in new_feature_keys] changed_features = [ key for key in previous_feature_keys if key in new_feature_keys - and (previous_datafile_reader.get_feature(key) or {}).get("hash") != (new_datafile_reader.get_feature(key) or {}).get("hash") + and (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 = [] diff --git a/src/featurevisor/instance.py b/src/featurevisor/instance.py index 85307fe..b69145e 100644 --- a/src/featurevisor/instance.py +++ b/src/featurevisor/instance.py @@ -5,12 +5,12 @@ from typing import Any, cast from .child import FeaturevisorChildInstance -from .datafile_reader import _DatafileReader +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 .helpers import get_value_by_type -from .logger import _Logger, _create_logger, _default_log_handler +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 @@ -21,16 +21,14 @@ class Featurevisor: def __init__(self, options: dict[str, Any] | None = None) -> None: options = options or {} self.context = options.get("context") or {} - self.logger: _Logger = _create_logger({ - "level": options.get("logLevel") or _Logger.default_level, - "handler": self._handle_internal_log, - }) + 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.closed = False self.module_diagnostic_subscriptions: list[dict[str, Any]] = [] - self.datafile_reader = _DatafileReader(datafile=empty_datafile, logger=self.logger) + self.evaluation_diagnostics = _EvaluationDiagnostics(self.report_diagnostic) + self.datafile = _InstanceEvaluationDataProvider(datafile=empty_datafile, diagnostics=self.evaluation_diagnostics) self.modules_manager = ModulesManager( modules=options.get("modules") or [], report_diagnostic=self.report_diagnostic, @@ -50,29 +48,9 @@ def __init__(self, options: dict[str, Any] | None = None) -> None: ) def set_log_level(self, level: LogLevel) -> None: - self.logger.set_level(level) - - def _handle_internal_log(self, level: str, message: str, details: dict[str, Any] | None = None) -> None: - details = dict(details or {}) - code = str(details.get("reason") or message) - if message == "feature is deprecated": - code = "deprecated_feature" - elif message == "variable is deprecated": - code = "deprecated_variable" - elif message == "feature not found": - code = "feature_not_found" - elif message == "variable schema not found": - code = "variable_not_found" - elif message == "no variations": - code = "no_variations" - elif message == "invalid bucketBy": - code = "invalid_bucket_by" - self.report_diagnostic({ - "level": level, - "code": code, - "message": message, - "details": details, - }) + if level not in LOG_LEVELS: + raise ValueError("Invalid log level") + self.log_level = level def set_datafile(self, datafile, replace: bool = False) -> None: if self.closed: @@ -88,10 +66,10 @@ def set_datafile(self, datafile, replace: bool = False) -> None: and isinstance(parsed.get("features"), dict) ): raise ValueError("Invalid datafile") - next_datafile = parsed if replace else self._merge_datafiles(self.datafile_reader.get_datafile(), parsed) - new_reader = _DatafileReader(datafile=cast(DatafileContent, next_datafile), logger=self.logger) - details = get_params_for_datafile_set_event(self.datafile_reader, new_reader, replace) - self.datafile_reader = new_reader + next_datafile = parsed if replace else self._merge_datafiles(self.datafile.get_datafile(), parsed) + new_datafile = _InstanceEvaluationDataProvider(datafile=cast(DatafileContent, next_datafile), diagnostics=self.evaluation_diagnostics) + details = get_params_for_datafile_set_event(self.datafile, new_datafile, replace) + self.datafile = new_datafile self.report_diagnostic({"level": "info", "code": "datafile_set", "message": "Datafile set", "details": details}) self.emitter.trigger("datafile_set", details) except Exception as exc: @@ -107,40 +85,44 @@ def set_sticky(self, sticky: dict[str, Any], replace: bool = False) -> None: self.emitter.trigger("sticky_set", params) def get_revision(self) -> str: - return self.datafile_reader.get_revision() + return self.datafile.get_revision() def get_schema_version(self) -> str: - return self.datafile_reader.get_schema_version() + return self.datafile.get_schema_version() def get_segment(self, segment_key: str): - return self.datafile_reader.get_segment(segment_key) + return self.datafile.get_segment(segment_key) def get_feature_keys(self) -> list[str]: - return self.datafile_reader.get_feature_keys() + return self.datafile.get_feature_keys() def get_variable_keys(self, feature_key: str) -> list[str]: - return self.datafile_reader.get_variable_keys(feature_key) + return self.datafile.get_variable_keys(feature_key) def has_variations(self, feature_key: str) -> bool: - return self.datafile_reader.has_variations(feature_key) + return self.datafile.has_variations(feature_key) def get_feature(self, feature_key: str): - return self.datafile_reader.get_feature(feature_key) + return self.datafile.get_feature(feature_key) def add_module(self, module: dict[str, Any] | FeaturevisorModule): if self.closed: return None return self.modules_manager.add(module) - def remove_module(self, name_or_module: str | FeaturevisorModule) -> None: + def remove_module(self, name: str) -> None: if self.closed: return - self.modules_manager.remove(name_or_module) + self.modules_manager.remove(name) def on(self, event_name, callback): + if self.closed: + return lambda: None return self.emitter.on(event_name, callback) def close(self) -> None: + if self.closed: + return self.closed = True self.modules_manager.close_all() self.module_diagnostic_subscriptions = [] @@ -167,15 +149,23 @@ def spawn(self, context: dict[str, Any] | None = None, options: dict[str, Any] | def _get_evaluation_dependencies(self, context: dict[str, Any], options: dict[str, Any] | None = None) -> dict[str, Any]: options = options or {} - return { + dependencies = { "context": self.get_context(context), - "logger": self.logger, + "diagnostics": self.evaluation_diagnostics, + "reportDiagnostic": self.report_diagnostic, "modulesManager": self.modules_manager, - "datafileReader": self.datafile_reader, - "sticky": options.get("__featurevisor_child_sticky") or self.sticky, - "defaultVariationValue": options.get("defaultVariationValue"), - "defaultVariableValue": options.get("defaultVariableValue"), + "datafile": self.datafile, + "sticky": ( + options["__featurevisor_child_sticky"] + if "__featurevisor_child_sticky" in options + else self.sticky + ), } + if "defaultVariationValue" in options: + dependencies["defaultVariationValue"] = options["defaultVariationValue"] + if "defaultVariableValue" in options: + dependencies["defaultVariableValue"] = options["defaultVariableValue"] + return dependencies def evaluate_flag(self, feature_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": "flag", "featureKey": feature_key}) @@ -184,7 +174,7 @@ def is_enabled(self, feature_key: str, context: dict[str, Any] | None = None, op try: return self.evaluate_flag(feature_key, context or {}, options).get("enabled") is True except Exception as exc: - self.logger.error("isEnabled", {"featureKey": feature_key, "error": exc}) + self.report_diagnostic({"level": "error", "code": "evaluation_error", "message": "isEnabled failed", "originalError": exc, "details": {"featureKey": feature_key}}) return False def evaluate_variation(self, feature_key: str, context: dict[str, Any] | None = None, options: dict[str, Any] | None = None): @@ -193,13 +183,13 @@ def evaluate_variation(self, feature_key: str, context: dict[str, Any] | None = def get_variation(self, feature_key: str, context: dict[str, Any] | None = None, options: dict[str, Any] | None = None): try: evaluation = self.evaluate_variation(feature_key, context or {}, options) - if evaluation.get("variationValue") is not None: - return evaluation.get("variationValue") + if "variationValue" in evaluation: + return evaluation["variationValue"] if evaluation.get("variation"): return evaluation["variation"]["value"] return None except Exception as exc: - self.logger.error("getVariation", {"featureKey": feature_key, "error": exc}) + 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): @@ -215,7 +205,7 @@ def get_variable(self, feature_key: str, variable_key: str, context: dict[str, A return value return None except Exception as exc: - self.logger.error("getVariable", {"featureKey": feature_key, "variableKey": variable_key, "error": exc}) + self.report_diagnostic({"level": "error", "code": "evaluation_error", "message": "getVariable failed", "originalError": exc, "details": {"featureKey": feature_key, "variableKey": 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): @@ -241,14 +231,14 @@ def get_variable_json(self, feature_key: str, variable_key: str, context: dict[s 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]: result: dict[str, Any] = {} - keys = feature_keys or self.datafile_reader.get_feature_keys() + keys = feature_keys or self.datafile.get_feature_keys() for feature_key in keys: evaluated: dict[str, Any] = {"enabled": self.is_enabled(feature_key, context or {}, options)} - if self.datafile_reader.has_variations(feature_key): + if self.datafile.has_variations(feature_key): variation = self.get_variation(feature_key, context or {}, options) if variation is not None: evaluated["variation"] = variation - variable_keys = self.datafile_reader.get_variable_keys(feature_key) + variable_keys = self.datafile.get_variable_keys(feature_key) if variable_keys: evaluated["variables"] = { variable_key: self.get_variable(feature_key, variable_key, context or {}, options) @@ -312,22 +302,19 @@ def report_diagnostic(self, diagnostic: dict[str, Any], source_module: Featurevi print("[Featurevisor] Diagnostic handler failed:", exc) if self.on_diagnostic: - if self._should_report_diagnostic(diagnostic["level"], self.logger.level): + if self._should_report_diagnostic(diagnostic["level"], self.log_level): try: self.on_diagnostic(diagnostic) except Exception as exc: print("[Featurevisor] Diagnostic handler failed:", exc) - elif self._should_report_diagnostic(diagnostic["level"], self.logger.level): - _default_log_handler(diagnostic["level"], diagnostic.get("message", ""), diagnostic) + elif self._should_report_diagnostic(diagnostic["level"], self.log_level): + write_diagnostic_to_console(diagnostic) if diagnostic["level"] == "error": self.emitter.trigger("error", {"diagnostic": diagnostic}) def _should_report_diagnostic(self, diagnostic_level: LogLevel, subscriber_level: LogLevel) -> bool: - try: - return _Logger.all_levels.index(subscriber_level) >= _Logger.all_levels.index(diagnostic_level) - except ValueError: - return False + return should_report(subscriber_level, diagnostic_level) def _merge_datafiles(self, previous: dict[str, Any], incoming: dict[str, Any]) -> dict[str, Any]: return { diff --git a/src/featurevisor/logger.py b/src/featurevisor/logger.py deleted file mode 100644 index 73e30b5..0000000 --- a/src/featurevisor/logger.py +++ /dev/null @@ -1,49 +0,0 @@ -from __future__ import annotations - -from typing import Any, Callable - -from .types import LogLevel - -_LogHandler = Callable[[LogLevel, str, dict[str, Any] | None], None] - -loggerPrefix = "[Featurevisor]" - - -def _default_log_handler(level: LogLevel, message: str, details: dict[str, Any] | None = None) -> None: - print(loggerPrefix, message, details or {}) - - -class _Logger: - all_levels: list[LogLevel] = ["fatal", "error", "warn", "info", "debug"] - default_level: LogLevel = "info" - - def __init__(self, level: LogLevel | None = None, handler: _LogHandler | None = None) -> None: - self.level = level or self.default_level - self.handle = handler or _default_log_handler - - def set_level(self, level: LogLevel) -> None: - self.level = level - - def log(self, level: LogLevel, message: str, details: dict[str, Any] | None = None) -> None: - if self.all_levels.index(self.level) < self.all_levels.index(level): - return - self.handle(level, message, details) - - def debug(self, message: str, details: dict[str, Any] | None = None) -> None: - self.log("debug", message, details) - - def info(self, message: str, details: dict[str, Any] | None = None) -> None: - self.log("info", message, details) - - def warn(self, message: str, details: dict[str, Any] | None = None) -> None: - self.log("warn", message, details) - - def error(self, message: str, details: dict[str, Any] | None = None) -> None: - self.log("error", message, details) - - setLevel = set_level - - -def _create_logger(options: dict[str, Any] | None = None) -> _Logger: - options = options or {} - return _Logger(level=options.get("level"), handler=options.get("handler")) diff --git a/src/featurevisor/tester.py b/src/featurevisor/tester.py index b58290b..acf569e 100644 --- a/src/featurevisor/tester.py +++ b/src/featurevisor/tester.py @@ -5,9 +5,9 @@ import uuid from typing import Any -from .datafile_reader import _DatafileReader +from .evaluation_data_provider import _InstanceEvaluationDataProvider from .instance import create_featurevisor -from .logger import _create_logger +from .diagnostics import _create_evaluation_diagnostics from .project import FeaturevisorProject, pretty_duration, timed_build @@ -110,7 +110,7 @@ def _assert_feature(sdk, feature_key: str, assertion: dict[str, Any], datafile: assertion_result["errors"].append({"type": "flag", "expected": expected, "actual": actual, "details": details_base or None}) if "expectedVariation" in assertion: override_options = {} - if assertion.get("defaultVariationValue") is not None: + if "defaultVariationValue" in assertion: override_options["defaultVariationValue"] = assertion["defaultVariationValue"] actual = sdk.get_variation(feature_key, context, override_options) expected = assertion["expectedVariation"] @@ -123,7 +123,7 @@ def _assert_feature(sdk, feature_key: str, assertion: dict[str, Any], datafile: variables_schema = feature_from_datafile.get("variablesSchema", {}) for variable_key, expected in assertion["expectedVariables"].items(): override_options = {} - if assertion.get("defaultVariableValues", {}).get(variable_key) is not None: + if variable_key in assertion.get("defaultVariableValues", {}): override_options["defaultVariableValue"] = assertion["defaultVariableValues"][variable_key] actual = sdk.get_variable(feature_key, variable_key, context, override_options) variable_schema = variables_schema.get(variable_key) @@ -160,8 +160,8 @@ def _assert_feature(sdk, feature_key: str, assertion: dict[str, Any], datafile: def test_segment(segment: dict[str, Any], assertion_options: dict[str, Any] | None = None) -> dict[str, Any]: options = assertion_options or {} - logger = _create_logger({"level": _log_level(options.get("verbose", False), options.get("quiet", False))}) - reader = _DatafileReader(datafile={"schemaVersion": "2", "revision": "tester", "segments": {}, "features": {}}, logger=logger) + diagnostics = _create_evaluation_diagnostics() + reader = _InstanceEvaluationDataProvider(datafile={"schemaVersion": "2", "revision": "tester", "segments": {}, "features": {}}, diagnostics=diagnostics) result = {"type": "segment", "key": segment["segment"], "notFound": False, "passed": True, "duration": 0, "assertions": []} start = time.perf_counter() for assertion in segment["assertions"]: diff --git a/tests/test_conditions_parity.py b/tests/test_conditions_parity.py index 7e5c4ba..60f7d13 100644 --- a/tests/test_conditions_parity.py +++ b/tests/test_conditions_parity.py @@ -6,16 +6,16 @@ sys.path.insert(0, "src") -from featurevisor.datafile_reader import _DatafileReader -from featurevisor.logger import _create_logger +from featurevisor.evaluation_data_provider import _InstanceEvaluationDataProvider +from featurevisor.diagnostics import _create_evaluation_diagnostics class ConditionsParityTests(unittest.TestCase): @classmethod def setUpClass(cls) -> None: - cls.reader = _DatafileReader( + cls.reader = _InstanceEvaluationDataProvider( datafile={"schemaVersion": "2.0", "revision": "1", "segments": {}, "features": {}}, - logger=_create_logger(), + diagnostics=_create_evaluation_diagnostics(), ) def test_should_be_a_function(self) -> None: @@ -33,6 +33,11 @@ def test_operator_cases(self) -> None: ([{"attribute": "browser.type", "operator": "equals", "value": "chrome"}], {"browser": {"type": "firefox"}}, False), ([{"attribute": "browser_type", "operator": "notEquals", "value": "chrome"}], {"browser_type": "firefox"}, True), ([{"attribute": "browser_type", "operator": "notEquals", "value": "chrome"}], {"browser_type": "chrome"}, False), + ([{"attribute": "missing", "operator": "notEquals", "value": None}], {}, True), + ([{"attribute": "value", "operator": "equals", "value": 1.0}], {"value": 1}, True), + ([{"attribute": "value", "operator": "equals", "value": True}], {"value": 1}, False), + ([{"attribute": "value", "operator": "equals", "value": [1]}], {"value": [1]}, False), + ([{"attribute": "value", "operator": "equals", "value": {"a": 1}}], {"value": {"a": 1}}, False), ([{"attribute": "browser_type", "operator": "exists"}], {"browser_type": "firefox"}, True), ([{"attribute": "browser_type", "operator": "exists"}], {"not_browser_type": "chrome"}, False), ([{"attribute": "browser.name", "operator": "exists"}], {"browser": {"name": "chrome"}}, True), @@ -58,6 +63,8 @@ def test_operator_cases(self) -> None: ([{"attribute": "browser_type", "operator": "in", "value": ["chrome", "firefox"]}], {"browser_type": "edge"}, False), ([{"attribute": "browser_type", "operator": "notIn", "value": ["chrome", "firefox"]}], {"browser_type": "edge"}, True), ([{"attribute": "browser_type", "operator": "notIn", "value": ["chrome", "firefox"]}], {"browser_type": "chrome"}, False), + ([{"attribute": "enabled", "operator": "in", "value": [True]}], {"enabled": True}, False), + ([{"attribute": "enabled", "operator": "notIn", "value": [False]}], {"enabled": True}, False), ([{"attribute": "age", "operator": "greaterThan", "value": 18}], {"age": 19}, True), ([{"attribute": "age", "operator": "greaterThan", "value": 18}], {"age": 17}, False), ([{"attribute": "age", "operator": "greaterThanOrEquals", "value": 18}], {"age": 18}, True), @@ -75,6 +82,10 @@ def test_operator_cases(self) -> None: ([{"attribute": "version", "operator": "semverLessThanOrEquals", "value": "1.0.0"}], {"version": "1.1.0"}, False), ([{"attribute": "date", "operator": "before", "value": "2023-05-13T16:23:59Z"}], {"date": "2023-05-12T00:00:00Z"}, True), ([{"attribute": "date", "operator": "before", "value": "2023-05-13T16:23:59Z"}], {"date": dt.datetime.fromisoformat("2023-05-14T00:00:00+00:00")}, False), + ([{"attribute": "date", "operator": "before", "value": "2023-05-13T16:23:59Z"}], {"date": "2023-05-12T00:00:00"}, False), + ([{"attribute": "date", "operator": "before", "value": "2023-05-13T16:23:59Z"}], {"date": "2023-05-13T17:23:59+01:00"}, False), + ([{"attribute": "version", "operator": "semverLessThan", "value": "1.2.3"}], {"version": "1.2.3-beta.1"}, True), + ([{"attribute": "version", "operator": "semverEquals", "value": "1.2.3+build.9"}], {"version": "1.2.3+build.5"}, True), ([{"attribute": "date", "operator": "after", "value": "2023-05-13T16:23:59Z"}], {"date": "2023-05-14T00:00:00Z"}, True), ([{"attribute": "date", "operator": "after", "value": "2023-05-13T16:23:59Z"}], {"date": "2023-05-12T00:00:00Z"}, False), ] diff --git a/tests/test_conformance.py b/tests/test_conformance.py index ff0791e..c00d9dd 100644 --- a/tests/test_conformance.py +++ b/tests/test_conformance.py @@ -4,10 +4,12 @@ import unittest from pathlib import Path -from featurevisor.datafile_reader import _DatafileReader +from featurevisor.evaluation_data_provider import _InstanceEvaluationDataProvider from featurevisor.evaluate import EvaluationReason from featurevisor.helpers import get_value_by_type -from featurevisor.logger import _Logger +from featurevisor.diagnostics import _create_evaluation_diagnostics +from featurevisor.bucketer import get_bucket_key +from featurevisor import create_featurevisor class SDKV3ConformanceTests(unittest.TestCase): @@ -22,10 +24,10 @@ 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"], 1) - reader = _DatafileReader( + self.assertEqual(self.fixture["version"], 2) + reader = _InstanceEvaluationDataProvider( datafile={"schemaVersion": "2", "revision": "conformance", "segments": {}, "features": {}}, - logger=_Logger(level="fatal"), + diagnostics=_create_evaluation_diagnostics(), ) traffic = {"allocation": self.fixture["bucketing"]["allocations"]} @@ -38,6 +40,48 @@ def test_allocation_and_typed_value_contracts(self) -> None: actual = get_value_by_type(item["value"], item["type"]) self.assertEqual(actual is not None, item["valid"]) + for item in self.fixture["numericBucketKeys"]: + actual = get_bucket_key( + featureKey="feature", + bucketBy="value", + context={"value": item["value"]}, + diagnostics=_create_evaluation_diagnostics(), + ) + self.assertEqual(actual, f'{item["expected"]}.feature') + + for item in self.fixture["regularExpressions"]["portableCases"]: + actual = reader.all_conditions_are_matched( + { + "attribute": "value", + "operator": "matches", + "value": item["pattern"], + "regexFlags": item["flags"], + }, + {"value": item["value"]}, + ) + self.assertEqual( + actual, + item["expected"], + f'pattern {item["pattern"]}, flags {item["flags"]}', + ) + + for item in self.fixture["conditionCases"]: + self.assertEqual( + reader.all_conditions_are_matched(item["condition"], item["context"]), + item["expected"], + item["name"], + ) + + aggregate_case = self.fixture["defaults"]["aggregateCase"] + featurevisor = create_featurevisor({"datafile": aggregate_case["datafile"]}) + evaluated = featurevisor.get_all_evaluations( + {}, + [], + {"defaultVariationValue": aggregate_case["defaultVariationValue"]}, + )["experiment"] + self.assertEqual(evaluated["enabled"], aggregate_case["expected"]["enabled"]) + self.assertEqual(evaluated["variation"], aggregate_case["expected"]["variation"]) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_datafile_reader_parity.py b/tests/test_evaluation_data_provider.py similarity index 91% rename from tests/test_datafile_reader_parity.py rename to tests/test_evaluation_data_provider.py index ab7a76e..cf43fae 100644 --- a/tests/test_datafile_reader_parity.py +++ b/tests/test_evaluation_data_provider.py @@ -5,17 +5,17 @@ sys.path.insert(0, "src") -from featurevisor.datafile_reader import _DatafileReader -from featurevisor.logger import _create_logger +from featurevisor.evaluation_data_provider import _InstanceEvaluationDataProvider +from featurevisor.diagnostics import _create_evaluation_diagnostics -class DatafileReaderParityTests(unittest.TestCase): +class EvaluationDataProviderTests(unittest.TestCase): @classmethod def setUpClass(cls) -> None: - cls.logger = _create_logger() + cls.diagnostics = _create_evaluation_diagnostics() def test_should_be_a_function(self) -> None: - self.assertTrue(callable(_DatafileReader)) + self.assertTrue(callable(_InstanceEvaluationDataProvider)) def test_v2_datafile_schema_should_return_requested_entities(self) -> None: datafile_json = { @@ -35,7 +35,7 @@ def test_v2_datafile_schema_should_return_requested_entities(self) -> None: "testWithNoVariations": {"key": "testWithNoVariations", "bucketBy": "userId", "traffic": [{"key": "1", "segments": "*", "percentage": 100000}]}, }, } - reader = _DatafileReader(datafile=datafile_json, logger=self.logger) + reader = _InstanceEvaluationDataProvider(datafile=datafile_json, diagnostics=self.diagnostics) self.assertEqual(reader.getRevision(), "1") self.assertEqual(reader.getSchemaVersion(), "2") self.assertEqual(reader.getSegment("netherlands"), datafile_json["segments"]["netherlands"]) @@ -79,7 +79,7 @@ def test_segments_cases(self) -> None: "version_5.5": {"key": "version_5.5", "conditions": [{"or": [{"attribute": "version", "operator": "equals", "value": "5.5"}, {"attribute": "version", "operator": "equals", "value": 5.5}]}]}, }, } - reader = _DatafileReader(datafile=datafile, logger=self.logger) + reader = _InstanceEvaluationDataProvider(datafile=datafile, diagnostics=self.diagnostics) matches = [ ("*", {}, True), ("*", {"foo": "foo"}, True), diff --git a/tests/test_sdk.py b/tests/test_sdk.py index 4ae9a16..4948d5c 100644 --- a/tests/test_sdk.py +++ b/tests/test_sdk.py @@ -11,10 +11,10 @@ from featurevisor.bucketer import MAX_BUCKETED_NUMBER, get_bucket_key, get_bucketed_number from featurevisor.compare_versions import compare_versions from featurevisor.conditions import condition_is_matched -from featurevisor.datafile_reader import _DatafileReader +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.emitter import Emitter -from featurevisor.logger import _create_logger +from featurevisor.diagnostics import _create_evaluation_diagnostics class SDKTests(unittest.TestCase): @@ -41,9 +41,19 @@ def test_condition_exists_and_not_exists(self) -> None: self.assertTrue(condition_is_matched({"attribute": "country", "operator": "notExists"}, {}, get_regex)) def test_bucket_key_keeps_none_values(self) -> None: - logger = _create_logger({"level": "fatal"}) - bucket_key = get_bucket_key(featureKey="my_feature", bucketBy="userId", context={"userId": None}, logger=logger) - self.assertEqual(bucket_key, "None.my_feature") + diagnostics = _create_evaluation_diagnostics() + bucket_key = get_bucket_key(featureKey="my_feature", bucketBy="userId", context={"userId": None}, diagnostics=diagnostics) + self.assertEqual(bucket_key, ".my_feature") + + def test_bucket_key_stringifies_numbers_like_javascript(self) -> None: + diagnostics = _create_evaluation_diagnostics() + bucket_key = get_bucket_key( + featureKey="feature", + bucketBy=["whole", "negativeZero", "small", "large"], + context={"whole": 1.0, "negativeZero": -0.0, "small": 1e-6, "large": 1e21}, + diagnostics=diagnostics, + ) + self.assertEqual(bucket_key, "1.0.0.000001.1e+21.feature") def test_bucketed_number_range(self) -> None: value = get_bucketed_number("foo.bar") @@ -51,10 +61,10 @@ def test_bucketed_number_range(self) -> None: self.assertLess(value, MAX_BUCKETED_NUMBER) def test_bucket_key_variants(self) -> None: - logger = _create_logger({"level": "fatal"}) - self.assertEqual(get_bucket_key(featureKey="test-feature", bucketBy="userId", context={"userId": "123"}, logger=logger), "123.test-feature") - self.assertEqual(get_bucket_key(featureKey="test-feature", bucketBy=["organizationId", "user.id"], context={"organizationId": "123", "user": {"id": "234"}}, logger=logger), "123.234.test-feature") - self.assertEqual(get_bucket_key(featureKey="test-feature", bucketBy={"or": ["userId", "deviceId"]}, context={"deviceId": "deviceIdHere"}, logger=logger), "deviceIdHere.test-feature") + diagnostics = _create_evaluation_diagnostics() + self.assertEqual(get_bucket_key(featureKey="test-feature", bucketBy="userId", context={"userId": "123"}, diagnostics=diagnostics), "123.test-feature") + self.assertEqual(get_bucket_key(featureKey="test-feature", bucketBy=["organizationId", "user.id"], context={"organizationId": "123", "user": {"id": "234"}}, diagnostics=diagnostics), "123.234.test-feature") + self.assertEqual(get_bucket_key(featureKey="test-feature", bucketBy={"or": ["userId", "deviceId"]}, context={"deviceId": "deviceIdHere"}, diagnostics=diagnostics), "deviceIdHere.test-feature") def test_instance_basic_flow(self) -> None: datafile = { @@ -99,7 +109,7 @@ def test_set_datafile_merges_by_default_and_replace_opt_in(self) -> None: ) self.assertEqual(instance.get_revision(), "2") - self.assertEqual(instance.datafile_reader.featurevisor_version, "3.1.0") + self.assertEqual(instance.datafile.featurevisor_version, "3.1.0") self.assertIsNotNone(instance.get_feature("first")) self.assertIsNotNone(instance.get_feature("second")) @@ -289,15 +299,38 @@ def test_module_diagnostic_subscribe_report_and_remove_cleanup(self) -> None: self.assertEqual(listener_diagnostics[-1]["code"], "module_warning") self.assertEqual(listener_closed, [True]) - def test_datafile_reader_parses_stringified_conditions(self) -> None: - reader = _DatafileReader( + def test_module_diagnostic_level_is_independent_from_instance_level(self) -> None: + observed = [] + instance = create_featurevisor( + { + "logLevel": "fatal", + "modules": [ + { + "name": "observer", + "setup": lambda api: api["onDiagnostic"]( + lambda diagnostic: observed.append(diagnostic), + {"logLevel": "debug"}, + ), + } + ], + } + ) + + instance.is_enabled("missing") + + diagnostic = next(item for item in observed if item["code"] == "feature_not_found") + self.assertEqual(diagnostic["details"]["featureKey"], "missing") + self.assertEqual(diagnostic["details"]["reason"], "feature_not_found") + + def test_evaluation_data_provider_parses_stringified_conditions(self) -> None: + reader = _InstanceEvaluationDataProvider( datafile={ "schemaVersion": "2", "revision": "1", "segments": {"eu": {"conditions": '[{"attribute":"country","operator":"equals","value":"nl"}]'}}, "features": {}, }, - logger=_create_logger({"level": "fatal"}), + diagnostics=_create_evaluation_diagnostics(), ) segment = reader.get_segment("eu") self.assertTrue(reader.segment_is_matched(segment, {"country": "nl"})) @@ -307,9 +340,9 @@ def test_events_helpers(self) -> None: get_params_for_sticky_set_event({"feature1": {"enabled": True}}, {"feature2": {"enabled": True}}, True), {"features": ["feature1", "feature2"], "replaced": True}, ) - logger = _create_logger({"level": "fatal"}) - previous = _DatafileReader(datafile={"schemaVersion": "2", "revision": "1", "segments": {}, "features": {"feature1": {"bucketBy": "userId", "hash": "hash1", "traffic": []}}}, logger=logger) - current = _DatafileReader(datafile={"schemaVersion": "2", "revision": "2", "segments": {}, "features": {"feature1": {"bucketBy": "userId", "hash": "hash2", "traffic": []}, "feature2": {"bucketBy": "userId", "hash": "hash3", "traffic": []}}}, logger=logger) + diagnostics = _create_evaluation_diagnostics() + previous = _InstanceEvaluationDataProvider(datafile={"schemaVersion": "2", "revision": "1", "segments": {}, "features": {"feature1": {"bucketBy": "userId", "hash": "hash1", "traffic": []}}}, diagnostics=diagnostics) + 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}, @@ -347,14 +380,6 @@ def second(_details: dict) -> None: self.assertEqual(calls, ["first", "second", "first"]) - def test_logger_handler_and_filtering(self) -> None: - calls = [] - logger = _create_logger({"level": "warn", "handler": lambda level, message, details=None: calls.append((level, message, details))}) - logger.info("nope") - logger.warn("yes") - logger.error("yep") - self.assertEqual(calls, [("warn", "yes", None), ("error", "yep", None)]) - def test_child_instance_flow(self) -> None: datafile = { "schemaVersion": "2", @@ -382,11 +407,46 @@ def test_child_instance_flow(self) -> None: self.assertEqual(child.get_context(), {"appVersion": "1.0.0", "userId": "123", "country": "be"}) self.assertTrue(child.is_enabled("test")) self.assertEqual(child.get_variation("test"), "control") + self.assertTrue(child.evaluate_flag("test")["enabled"]) + self.assertEqual(child.evaluate_variation("test")["variation"]["value"], "control") self.assertEqual(child.get_variable("test", "color"), "black") + self.assertEqual(child.evaluate_variable("test", "color")["variableValue"], "black") self.assertEqual(child.get_variable_json("test", "nestedConfig"), {"key": {"nested": "value"}}) unsubscribe() self.assertTrue(changes) + def test_child_close_removes_delegated_subscriptions_and_empty_sticky_is_isolated(self) -> None: + instance = create_featurevisor({ + "logLevel": "fatal", + "sticky": {"test": {"enabled": True}}, + }) + child = instance.spawn({}, {"sticky": {}}) + delegated = [] + child.on("datafile_set", lambda details: delegated.append(details)) + + self.assertFalse(child.is_enabled("test")) + child.close() + child.close() + instance.set_datafile({ + "schemaVersion": "2", + "revision": "after-close", + "segments": {}, + "features": {}, + }) + + self.assertEqual(delegated, []) + + def test_child_context_matches_javascript_snapshot_behavior(self) -> None: + instance = create_featurevisor({"context": {"country": "nl", "plan": "free"}, "logLevel": "fatal"}) + child = instance.spawn({"country": "de"}) + instance.set_context({"plan": "pro", "locale": "de-DE"}) + + self.assertEqual(child.get_context(), { + "country": "de", + "plan": "free", + "locale": "de-DE", + }) + def test_default_variable_json_parsing(self) -> None: datafile = { "schemaVersion": "2", From f01e149d07ead726f83c7202e3764c0d5d056de5 Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Thu, 23 Jul 2026 23:53:06 +0200 Subject: [PATCH 2/2] updates --- .github/workflows/checks.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 174aba8..7b699ed 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -23,7 +23,7 @@ jobs: python-version: ['3.10', '3.14'] steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: actions/setup-python@v6 with: @@ -41,7 +41,7 @@ jobs: timeout-minutes: 10 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: actions/setup-python@v6 with: @@ -93,14 +93,14 @@ jobs: timeout-minutes: 15 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: actions/setup-python@v6 with: python-version: '3.14' cache: pip - - uses: actions/setup-node@v6 + - uses: actions/setup-node@v7 with: node-version-file: '.nvmrc'