-
Notifications
You must be signed in to change notification settings - Fork 1
feat: add OpenFeature Python server provider #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
7a4b79a
feat: add FeatBit OpenFeature Python server provider
WuJiayi0307 e344b51
docs: add packaging and usage metadata
WuJiayi0307 baf6928
docs: record implementation and verification results
WuJiayi0307 b9396b5
feat: finalize standalone OpenFeature provider
WuJiayi0307 cd34253
fix: align package license with repository
WuJiayi0307 6a89dc1
fix: address provider lifecycle and release review
WuJiayi0307 68864cc
fix: synchronize provider shutdown state
WuJiayi0307 e69a17e
refactor: keep shutdown synchronization minimal
WuJiayi0307 153e7fa
test: remove obsolete initialization mock
WuJiayi0307 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| [flake8] | ||
| max-line-length = 100 | ||
| exclude = .git,.venv,build,dist |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| 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 | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| with: | ||
| persist-credentials: false | ||
| - 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" | ||
| # Temporary immutable pin until fb-python-sdk 1.1.8 is published. | ||
| python -m pip install "fb-python-sdk @ git+https://github.com/featbit/featbit-python-sdk.git@7c74e19bbb5934084802c0bee2cab6d39f4ec00d" | ||
| python -m pip install -e . --no-deps | ||
| python -m pip check | ||
| - 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 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| name: Publish to PyPI | ||
|
|
||
| on: | ||
| release: | ||
| types: [published] | ||
|
|
||
| permissions: | ||
| contents: read | ||
|
|
||
| jobs: | ||
| build: | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - uses: actions/checkout@v4 | ||
| with: | ||
| persist-credentials: false | ||
| - uses: actions/setup-python@v5 | ||
| with: | ||
| python-version: "3.12" | ||
| - name: Build distributions | ||
| run: | | ||
| python -m pip install --upgrade pip | ||
| python -m pip install "build>=1,<2" | ||
| python -m build | ||
| - name: Upload distributions | ||
| uses: actions/upload-artifact@v4 | ||
| with: | ||
| name: python-package-distributions | ||
| path: dist/ | ||
| if-no-files-found: error | ||
|
|
||
| publish: | ||
| needs: build | ||
| runs-on: ubuntu-latest | ||
| environment: | ||
| name: pypi | ||
| url: https://pypi.org/p/featbit-openfeature-server | ||
| permissions: | ||
| id-token: write | ||
| steps: | ||
| - name: Download distributions | ||
| uses: actions/download-artifact@v4 | ||
| with: | ||
| name: python-package-distributions | ||
| path: dist/ | ||
| - name: Publish distributions to PyPI | ||
| uses: pypa/gh-action-pypi-publish@release/v1 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| __pycache__/ | ||
| .pytest_cache/ | ||
| .venv/ | ||
| build/ | ||
| dist/ | ||
| *.egg-info/ |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| include LICENSE | ||
| include README.md | ||
| recursive-include tests *.json | ||
| recursive-include tests *.py |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,124 @@ | ||
| # 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() | ||
| ``` | ||
|
|
||
| The provider owns the injected `FBClient`. Calling `api.shutdown()` removes | ||
| provider listeners and stops the FeatBit client 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` | | ||
| | off observed while provider is active | `FATAL` | | ||
|
|
||
| After `api.shutdown()`, OpenFeature removes this provider and installs its default | ||
| no-op provider. Status after that point belongs to the OpenFeature SDK rather than | ||
| to FeatBit. | ||
|
|
||
| The included `py.typed` marker declares this package as PEP 561 typed, allowing | ||
| type checkers to use the annotations shipped with the provider after install. | ||
|
|
||
| ## 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 | ||
| ``` | ||
|
|
||
| ## Release | ||
|
|
||
| Publishing is handled by `.github/workflows/release.yml` when a GitHub Release | ||
| is published. The build and publish jobs are separated so package build code | ||
| never receives an OpenID Connect token. The publish job uses PyPI Trusted | ||
| Publishing and does not store a long-lived PyPI password in GitHub. | ||
|
|
||
| Before the first release, configure the PyPI trusted publisher with owner | ||
| `featbit`, repository `openfeature-provider-python-server`, workflow | ||
| `release.yml`, and GitHub environment `pypi`. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| from featbit_openfeature.provider import FeatBitProvider | ||
|
|
||
|
|
||
| __all__ = ["FeatBitProvider"] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| """Internal converters used by the FeatBit OpenFeature provider.""" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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, | ||
| ) |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.