From 6db9e932cc96bc6892884c9e194f15c94f5e154b Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Tue, 14 Jul 2026 21:36:46 +0200 Subject: [PATCH 1/2] feat: OpenFeature provider for Python --- Makefile | 5 +- README.md | 42 ++++++ pyproject.toml | 3 + src/featurevisor/openfeature.py | 227 +++++++++++++++++++++++++++++ tests_openfeature/test_provider.py | 101 +++++++++++++ 5 files changed, 377 insertions(+), 1 deletion(-) create mode 100644 src/featurevisor/openfeature.py create mode 100644 tests_openfeature/test_provider.py diff --git a/Makefile b/Makefile index 4ca2334..a9c876c 100644 --- a/Makefile +++ b/Makefile @@ -25,10 +25,13 @@ setup-golang-sdk: update-golang-sdk: (cd featurevisor-go && git pull origin main) -.PHONY: test +.PHONY: test test-openfeature test: PYTHONPATH=src python3 -m unittest discover -s tests -v +test-openfeature: + PYTHONPATH=src python3 -m unittest discover -s tests_openfeature -v + .PHONY: test-example-1 test-example-1: PYTHONPATH=src python3 -m unittest discover -s tests -v diff --git a/README.md b/README.md index bc1e151..43c1c71 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ This SDK is compatible with Featurevisor v3 projects and v2 datafiles. - [Registering modules](#registering-modules) - [Child instance](#child-instance) - [Close](#close) +- [OpenFeature](#openfeature) - [CLI usage](#cli-usage) - [Test](#test) - [Benchmark](#benchmark) @@ -580,6 +581,47 @@ python -m featurevisor assess-distribution \ --populateUuid=deviceId ``` +## OpenFeature + +Install Featurevisor with its optional OpenFeature dependency: + +```bash +pip install "featurevisor[openfeature]" +``` + +```python +from featurevisor.openfeature import FeaturevisorOpenFeatureProvider +from openfeature import api +from openfeature.evaluation_context import EvaluationContext + +provider = FeaturevisorOpenFeatureProvider({"datafile": datafile_content}) +api.set_provider(provider) + +client = api.get_client() +enabled = client.get_boolean_value( + "checkout", + False, + EvaluationContext(targeting_key="user-123", attributes={"country": "nl"}), +) +``` + +Use `checkout` for a flag, `checkout:variation` for its variation, and `checkout:title` for its `title` variable. Boolean variables use the boolean resolver. Sequences, mappings, and JSON variables use the object resolver. + +OpenFeature's targeting key maps to `userId` by default. `targeting_key_field`, `key_separator`, and `variation_key` can customize the mapping. + +You can also reuse an existing Featurevisor instance: + +```python +from featurevisor import create_featurevisor + +featurevisor = create_featurevisor({"datafile": datafile_content}) +provider = FeaturevisorOpenFeatureProvider(featurevisor=featurevisor) +``` + +The caller owns an instance passed this way. Provider shutdown does not close it. Call `featurevisor.close()` when every consumer is finished with it. When the provider creates the instance from options, the provider owns and closes it. If both are supplied, `featurevisor` takes precedence over the options dictionary. + +See the [OpenFeature provider guide](https://featurevisor.com/docs/sdks/openfeature/) for resolution reasons, errors, metadata, tracking, lifecycle, and providers for other languages. + ## Development diff --git a/pyproject.toml b/pyproject.toml index 93ff845..e5511a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,9 @@ classifiers = [ [project.scripts] featurevisor = "featurevisor.cli:main" +[project.optional-dependencies] +openfeature = ["openfeature-sdk>=0.10.0,<0.11"] + [tool.setuptools] package-dir = { "" = "src" } diff --git a/src/featurevisor/openfeature.py b/src/featurevisor/openfeature.py new file mode 100644 index 0000000..6507d8f --- /dev/null +++ b/src/featurevisor/openfeature.py @@ -0,0 +1,227 @@ +from __future__ import annotations + +import json +import math +from collections.abc import Mapping, Sequence +from datetime import datetime +from typing import Any, Callable + +from openfeature.evaluation_context import EvaluationContext +from openfeature.exception import ErrorCode +from openfeature.flag_evaluation import FlagResolutionDetails, Reason +from openfeature.provider import AbstractProvider +from openfeature.provider.metadata import Metadata +from openfeature.track import TrackingEventDetails + +from .instance import Featurevisor, create_featurevisor + + +class FeaturevisorOpenFeatureProvider(AbstractProvider): + """OpenFeature provider backed by the Featurevisor v3 SDK.""" + + def __init__( + self, + options: dict[str, Any] | None = None, + *, + featurevisor: Featurevisor | None = None, + targeting_key_field: str = "userId", + key_separator: str = ":", + variation_key: str = "variation", + 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.on_track = on_track + self.datafile_error: str | None = None + self._owns_featurevisor = featurevisor is None + if featurevisor is not None: + self.featurevisor = featurevisor + else: + featurevisor_options = dict(options or {}) + datafile = featurevisor_options.get("datafile") + if isinstance(datafile, str): + try: + json.loads(datafile) + except (TypeError, ValueError): + self.datafile_error = "Could not parse datafile" + original_handler = featurevisor_options.get("onDiagnostic") or featurevisor_options.get("on_diagnostic") + + def on_diagnostic(diagnostic: dict[str, Any]) -> None: + if diagnostic.get("code") == "invalid_datafile": + self.datafile_error = str(diagnostic.get("message")) + if diagnostic.get("code") == "datafile_set": + self.datafile_error = None + if original_handler: + original_handler(diagnostic) + + featurevisor_options["onDiagnostic"] = on_diagnostic + self.featurevisor = create_featurevisor(featurevisor_options) + self._datafile_unsubscribe = self.featurevisor.on("datafile_set", lambda _: setattr(self, "datafile_error", None)) + + def get_metadata(self) -> Metadata: + return Metadata(name="Featurevisor") + + def shutdown(self) -> None: + self._datafile_unsubscribe() + if self._owns_featurevisor: + self.featurevisor.close() + + def track( + self, + tracking_event_name: str, + evaluation_context: EvaluationContext | None = None, + tracking_event_details: TrackingEventDetails | None = None, + ) -> None: + if self.on_track: + self.on_track(tracking_event_name, evaluation_context, tracking_event_details) + + def resolve_boolean_details(self, flag_key: str, default_value: bool, evaluation_context: EvaluationContext | None = None) -> FlagResolutionDetails[bool]: + return self._resolve(flag_key, default_value, evaluation_context, "boolean") + + def resolve_string_details(self, flag_key: str, default_value: str, evaluation_context: EvaluationContext | None = None) -> FlagResolutionDetails[str]: + return self._resolve(flag_key, default_value, evaluation_context, "string") + + def resolve_integer_details(self, flag_key: str, default_value: int, evaluation_context: EvaluationContext | None = None) -> FlagResolutionDetails[int]: + return self._resolve(flag_key, default_value, evaluation_context, "integer") + + def resolve_float_details(self, flag_key: str, default_value: float, evaluation_context: EvaluationContext | None = None) -> FlagResolutionDetails[float]: + return self._resolve(flag_key, default_value, evaluation_context, "number") + + def resolve_object_details(self, flag_key: str, default_value: Sequence[Any] | Mapping[str, Any], evaluation_context: EvaluationContext | None = None) -> FlagResolutionDetails[Sequence[Any] | Mapping[str, Any]]: + return self._resolve(flag_key, default_value, evaluation_context, "object") + + def _resolve(self, flag_key: str, default_value: Any, evaluation_context: EvaluationContext | None, expected_type: str) -> FlagResolutionDetails[Any]: + if self.datafile_error: + return self._error(default_value, ErrorCode.PARSE_ERROR, self.datafile_error) + + feature_key, separator, selector = flag_key.partition(self.key_separator) + selector = selector if separator else None + context = self._context(evaluation_context) + + if not selector: + if expected_type != "boolean": + return self._type_mismatch(flag_key, default_value, expected_type) + evaluation = self.featurevisor.evaluate_flag(feature_key, context) + value = evaluation.get("enabled") + elif selector == self.variation_key: + evaluation = self.featurevisor.evaluate_variation(feature_key, context) + value = evaluation.get("variationValue") + if value is None and evaluation.get("variation"): + value = evaluation["variation"].get("value") + else: + evaluation = self.featurevisor.evaluate_variable(feature_key, selector, context) + value = evaluation.get("variableValue") + if evaluation.get("variableSchema", {}).get("type") == "json" and isinstance(value, str): + try: + value = json.loads(value) + except (TypeError, ValueError): + pass + + metadata = self._metadata(evaluation) + error_code = self._error_code(evaluation.get("reason")) + if error_code: + return self._error(default_value, error_code, self._error_message(evaluation), metadata) + if value is None: + value = default_value + elif not self._matches(value, expected_type): + return self._type_mismatch(flag_key, default_value, expected_type, metadata) + + return FlagResolutionDetails( + value=value, + variant=self._variant(evaluation), + reason=self._reason(evaluation.get("reason")), + flag_metadata=metadata, + ) + + def _context(self, context: EvaluationContext | None) -> dict[str, Any]: + result = self._normalize(dict(context.attributes)) if context else {} + if context and context.targeting_key: + result[self.targeting_key_field] = context.targeting_key + return result + + 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(), + } + revision = self.featurevisor.get_revision() + if revision: + metadata["revision"] = revision + for key in ("variableKey", "ruleKey", "bucketKey", "bucketValue", "forceIndex", "variableOverrideIndex"): + if evaluation.get(key) is not None: + metadata[key] = evaluation[key] + return metadata + + @staticmethod + def _reason(reason: str | None) -> Reason: + if reason in {"feature_not_found", "variable_not_found", "no_variations", "error"}: + return Reason.ERROR + if reason in {"required", "forced", "sticky", "rule", "variable_override_variation", "variable_override_rule"}: + return Reason.TARGETING_MATCH + if reason == "allocated": + return Reason.SPLIT + if reason in {"disabled", "variation_disabled", "variable_disabled"}: + return Reason.DISABLED + return Reason.DEFAULT + + @staticmethod + def _error_code(reason: str | None) -> ErrorCode | None: + if reason in {"feature_not_found", "variable_not_found", "no_variations"}: + return ErrorCode.FLAG_NOT_FOUND + if reason == "error": + return ErrorCode.GENERAL + return None + + @staticmethod + def _error_message(evaluation: dict[str, Any]) -> str: + error = evaluation.get("error") + if error: + return str(error) + 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("reason") == "no_variations": + return f'Feature "{evaluation["featureKey"]}" has no variations' + return "Featurevisor evaluation failed" + + @staticmethod + def _variant(evaluation: dict[str, Any]) -> str | None: + if evaluation.get("variationValue") is not None: + return str(evaluation["variationValue"]) + if evaluation.get("variation"): + return str(evaluation["variation"].get("value")) + return None + + @staticmethod + def _matches(value: Any, expected_type: str) -> bool: + if expected_type == "boolean": + return isinstance(value, bool) + if expected_type == "string": + return isinstance(value, str) + if expected_type == "integer": + return isinstance(value, int) and not isinstance(value, bool) + if expected_type == "number": + return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value) + return isinstance(value, (dict, list, tuple)) + + @classmethod + def _normalize(cls, value: Any) -> Any: + if isinstance(value, datetime): + return value.isoformat() + if isinstance(value, Mapping): + return {key: cls._normalize(item) for key, item in value.items()} + if isinstance(value, Sequence) and not isinstance(value, (str, bytes)): + return [cls._normalize(item) for item in value] + return value + + @staticmethod + def _error(value: Any, code: ErrorCode, message: str, metadata: Mapping[str, Any] | None = None) -> FlagResolutionDetails[Any]: + return FlagResolutionDetails(value=value, reason=Reason.ERROR, error_code=code, error_message=message, flag_metadata=metadata or {}) + + @classmethod + def _type_mismatch(cls, flag_key: str, value: Any, expected_type: str, metadata: Mapping[str, Any] | None = None) -> FlagResolutionDetails[Any]: + return cls._error(value, ErrorCode.TYPE_MISMATCH, f'Flag "{flag_key}" did not resolve to a {expected_type} value', metadata) diff --git a/tests_openfeature/test_provider.py b/tests_openfeature/test_provider.py new file mode 100644 index 0000000..3f1df2d --- /dev/null +++ b/tests_openfeature/test_provider.py @@ -0,0 +1,101 @@ +import unittest + +from openfeature import api +from openfeature.evaluation_context import EvaluationContext +from openfeature.exception import ErrorCode + +from featurevisor.openfeature import FeaturevisorOpenFeatureProvider +from featurevisor import create_featurevisor + + +DATAFILE = { + "schemaVersion": "2", + "revision": "openfeature-test", + "segments": {}, + "features": { + "checkout": { + "bucketBy": "userId", + "variations": [{ + "value": "on", + "variables": { + "title": "Hello", "count": 3, "ratio": 1.5, "visible": True, + "items": ["a"], "config": {"color": "blue"}, "json": '{"nested":true}', + }, + }], + "variablesSchema": { + "title": {"type": "string", "defaultValue": "Default"}, + "count": {"type": "integer", "defaultValue": 0}, + "ratio": {"type": "double", "defaultValue": 0}, + "visible": {"type": "boolean", "defaultValue": False}, + "items": {"type": "array", "defaultValue": []}, + "config": {"type": "object", "defaultValue": {}}, + "json": {"type": "json", "defaultValue": "{}"}, + }, + "force": [{"conditions": {"attribute": "userId", "operator": "equals", "value": "forced-user"}, "enabled": True, "variation": "on"}], + "traffic": [{"key": "all", "segments": "*", "percentage": 100000, "variation": "on"}], + } + }, +} + + +class OpenFeatureProviderTest(unittest.TestCase): + def provider(self, **kwargs): + return FeaturevisorOpenFeatureProvider({"datafile": DATAFILE, "logLevel": "fatal"}, **kwargs) + + def test_resolves_every_type_and_maps_targeting_key(self): + provider = self.provider() + context = EvaluationContext(targeting_key="forced-user") + self.assertTrue(provider.resolve_boolean_details("checkout", False, context).value) + self.assertEqual(provider.resolve_string_details("checkout:variation", "fallback", context).value, "on") + self.assertEqual(provider.resolve_string_details("checkout:title", "fallback", context).value, "Hello") + self.assertEqual(provider.resolve_integer_details("checkout:count", 0, context).value, 3) + self.assertEqual(provider.resolve_float_details("checkout:ratio", 0, context).value, 1.5) + self.assertTrue(provider.resolve_boolean_details("checkout:visible", False, context).value) + self.assertEqual(provider.resolve_object_details("checkout:items", [], context).value, ["a"]) + self.assertEqual(provider.resolve_object_details("checkout:config", {}, context).value, {"color": "blue"}) + self.assertEqual(provider.resolve_object_details("checkout:json", {}, context).value, {"nested": True}) + + def test_errors_custom_grammar_tracking_and_shutdown(self): + tracked = [] + provider = self.provider(key_separator="/", variation_key="$variation", on_track=lambda *args: tracked.append(args)) + self.assertEqual(provider.resolve_string_details("checkout/$variation", "fallback").value, "on") + self.assertEqual(provider.resolve_string_details("missing", "fallback").error_code, ErrorCode.TYPE_MISMATCH) + missing = provider.resolve_boolean_details("missing", True) + self.assertTrue(missing.value) + self.assertEqual(missing.error_code, ErrorCode.FLAG_NOT_FOUND) + provider.track("purchase", EvaluationContext(targeting_key="u"), None) + self.assertEqual(tracked[0][0], "purchase") + provider.shutdown() + + def test_malformed_datafile(self): + provider = FeaturevisorOpenFeatureProvider({"datafile": "{", "logLevel": "fatal"}) + result = provider.resolve_boolean_details("checkout", False) + self.assertEqual(result.error_code, ErrorCode.PARSE_ERROR) + self.assertEqual(result.error_message, "Could not parse datafile") + provider.featurevisor.set_datafile(DATAFILE, replace=True) + self.assertTrue(provider.resolve_boolean_details("checkout", False, EvaluationContext(targeting_key="forced-user")).value) + + def test_works_through_openfeature_api(self): + api.set_provider(self.provider()) + client = api.get_client() + self.assertTrue(client.get_boolean_value("checkout", False, EvaluationContext(targeting_key="forced-user"))) + + def test_borrows_existing_featurevisor(self): + closed = [] + featurevisor = create_featurevisor({ + "datafile": DATAFILE, + "logLevel": "fatal", + "modules": [{"name": "owner", "close": lambda: closed.append(True)}], + }) + provider = FeaturevisorOpenFeatureProvider(featurevisor=featurevisor) + + self.assertIs(provider.featurevisor, featurevisor) + provider.shutdown() + self.assertEqual(closed, []) + + featurevisor.close() + self.assertEqual(closed, [True]) + + +if __name__ == "__main__": + unittest.main() From 2a89743807f95ce140b24fe49ebc3998a762c314 Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Wed, 22 Jul 2026 23:36:59 +0200 Subject: [PATCH 2/2] adjustments --- .github/workflows/checks.yml | 110 +++++++- .github/workflows/publish.yml | 35 ++- MANIFEST.in | 5 + Makefile | 7 +- README.md | 96 ++++++- pyproject.toml | 35 ++- src/featurevisor/conditions.py | 2 + src/featurevisor/instance.py | 13 +- src/featurevisor/openfeature.py | 32 ++- src/featurevisor/project.py | 11 +- src/featurevisor/py.typed | 1 + src/featurevisor/tester.py | 11 +- src/featurevisor/types.py | 10 +- tests_openfeature/test_provider.py | 397 ++++++++++++++++++++++++----- 14 files changed, 648 insertions(+), 117 deletions(-) create mode 100644 MANIFEST.in create mode 100644 src/featurevisor/py.typed diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index a6cb849..174aba8 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -3,35 +3,117 @@ name: Checks on: push: branches: ['**'] + pull_request: + +permissions: + contents: read + +concurrency: + group: checks-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: - checks: + tests: + name: Python ${{ matrix.python-version }} runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + python-version: ['3.10', '3.14'] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6 with: - python-version: '3.13' + python-version: ${{ matrix.python-version }} + cache: pip + + - name: Install development dependencies + run: python -m pip install -e '.[dev]' + + - name: Check + run: make check - - name: Test - run: make test + package: + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - uses: actions/checkout@v6 - ## - # Example project - # - - uses: actions/setup-node@v4 + - uses: actions/setup-python@v6 + with: + python-version: '3.14' + cache: pip + + - name: Install development dependencies + run: python -m pip install -e '.[dev]' + + - name: Build and validate distributions + run: | + python -m build + python -m twine check dist/* + + - name: Verify source distribution contents + run: | + tar -tzf dist/featurevisor-*.tar.gz | grep '/conformance/sdk-v3.json$' + tar -tzf dist/featurevisor-*.tar.gz | grep '/tests/test_sdk.py$' + tar -tzf dist/featurevisor-*.tar.gz | grep '/tests_openfeature/test_provider.py$' + + - name: Verify base wheel installation + run: | + python -m venv /tmp/featurevisor-base + /tmp/featurevisor-base/bin/python -m pip install --no-deps dist/featurevisor-*.whl + /tmp/featurevisor-base/bin/python -c 'import featurevisor; assert featurevisor.__all__ == ["Featurevisor", "FeaturevisorChildInstance", "FeaturevisorModule", "create_featurevisor"]' + /tmp/featurevisor-base/bin/python -c 'from importlib.util import find_spec; assert find_spec("openfeature") is None' + /tmp/featurevisor-base/bin/python -c 'from pathlib import Path; import featurevisor; assert Path(featurevisor.__file__).with_name("py.typed").is_file()' + /tmp/featurevisor-base/bin/python - <<'PY' + try: + import featurevisor.openfeature + except ModuleNotFoundError as exc: + assert "featurevisor[openfeature]" in str(exc) + else: + raise AssertionError("OpenFeature import unexpectedly succeeded without its optional dependency") + PY + /tmp/featurevisor-base/bin/python -m unittest discover -s tests -v + /tmp/featurevisor-base/bin/python -m pip check + + - name: Verify OpenFeature wheel installation + run: | + python -m venv /tmp/featurevisor-openfeature + WHEEL=$(find dist -name 'featurevisor-*.whl' -print -quit) + /tmp/featurevisor-openfeature/bin/python -m pip install "${WHEEL}[openfeature]" + /tmp/featurevisor-openfeature/bin/python -m unittest discover -s tests_openfeature -v + /tmp/featurevisor-openfeature/bin/python -m pip check + + example-project: + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-python@v6 + with: + python-version: '3.14' + cache: pip + + - uses: actions/setup-node@v6 with: node-version-file: '.nvmrc' - - name: Setup Featurevisor example-1 project + - name: Install Python SDK + run: python -m pip install -e . + + - name: Set up Featurevisor v3 example-1 project run: | mkdir example-1 - (cd example-1 && npx @featurevisor/cli@2.x init --example=1) + (cd example-1 && npx --yes @featurevisor/cli@3.x init --example=1) (cd example-1 && npm install) (cd example-1 && npx featurevisor build) (cd example-1 && npx featurevisor test) - - name: Run Featurevisor project tests against Python SDK - run: PYTHONPATH=src python3 -m featurevisor test --projectDirectoryPath=./example-1 + - name: Run project tests against Python SDK + run: python -m featurevisor test --projectDirectoryPath=./example-1 --onlyFailures diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 4ad56d7..b6e436b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -6,22 +6,45 @@ on: - 'v*.*.*' permissions: + contents: read id-token: write - contents: write jobs: publish: runs-on: ubuntu-latest + timeout-minutes: 15 steps: - name: Check out - uses: actions/checkout@v4 + uses: actions/checkout@v6 - - name: Install build tools - run: pip install hatch + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.14' + cache: pip - - name: Build package - run: hatch build + - name: Install development dependencies + run: python -m pip install -e '.[dev]' + + - name: Verify tag matches package version + run: test "${GITHUB_REF_NAME#v}" = "$(python -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')" + + - name: Run checks + run: make check + + - name: Build and validate package + run: | + python -m build + python -m twine check dist/* + + - name: Verify built OpenFeature package + run: | + python -m venv /tmp/featurevisor-release + WHEEL=$(find dist -name 'featurevisor-*.whl' -print -quit) + /tmp/featurevisor-release/bin/python -m pip install "${WHEEL}[openfeature]" + /tmp/featurevisor-release/bin/python -m unittest discover -s tests_openfeature -v + /tmp/featurevisor-release/bin/python -m pip check - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..7c879be --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,5 @@ +include LICENSE +include README.md +recursive-include conformance *.json +recursive-include tests *.py +recursive-include tests_openfeature *.py diff --git a/Makefile b/Makefile index a9c876c..f31e0b0 100644 --- a/Makefile +++ b/Makefile @@ -25,13 +25,18 @@ setup-golang-sdk: update-golang-sdk: (cd featurevisor-go && git pull origin main) -.PHONY: test test-openfeature +.PHONY: test test-openfeature typecheck check test: PYTHONPATH=src python3 -m unittest discover -s tests -v test-openfeature: PYTHONPATH=src python3 -m unittest discover -s tests_openfeature -v +typecheck: + python3 -m mypy + +check: test test-openfeature typecheck + .PHONY: test-example-1 test-example-1: PYTHONPATH=src python3 -m unittest discover -s tests -v diff --git a/README.md b/README.md index 43c1c71..ae60fd7 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,13 @@ This SDK is compatible with Featurevisor v3 projects and v2 datafiles. - [Child instance](#child-instance) - [Close](#close) - [OpenFeature](#openfeature) + - [Installation](#installation-1) + - [Provider setup](#provider-setup) + - [Flag key mapping](#flag-key-mapping) + - [Context mapping](#context-mapping) + - [Resolution details](#resolution-details) + - [Tracking](#tracking) + - [Using an existing Featurevisor instance](#using-an-existing-featurevisor-instance) - [CLI usage](#cli-usage) - [Test](#test) - [Benchmark](#benchmark) @@ -583,19 +590,25 @@ python -m featurevisor assess-distribution \ ## OpenFeature -Install Featurevisor with its optional OpenFeature dependency: +The provider targets OpenFeature specification `0.8.0` through OpenFeature Python SDK `0.10.x`. OpenFeature remains optional and is not installed or imported by the base Featurevisor package. + +### Installation ```bash pip install "featurevisor[openfeature]" ``` +If the extra is not installed, importing `featurevisor.openfeature` reports the installation command needed to enable it. + +### Provider setup + ```python from featurevisor.openfeature import FeaturevisorOpenFeatureProvider from openfeature import api from openfeature.evaluation_context import EvaluationContext provider = FeaturevisorOpenFeatureProvider({"datafile": datafile_content}) -api.set_provider(provider) +api.set_provider_and_wait(provider) client = api.get_client() enabled = client.get_boolean_value( @@ -605,11 +618,77 @@ enabled = client.get_boolean_value( ) ``` -Use `checkout` for a flag, `checkout:variation` for its variation, and `checkout:title` for its `title` variable. Boolean variables use the boolean resolver. Sequences, mappings, and JSON variables use the object resolver. +Call `api.shutdown()` during application shutdown. This closes a Featurevisor instance created by the provider and releases provider subscriptions. + +### Flag key mapping + +| OpenFeature key | Featurevisor evaluation | +| --- | --- | +| `checkout` | Boolean flag for `checkout` | +| `checkout:variation` | Variation value for `checkout` | +| `checkout:title` | Variable `title` for `checkout` | + +Boolean variables use the boolean resolver. Integer and double variables use their matching numeric resolvers. Arrays, objects, and JSON variables use the object resolver. + +The first separator divides the feature key from the selector. Use `key_separator` and `variation_key` when project keys require a different grammar: + +```python +provider = FeaturevisorOpenFeatureProvider( + {"datafile": datafile_content}, + key_separator="/", + variation_key="$variation", +) +``` + +This makes `checkout/$variation` the variation key and `checkout/title` a variable key. -OpenFeature's targeting key maps to `userId` by default. `targeting_key_field`, `key_separator`, and `variation_key` can customize the mapping. +### Context mapping -You can also reuse an existing Featurevisor instance: +OpenFeature's targeting key maps to `userId` by default. Use `targeting_key_field` to map it to another Featurevisor context field: + +```python +provider = FeaturevisorOpenFeatureProvider( + {"datafile": datafile_content}, + targeting_key_field="accountId", +) +``` + +OpenFeature context attributes are copied without mutating the incoming context. Nested arrays and mappings are preserved. Datetimes are normalized to UTC ISO strings, matching the JavaScript provider. + +### Resolution details + +The provider maps Featurevisor evaluation results to OpenFeature details: + +| Featurevisor result | OpenFeature result | +| --- | --- | +| Required, forced, sticky, or rule match | `TARGETING_MATCH` | +| Traffic allocation | `SPLIT` | +| Disabled variation or 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` | +| Invalid datafile | `ERROR` with `PARSE_ERROR` | +| Evaluation failure | `ERROR` with `GENERAL` | + +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. + +### Tracking + +Tracking is a no-op unless `on_track` is configured: + +```python +def handle_track(name, context, details): + print(name, context, details) + +provider = FeaturevisorOpenFeatureProvider( + {"datafile": datafile_content}, + on_track=handle_track, +) +``` + +### Using an existing Featurevisor instance ```python from featurevisor import create_featurevisor @@ -635,14 +714,17 @@ This repository assumes: Run the local test suite: ```bash -make test +python -m pip install -e '.[dev]' +make check ``` +`make check` runs the base SDK tests, OpenFeature provider tests, and static type checking. You can also run them separately with `make test`, `make test-openfeature`, and `make typecheck`. + Run the example project integration directly: ```bash PYTHONPATH=src python3 -m featurevisor test \ - --projectDirectoryPath=/Users/fahad/Projects/featurevisor/featurevisor/examples/example-1 \ + --projectDirectoryPath=../featurevisor/examples/example-1 \ --onlyFailures ``` diff --git a/pyproject.toml b/pyproject.toml index e5511a0..7439d7b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,14 +1,18 @@ [build-system] -requires = ["setuptools>=68"] +requires = ["setuptools>=77"] build-backend = "setuptools.build_meta" [project] name = "featurevisor" -version = "1.0.0" +version = "1.1.0" description = "Featurevisor Python SDK" readme = "README.md" requires-python = ">=3.10" -license = { text = "MIT" } +dependencies = [ + "typing-extensions>=4.1; python_version < '3.11'", +] +license = "MIT" +license-files = ["LICENSE"] authors = [ { name = "Fahad Heylaal" } ] @@ -19,16 +23,41 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Typing :: Typed", ] +[project.urls] +Homepage = "https://featurevisor.com" +Documentation = "https://featurevisor.com/docs/sdks/python/" +Repository = "https://github.com/featurevisor/featurevisor-python" +Issues = "https://github.com/featurevisor/featurevisor-python/issues" + [project.scripts] featurevisor = "featurevisor.cli:main" [project.optional-dependencies] openfeature = ["openfeature-sdk>=0.10.0,<0.11"] +dev = [ + "build>=1.2,<2", + "mypy>=1.10,<2", + "openfeature-sdk>=0.10.0,<0.11", + "twine>=5,<7", +] [tool.setuptools] package-dir = { "" = "src" } [tool.setuptools.packages.find] where = ["src"] + +[tool.setuptools.package-data] +featurevisor = ["py.typed"] + +[tool.mypy] +python_version = "3.10" +files = ["src/featurevisor"] +check_untyped_defs = true +no_implicit_optional = true +warn_redundant_casts = true +warn_unused_ignores = true diff --git a/src/featurevisor/conditions.py b/src/featurevisor/conditions.py index 7e48565..9972d9a 100644 --- a/src/featurevisor/conditions.py +++ b/src/featurevisor/conditions.py @@ -36,6 +36,8 @@ def condition_is_matched(condition: dict[str, Any], context: Context, get_regex: operator = condition.get("operator") value = condition.get("value") regex_flags = condition.get("regexFlags", "") + if not isinstance(attribute, str): + return False context_value = get_value_from_context(context, attribute) if operator == "equals": diff --git a/src/featurevisor/instance.py b/src/featurevisor/instance.py index 36ee53a..85307fe 100644 --- a/src/featurevisor/instance.py +++ b/src/featurevisor/instance.py @@ -2,7 +2,7 @@ import json import uuid -from typing import Any +from typing import Any, cast from .child import FeaturevisorChildInstance from .datafile_reader import _DatafileReader @@ -12,8 +12,9 @@ from .helpers import get_value_by_type from .logger import _Logger, _create_logger, _default_log_handler from .modules import FeaturevisorModule, ModulesManager +from .types import DatafileContent, LogLevel -empty_datafile = {"schemaVersion": "2", "revision": "unknown", "segments": {}, "features": {}} +empty_datafile: DatafileContent = {"schemaVersion": "2", "revision": "unknown", "segments": {}, "features": {}} class Featurevisor: @@ -48,7 +49,7 @@ def __init__(self, options: dict[str, Any] | None = None) -> None: } ) - def set_log_level(self, level: str) -> 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: @@ -88,7 +89,7 @@ def set_datafile(self, datafile, replace: bool = False) -> None: ): raise ValueError("Invalid datafile") next_datafile = parsed if replace else self._merge_datafiles(self.datafile_reader.get_datafile(), parsed) - new_reader = _DatafileReader(datafile=next_datafile, logger=self.logger) + 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 self.report_diagnostic({"level": "info", "code": "datafile_set", "message": "Datafile set", "details": details}) @@ -242,7 +243,7 @@ def get_all_evaluations(self, context: dict[str, Any] | None = None, feature_key result: dict[str, Any] = {} keys = feature_keys or self.datafile_reader.get_feature_keys() for feature_key in keys: - evaluated = {"enabled": self.is_enabled(feature_key, context or {}, options)} + evaluated: dict[str, Any] = {"enabled": self.is_enabled(feature_key, context or {}, options)} if self.datafile_reader.has_variations(feature_key): variation = self.get_variation(feature_key, context or {}, options) if variation is not None: @@ -322,7 +323,7 @@ def report_diagnostic(self, diagnostic: dict[str, Any], source_module: Featurevi if diagnostic["level"] == "error": self.emitter.trigger("error", {"diagnostic": diagnostic}) - def _should_report_diagnostic(self, diagnostic_level: str, subscriber_level: str) -> bool: + 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: diff --git a/src/featurevisor/openfeature.py b/src/featurevisor/openfeature.py index 6507d8f..df25e47 100644 --- a/src/featurevisor/openfeature.py +++ b/src/featurevisor/openfeature.py @@ -3,15 +3,23 @@ import json import math from collections.abc import Mapping, Sequence -from datetime import datetime +from datetime import datetime, timezone from typing import Any, Callable -from openfeature.evaluation_context import EvaluationContext -from openfeature.exception import ErrorCode -from openfeature.flag_evaluation import FlagResolutionDetails, Reason -from openfeature.provider import AbstractProvider -from openfeature.provider.metadata import Metadata -from openfeature.track import TrackingEventDetails +try: + from openfeature.evaluation_context import EvaluationContext + from openfeature.exception import ErrorCode + from openfeature.flag_evaluation import FlagResolutionDetails, Reason + from openfeature.provider import AbstractProvider + from openfeature.provider.metadata import Metadata + from openfeature.track import TrackingEventDetails +except ModuleNotFoundError as exc: + if exc.name == "openfeature": + raise ModuleNotFoundError( + 'Featurevisor OpenFeature support requires the optional dependency. ' + 'Install it with: pip install "featurevisor[openfeature]"' + ) from exc + raise from .instance import Featurevisor, create_featurevisor @@ -96,8 +104,8 @@ def _resolve(self, flag_key: str, default_value: Any, evaluation_context: Evalua if self.datafile_error: return self._error(default_value, ErrorCode.PARSE_ERROR, self.datafile_error) - feature_key, separator, selector = flag_key.partition(self.key_separator) - selector = selector if separator else None + feature_key, separator, parsed_selector = flag_key.partition(self.key_separator) + selector: str | None = parsed_selector if separator else None context = self._context(evaluation_context) if not selector: @@ -211,7 +219,11 @@ def _matches(value: Any, expected_type: str) -> bool: @classmethod def _normalize(cls, value: Any) -> Any: if isinstance(value, datetime): - return value.isoformat() + # OpenFeature treats datetimes without a timezone as UTC. Match the + # JavaScript provider's Date.toISOString() representation so the + # same context behaves consistently across SDKs. + normalized = value.replace(tzinfo=timezone.utc) if value.tzinfo is None else value.astimezone(timezone.utc) + return normalized.isoformat(timespec="milliseconds").replace("+00:00", "Z") if isinstance(value, Mapping): return {key: cls._normalize(item) for key, item in value.items()} if isinstance(value, Sequence) and not isinstance(value, (str, bytes)): diff --git a/src/featurevisor/project.py b/src/featurevisor/project.py index f8d1242..25cbdf2 100644 --- a/src/featurevisor/project.py +++ b/src/featurevisor/project.py @@ -45,11 +45,12 @@ def list_features(self) -> list[dict[str, Any]]: def list_targets(self) -> list[str]: targets = self.run_json("list", "--targets", "--json") if isinstance(targets, list): - return [ - (target.get("name") or target.get("key")) if isinstance(target, dict) else target - for target in targets - if ((target.get("name") or target.get("key")) if isinstance(target, dict) else target) - ] + result: list[str] = [] + for target in targets: + value = (target.get("name") or target.get("key")) if isinstance(target, dict) else target + if isinstance(value, str) and value: + result.append(value) + return result if isinstance(targets, dict): return list(targets.keys()) return [] diff --git a/src/featurevisor/py.typed b/src/featurevisor/py.typed new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/featurevisor/py.typed @@ -0,0 +1 @@ + diff --git a/src/featurevisor/tester.py b/src/featurevisor/tester.py index a97555e..b58290b 100644 --- a/src/featurevisor/tester.py +++ b/src/featurevisor/tester.py @@ -288,7 +288,10 @@ def run_test_project(project_directory_path: str, *, key_pattern: str | None = N 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: project = FeaturevisorProject(project_directory_path) selected_targets = _resolve_targets(project, targets) - entries = selected_targets or [None] + entries: list[str | None] = [] + entries.extend(selected_targets) + if not entries: + entries.append(None) for target in entries: datafile, build_duration = timed_build(project, environment=environment, inflate=inflate or None, target=target) _run_benchmark_datafile(datafile, build_duration, environment=environment, target=target, feature=feature, context=context, n=n, variation=variation, variable=variable, verbose=verbose, quiet=quiet) @@ -338,7 +341,11 @@ def _run_benchmark_datafile(datafile: dict[str, Any], build_duration: float, *, def run_assess_distribution(project_directory_path: str, *, environment: str, feature: str, context: dict[str, Any] | None = None, n: int = 1000, populate_uuid: list[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) - for target in selected_targets or [None]: + entries: list[str | None] = [] + entries.extend(selected_targets) + if not entries: + entries.append(None) + for target in entries: datafile = project.build_datafile_json(environment=environment, inflate=inflate or None, target=target) _run_assess_datafile(datafile, environment=environment, target=target, feature=feature, context=context, n=n, populate_uuid=populate_uuid, verbose=verbose, quiet=quiet) return 0 diff --git a/src/featurevisor/types.py b/src/featurevisor/types.py index d7638f5..916618c 100644 --- a/src/featurevisor/types.py +++ b/src/featurevisor/types.py @@ -1,6 +1,12 @@ from __future__ import annotations -from typing import Any, Callable, Literal, NotRequired, TypedDict +import sys +from typing import Any, Callable, Literal, TypedDict + +if sys.version_info >= (3, 11): + from typing import NotRequired +else: + from typing_extensions import NotRequired AttributeValue = Any VariableValue = Any @@ -99,7 +105,7 @@ class Segment(TypedDict, total=False): class DatafileContent(TypedDict): - schema_version: str + schemaVersion: str revision: str featurevisorVersion: NotRequired[str] segments: dict[SegmentKey, Segment] diff --git a/tests_openfeature/test_provider.py b/tests_openfeature/test_provider.py index 3f1df2d..0faa887 100644 --- a/tests_openfeature/test_provider.py +++ b/tests_openfeature/test_provider.py @@ -1,101 +1,376 @@ +from __future__ import annotations + +import math import unittest +from datetime import datetime, timedelta, timezone +from unittest.mock import patch from openfeature import api from openfeature.evaluation_context import EvaluationContext from openfeature.exception import ErrorCode +from openfeature.flag_evaluation import Reason -from featurevisor.openfeature import FeaturevisorOpenFeatureProvider from featurevisor import create_featurevisor +from featurevisor.openfeature import FeaturevisorOpenFeatureProvider + +def feature(**overrides): + result = { + "bucketBy": "userId", + "traffic": [{"key": "everyone", "segments": "*", "percentage": 100000, "variation": "on"}], + } + result.update(overrides) + return result -DATAFILE = { - "schemaVersion": "2", - "revision": "openfeature-test", - "segments": {}, - "features": { - "checkout": { - "bucketBy": "userId", - "variations": [{ - "value": "on", - "variables": { - "title": "Hello", "count": 3, "ratio": 1.5, "visible": True, - "items": ["a"], "config": {"color": "blue"}, "json": '{"nested":true}', + +def datafile(): + return { + "schemaVersion": "2", + "revision": "revision-1", + "featurevisorVersion": "3.0.1", + "segments": {}, + "features": { + "checkout": feature( + variations=[ + { + "value": "on", + "variables": { + "title": "Hello", + "count": 3, + "ratio": 1.5, + "visible": True, + "items": ["a", "b"], + "config": {"color": "blue"}, + "json": '{"nested":true}', + "invalidJson": "not-json", + }, + } + ], + variablesSchema={ + "title": {"type": "string", "defaultValue": "Default"}, + "count": {"type": "integer", "defaultValue": 0}, + "ratio": {"type": "double", "defaultValue": 0}, + "visible": {"type": "boolean", "defaultValue": False}, + "items": {"type": "array", "defaultValue": []}, + "config": {"type": "object", "defaultValue": {}}, + "json": {"type": "json", "defaultValue": "{}"}, + "invalidJson": {"type": "json", "defaultValue": "{}"}, }, - }], - "variablesSchema": { - "title": {"type": "string", "defaultValue": "Default"}, - "count": {"type": "integer", "defaultValue": 0}, - "ratio": {"type": "double", "defaultValue": 0}, - "visible": {"type": "boolean", "defaultValue": False}, - "items": {"type": "array", "defaultValue": []}, - "config": {"type": "object", "defaultValue": {}}, - "json": {"type": "json", "defaultValue": "{}"}, - }, - "force": [{"conditions": {"attribute": "userId", "operator": "equals", "value": "forced-user"}, "enabled": True, "variation": "on"}], - "traffic": [{"key": "all", "segments": "*", "percentage": 100000, "variation": "on"}], - } - }, -} + ), + "disabled": feature( + disabledVariationValue="off", + variations=[{"value": "on"}], + force=[ + { + "conditions": {"attribute": "blocked", "operator": "equals", "value": True}, + "enabled": False, + } + ], + ), + "emptyVariation": feature(variations=[]), + }, + } class OpenFeatureProviderTest(unittest.TestCase): + def tearDown(self) -> None: + api.shutdown() + def provider(self, **kwargs): - return FeaturevisorOpenFeatureProvider({"datafile": DATAFILE, "logLevel": "fatal"}, **kwargs) + return FeaturevisorOpenFeatureProvider({"datafile": datafile(), "logLevel": "fatal"}, **kwargs) - def test_resolves_every_type_and_maps_targeting_key(self): + def test_resolves_flags_variations_and_every_openfeature_type(self): provider = self.provider() - context = EvaluationContext(targeting_key="forced-user") - self.assertTrue(provider.resolve_boolean_details("checkout", False, context).value) - self.assertEqual(provider.resolve_string_details("checkout:variation", "fallback", context).value, "on") + context = EvaluationContext(targeting_key="user-1") + + flag = provider.resolve_boolean_details("checkout", False, context) + self.assertTrue(flag.value) + self.assertEqual(flag.reason, Reason.TARGETING_MATCH) + + variation = provider.resolve_string_details("checkout:variation", "fallback", context) + self.assertEqual(variation.value, "on") + self.assertEqual(variation.variant, "on") + self.assertEqual(variation.reason, Reason.TARGETING_MATCH) + self.assertEqual(provider.resolve_string_details("checkout:title", "fallback", context).value, "Hello") self.assertEqual(provider.resolve_integer_details("checkout:count", 0, context).value, 3) - self.assertEqual(provider.resolve_float_details("checkout:ratio", 0, context).value, 1.5) + self.assertEqual(provider.resolve_float_details("checkout:ratio", 0.0, context).value, 1.5) self.assertTrue(provider.resolve_boolean_details("checkout:visible", False, context).value) - self.assertEqual(provider.resolve_object_details("checkout:items", [], context).value, ["a"]) + self.assertEqual(provider.resolve_object_details("checkout:items", [], context).value, ["a", "b"]) self.assertEqual(provider.resolve_object_details("checkout:config", {}, context).value, {"color": "blue"}) self.assertEqual(provider.resolve_object_details("checkout:json", {}, context).value, {"nested": True}) - def test_errors_custom_grammar_tracking_and_shutdown(self): - tracked = [] - provider = self.provider(key_separator="/", variation_key="$variation", on_track=lambda *args: tracked.append(args)) + def test_maps_targeting_key_dates_arrays_and_nested_context_without_mutation(self): + contexts = [] + created_at = datetime(2026, 1, 2, 4, 4, 5, tzinfo=timezone(timedelta(hours=1))) + nested_date = datetime(2026, 1, 1) + attributes = { + "createdAt": created_at, + "nested": {"dates": [nested_date]}, + } + provider = FeaturevisorOpenFeatureProvider( + { + "datafile": datafile(), + "logLevel": "fatal", + "modules": [ + { + "name": "capture", + "before": lambda options: contexts.append(options["context"]) or options, + } + ], + }, + targeting_key_field="accountId", + ) + + provider.resolve_boolean_details( + "checkout", + False, + EvaluationContext(targeting_key="subject", attributes=attributes), + ) + + self.assertEqual( + contexts[0], + { + "accountId": "subject", + "createdAt": "2026-01-02T03:04:05.000Z", + "nested": {"dates": ["2026-01-01T00:00:00.000Z"]}, + }, + ) + self.assertIs(attributes["createdAt"], created_at) + self.assertIs(attributes["nested"]["dates"][0], nested_date) + + def test_supports_custom_key_separator_and_variation_selector(self): + provider = self.provider(key_separator="/", variation_key="$variation") self.assertEqual(provider.resolve_string_details("checkout/$variation", "fallback").value, "on") - self.assertEqual(provider.resolve_string_details("missing", "fallback").error_code, ErrorCode.TYPE_MISMATCH) - missing = provider.resolve_boolean_details("missing", True) - self.assertTrue(missing.value) - self.assertEqual(missing.error_code, ErrorCode.FLAG_NOT_FOUND) - provider.track("purchase", EvaluationContext(targeting_key="u"), None) - self.assertEqual(tracked[0][0], "purchase") - provider.shutdown() + self.assertEqual(provider.resolve_string_details("checkout/title", "fallback").value, "Hello") - def test_malformed_datafile(self): - provider = FeaturevisorOpenFeatureProvider({"datafile": "{", "logLevel": "fatal"}) - result = provider.resolve_boolean_details("checkout", False) + def test_returns_defaults_and_standard_errors_for_missing_entities_and_malformed_datafiles(self): + provider = self.provider() + + missing_feature = provider.resolve_boolean_details("missing", True) + self.assertTrue(missing_feature.value) + self.assertEqual(missing_feature.reason, Reason.ERROR) + self.assertEqual(missing_feature.error_code, ErrorCode.FLAG_NOT_FOUND) + + missing_variable = provider.resolve_string_details("checkout:missing", "fallback") + self.assertEqual(missing_variable.value, "fallback") + self.assertEqual(missing_variable.error_code, ErrorCode.FLAG_NOT_FOUND) + + no_variations = provider.resolve_string_details("emptyVariation:variation", "fallback") + self.assertEqual(no_variations.value, "fallback") + self.assertEqual(no_variations.error_code, ErrorCode.FLAG_NOT_FOUND) + + malformed = FeaturevisorOpenFeatureProvider({"datafile": "{", "logLevel": "fatal"}) + result = malformed.resolve_boolean_details("checkout", False) + self.assertFalse(result.value) + self.assertEqual(result.reason, Reason.ERROR) self.assertEqual(result.error_code, ErrorCode.PARSE_ERROR) self.assertEqual(result.error_message, "Could not parse datafile") - provider.featurevisor.set_datafile(DATAFILE, replace=True) - self.assertTrue(provider.resolve_boolean_details("checkout", False, EvaluationContext(targeting_key="forced-user")).value) - def test_works_through_openfeature_api(self): - api.set_provider(self.provider()) - client = api.get_client() - self.assertTrue(client.get_boolean_value("checkout", False, EvaluationContext(targeting_key="forced-user"))) + def test_recovers_after_a_malformed_datafile_is_replaced(self): + provider = FeaturevisorOpenFeatureProvider({"datafile": "{", "logLevel": "fatal"}) + self.assertEqual(provider.resolve_boolean_details("checkout", False).error_code, ErrorCode.PARSE_ERROR) - def test_borrows_existing_featurevisor(self): - closed = [] - featurevisor = create_featurevisor({ - "datafile": DATAFILE, - "logLevel": "fatal", - "modules": [{"name": "owner", "close": lambda: closed.append(True)}], - }) + provider.featurevisor.set_datafile(datafile(), replace=True) + result = provider.resolve_boolean_details("checkout", False, EvaluationContext(targeting_key="user")) + self.assertTrue(result.value) + self.assertIsNone(result.error_code) + + def test_rejects_mismatched_values_non_finite_numbers_and_invalid_json(self): + provider = self.provider() + + cases = [ + provider.resolve_string_details("checkout", "no"), + provider.resolve_boolean_details("checkout:title", False), + provider.resolve_object_details("checkout:invalidJson", {}), + provider.resolve_integer_details("checkout:ratio", 0), + ] + for result in cases: + self.assertEqual(result.reason, Reason.ERROR) + self.assertEqual(result.error_code, ErrorCode.TYPE_MISMATCH) + + self.assertEqual(provider.resolve_float_details("checkout:count", math.nan).value, 3) + + for value, resolver in [ + (math.nan, provider.resolve_float_details), + (math.inf, provider.resolve_float_details), + (-math.inf, provider.resolve_float_details), + (True, provider.resolve_integer_details), + (True, provider.resolve_float_details), + ]: + with self.subTest(value=value, resolver=resolver.__name__): + evaluation = { + "type": "variable", + "featureKey": "checkout", + "variableKey": "ratio", + "reason": "allocated", + "variableValue": value, + "variableSchema": {"type": "double"}, + } + with patch.object(provider.featurevisor, "evaluate_variable", return_value=evaluation): + result = resolver("checkout:ratio", 0) + self.assertEqual(result.value, 0) + self.assertEqual(result.reason, Reason.ERROR) + self.assertEqual(result.error_code, ErrorCode.TYPE_MISMATCH) + + def test_maps_disabled_evaluations(self): + provider = self.provider() + context = EvaluationContext(attributes={"blocked": True}) + + flag = provider.resolve_boolean_details("disabled", True, context) + self.assertFalse(flag.value) + self.assertEqual(flag.reason, Reason.TARGETING_MATCH) + + variation = provider.resolve_string_details("disabled:variation", "fallback", context) + self.assertEqual(variation.value, "off") + self.assertEqual(variation.reason, Reason.DISABLED) + + def test_maps_all_featurevisor_reasons(self): + mappings = { + "required": Reason.TARGETING_MATCH, + "forced": Reason.TARGETING_MATCH, + "sticky": Reason.TARGETING_MATCH, + "rule": Reason.TARGETING_MATCH, + "variable_override_variation": Reason.TARGETING_MATCH, + "variable_override_rule": Reason.TARGETING_MATCH, + "allocated": Reason.SPLIT, + "disabled": Reason.DISABLED, + "variation_disabled": Reason.DISABLED, + "variable_disabled": Reason.DISABLED, + "out_of_range": Reason.DEFAULT, + "no_match": Reason.DEFAULT, + "variable_default": Reason.DEFAULT, + } + + for featurevisor_reason, expected_reason in mappings.items(): + with self.subTest(featurevisor_reason=featurevisor_reason): + featurevisor = create_featurevisor({"datafile": datafile(), "logLevel": "fatal"}) + provider = FeaturevisorOpenFeatureProvider(featurevisor=featurevisor) + evaluation = { + "type": "flag", + "featureKey": "checkout", + "reason": featurevisor_reason, + "enabled": True, + } + with patch.object(featurevisor, "evaluate_flag", return_value=evaluation): + result = provider.resolve_boolean_details("checkout", False) + self.assertEqual(result.reason, expected_reason) + self.assertIsNone(result.error_code) + provider.shutdown() + featurevisor.close() + + def test_maps_general_evaluation_errors(self): + featurevisor = create_featurevisor({"datafile": datafile(), "logLevel": "fatal"}) + provider = FeaturevisorOpenFeatureProvider(featurevisor=featurevisor) + evaluation = { + "type": "flag", + "featureKey": "checkout", + "reason": "error", + "error": RuntimeError("Evaluation failed"), + } + + with patch.object(featurevisor, "evaluate_flag", return_value=evaluation): + result = provider.resolve_boolean_details("checkout", False) + + self.assertFalse(result.value) + self.assertEqual(result.reason, Reason.ERROR) + self.assertEqual(result.error_code, ErrorCode.GENERAL) + self.assertEqual(result.error_message, "Evaluation failed") + + def test_returns_stable_featurevisor_metadata(self): + result = self.provider().resolve_string_details( + "checkout:title", + "fallback", + EvaluationContext(targeting_key="u"), + ) + self.assertEqual(result.flag_metadata["featureKey"], "checkout") + self.assertEqual(result.flag_metadata["variableKey"], "title") + self.assertEqual(result.flag_metadata["featurevisorReason"], "allocated") + self.assertEqual(result.flag_metadata["revision"], "revision-1") + self.assertEqual(result.flag_metadata["schemaVersion"], "2") + + def test_includes_all_metadata_and_selected_variant(self): + featurevisor = create_featurevisor({"datafile": datafile(), "logLevel": "fatal"}) provider = FeaturevisorOpenFeatureProvider(featurevisor=featurevisor) + evaluation = { + "type": "variation", + "featureKey": "checkout", + "variableKey": "title", + "reason": "allocated", + "ruleKey": "rule-1", + "bucketKey": "checkout.user-1", + "bucketValue": 0, + "forceIndex": 0, + "variableOverrideIndex": 0, + "variationValue": "on", + } + + with patch.object(featurevisor, "evaluate_variation", return_value=evaluation): + result = provider.resolve_string_details("checkout:variation", "fallback") + + self.assertEqual(result.variant, "on") + self.assertEqual( + result.flag_metadata, + { + "featureKey": "checkout", + "variableKey": "title", + "featurevisorReason": "allocated", + "revision": "revision-1", + "schemaVersion": "2", + "ruleKey": "rule-1", + "bucketKey": "checkout.user-1", + "bucketValue": 0, + "forceIndex": 0, + "variableOverrideIndex": 0, + }, + ) + + def test_forwards_tracking_and_closes_an_owned_instance_idempotently(self): + events = [] + closed = [] + provider = FeaturevisorOpenFeatureProvider( + { + "datafile": datafile(), + "logLevel": "fatal", + "modules": [{"name": "close", "close": lambda: closed.append(True)}], + }, + on_track=lambda name, context, details: events.append((name, context, details)), + ) + context = EvaluationContext(targeting_key="u") + provider.track("checkout", context, None) + self.assertEqual(events, [("checkout", context, None)]) + + provider.shutdown() + provider.shutdown() + self.assertEqual(closed, [True]) + + def test_reuses_but_does_not_close_a_caller_owned_instance(self): + closed = [] + featurevisor = create_featurevisor( + { + "datafile": datafile(), + "logLevel": "fatal", + "modules": [{"name": "owner", "close": lambda: closed.append(True)}], + } + ) + provider = FeaturevisorOpenFeatureProvider({"datafile": "{"}, featurevisor=featurevisor) self.assertIs(provider.featurevisor, featurevisor) + self.assertTrue(provider.resolve_boolean_details("checkout", False, EvaluationContext(targeting_key="user")).value) provider.shutdown() self.assertEqual(closed, []) + featurevisor.set_datafile({**datafile(), "features": {}}, replace=True) + self.assertEqual(featurevisor.evaluate_flag("checkout")["reason"], "feature_not_found") featurevisor.close() self.assertEqual(closed, [True]) + def test_works_through_openfeature_api(self): + provider = self.provider() + api.set_provider_and_wait(provider) + client = api.get_client() + self.assertTrue(client.get_boolean_value("checkout", False, EvaluationContext(targeting_key="user"))) + if __name__ == "__main__": unittest.main()