-
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
base: main
Are you sure you want to change the base?
Changes from all commits
7a4b79a
e344b51
baf6928
b9396b5
cd34253
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| [flake8] | ||
| max-line-length = 100 | ||
| exclude = .git,.venv,build,dist |
| 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 | ||
| - 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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)
PYRepository: featbit/openfeature-provider-python-server Length of output: 863 🌐 Web query:
💡 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
🤖 Prompt for AI Agents |
||
| - 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 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| __pycache__/ | ||
| .pytest_cache/ | ||
| .venv/ | ||
| build/ | ||
| dist/ | ||
| *.egg-info/ |
| 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 |
| 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 | ||
| ``` |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| from featbit_openfeature.provider import FeatBitProvider | ||
|
|
||
|
|
||
| __all__ = ["FeatBitProvider"] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| """Internal converters used by the FeatBit OpenFeature provider.""" |
| 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() |
| 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, | ||
| ) |
There was a problem hiding this comment.
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
📝 Committable suggestion
🧰 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
Source: Linters/SAST tools