Python SDK for GreenFlags feature flags: snapshot + cache reads, polling, geofence evaluation. Zero dependencies.
Project description
greenflags
Official Python SDK for consuming GreenFlags feature flags from any Python service: Django, FastAPI, Flask, Celery workers, scripts, or anything else that runs Python 3.9+.
Zero dependencies — standard library only. Built to minimize billable requests: one network call fetches the whole environment; every read after that is served from memory. Thread-safe by design for multi-worker servers.
Status:
0.1.0, published on PyPI. Full changelog inCHANGELOG.md.
pip install greenflags
Table of Contents
- Why it exists
- Features
- Requirements
- Quick Start
- Usage Guide
- API Reference
- Geofence
- Types
- Error Handling
- Billing Model
- Handling the
api_token - Framework Recipes
- Development
- Versioning
Why it exists
GreenFlags exposes a read endpoint (GET /v1/flags) where every 2xx response counts as a billable read. A naive integration that fetches a flag on every request handler, or on every conditional check, can generate thousands of unnecessary requests and burn through your quota for no reason.
greenflags solves this with a snapshot + cache model:
- One call (
refresh()) fetches every flag in theproject + environmenttied to your API token. - That response is stored in memory.
- Every read after that (
get_flag,is_enabled,get_all_flags,get_snapshot) is local — zero additional requests.
There is intentionally no method to fetch a single flag over the network — that would break the billing model.
Features
- ✅ Zero dependencies —
urllibfrom the standard library, nothing else. - ✅ Snapshot + in-memory cache — billing-safe by design.
- ✅ Thread-safe — one client instance can be shared across WSGI/ASGI workers and threads.
- ✅ Opt-in polling (
start_polling) on a daemon thread — you decide if and how often it refreshes. - ✅ Fail-open — if the network fails, your app keeps working with the last good snapshot (or the
defaultyou set). - ✅ Client-side geofence evaluation — the end-user's location never leaves your process.
- ✅ Fully typed (
py.typed) — mypy/pyright friendly. - ✅ Injectable HTTP layer — trivial to mock in tests, or to swap for
requests/httpxif you prefer.
Requirements
- Python 3.9+.
- A GreenFlags API token, generated from the dashboard for a specific
project + environment. The token determines which flags the SDK sees — there's no separateproject/environmentto pass. See the API docs for the full contract.
Quick Start
from greenflags import GreenFlagsClient
flags = GreenFlagsClient(
url="https://app.greenflags.dev",
api_token="gf_your_token_here",
)
flags.refresh() # 1 billable request — fetches the whole environment
if flags.is_enabled("new-checkout"):
...
Usage Guide
from greenflags import GreenFlagsClient
flags = GreenFlagsClient(url="https://app.greenflags.dev", api_token="gf_...")
# 1. Fetch the initial snapshot (required before reading real flag values)
flags.refresh()
# 2. Read flags — always from memory, never hits the network
enabled = flags.is_enabled("my-feature") # bool sugar
theme = flags.get_flag("theme", default="light") # str
limit = flags.get_flag("rate-limit", default=100) # number
config = flags.get_flag("config", default={}) # json/dict
# 3. List everything available
all_flags = flags.get_all_flags() # list[Flag]
snapshot = flags.get_snapshot() # dict[str, Flag]
# 4. Subscribe to updates (fires on every successful refresh())
unsubscribe = flags.subscribe(lambda snapshot: print("flags updated"))
# 5. Opt-in polling — without this, the SDK NEVER fetches data on its own
flags.start_polling(60) # seconds; every tick = 1 billable request
# 6. Stop polling (the in-memory snapshot is preserved)
flags.stop_polling()
unsubscribe()
Ground rules
get_flag/is_enablednever raise for missing flags —get_flagreturns yourdefaultandis_enabledreturnsFalseuntil data arrives or when the key doesn't exist.refresh()can raiseGreenFlagsError(network error, invalid token, quota exceeded) — wrap it intry/exceptif you want to log failures. The previous snapshot is kept either way.- Create one client per process and share it — don't build a client (or call
refresh()) per request. Refresh once at startup and usestart_pollingonly if you need near-live data. - Polling runs on a daemon thread: it never blocks interpreter shutdown, and a failed tick doesn't break the next one.
API Reference
GreenFlagsClient(...)
GreenFlagsClient(
url: str, # API base URL, trailing slash optional
api_token: str, # token for the environment you're consuming
coordinates: Coordinates | None = None, # optional — enables geofence evaluation
http_get = ..., # optional — inject the HTTP layer: (url, headers) -> (status, body)
)
Methods
| Method | Signature | Description |
|---|---|---|
refresh |
() -> None |
1 request to GET /v1/flags. Replaces the snapshot and notifies subscribers. Raises GreenFlagsError on network/API error. |
get_snapshot |
() -> dict[str, Flag] |
Copy of the current snapshot, keyed by flag key, geofence-evaluated (see Geofence). |
get_all_flags |
() -> list[Flag] |
Every flag in the current snapshot, geofence-evaluated. |
get_flag |
(key, default=None) -> FlagValue |
Evaluated value of one flag. Fail-open: returns default if missing. |
is_enabled |
(key) -> bool |
True only when the flag exists and currently evaluates to True. |
subscribe |
(listener) -> unsubscribe |
listener(snapshot) runs after every successful refresh(). Returns an unsubscribe callable. |
start_polling |
(interval_seconds) -> None |
Automatic refresh() on a daemon thread. Opt-in — no default. Fail-open. |
stop_polling |
() -> None |
Stops polling. The in-memory snapshot is preserved. |
set_coordinates |
(Coordinates | None) -> None |
Sets or clears the end-user location used for geofence evaluation. No network request. |
Geofence
Some flags can carry an optional geofence — a latitude/longitude/radius target configured in the dashboard. When the SDK has coordinates (constructor or set_coordinates), it evaluates each geofenced flag locally — coordinates are never sent to the server:
- Inside the radius (on-edge counts as inside): the flag's normal value is returned.
- Outside the radius: the flag returns its off value —
Falseforbooleanflags,Noneforstring/number/jsonflags. - No coordinates supplied, or no geofence on the flag: the flag's normal value is returned, unaffected.
Fail-open, by design: a geofence is not a security boundary — any caller that omits coordinates sees the flag's normal value regardless of location.
from greenflags import Coordinates
flags.set_coordinates(Coordinates(latitude=19.4326, longitude=-99.1332))
flags.is_enabled("store-promo") # evaluated against the geofence, if any
flags.set_coordinates(None) # back to "ignore geofence" for every flag
Types
FlagType = Literal["boolean", "string", "number", "json"]
FlagValue = bool | str | int | float | dict | None # None: geofenced-off for non-boolean
@dataclass(frozen=True)
class Coordinates:
latitude: float
longitude: float
@dataclass(frozen=True)
class Flag:
key: str
type: FlagType
value: FlagValue
geofence: Geofence | None
class GreenFlagsError(Exception):
code: str # API error code, or NETWORK_ERROR / PARSE_ERROR
message: str
status: int # HTTP status; 0 for network failures
These types mirror the backend contract (GET /v1/flags) exactly.
Error Handling
from greenflags import GreenFlagsError
try:
flags.refresh()
except GreenFlagsError as err:
print(err.code, err.status, err.message)
Codes that GET /v1/flags can actually return:
err.code |
err.status |
Cause |
|---|---|---|
INVALID_TOKEN |
401 | Token missing, invalid, or revoked |
QUOTA_EXCEEDED |
429 | Monthly read quota exhausted |
BILLING_NO_SUBSCRIPTION |
429 | The workspace has no active subscription |
BILLING_CANCELED |
429 | Subscription canceled |
BILLING_PAST_DUE |
429 | Payment past due |
BILLING_TRIAL_EXPIRED |
429 | Trial expired |
BILLING_LIMIT_REACHED |
429 | Billing limit reached |
NETWORK_ERROR |
0 | The request failed before a response was received |
PARSE_ERROR |
response status | Body wasn't valid JSON, or was missing data.flags |
REQUEST_ERROR |
response status | Non-2xx response with no parseable error code |
get_flag(), is_enabled(), get_all_flags() and get_snapshot() never raise any of these — they're always local reads.
Billing Model
Every call to refresh() (manual or from start_polling) is exactly one HTTP request, and every 2xx response counts as one billable read. All flag reads are 100% in memory — zero requests, no matter how many times you call them.
Recommendation: refresh() once at process startup, then start_polling(60) or more if you need periodic updates. With N workers each running its own poller, remember you pay N reads per tick — prefer fewer, longer-lived clients.
Handling the api_token
The token is a secret — treat it like a database password:
- Never hardcode it. Read it from the environment:
os.environ["GREENFLAGS_API_TOKEN"]. - Keep it out of version control (
.env+.gitignore; ship a.env.examplewithout values). - Python services run server-side, so the mobile-extraction concern doesn't apply — but per-token monthly quotas (dashboard → API Tokens) are still a good blast-radius cap for leaked CI logs or misconfigured staging.
import os
from greenflags import GreenFlagsClient
flags = GreenFlagsClient(
url=os.environ.get("GREENFLAGS_URL", "https://app.greenflags.dev"),
api_token=os.environ["GREENFLAGS_API_TOKEN"],
)
Framework Recipes
FastAPI — refresh at startup, poll in the background, read anywhere:
from contextlib import asynccontextmanager
from fastapi import FastAPI
@asynccontextmanager
async def lifespan(app: FastAPI):
flags.refresh()
flags.start_polling(60)
yield
flags.stop_polling()
app = FastAPI(lifespan=lifespan)
@app.get("/checkout")
def checkout():
if flags.is_enabled("new-checkout"):
...
Django — call flags.refresh() in AppConfig.ready() and share the module-level client. Celery — same client per worker process; refresh in worker_process_init.
Development
cd sdks/python
PYTHONPATH=src python3 -m pytest # 12 tests, mocked HTTP — no real network
Tests cover envelope parsing, error mapping, geofence evaluation, fail-open behavior, subscriptions, and polling.
Versioning
Semver, while in 0.x: MINOR can include API changes (no stability guarantee yet), PATCH are fixes. Version-by-version detail in CHANGELOG.md.
Related
- API reference
@greenflags/client— JavaScript/TypeScript SDK@greenflags/react— React hooksgreenflags— Dart/Flutter SDK@greenflags/mcp— MCP server for AI agents
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file greenflags-0.1.0.tar.gz.
File metadata
- Download URL: greenflags-0.1.0.tar.gz
- Upload date:
- Size: 11.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.1
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
5ecaaa30e6fc317169be47cdd5a3f188f2faa00a4d9f5ce5dbbd13c1a440e093
|
|
| MD5 |
964108690c1df9eaf24f5b71232a9053
|
|
| BLAKE2b-256 |
a287547ecdb6494b7018d837a14cb872e4a043c2828eac1cfc0462168acd0729
|
File details
Details for the file greenflags-0.1.0-py3-none-any.whl.
File metadata
- Download URL: greenflags-0.1.0-py3-none-any.whl
- Upload date:
- Size: 12.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.1
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
99c8b227f9ef800fa6997d4892e2d68b6ca86b7bf865bc9269b12af8231634f2
|
|
| MD5 |
471eb36dda25e0cf40c8fee34c9f0404
|
|
| BLAKE2b-256 |
b50cbe1c817dbc612bb01f0343feb4d301f5a95e80d8fde46977fad61e70f433
|