Metadata-Version: 2.5
Name: furagu
Version: 0.1.0
Summary: furagu feature flags for Python servers: an OpenFeature provider with in-process evaluation, streaming updates, and Django, FastAPI and Flask integrations.
Project-URL: Homepage, https://furagu.dev
Project-URL: Documentation, https://docs.furagu.dev
Project-URL: Repository, https://github.com/furagu-dev/python-server-sdk
Project-URL: Issues, https://github.com/furagu-dev/python-server-sdk/issues
Project-URL: Changelog, https://github.com/furagu-dev/python-server-sdk/blob/main/CHANGELOG.md
Author-email: furagu <hello@furagu.dev>
License-Expression: MIT
License-File: LICENSE
Keywords: django,fastapi,feature-flags,feature-toggles,flask,furagu,openfeature,openfeature-provider
Classifier: Development Status :: 4 - Beta
Classifier: Framework :: Django
Classifier: Framework :: FastAPI
Classifier: Framework :: Flask
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: openfeature-sdk<1,>=0.10.0
Requires-Dist: websockets<18,>=13
Provides-Extra: django
Requires-Dist: django>=4.2; extra == 'django'
Provides-Extra: fastapi
Requires-Dist: fastapi>=0.100; extra == 'fastapi'
Provides-Extra: flask
Requires-Dist: flask>=2.3; extra == 'flask'
Provides-Extra: orjson
Requires-Dist: orjson>=3.9; extra == 'orjson'
Description-Content-Type: text/markdown

# furagu Python SDK

[furagu](https://furagu.dev) feature flags for Python servers. This is an [OpenFeature](https://openfeature.dev) provider that downloads your environment's ruleset once and evaluates every flag in-process. Publishes reach it over a WebSocket in about a second, with polling as the fallback. It comes with integrations for Django, FastAPI, Flask and any other WSGI or ASGI app.

The provider runs the same evaluation engine as the furagu API, so a server evaluating locally and a browser asking the API get the same answer for the same context.

```bash
pip install furagu          # or: uv add furagu
```

## Quickstart

```python
import os

from openfeature import api
from openfeature.evaluation_context import EvaluationContext

from furagu import FuraguProvider

api.set_provider_and_wait(FuraguProvider(os.environ["FURAGU_SERVER_KEY"]))

client = api.get_client()
on = client.get_boolean_value("checkout-v2", False, EvaluationContext("user-8231", {"plan": "pro"}))
details = client.get_boolean_details("checkout-v2", False, EvaluationContext("user-8231"))
details.value, details.reason, details.variant, details.error_code

api.shutdown()  # on exit: closes the stream, stops polling, flushes usage counts (at most 2 s)
```

You need a server key (`furagu_srv_...`), and it is a secret. Every `get_*_value` or `get_*_details` call is an in-memory lookup that takes microseconds and never touches the network. The `_async` variants exist for async code and resolve the same way.

OpenFeature has five value types and furagu has four flag types. They map like this:

| Flag type | Client method | Note |
|---|---|---|
| boolean | `get_boolean_*` | |
| string | `get_string_*` | |
| number | `get_integer_*` | JSON integers only; `42.0` or `0.5` is a `TYPE_MISMATCH` |
| number | `get_float_*` | Any finite JSON number, returned as a `float` |
| json | `get_object_*` | A JSON object or array |

## Framework integrations

Each integration installs the provider when the app starts and shuts it down when the app exits. It also sets the OpenFeature transaction context for the current request, which is what lets a view call `get_client().get_boolean_value("flag", False)` without passing a context. If you do pass one, its attributes win.

### Django

```python
# settings.py
INSTALLED_APPS = [..., "furagu.contrib.django"]
MIDDLEWARE = [
    ...,
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "furagu.contrib.django.FuraguMiddleware",
]
FURAGU = {"SDK_KEY": os.environ["FURAGU_SERVER_KEY"]}
```

```python
from furagu.contrib.django import get_client


def checkout(request):
    return JsonResponse({"v2": get_client().get_boolean_value("checkout-v2", False)})
```

By default the targeting key is the authenticated user's primary key, or anonymous, and the attributes are `authenticated`, `path` and `method`. To build your own, set `FURAGU_CONTEXT = "myapp.flags.context"`, a dotted path or callable that takes the request and returns an `EvaluationContext`, a dict, or `None`.

The `FURAGU` dict takes every provider option in upper case (`BASE_URL`, `POLL_INTERVAL`, `EVENTS`, `STREAMING` and so on). Three extra keys are Django-specific: `WAIT` blocks startup until the ruleset is downloaded (default `True`), `DOMAIN` names an OpenFeature client domain, and `ENABLED` turns the whole thing off, which is handy in tests and one-off management commands.

### Django REST Framework

DRF authenticates inside the view, after Django's middleware has run. Session-authenticated requests are already covered by the middleware above, but for token, JWT or Basic authentication the middleware sees an anonymous user. Add the mixin in front of your DRF base class and the context is set again once DRF has authenticated:

```python
from rest_framework.views import APIView
from furagu.contrib.drf import FuraguContextMixin


class CheckoutView(FuraguContextMixin, APIView):
    def get(self, request):
        return Response({"v2": get_client().get_boolean_value("checkout-v2", False)})
```

It works with `APIView`, generic views and viewsets, uses the same default context and `FURAGU_CONTEXT` builder as plain Django views, and runs before permission classes so those can evaluate flags too. If you prefer explicit contexts, `furagu.contrib.drf.context_for(request)` builds one from an authenticated DRF request.

### FastAPI / Starlette

```python
from fastapi import Depends, FastAPI
from openfeature.client import OpenFeatureClient

from furagu import FuraguProvider
from furagu.contrib.fastapi import get_client, install

app = FastAPI()
install(app, FuraguProvider(os.environ["FURAGU_SERVER_KEY"]))


@app.get("/checkout")
async def checkout(flags: OpenFeatureClient = Depends(get_client)):
    return {"v2": flags.get_boolean_value("checkout-v2", False)}
```

`install()` wraps the app's lifespan, composing with one you already have, and adds the ASGI middleware. Pass `context_from_request=` to derive the targeting key from your own auth. Without it the context is anonymous with `path` and `method`, or the identity of Starlette's `request.user` if you run `AuthenticationMiddleware`. If you would rather wire things yourself, `furagu.contrib.fastapi.lifespan(provider)` works directly with `FastAPI(lifespan=...)`.

### Flask

```python
from flask import Flask
from furagu.contrib.flask import Furagu

app = Flask(__name__)
app.config["FURAGU_SDK_KEY"] = os.environ["FURAGU_SERVER_KEY"]
furagu = Furagu(app)


@app.get("/checkout")
def checkout():
    return {"v2": furagu.client.get_boolean_value("checkout-v2", False)}
```

Config keys are the provider options with a `FURAGU_` prefix (`FURAGU_BASE_URL`, `FURAGU_POLL_INTERVAL` and so on), plus `FURAGU_WAIT` and `FURAGU_ENABLED`. The app-factory pattern works too: `Furagu(provider=..., context_from_request=...)` now, `init_app(app)` later.

### Any ASGI or WSGI app

```python
from furagu.contrib.asgi import FuraguMiddleware  # Litestar, Quart, Django ASGI, raw Starlette, ...

app = FuraguMiddleware(app, provider=FuraguProvider(key), context_from_scope=lambda scope: {...})

from furagu.contrib.wsgi import FuraguMiddleware  # any PEP 3333 app

application = FuraguMiddleware(application, provider=FuraguProvider(key))
```

The ASGI middleware installs the provider on `lifespan.startup` and shuts it down on `lifespan.shutdown`. WSGI has no startup hook, so that middleware installs the provider when it is constructed. Both take a context builder that returns an `EvaluationContext`, a dict with `targeting_key` plus attributes, or `None`.

## Options

```python
FuraguProvider(
    sdk_key,
    base_url="https://api.furagu.dev",
    poll_interval=30.0,  # seconds between GET /v1/ruleset polls (jittered ±10%)
    events=True,  # report evaluation counts so the dashboard shows flag usage
    events_flush_interval=30.0,  # seconds between batched count uploads (jittered ±10%)
    streaming=True,  # hold a WebSocket and fetch a new ruleset the moment one is published
    streaming_poll_interval=900.0,  # fallback poll interval while the socket is up
    initial_ruleset=None,  # a Ruleset or its JSON shape to serve before the first download
    transport=None,  # a custom HTTP transport (proxies with special needs, tests)
    logger=None,  # defaults to logging.getLogger("furagu")
)
```

Requests to the API time out after 5 seconds. Polls never overlap, and they are free: the only metered call is `POST /v1/evaluations`, which this SDK does not make.

### Streaming

With `streaming=True` (the default) the provider keeps a WebSocket open to `GET /v1/stream`. The API announces new versions on it and the provider fetches them right away with `GET /v1/ruleset?v=N`, so a publish reaches your servers in about a second. Polling continues underneath at `streaming_poll_interval` in case a message is missed. If the socket drops, polling goes back to `poll_interval` and runs once immediately, while the stream reconnects with exponential backoff from 1 second up to 5 minutes.

Streaming is worth turning off for processes that live a few seconds (functions, cron jobs, CLIs), for networks that block WebSockets, and for very large fleets where a thousand open sockets per environment is a real number.

### Bootstrap

`provider.current_ruleset` is the ruleset being served. Save `current_ruleset.to_json()` somewhere and pass it back as `initial_ruleset=` on the next start. With a bootstrap in hand, a failed first download no longer fails initialization. The provider comes up `STALE`, serves the bootstrap, and recovers on the first successful poll.

## Status and events

| Status | Meaning |
|---|---|
| `READY` | Serving a ruleset downloaded from the API |
| `STALE` | The last refresh failed (network, 5xx, timeout). The previous ruleset is still served and polling keeps trying |
| `FATAL` | The key was rejected with a 401 or 403, for example because it was revoked or it is a client key. Polling, streaming and reporting all stop |
| `ERROR` | The first download failed and there was no bootstrap, so `set_provider_and_wait` raised |

The provider emits `PROVIDER_READY`, `PROVIDER_STALE`, `PROVIDER_ERROR` and `PROVIDER_CONFIGURATION_CHANGED`. The last one carries the keys of the flags whose version changed. Subscribe with `api.add_handler(...)`, and keep in mind that the OpenFeature SDK runs handlers on its own thread pool.

## Reasons and errors

| furagu | OpenFeature `reason` | `error_code` |
|---|---|---|
| flag disabled | `DISABLED` | |
| individual target matched | `TARGETING_MATCH` | |
| rule matched (`flag_metadata["ruleId"]` names it) | `TARGETING_MATCH` | |
| fallthrough, fixed variation | `DEFAULT` | |
| fallthrough, percentage rollout | `SPLIT` | |
| unknown flag key | `ERROR` | `FLAG_NOT_FOUND` |
| malformed flag | `ERROR` | `GENERAL` (the message names the problem) |
| value of another type | `ERROR` | `TYPE_MISMATCH` |
| no ruleset loaded yet | `ERROR` | `PROVIDER_NOT_READY` |

Evaluation never raises. Every error case hands back the default you passed in.

## Context

`EvaluationContext(targeting_key, attributes)` maps onto the furagu context: `targeting_key` becomes `key`, which is what individual targets and percentage rollouts bucket on, and attributes pass through for rules to match. A missing targeting key becomes `"anonymous"`. Attribute types are strict, as in every furagu SDK: `"30"` does not match `30`, and `True` does not match `1`.

## Deployment

### Threads and forking

The provider runs four small daemon threads: poll, stream, refresh and event reporting. Threads do not survive `fork()`, so create and install the provider after the fork.

- gunicorn: install in a `post_fork` hook, or just skip `--preload` so each worker imports the app itself.
- uvicorn with `--workers N`: each worker imports the app, so lifespan-based installation already happens after the fork.
- Celery: install from the `worker_process_init` signal.

If you forget, the provider has a safety net: an `os.register_at_fork` hook restarts its threads in the child. Pending usage counts are dropped in that case, the ruleset snapshot is kept.

### Shutdown

`api.shutdown()` closes the stream, stops polling and flushes pending counts, all within 2 seconds. The provider also registers an `atexit` hook so a plain script flushes on exit without being asked.

### Proxies

The default transport honours `HTTPS_PROXY`, `HTTP_PROXY` and `NO_PROXY`. The WebSocket goes through the `websockets` library's own proxy support.

## Performance

An evaluation reads one reference to an immutable ruleset snapshot and walks it in memory. It never waits for the network or for a background refresh.

The one cost worth knowing about is parsing a newly downloaded ruleset. CPython's JSON decoder holds the GIL while it builds objects, so request threads pause for that parse once per publish per process. That is about 2 ms for a typical ruleset and up to about 25 ms at the 4 MiB limit. `pip install furagu[orjson]` roughly halves it, and free-threaded Python builds have no pause at all.

## Logging

Everything logs under the `furagu` logger. The SDK key is never logged.

## Development

```bash
uv sync --group dev
uv run ruff check . && uv run ruff format --check .
uv run mypy
uv run pytest --cov
```

See [CONTRIBUTING.md](CONTRIBUTING.md). Bugs and feature requests go to [GitHub issues](https://github.com/furagu-dev/python-server-sdk/issues); for security reports see [SECURITY.md](SECURITY.md).

## License

[MIT](LICENSE) © 2026 furagu
