Metadata-Version: 2.4
Name: greenflags
Version: 0.1.0
Summary: Python SDK for GreenFlags feature flags: snapshot + cache reads, polling, geofence evaluation. Zero dependencies.
Project-URL: Homepage, https://greenflags.dev
Project-URL: Documentation, https://greenflags.dev/docs/
Project-URL: Repository, https://github.com/greenflags-dev/greenflags-python
Project-URL: Changelog, https://github.com/greenflags-dev/greenflags-python/blob/main/CHANGELOG.md
License: MIT License
        
        Copyright (c) 2026 GreenFlags
        
        Permission is hereby granted, free of charge, to any person obtaining a copy
        of this software and associated documentation files (the "Software"), to deal
        in the Software without restriction, including without limitation the rights
        to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
        copies of the Software, and to permit persons to whom the Software is
        furnished to do so, subject to the following conditions:
        
        The above copyright notice and this permission notice shall be included in all
        copies or substantial portions of the Software.
        
        THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
        IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
        FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
        AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
        LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
        OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
        SOFTWARE.
License-File: LICENSE
Keywords: feature-flags,feature-toggles,greenflags
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Topic :: Software Development :: Libraries
Classifier: Typing :: Typed
Requires-Python: >=3.9
Description-Content-Type: text/markdown

# 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`](./CHANGELOG.md).

```sh
pip install greenflags
```

---

## Table of Contents

- [Why it exists](#why-it-exists)
- [Features](#features)
- [Requirements](#requirements)
- [Quick Start](#quick-start)
- [Usage Guide](#usage-guide)
- [API Reference](#api-reference)
- [Geofence](#geofence)
- [Types](#types)
- [Error Handling](#error-handling)
- [Billing Model](#billing-model)
- [Handling the `api_token`](#handling-the-api_token)
- [Framework Recipes](#framework-recipes)
- [Development](#development)
- [Versioning](#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:

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](https://app.greenflags.dev) 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](https://greenflags.dev/docs/) for the full contract.

## Quick Start

```python
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

```python
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 flags** — `get_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(...)`

```python
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](#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.

```python
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

```python
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

```python
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.

```python
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:

```python
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

```sh
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`](./CHANGELOG.md).

## Related

- [API reference](https://greenflags.dev/docs/)
- [`@greenflags/client`](https://www.npmjs.com/package/@greenflags/client) — JavaScript/TypeScript SDK
- [`@greenflags/react`](https://www.npmjs.com/package/@greenflags/react) — React hooks
- [`greenflags`](https://pub.dev/packages/greenflags) — Dart/Flutter SDK
- [`@greenflags/mcp`](https://www.npmjs.com/package/@greenflags/mcp) — MCP server for AI agents
