diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..f79c467 --- /dev/null +++ b/.flake8 @@ -0,0 +1,3 @@ +[flake8] +max-line-length = 100 +exclude = .git,.venv,build,dist diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..addede2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,36 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12"] + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install + run: | + python -m pip install --upgrade pip + python -m pip install "openfeature-sdk>=0.10,<1" "build>=1,<2" "flake8>=7,<8" "pytest>=8,<10" + python -m pip install "fb-python-sdk @ git+https://github.com/featbit/featbit-python-sdk.git@refs/pull/14/head" + python -m pip install -e . --no-deps + - name: Lint + run: python -m flake8 featbit_openfeature tests + - name: Test + run: python -m pytest -q + - name: Build + if: matrix.python-version == '3.12' + run: python -m build diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..982bbd1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +.pytest_cache/ +.venv/ +build/ +dist/ +*.egg-info/ diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..b27661b --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,4 @@ +include LICENSE +include README.md +recursive-include tests *.json +recursive-include tests *.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..edf348b --- /dev/null +++ b/README.md @@ -0,0 +1,108 @@ +# FeatBit OpenFeature Provider for Python Server SDK + +Thin OpenFeature provider backed by an existing FeatBit Python Server SDK client. +The provider converts OpenFeature evaluation contexts and resolution details; feature +flag synchronization, local evaluation, and insight events remain owned by +`fb-python-sdk`. + +## Installation + +```shell +pip install featbit-openfeature-server +``` + +The provider requires `fb-python-sdk>=1.1.8`, which exposes variation IDs and +data-update status listeners. + +## Usage + +```python +from fbclient.client import FBClient +from fbclient.config import Config +from openfeature import api +from openfeature.evaluation_context import EvaluationContext + +from featbit_openfeature import FeatBitProvider + +fb_client = FBClient( + Config( + "server-side-environment-secret", + event_url="https://app-eval.featbit.co", + streaming_url="wss://app-eval.featbit.co", + ), + start_wait=0, +) + +api.set_provider_and_wait(FeatBitProvider(fb_client)) +client = api.get_client() + +details = client.get_boolean_details( + "new-checkout", + False, + EvaluationContext( + targeting_key="user-123", + attributes={"name": "Alice", "plan": "beta"}, + ), +) + +print(details.value, details.reason, details.variant) + +api.shutdown() +fb_client.stop() +``` + +The application owns the injected `FBClient`. Calling `api.shutdown()` removes +provider listeners but does not stop the FeatBit client; call `fb_client.stop()` +afterward so queued events are flushed and background resources are released. + +## Supported mappings + +- Boolean, string, integer, float, and object resolution. +- OpenFeature targeting key and scalar attributes to a FeatBit user dictionary. +- FeatBit reasons and failures to OpenFeature `Reason` and `ErrorCode`. +- FeatBit variation ID to OpenFeature `variant`. +- FeatBit flag changes to `PROVIDER_CONFIGURATION_CHANGED`. +- FeatBit runtime data-source status to OpenFeature provider status. +- OpenFeature tracking value to `FBClient.track_metric()`. + +Structured evaluation-context attributes and tracking custom attributes are not +supported by the current FeatBit user and metric schemas. They are ignored rather +than represented inaccurately. + +## Provider status + +Applications can read the status through the standard OpenFeature client: + +```python +from openfeature.provider import ProviderStatus + +status = client.get_provider_status() +assert status in { + ProviderStatus.NOT_READY, + ProviderStatus.READY, + ProviderStatus.STALE, + ProviderStatus.ERROR, + ProviderStatus.FATAL, +} +``` + +FeatBit states are mapped as follows: + +| FeatBit state | OpenFeature status | +| --- | --- | +| initializing | `NOT_READY` during provider initialization | +| OK | `READY` | +| interrupted | `STALE` | +| permanently off | `FATAL` | +| provider shutdown | `NOT_READY` | + +## Development tests + +Unit tests use mocks, and the integration test uses FeatBit offline data. Neither +requires a FeatBit account. + +```shell +python -m pytest -q +python -m flake8 featbit_openfeature tests +python -m build +``` diff --git a/featbit_openfeature/__init__.py b/featbit_openfeature/__init__.py new file mode 100644 index 0000000..3f17247 --- /dev/null +++ b/featbit_openfeature/__init__.py @@ -0,0 +1,4 @@ +from featbit_openfeature.provider import FeatBitProvider + + +__all__ = ["FeatBitProvider"] diff --git a/featbit_openfeature/impl/__init__.py b/featbit_openfeature/impl/__init__.py new file mode 100644 index 0000000..5b1b5e2 --- /dev/null +++ b/featbit_openfeature/impl/__init__.py @@ -0,0 +1 @@ +"""Internal converters used by the FeatBit OpenFeature provider.""" diff --git a/featbit_openfeature/impl/context_converter.py b/featbit_openfeature/impl/context_converter.py new file mode 100644 index 0000000..4b079a4 --- /dev/null +++ b/featbit_openfeature/impl/context_converter.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import logging +from datetime import datetime, timezone +from typing import Any + +from openfeature.evaluation_context import EvaluationContext +from openfeature.exception import TargetingKeyMissingError + + +logger = logging.getLogger("featbit-openfeature-server") + +_RESERVED_ATTRIBUTES = {"key", "keyid", "targetingKey", "name"} + + +class EvaluationContextConverter: + """Converts an OpenFeature evaluation context to a FeatBit user mapping.""" + + def to_fb_user(self, context: EvaluationContext | None) -> dict[str, Any]: + if context is None: + raise TargetingKeyMissingError("evaluation context is required") + + attributes = dict(context.attributes) + targeting_key = self._targeting_key(context, attributes) + name = attributes.get("name") + if not self._is_non_empty_string(name): + if name is not None: + logger.warning("FeatBit user name must be a non-empty string; using targeting key") + name = targeting_key + + user: dict[str, Any] = {"key": targeting_key, "name": name} + for key, value in attributes.items(): + if not isinstance(key, str) or key in _RESERVED_ATTRIBUTES: + continue + + converted = self._convert_attribute(value) + if converted is _UNSUPPORTED_ATTRIBUTE: + logger.debug("Ignoring unsupported structured EvaluationContext attribute %r", key) + continue + user[key] = converted + + return user + + @classmethod + def _targeting_key( + cls, context: EvaluationContext, attributes: dict[str, Any] + ) -> str: + if cls._is_non_empty_string(context.targeting_key): + return context.targeting_key # type: ignore[return-value] + + attribute_key = attributes.get("key") + if cls._is_non_empty_string(attribute_key): + return attribute_key + + raise TargetingKeyMissingError( + "evaluation context must contain a non-empty targeting key" + ) + + @staticmethod + def _is_non_empty_string(value: Any) -> bool: + return isinstance(value, str) and bool(value.strip()) + + @staticmethod + def _convert_attribute(value: Any) -> Any: + if isinstance(value, datetime): + if value.tzinfo is None: + value = value.replace(tzinfo=timezone.utc) + return value.isoformat() + if isinstance(value, (str, bool, int, float)): + return value + return _UNSUPPORTED_ATTRIBUTE + + +_UNSUPPORTED_ATTRIBUTE = object() diff --git a/featbit_openfeature/impl/details_converter.py b/featbit_openfeature/impl/details_converter.py new file mode 100644 index 0000000..376f466 --- /dev/null +++ b/featbit_openfeature/impl/details_converter.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from typing import Any + +from fbclient.common_types import EvalDetail +from fbclient.evaluator import ( + REASON_CLIENT_NOT_READY, + REASON_ERROR, + REASON_FLAG_NOT_FOUND, + REASON_FLAG_OFF, + REASON_RULE_MATCH, + REASON_TARGET_MATCH, + REASON_USER_NOT_SPECIFIED, + REASON_WRONG_TYPE, +) +from openfeature.exception import ErrorCode +from openfeature.flag_evaluation import FlagResolutionDetails, Reason + + +_STANDARD_REASONS = { + REASON_FLAG_OFF: Reason.DISABLED, + REASON_TARGET_MATCH: Reason.TARGETING_MATCH, + REASON_RULE_MATCH: Reason.TARGETING_MATCH, +} + +_ERROR_CODES = { + REASON_CLIENT_NOT_READY: ErrorCode.PROVIDER_NOT_READY, + REASON_FLAG_NOT_FOUND: ErrorCode.FLAG_NOT_FOUND, + REASON_USER_NOT_SPECIFIED: ErrorCode.TARGETING_KEY_MISSING, + REASON_WRONG_TYPE: ErrorCode.TYPE_MISMATCH, + REASON_ERROR: ErrorCode.GENERAL, +} + + +class ResolutionDetailsConverter: + """Converts FeatBit evaluation details to OpenFeature resolution details.""" + + def to_resolution_details( + self, result: EvalDetail, resolved_value: Any + ) -> FlagResolutionDetails[Any]: + raw_reason = result.reason or "" + error_code = _ERROR_CODES.get(raw_reason) + + if error_code is not None: + reason: str | Reason = Reason.ERROR + error_message = raw_reason + variant = None + else: + reason = _STANDARD_REASONS.get(raw_reason, raw_reason or Reason.UNKNOWN) + error_message = None + variant = result.variation_id + + return FlagResolutionDetails( + value=resolved_value, + error_code=error_code, + error_message=error_message, + reason=reason, + variant=variant, + ) diff --git a/featbit_openfeature/provider.py b/featbit_openfeature/provider.py new file mode 100644 index 0000000..3950ec7 --- /dev/null +++ b/featbit_openfeature/provider.py @@ -0,0 +1,309 @@ +from __future__ import annotations + +import logging +import threading +from collections.abc import Mapping, Sequence +from typing import Any + +from fbclient.client import FBClient +from fbclient.flag_change_notification import FlagChangedListener, FlagChangedNotice +from fbclient.status_types import State, StateType +from openfeature.evaluation_context import EvaluationContext +from openfeature.event import ProviderEventDetails +from openfeature.exception import ( + ErrorCode, + ProviderFatalError, + ProviderNotReadyError, + TargetingKeyMissingError, +) +from openfeature.flag_evaluation import ( + FlagResolutionDetails, + FlagType, + FlagValueType, + Reason, +) +from openfeature.provider import AbstractProvider +from openfeature.provider.metadata import Metadata +from openfeature.track import TrackingEventDetails + +from featbit_openfeature.impl.context_converter import EvaluationContextConverter +from featbit_openfeature.impl.details_converter import ResolutionDetailsConverter + + +logger = logging.getLogger("featbit-openfeature-server") + +_TYPE_MISMATCH = object() + + +class _FlagChangeListener(FlagChangedListener): + def __init__(self, callback: Any): + self._callback = callback + + def on_flag_change(self, notice: FlagChangedNotice): + self._callback(notice.flag_key) + + +class FeatBitProvider(AbstractProvider): + """OpenFeature provider backed by an existing thread-safe ``FBClient``. + + The provider does not own the client. ``shutdown`` removes the listeners + installed by the provider, but the application remains responsible for + calling ``FBClient.stop`` when it no longer needs the SDK. + """ + + def __init__(self, client: FBClient, initialization_timeout: float = 15.0): + super().__init__() + if client is None: + raise ValueError("client is required") + if initialization_timeout <= 0: + raise ValueError("initialization_timeout must be greater than zero") + + self._client = client + self._initialization_timeout = initialization_timeout + self._context_converter = EvaluationContextConverter() + self._details_converter = ResolutionDetailsConverter() + self._lifecycle_lock = threading.Lock() + self._flag_change_listener: _FlagChangeListener | None = None + self._status_listener_registered = False + + @property + def client(self) -> FBClient: + """Return the FeatBit client supplied to this provider.""" + return self._client + + def initialize(self, evaluation_context: EvaluationContext) -> None: + if not self._client.initialize: + ready = self._client.update_status_provider.wait_for_OKState( + self._initialization_timeout + ) + if not ready: + state = self._client.update_status_provider.current_state + message = self._state_message(state) + if state.state_type == StateType.OFF: + raise ProviderFatalError(message) + raise ProviderNotReadyError(message) + + self._register_listeners() + + def shutdown(self) -> None: + with self._lifecycle_lock: + flag_listener = self._flag_change_listener + status_listener_registered = self._status_listener_registered + + if flag_listener is not None: + try: + self._client.flag_tracker.remove_flag_change_notifier(flag_listener) + except Exception: + logger.exception("Failed to remove FeatBit flag change listener") + else: + with self._lifecycle_lock: + if self._flag_change_listener is flag_listener: + self._flag_change_listener = None + + if status_listener_registered: + try: + self._client.update_status_provider.remove_listener( + self._handle_data_update_status + ) + except Exception: + logger.exception("Failed to remove FeatBit status listener") + else: + with self._lifecycle_lock: + self._status_listener_registered = False + + def get_metadata(self) -> Metadata: + return Metadata("featbit-openfeature-server") + + def resolve_boolean_details( + self, + flag_key: str, + default_value: bool, + evaluation_context: EvaluationContext | None = None, + ) -> FlagResolutionDetails[bool]: + return self._resolve_value( + FlagType.BOOLEAN, flag_key, default_value, evaluation_context + ) + + def resolve_string_details( + self, + flag_key: str, + default_value: str, + evaluation_context: EvaluationContext | None = None, + ) -> FlagResolutionDetails[str]: + return self._resolve_value( + FlagType.STRING, flag_key, default_value, evaluation_context + ) + + def resolve_integer_details( + self, + flag_key: str, + default_value: int, + evaluation_context: EvaluationContext | None = None, + ) -> FlagResolutionDetails[int]: + return self._resolve_value( + FlagType.INTEGER, flag_key, default_value, evaluation_context + ) + + def resolve_float_details( + self, + flag_key: str, + default_value: float, + evaluation_context: EvaluationContext | None = None, + ) -> FlagResolutionDetails[float]: + return self._resolve_value( + FlagType.FLOAT, flag_key, default_value, evaluation_context + ) + + def resolve_object_details( + self, + flag_key: str, + default_value: Sequence[FlagValueType] | Mapping[str, FlagValueType], + evaluation_context: EvaluationContext | None = None, + ) -> FlagResolutionDetails[ + Sequence[FlagValueType] | Mapping[str, FlagValueType] + ]: + return self._resolve_value( + FlagType.OBJECT, flag_key, default_value, evaluation_context + ) + + def track( + self, + tracking_event_name: str, + evaluation_context: EvaluationContext | None = None, + tracking_event_details: TrackingEventDetails | None = None, + ) -> None: + try: + user = self._context_converter.to_fb_user(evaluation_context) + metric_value = 1.0 + if tracking_event_details is not None: + if tracking_event_details.attributes: + logger.debug( + "FeatBit track_metric does not support tracking attributes; " + "ignoring them" + ) + if tracking_event_details.value is not None: + metric_value = float(tracking_event_details.value) + self._client.track_metric(user, tracking_event_name, metric_value) + except TargetingKeyMissingError: + logger.warning("Ignoring FeatBit tracking call without a targeting key") + except Exception: + logger.exception("FeatBit tracking failed") + + def _resolve_value( + self, + flag_type: FlagType, + flag_key: str, + default_value: Any, + evaluation_context: EvaluationContext | None, + ) -> FlagResolutionDetails[Any]: + try: + user = self._context_converter.to_fb_user(evaluation_context) + result = self._client.variation_detail(flag_key, user, default_value) + resolved_value = self._validate_and_cast_value( + flag_type, result.variation + ) + if resolved_value is _TYPE_MISMATCH: + return self._type_mismatch_details(default_value) + return self._details_converter.to_resolution_details( + result, resolved_value + ) + except TargetingKeyMissingError as error: + return FlagResolutionDetails( + value=default_value, + reason=Reason.ERROR, + error_code=ErrorCode.TARGETING_KEY_MISSING, + error_message=error.error_message, + ) + except Exception: + logger.exception("FeatBit flag evaluation failed") + return FlagResolutionDetails( + value=default_value, + reason=Reason.ERROR, + error_code=ErrorCode.GENERAL, + error_message="FeatBit flag evaluation failed", + ) + + @staticmethod + def _validate_and_cast_value(flag_type: FlagType, value: Any) -> Any: + if flag_type == FlagType.BOOLEAN and isinstance(value, bool): + return value + if flag_type == FlagType.STRING and isinstance(value, str): + return value + if ( + flag_type == FlagType.INTEGER + and isinstance(value, (int, float)) + and not isinstance(value, bool) + ): + return int(value) + if ( + flag_type == FlagType.FLOAT + and isinstance(value, (int, float)) + and not isinstance(value, bool) + ): + return float(value) + if flag_type == FlagType.OBJECT and isinstance(value, (dict, list)): + return value + return _TYPE_MISMATCH + + @staticmethod + def _type_mismatch_details(default_value: Any) -> FlagResolutionDetails[Any]: + return FlagResolutionDetails( + value=default_value, + reason=Reason.ERROR, + error_code=ErrorCode.TYPE_MISMATCH, + error_message="FeatBit flag value does not match the requested type", + ) + + def _register_listeners(self) -> None: + with self._lifecycle_lock: + if not self._status_listener_registered: + try: + self._client.update_status_provider.add_listener( + self._handle_data_update_status + ) + except Exception: + logger.exception("Failed to register FeatBit status listener") + else: + self._status_listener_registered = True + + if self._flag_change_listener is None: + listener = _FlagChangeListener(self._emit_configuration_changed) + try: + self._client.flag_tracker.add_flag_changed_listener(listener) + except Exception: + logger.exception("Failed to register FeatBit flag change listener") + else: + self._flag_change_listener = listener + + def _emit_configuration_changed(self, flag_key: str) -> None: + try: + self.emit_provider_configuration_changed( + ProviderEventDetails(flags_changed=[flag_key]) + ) + except Exception: + logger.exception("Failed to emit OpenFeature configuration change event") + + def _handle_data_update_status(self, state: State) -> None: + try: + if state.state_type == StateType.OK: + self.emit_provider_ready(ProviderEventDetails()) + elif state.state_type == StateType.INTERRUPTED: + self.emit_provider_stale( + ProviderEventDetails(message=self._state_message(state)) + ) + elif state.state_type == StateType.OFF: + self.emit_provider_error( + ProviderEventDetails( + error_code=ErrorCode.PROVIDER_FATAL, + message=self._state_message(state), + ) + ) + except Exception: + logger.exception("Failed to emit OpenFeature provider status event") + + @staticmethod + def _state_message(state: Any) -> str: + error = getattr(state, "error_track", None) + message = getattr(error, "message", None) + state_type = getattr(getattr(state, "state_type", None), "name", "UNKNOWN") + return message or f"FeatBit data source state is {state_type}" diff --git a/featbit_openfeature/py.typed b/featbit_openfeature/py.typed new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/featbit_openfeature/py.typed @@ -0,0 +1 @@ + diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c862021 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,36 @@ +[build-system] +requires = ["setuptools>=77", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "featbit-openfeature-server" +version = "0.1.0" +description = "OpenFeature provider for the FeatBit Python server SDK" +readme = "README.md" +requires-python = ">=3.10,<3.13" +license = "Apache-2.0" +authors = [{name = "FeatBit"}] +dependencies = [ + "openfeature-sdk>=0.10,<1", + "fb-python-sdk>=1.1.8,<2", +] + +[project.optional-dependencies] +dev = [ + "build>=1,<2", + "flake8>=7,<8", + "pytest>=8,<10", +] + +[project.urls] +Homepage = "https://github.com/featbit/openfeature-provider-python-server" +Issues = "https://github.com/featbit/openfeature-provider-python-server/issues" + +[tool.setuptools.packages.find] +include = ["featbit_openfeature*"] + +[tool.setuptools.package-data] +featbit_openfeature = ["py.typed"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/tests/fixtures/bootstrap.json b/tests/fixtures/bootstrap.json new file mode 100644 index 0000000..75d5de2 --- /dev/null +++ b/tests/fixtures/bootstrap.json @@ -0,0 +1,41 @@ +{ + "messageType": "data-sync", + "data": { + "eventType": "full", + "featureFlags": [ + { + "envId": "test-environment", + "name": "Python App Progressive Release", + "key": "python-app-release", + "variationType": "string", + "variations": [ + {"id": "variation-v2", "value": "v2"}, + {"id": "variation-v1", "value": "v1"} + ], + "targetUsers": [ + {"keyIds": ["employee-001"], "variationId": "variation-v2"} + ], + "rules": [], + "isEnabled": true, + "disabledVariationId": "variation-v1", + "fallthrough": { + "dispatchKey": null, + "includedInExpt": false, + "variations": [ + {"id": "variation-v1", "rollout": [0, 1], "exptRollout": 1} + ] + }, + "exptIncludeAllTargets": true, + "tags": [], + "isArchived": false, + "disabledVariation": {"id": "variation-v1", "value": "v1"}, + "creatorId": "test", + "updatorId": "test", + "createdAt": "2026-07-22T00:00:00Z", + "updatedAt": "2026-07-22T00:00:00Z", + "id": "python-app-release-id" + } + ], + "segments": [] + } +} diff --git a/tests/impl/test_context_converter.py b/tests/impl/test_context_converter.py new file mode 100644 index 0000000..83c2b05 --- /dev/null +++ b/tests/impl/test_context_converter.py @@ -0,0 +1,96 @@ +import logging +from datetime import datetime, timedelta, timezone + +import pytest +from openfeature.evaluation_context import EvaluationContext +from openfeature.exception import TargetingKeyMissingError + +from featbit_openfeature.impl.context_converter import EvaluationContextConverter + + +@pytest.fixture +def converter() -> EvaluationContextConverter: + return EvaluationContextConverter() + + +def test_targeting_key_and_name_are_converted(converter): + context = EvaluationContext("user-123", {"name": "Alice", "plan": "beta"}) + + assert converter.to_fb_user(context) == { + "key": "user-123", + "name": "Alice", + "plan": "beta", + } + + +def test_attribute_key_is_supported_as_compatibility_fallback(converter): + context = EvaluationContext(None, {"key": "legacy-user"}) + + assert converter.to_fb_user(context) == { + "key": "legacy-user", + "name": "legacy-user", + } + + +def test_targeting_key_takes_precedence_over_attribute_key(converter): + context = EvaluationContext("preferred", {"key": "legacy", "name": "Alice"}) + + user = converter.to_fb_user(context) + + assert user["key"] == "preferred" + assert "keyid" not in user + assert "targetingKey" not in user + + +@pytest.mark.parametrize("context", [None, EvaluationContext(), EvaluationContext(" ")]) +def test_missing_targeting_key_is_rejected(converter, context): + with pytest.raises(TargetingKeyMissingError): + converter.to_fb_user(context) + + +def test_invalid_name_falls_back_to_targeting_key(converter, caplog): + context = EvaluationContext("user-123", {"name": 42}) + + user = converter.to_fb_user(context) + + assert user["name"] == "user-123" + assert "using targeting key" in caplog.records[0].message + + +def test_scalar_attributes_are_preserved(converter): + context = EvaluationContext( + "user-123", + {"string": "value", "boolean": True, "integer": 3, "float": 1.5}, + ) + + user = converter.to_fb_user(context) + + assert user["string"] == "value" + assert user["boolean"] is True + assert user["integer"] == 3 + assert user["float"] == 1.5 + + +def test_datetime_attributes_are_iso_formatted(converter): + naive = datetime(2026, 7, 22, 12, 30) + aware = datetime(2026, 7, 22, 12, 30, tzinfo=timezone(timedelta(hours=8))) + context = EvaluationContext("user-123", {"naive": naive, "aware": aware}) + + user = converter.to_fb_user(context) + + assert user["naive"] == "2026-07-22T12:30:00+00:00" + assert user["aware"] == "2026-07-22T12:30:00+08:00" + + +def test_structured_attributes_are_ignored_without_mutating_context(converter, caplog): + caplog.set_level(logging.DEBUG, logger="featbit-openfeature-server") + attributes = {"groups": ["beta"], "profile": {"region": "cn"}, "plan": "pro"} + context = EvaluationContext("user-123", attributes) + + user = converter.to_fb_user(context) + + assert user["plan"] == "pro" + assert "groups" not in user + assert "profile" not in user + assert context.attributes == attributes + assert len(caplog.records) == 2 diff --git a/tests/impl/test_details_converter.py b/tests/impl/test_details_converter.py new file mode 100644 index 0000000..7c44616 --- /dev/null +++ b/tests/impl/test_details_converter.py @@ -0,0 +1,68 @@ +import pytest +from fbclient.common_types import EvalDetail +from fbclient.evaluator import ( + REASON_CLIENT_NOT_READY, + REASON_ERROR, + REASON_FALLTHROUGH, + REASON_FLAG_NOT_FOUND, + REASON_FLAG_OFF, + REASON_PREREQUISITE_FAILED, + REASON_RULE_MATCH, + REASON_TARGET_MATCH, + REASON_USER_NOT_SPECIFIED, + REASON_WRONG_TYPE, +) +from openfeature.exception import ErrorCode +from openfeature.flag_evaluation import Reason + +from featbit_openfeature.impl.details_converter import ResolutionDetailsConverter + + +@pytest.fixture +def converter() -> ResolutionDetailsConverter: + return ResolutionDetailsConverter() + + +@pytest.mark.parametrize( + "featbit_reason,openfeature_reason", + [ + (REASON_FLAG_OFF, Reason.DISABLED), + (REASON_TARGET_MATCH, Reason.TARGETING_MATCH), + (REASON_RULE_MATCH, Reason.TARGETING_MATCH), + (REASON_FALLTHROUGH, REASON_FALLTHROUGH), + (REASON_PREREQUISITE_FAILED, REASON_PREREQUISITE_FAILED), + ("vendor-specific-reason", "vendor-specific-reason"), + ("", Reason.UNKNOWN), + ], +) +def test_success_reason_mapping(converter, featbit_reason, openfeature_reason): + detail = EvalDetail(featbit_reason, True, "flag", "Flag", "variation-id") + + result = converter.to_resolution_details(detail, True) + + assert result.value is True + assert result.reason == openfeature_reason + assert result.error_code is None + assert result.error_message is None + assert result.variant == "variation-id" + + +@pytest.mark.parametrize( + "featbit_reason,error_code", + [ + (REASON_CLIENT_NOT_READY, ErrorCode.PROVIDER_NOT_READY), + (REASON_FLAG_NOT_FOUND, ErrorCode.FLAG_NOT_FOUND), + (REASON_USER_NOT_SPECIFIED, ErrorCode.TARGETING_KEY_MISSING), + (REASON_WRONG_TYPE, ErrorCode.TYPE_MISMATCH), + (REASON_ERROR, ErrorCode.GENERAL), + ], +) +def test_error_mapping_does_not_return_variant(converter, featbit_reason, error_code): + detail = EvalDetail(featbit_reason, False, "flag", "Flag", "must-not-leak") + + result = converter.to_resolution_details(detail, False) + + assert result.reason == Reason.ERROR + assert result.error_code == error_code + assert result.error_message == featbit_reason + assert result.variant is None diff --git a/tests/test_offline_integration.py b/tests/test_offline_integration.py new file mode 100644 index 0000000..9aa0dae --- /dev/null +++ b/tests/test_offline_integration.py @@ -0,0 +1,49 @@ +from pathlib import Path + +from fbclient.client import FBClient +from fbclient.config import Config +from fbclient.status_types import State +from openfeature import api +from openfeature.evaluation_context import EvaluationContext +from openfeature.flag_evaluation import Reason +from openfeature.provider import ProviderStatus + +from featbit_openfeature import FeatBitProvider + + +def test_openfeature_client_evaluates_with_real_featbit_sdk_offline(): + data = Path(__file__).parent.joinpath("fixtures", "bootstrap.json").read_text( + encoding="utf-8" + ) + config = Config("offline-secret", "http://offline", "http://offline", offline=True) + fb_client = FBClient(config, start_wait=0) + try: + assert fb_client.initialize_from_external_json(data) + assert fb_client.initialize + + api.set_provider_and_wait(FeatBitProvider(fb_client)) + client = api.get_client() + details = client.get_string_details( + "python-app-release", + "v1", + EvaluationContext( + "employee-001", {"name": "Employee", "plan": "standard"} + ), + ) + + assert details.value == "v2" + assert details.reason == Reason.TARGETING_MATCH + assert details.variant == "variation-v2" + assert details.error_code is None + + fb_client.update_status_provider.update_state(State.ok_state()) + fb_client.update_status_provider.update_state( + State.interrupted_state("network", "connection lost") + ) + assert client.get_provider_status() == ProviderStatus.STALE + + fb_client.update_status_provider.update_state(State.ok_state()) + assert client.get_provider_status() == ProviderStatus.READY + finally: + api.shutdown() + fb_client.stop() diff --git a/tests/test_packaging_metadata.py b/tests/test_packaging_metadata.py new file mode 100644 index 0000000..957b082 --- /dev/null +++ b/tests/test_packaging_metadata.py @@ -0,0 +1,13 @@ +from pathlib import Path + + +ROOT = Path(__file__).parent.parent + + +def test_project_license_matches_repository_license(): + pyproject = ROOT.joinpath("pyproject.toml").read_text(encoding="utf-8") + license_text = ROOT.joinpath("LICENSE").read_text(encoding="utf-8") + + assert 'license = "Apache-2.0"' in pyproject + assert "Apache License" in license_text + assert "Version 2.0" in license_text diff --git a/tests/test_provider.py b/tests/test_provider.py new file mode 100644 index 0000000..07158b2 --- /dev/null +++ b/tests/test_provider.py @@ -0,0 +1,332 @@ +from concurrent.futures import ThreadPoolExecutor +from unittest.mock import Mock + +import pytest +from fbclient.common_types import EvalDetail +from fbclient.evaluator import REASON_CLIENT_NOT_READY, REASON_TARGET_MATCH +from fbclient.flag_change_notification import FlagChangedNotice +from fbclient.status_types import State +from openfeature import api +from openfeature.evaluation_context import EvaluationContext +from openfeature.event import ProviderEvent +from openfeature.exception import ( + ErrorCode, + ProviderFatalError, + ProviderNotReadyError, +) +from openfeature.flag_evaluation import Reason +from openfeature.provider import ProviderStatus +from openfeature.track import TrackingEventDetails + +from featbit_openfeature import FeatBitProvider + + +def make_client(initialized=True): + client = Mock() + client.initialize = initialized + client.flag_tracker = Mock() + client.update_status_provider = Mock() + return client + + +def make_context(): + return EvaluationContext("user-123", {"name": "Alice", "plan": "beta"}) + + +def test_metadata_and_client_access(): + client = make_client() + provider = FeatBitProvider(client) + + assert provider.get_metadata().name == "featbit-openfeature-server" + assert provider.client is client + + +def test_constructor_validation(): + with pytest.raises(ValueError): + FeatBitProvider(None) # type: ignore[arg-type] + with pytest.raises(ValueError): + FeatBitProvider(make_client(), initialization_timeout=0) + + +@pytest.mark.parametrize( + "method_name,default_value,return_value,expected_value", + [ + ("resolve_boolean_details", False, True, True), + ("resolve_string_details", "default", "enabled", "enabled"), + ("resolve_integer_details", 1, 2.9, 2), + ("resolve_float_details", 1.0, 2, 2.0), + ("resolve_object_details", {"default": True}, {"enabled": True}, {"enabled": True}), + ("resolve_object_details", ["default"], ["enabled"], ["enabled"]), + ], +) +def test_typed_resolution_methods(method_name, default_value, return_value, expected_value): + client = make_client() + client.variation_detail.return_value = EvalDetail( + REASON_TARGET_MATCH, return_value, "flag-key", "Flag", "variation-id" + ) + provider = FeatBitProvider(client) + + result = getattr(provider, method_name)("flag-key", default_value, make_context()) + + assert result.value == expected_value + assert result.reason == Reason.TARGETING_MATCH + assert result.variant == "variation-id" + client.variation_detail.assert_called_once_with( + "flag-key", + {"key": "user-123", "name": "Alice", "plan": "beta"}, + default_value, + ) + + +@pytest.mark.parametrize( + "method_name,default_value,return_value", + [ + ("resolve_boolean_details", False, 1), + ("resolve_boolean_details", False, "true"), + ("resolve_string_details", "default", True), + ("resolve_integer_details", 1, True), + ("resolve_float_details", 1.0, True), + ("resolve_object_details", {}, "json"), + ], +) +def test_type_mismatch_returns_default(method_name, default_value, return_value): + client = make_client() + client.variation_detail.return_value = EvalDetail( + REASON_TARGET_MATCH, return_value, "flag-key", "Flag", "variation-id" + ) + provider = FeatBitProvider(client) + + result = getattr(provider, method_name)("flag-key", default_value, make_context()) + + assert result.value == default_value + assert result.reason == Reason.ERROR + assert result.error_code == ErrorCode.TYPE_MISMATCH + assert result.variant is None + + +def test_missing_context_returns_default_without_calling_featbit(): + client = make_client() + provider = FeatBitProvider(client) + + result = provider.resolve_boolean_details("flag-key", False, None) + + assert result.value is False + assert result.reason == Reason.ERROR + assert result.error_code == ErrorCode.TARGETING_KEY_MISSING + client.variation_detail.assert_not_called() + + +def test_featbit_error_details_are_preserved(): + client = make_client() + client.variation_detail.return_value = EvalDetail( + REASON_CLIENT_NOT_READY, False, "flag-key", "Flag" + ) + provider = FeatBitProvider(client) + + result = provider.resolve_boolean_details("flag-key", False, make_context()) + + assert result.value is False + assert result.reason == Reason.ERROR + assert result.error_code == ErrorCode.PROVIDER_NOT_READY + assert result.error_message == REASON_CLIENT_NOT_READY + + +def test_unexpected_evaluation_exception_never_escapes(): + client = make_client() + client.variation_detail.side_effect = RuntimeError("boom") + provider = FeatBitProvider(client) + + result = provider.resolve_string_details("flag-key", "safe", make_context()) + + assert result.value == "safe" + assert result.error_code == ErrorCode.GENERAL + assert result.reason == Reason.ERROR + + +def test_initialize_returns_immediately_when_client_is_ready(): + client = make_client(initialized=True) + provider = FeatBitProvider(client) + + provider.initialize(EvaluationContext()) + + client.update_status_provider.wait_for_OKState.assert_not_called() + client.flag_tracker.add_flag_changed_listener.assert_called_once() + + +def test_initialize_waits_for_ready_client(): + client = make_client(initialized=False) + client.update_status_provider.wait_for_OKState.return_value = True + provider = FeatBitProvider(client, initialization_timeout=3) + + provider.initialize(EvaluationContext()) + + client.update_status_provider.wait_for_OKState.assert_called_once_with(3) + + +def test_initialize_timeout_raises_provider_not_ready(): + client = make_client(initialized=False) + client.update_status_provider.wait_for_OKState.return_value = False + client.update_status_provider.current_state = State.intializing_state() + provider = FeatBitProvider(client, initialization_timeout=3) + + with pytest.raises(ProviderNotReadyError): + provider.initialize(EvaluationContext()) + + +def test_initialize_off_state_raises_provider_fatal(): + client = make_client(initialized=False) + client.update_status_provider.wait_for_OKState.return_value = False + client.update_status_provider.current_state = State.error_off_state( + "credential", "invalid environment secret" + ) + provider = FeatBitProvider(client, initialization_timeout=3) + + with pytest.raises(ProviderFatalError) as error: + provider.initialize(EvaluationContext()) + + assert error.value.error_message == "invalid environment secret" + + +def test_flag_change_is_emitted_and_shutdown_removes_listener(): + client = make_client() + provider = FeatBitProvider(client) + emitter = Mock() + provider.attach(emitter) + provider.initialize(EvaluationContext()) + listener = client.flag_tracker.add_flag_changed_listener.call_args.args[0] + + listener.on_flag_change(FlagChangedNotice("checkout")) + provider.shutdown() + provider.shutdown() + + emitted_provider, event, details = emitter.call_args.args + assert emitted_provider is provider + assert event == ProviderEvent.PROVIDER_CONFIGURATION_CHANGED + assert details.flags_changed == ["checkout"] + client.flag_tracker.remove_flag_change_notifier.assert_called_once_with(listener) + client.update_status_provider.remove_listener.assert_called_once_with( + provider._handle_data_update_status + ) + client.stop.assert_not_called() + + +def test_flag_change_callback_exception_never_escapes(): + client = make_client() + provider = FeatBitProvider(client) + emitter = Mock(side_effect=RuntimeError("boom")) + provider.attach(emitter) + provider.initialize(EvaluationContext()) + listener = client.flag_tracker.add_flag_changed_listener.call_args.args[0] + + listener.on_flag_change(FlagChangedNotice("checkout")) + + emitter.assert_called_once() + + +def test_shutdown_retries_listener_removal_after_failure(): + client = make_client() + provider = FeatBitProvider(client) + provider.initialize(EvaluationContext()) + listener = client.flag_tracker.add_flag_changed_listener.call_args.args[0] + client.flag_tracker.remove_flag_change_notifier.side_effect = [ + RuntimeError("boom"), + None, + ] + + provider.shutdown() + provider.initialize(EvaluationContext()) + provider.shutdown() + + client.flag_tracker.add_flag_changed_listener.assert_called_once_with(listener) + assert client.flag_tracker.remove_flag_change_notifier.call_count == 2 + + +def test_runtime_featbit_status_updates_openfeature_provider_status(): + client = make_client() + provider = FeatBitProvider(client) + + try: + api.set_provider_and_wait(provider) + openfeature_client = api.get_client() + listener = client.update_status_provider.add_listener.call_args.args[0] + + assert openfeature_client.get_provider_status() == ProviderStatus.READY + + listener(State.interrupted_state("network", "connection lost")) + assert openfeature_client.get_provider_status() == ProviderStatus.STALE + + listener(State.ok_state()) + assert openfeature_client.get_provider_status() == ProviderStatus.READY + + listener(State.error_off_state("credential", "invalid secret")) + assert openfeature_client.get_provider_status() == ProviderStatus.FATAL + finally: + api.shutdown() + + +def test_status_callback_exception_never_escapes(): + client = make_client() + provider = FeatBitProvider(client) + emitter = Mock(side_effect=RuntimeError("boom")) + provider.attach(emitter) + + provider._handle_data_update_status( + State.interrupted_state("network", "connection lost") + ) + + emitter.assert_called_once() + + +def test_track_maps_numeric_value_and_context(): + client = make_client() + provider = FeatBitProvider(client) + + provider.track( + "checkout", + make_context(), + TrackingEventDetails(value=2.5, attributes={"currency": "CNY"}), + ) + + client.track_metric.assert_called_once_with( + {"key": "user-123", "name": "Alice", "plan": "beta"}, + "checkout", + 2.5, + ) + + +def test_track_without_context_or_on_error_never_raises(): + client = make_client() + provider = FeatBitProvider(client) + + provider.track("missing-context") + client.track_metric.side_effect = RuntimeError("boom") + provider.track("failure", make_context()) + + assert client.track_metric.call_count == 1 + + +def test_concurrent_resolutions_do_not_share_request_state(): + client = make_client() + + def evaluate(_flag_key, user, _default): + return EvalDetail( + REASON_TARGET_MATCH, + user["request"], + "flag-key", + "Flag", + "variation-id", + ) + + client.variation_detail.side_effect = evaluate + provider = FeatBitProvider(client) + + def resolve(index): + context = EvaluationContext( + f"user-{index}", {"name": f"User {index}", "request": str(index)} + ) + return provider.resolve_string_details("flag-key", "fallback", context).value + + with ThreadPoolExecutor(max_workers=16) as executor: + results = list(executor.map(resolve, range(1000))) + + assert results == [str(index) for index in range(1000)]