Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .flake8
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[flake8]
max-line-length = 100
exclude = .git,.venv,build,dist
40 changes: 40 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
name: CI
Comment thread
WuJiayi0307 marked this conversation as resolved.

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
Comment thread
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
47 changes: 47 additions & 0 deletions .github/workflows/release.yml
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
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
__pycache__/
.pytest_cache/
.venv/
build/
dist/
*.egg-info/
4 changes: 4 additions & 0 deletions MANIFEST.in
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
124 changes: 124 additions & 0 deletions README.md
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`.
4 changes: 4 additions & 0 deletions featbit_openfeature/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from featbit_openfeature.provider import FeatBitProvider


__all__ = ["FeatBitProvider"]
1 change: 1 addition & 0 deletions featbit_openfeature/impl/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Internal converters used by the FeatBit OpenFeature provider."""
74 changes: 74 additions & 0 deletions featbit_openfeature/impl/context_converter.py
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()
59 changes: 59 additions & 0 deletions featbit_openfeature/impl/details_converter.py
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,
)
Loading