Skip to main content

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 in CHANGELOG.md.

pip install greenflags

Table of Contents


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:

  1. One call (refresh()) fetches every flag in the project + environment tied to your API token.
  2. That response is stored in memory.
  3. 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 — urllib from 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 default you 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/httpx if 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 separate project/environment to 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_enabled never raise for missing flagsget_flag returns your default and is_enabled returns False until data arrives or when the key doesn't exist.
  • refresh() can raise GreenFlagsError (network error, invalid token, quota exceeded) — wrap it in try/except if 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 use start_polling only 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 — False for boolean flags, None for string/number/json flags.
  • 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.example without 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

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

greenflags-0.1.0.tar.gz (11.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

greenflags-0.1.0-py3-none-any.whl (12.5 kB view details)

Uploaded Python 3

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

Hashes for greenflags-0.1.0.tar.gz
Algorithm Hash digest
SHA256 5ecaaa30e6fc317169be47cdd5a3f188f2faa00a4d9f5ce5dbbd13c1a440e093
MD5 964108690c1df9eaf24f5b71232a9053
BLAKE2b-256 a287547ecdb6494b7018d837a14cb872e4a043c2828eac1cfc0462168acd0729

See more details on using hashes here.

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

Hashes for greenflags-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 99c8b227f9ef800fa6997d4892e2d68b6ca86b7bf865bc9269b12af8231634f2
MD5 471eb36dda25e0cf40c8fee34c9f0404
BLAKE2b-256 b50cbe1c817dbc612bb01f0343feb4d301f5a95e80d8fde46977fad61e70f433

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page