Skip to content

Commit 8f86d71

Browse files
authored
feat: add OpenFeature Python server provider
2 parents 6574599 + 153e7fa commit 8f86d71

19 files changed

Lines changed: 1375 additions & 0 deletions

‎.flake8‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[flake8]
2+
max-line-length = 100
3+
exclude = .git,.venv,build,dist

‎.github/workflows/ci.yml‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
8+
permissions:
9+
contents: read
10+
11+
jobs:
12+
test:
13+
runs-on: ubuntu-latest
14+
strategy:
15+
fail-fast: false
16+
matrix:
17+
python-version: ["3.10", "3.11", "3.12"]
18+
19+
steps:
20+
- uses: actions/checkout@v4
21+
with:
22+
persist-credentials: false
23+
- uses: actions/setup-python@v5
24+
with:
25+
python-version: ${{ matrix.python-version }}
26+
- name: Install
27+
run: |
28+
python -m pip install --upgrade pip
29+
python -m pip install "openfeature-sdk>=0.10,<1" "build>=1,<2" "flake8>=7,<8" "pytest>=8,<10"
30+
# Temporary immutable pin until fb-python-sdk 1.1.8 is published.
31+
python -m pip install "fb-python-sdk @ git+https://github.com/featbit/featbit-python-sdk.git@7c74e19bbb5934084802c0bee2cab6d39f4ec00d"
32+
python -m pip install -e . --no-deps
33+
python -m pip check
34+
- name: Lint
35+
run: python -m flake8 featbit_openfeature tests
36+
- name: Test
37+
run: python -m pytest -q
38+
- name: Build
39+
if: matrix.python-version == '3.12'
40+
run: python -m build

‎.github/workflows/release.yml‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
name: Publish to PyPI
2+
3+
on:
4+
release:
5+
types: [published]
6+
7+
permissions:
8+
contents: read
9+
10+
jobs:
11+
build:
12+
runs-on: ubuntu-latest
13+
steps:
14+
- uses: actions/checkout@v4
15+
with:
16+
persist-credentials: false
17+
- uses: actions/setup-python@v5
18+
with:
19+
python-version: "3.12"
20+
- name: Build distributions
21+
run: |
22+
python -m pip install --upgrade pip
23+
python -m pip install "build>=1,<2"
24+
python -m build
25+
- name: Upload distributions
26+
uses: actions/upload-artifact@v4
27+
with:
28+
name: python-package-distributions
29+
path: dist/
30+
if-no-files-found: error
31+
32+
publish:
33+
needs: build
34+
runs-on: ubuntu-latest
35+
environment:
36+
name: pypi
37+
url: https://pypi.org/p/featbit-openfeature-server
38+
permissions:
39+
id-token: write
40+
steps:
41+
- name: Download distributions
42+
uses: actions/download-artifact@v4
43+
with:
44+
name: python-package-distributions
45+
path: dist/
46+
- name: Publish distributions to PyPI
47+
uses: pypa/gh-action-pypi-publish@release/v1

‎.gitignore‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
__pycache__/
2+
.pytest_cache/
3+
.venv/
4+
build/
5+
dist/
6+
*.egg-info/

‎MANIFEST.in‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
include LICENSE
2+
include README.md
3+
recursive-include tests *.json
4+
recursive-include tests *.py

‎README.md‎

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
# FeatBit OpenFeature Provider for Python Server SDK
2+
3+
Thin OpenFeature provider backed by an existing FeatBit Python Server SDK client.
4+
The provider converts OpenFeature evaluation contexts and resolution details; feature
5+
flag synchronization, local evaluation, and insight events remain owned by
6+
`fb-python-sdk`.
7+
8+
## Installation
9+
10+
```shell
11+
pip install featbit-openfeature-server
12+
```
13+
14+
The provider requires `fb-python-sdk>=1.1.8`, which exposes variation IDs and
15+
data-update status listeners.
16+
17+
## Usage
18+
19+
```python
20+
from fbclient.client import FBClient
21+
from fbclient.config import Config
22+
from openfeature import api
23+
from openfeature.evaluation_context import EvaluationContext
24+
25+
from featbit_openfeature import FeatBitProvider
26+
27+
fb_client = FBClient(
28+
Config(
29+
"server-side-environment-secret",
30+
event_url="https://app-eval.featbit.co",
31+
streaming_url="wss://app-eval.featbit.co",
32+
),
33+
start_wait=0,
34+
)
35+
36+
api.set_provider_and_wait(FeatBitProvider(fb_client))
37+
client = api.get_client()
38+
39+
details = client.get_boolean_details(
40+
"new-checkout",
41+
False,
42+
EvaluationContext(
43+
targeting_key="user-123",
44+
attributes={"name": "Alice", "plan": "beta"},
45+
),
46+
)
47+
48+
print(details.value, details.reason, details.variant)
49+
50+
api.shutdown()
51+
```
52+
53+
The provider owns the injected `FBClient`. Calling `api.shutdown()` removes
54+
provider listeners and stops the FeatBit client so queued events are flushed
55+
and background resources are released.
56+
57+
## Supported mappings
58+
59+
- Boolean, string, integer, float, and object resolution.
60+
- OpenFeature targeting key and scalar attributes to a FeatBit user dictionary.
61+
- FeatBit reasons and failures to OpenFeature `Reason` and `ErrorCode`.
62+
- FeatBit variation ID to OpenFeature `variant`.
63+
- FeatBit flag changes to `PROVIDER_CONFIGURATION_CHANGED`.
64+
- FeatBit runtime data-source status to OpenFeature provider status.
65+
- OpenFeature tracking value to `FBClient.track_metric()`.
66+
67+
Structured evaluation-context attributes and tracking custom attributes are not
68+
supported by the current FeatBit user and metric schemas. They are ignored rather
69+
than represented inaccurately.
70+
71+
## Provider status
72+
73+
Applications can read the status through the standard OpenFeature client:
74+
75+
```python
76+
from openfeature.provider import ProviderStatus
77+
78+
status = client.get_provider_status()
79+
assert status in {
80+
ProviderStatus.NOT_READY,
81+
ProviderStatus.READY,
82+
ProviderStatus.STALE,
83+
ProviderStatus.ERROR,
84+
ProviderStatus.FATAL,
85+
}
86+
```
87+
88+
FeatBit states are mapped as follows:
89+
90+
| FeatBit state | OpenFeature status |
91+
| --- | --- |
92+
| initializing | `NOT_READY` during provider initialization |
93+
| OK | `READY` |
94+
| interrupted | `STALE` |
95+
| off observed while provider is active | `FATAL` |
96+
97+
After `api.shutdown()`, OpenFeature removes this provider and installs its default
98+
no-op provider. Status after that point belongs to the OpenFeature SDK rather than
99+
to FeatBit.
100+
101+
The included `py.typed` marker declares this package as PEP 561 typed, allowing
102+
type checkers to use the annotations shipped with the provider after install.
103+
104+
## Development tests
105+
106+
Unit tests use mocks, and the integration test uses FeatBit offline data. Neither
107+
requires a FeatBit account.
108+
109+
```shell
110+
python -m pytest -q
111+
python -m flake8 featbit_openfeature tests
112+
python -m build
113+
```
114+
115+
## Release
116+
117+
Publishing is handled by `.github/workflows/release.yml` when a GitHub Release
118+
is published. The build and publish jobs are separated so package build code
119+
never receives an OpenID Connect token. The publish job uses PyPI Trusted
120+
Publishing and does not store a long-lived PyPI password in GitHub.
121+
122+
Before the first release, configure the PyPI trusted publisher with owner
123+
`featbit`, repository `openfeature-provider-python-server`, workflow
124+
`release.yml`, and GitHub environment `pypi`.

‎featbit_openfeature/__init__.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from featbit_openfeature.provider import FeatBitProvider
2+
3+
4+
__all__ = ["FeatBitProvider"]
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Internal converters used by the FeatBit OpenFeature provider."""
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
from datetime import datetime, timezone
5+
from typing import Any
6+
7+
from openfeature.evaluation_context import EvaluationContext
8+
from openfeature.exception import TargetingKeyMissingError
9+
10+
11+
logger = logging.getLogger("featbit-openfeature-server")
12+
13+
_RESERVED_ATTRIBUTES = {"key", "keyid", "targetingKey", "name"}
14+
15+
16+
class EvaluationContextConverter:
17+
"""Converts an OpenFeature evaluation context to a FeatBit user mapping."""
18+
19+
def to_fb_user(self, context: EvaluationContext | None) -> dict[str, Any]:
20+
if context is None:
21+
raise TargetingKeyMissingError("evaluation context is required")
22+
23+
attributes = dict(context.attributes)
24+
targeting_key = self._targeting_key(context, attributes)
25+
name = attributes.get("name")
26+
if not self._is_non_empty_string(name):
27+
if name is not None:
28+
logger.warning("FeatBit user name must be a non-empty string; using targeting key")
29+
name = targeting_key
30+
31+
user: dict[str, Any] = {"key": targeting_key, "name": name}
32+
for key, value in attributes.items():
33+
if not isinstance(key, str) or key in _RESERVED_ATTRIBUTES:
34+
continue
35+
36+
converted = self._convert_attribute(value)
37+
if converted is _UNSUPPORTED_ATTRIBUTE:
38+
logger.debug("Ignoring unsupported structured EvaluationContext attribute %r", key)
39+
continue
40+
user[key] = converted
41+
42+
return user
43+
44+
@classmethod
45+
def _targeting_key(
46+
cls, context: EvaluationContext, attributes: dict[str, Any]
47+
) -> str:
48+
if cls._is_non_empty_string(context.targeting_key):
49+
return context.targeting_key # type: ignore[return-value]
50+
51+
attribute_key = attributes.get("key")
52+
if cls._is_non_empty_string(attribute_key):
53+
return attribute_key
54+
55+
raise TargetingKeyMissingError(
56+
"evaluation context must contain a non-empty targeting key"
57+
)
58+
59+
@staticmethod
60+
def _is_non_empty_string(value: Any) -> bool:
61+
return isinstance(value, str) and bool(value.strip())
62+
63+
@staticmethod
64+
def _convert_attribute(value: Any) -> Any:
65+
if isinstance(value, datetime):
66+
if value.tzinfo is None:
67+
value = value.replace(tzinfo=timezone.utc)
68+
return value.isoformat()
69+
if isinstance(value, (str, bool, int, float)):
70+
return value
71+
return _UNSUPPORTED_ATTRIBUTE
72+
73+
74+
_UNSUPPORTED_ATTRIBUTE = object()
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
from __future__ import annotations
2+
3+
from typing import Any
4+
5+
from fbclient.common_types import EvalDetail
6+
from fbclient.evaluator import (
7+
REASON_CLIENT_NOT_READY,
8+
REASON_ERROR,
9+
REASON_FLAG_NOT_FOUND,
10+
REASON_FLAG_OFF,
11+
REASON_RULE_MATCH,
12+
REASON_TARGET_MATCH,
13+
REASON_USER_NOT_SPECIFIED,
14+
REASON_WRONG_TYPE,
15+
)
16+
from openfeature.exception import ErrorCode
17+
from openfeature.flag_evaluation import FlagResolutionDetails, Reason
18+
19+
20+
_STANDARD_REASONS = {
21+
REASON_FLAG_OFF: Reason.DISABLED,
22+
REASON_TARGET_MATCH: Reason.TARGETING_MATCH,
23+
REASON_RULE_MATCH: Reason.TARGETING_MATCH,
24+
}
25+
26+
_ERROR_CODES = {
27+
REASON_CLIENT_NOT_READY: ErrorCode.PROVIDER_NOT_READY,
28+
REASON_FLAG_NOT_FOUND: ErrorCode.FLAG_NOT_FOUND,
29+
REASON_USER_NOT_SPECIFIED: ErrorCode.TARGETING_KEY_MISSING,
30+
REASON_WRONG_TYPE: ErrorCode.TYPE_MISMATCH,
31+
REASON_ERROR: ErrorCode.GENERAL,
32+
}
33+
34+
35+
class ResolutionDetailsConverter:
36+
"""Converts FeatBit evaluation details to OpenFeature resolution details."""
37+
38+
def to_resolution_details(
39+
self, result: EvalDetail, resolved_value: Any
40+
) -> FlagResolutionDetails[Any]:
41+
raw_reason = result.reason or ""
42+
error_code = _ERROR_CODES.get(raw_reason)
43+
44+
if error_code is not None:
45+
reason: str | Reason = Reason.ERROR
46+
error_message = raw_reason
47+
variant = None
48+
else:
49+
reason = _STANDARD_REASONS.get(raw_reason, raw_reason or Reason.UNKNOWN)
50+
error_message = None
51+
variant = result.variation_id
52+
53+
return FlagResolutionDetails(
54+
value=resolved_value,
55+
error_code=error_code,
56+
error_message=error_message,
57+
reason=reason,
58+
variant=variant,
59+
)

0 commit comments

Comments
 (0)