Skip to content
Open
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
36 changes: 36 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Disable checkout credential persistence.

PR-controlled install and test code can read the persisted GitHub token from the local Git configuration. Set persist-credentials: false; no later step requires Git authentication.

Proposed fix
       - uses: actions/checkout@v4
+        with:
+          persist-credentials: false
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- uses: actions/checkout@v4
- uses: actions/checkout@v4
with:
persist-credentials: false
🧰 Tools
🪛 zizmor (1.26.1)

[warning] 20-20: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false

(artipacked)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/ci.yml at line 20, Update the actions/checkout@v4 step to
set persist-credentials to false, ensuring the workflow does not retain the
GitHub token in local Git configuration; leave the remaining checkout behavior
unchanged.

Source: Linters/SAST tools

- 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
Comment on lines +28 to +29

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== workflow =="
if [ -f .github/workflows/ci.yml ]; then
  nl -ba .github/workflows/ci.yml | sed -n '1,140p'
else
  echo ".github/workflows/ci.yml not found"
fi

echo
echo "== pyproject / package metadata =="
if [ -f pyproject.toml ]; then
  nl -ba pyproject.toml | sed -n '1,220p'
else
  fd -a 'pyproject.toml|setup.py|setup.cfg|requirements.*' . | sed -n '1,50p'
fi

echo
echo "== references to fb-python-sdk ==
rg -n "fb-python-sdk|featbit-python-sdk|refs/pull/14/head|--no-deps" .

Repository: featbit/openfeature-provider-python-server

Length of output: 235


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== workflow =="
if [ -f .github/workflows/ci.yml ]; then
  cat -n .github/workflows/ci.yml | sed -n '1,140p'
else
  echo ".github/workflows/ci.yml not found"
fi

echo
echo "== pyproject / package metadata =="
if [ -f pyproject.toml ]; then
  cat -n pyproject.toml | sed -n '1,220p'
else
  find . -maxdepth 3 \( -name 'pyproject.toml' -o -name 'setup.py' -o -name 'setup.cfg' -o -name 'requirements*.txt' \) -print | sort
fi

echo
echo "== references to fb-python-sdk / mutable refs / no-deps =="
grep -RIn "fb-python-sdk|featbit-python-sdk|refs/pull/14/head|uses:|pip install|\\-\\-no-deps" .github pyproject.toml 2>/dev/null || true

echo
echo "== current diff/stat summary =="
git diff --stat || true
git diff -- .github/workflows/ci.yml pyproject.toml 2>/dev/null | sed -n '1,220p'

Repository: featbit/openfeature-provider-python-server

Length of output: 2667


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== pip availability/version =="
python3 -c "import shutil, sys; print('pip', shutil.which('pip'), sys.executable)" || true
python -m pip --version 2>/dev/null || true
pip --version 2>/dev/null || true

echo
echo "== behavior probe: pip resolver with mutable git ref and declared range constraint =="
python3 - <<'PY'
import subprocess, tempfile, pathlib, shlex, json, sys

cmds = [
    "--dry-run",
    "-r",
    "/dev/stdin",
]

cases = [
    ("declared range", "fb-python-sdk>=1.1.8,<2"),
    ("mutable git ref", "git+https://github.com/featbit/featbit-python-sdk.git@refs/pull/14/head"),
]

for name, req in cases:
    cmdline = [sys.executable, "-m", "pip", "install"] + cmds + [req]
    print(f"\nCASE {name}: {' '.join(shlex.quote(p) for p in cmdline)}")
    try:
        p = subprocess.run(cmdline, text=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=80)
        decoded_stderr = p.stderr.decode("utf-8", "replace")
        decoded_stdout = p.stdout.decode("utf-8", "replace")
        print("returncode=", p.returncode)
        for line in decoded_stderr.splitlines()[-40:]:
            if line.strip().startswith(("Collecting", "Downloading", "Installed", "Ignoring", "Requirement already satisfied", "Resolved:")) or "error" in line.lower().encode("ascii", "ignore"):
                print(line)
        for line in decoded_stdout.splitlines()[-10:]:
            print(line)
    except Exception as e:
        print("error", type(e).__name__, e)
PY

Repository: featbit/openfeature-provider-python-server

Length of output: 863


🌐 Web query:

featbit Python SDK refs/pull/14/head PyPI fb-python-sdk 1.1.8

💡 Result:

As of July 24, 2026, the official PyPI package for the FeatBit Python SDK is fb-python-sdk [1][2]. The most recent version available on PyPI is 1.1.7, which was released on June 5, 2025 [1][3]. There is no official release of version 1.1.8 for the fb-python-sdk on PyPI, nor is there an open or merged pull request numbered 14 in the official featbit/featbit-python-sdk repository that references such a version [1][4][5]. Users should rely on the official releases documented in the GitHub repository and PyPI [1][4].

Citations:


Install an immutable fb-python-sdk requirement instead of the PR ref.

.github/workflows/ci.yml:28 points at refs/pull/14/head, while pyproject.toml declares fb-python-sdk>=1.1.8,<2. Since CI installs the editable package with --no-deps, this bypasses the declared dependency range and depends on a moving PR ref. Use the declared constraint, or pin a full commit and document the temporary override.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/ci.yml around lines 28 - 29, The CI dependency
installation should not use the moving `refs/pull/14/head` override. Update the
`fb-python-sdk` install command in the workflow to honor the `pyproject.toml`
constraint `>=1.1.8,<2`, or replace it with an immutable full commit pin and
document the temporary override.

- 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
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
108 changes: 108 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
```
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