greenflags 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,5 @@
1
+ __pycache__/
2
+ *.egg-info/
3
+ dist/
4
+ .venv/
5
+ .pytest_cache/
@@ -0,0 +1,13 @@
1
+ # Changelog
2
+
3
+ Format based on [Keep a Changelog](https://keepachangelog.com/). Versioning: [SemVer](https://semver.org/) (while in `0.x`, a MINOR release may include breaking changes; strict semver applies from `1.0.0` onward).
4
+
5
+ ## [0.1.0] - 2026-07-10
6
+
7
+ ### Added
8
+ - Initial release, mirroring `@greenflags/client` 0.2.x semantics in pure Python (3.9+, zero dependencies, `py.typed`).
9
+ - `GreenFlagsClient` with `refresh`, `get_snapshot`, `get_all_flags`, `get_flag` (with `default`), `is_enabled`, `subscribe`, `start_polling`/`stop_polling` (daemon thread, fail-open), `set_coordinates`.
10
+ - Thread-safe snapshot access (RLock) for multi-worker servers.
11
+ - Client-side geofence evaluation (haversine): outside the radius a `boolean` flag evaluates to `False`, other types to `None`. Every read path goes through evaluation — the raw snapshot is never exposed.
12
+ - Typed `GreenFlagsError` with API error `code`, `message` and HTTP `status` (`NETWORK_ERROR`/`PARSE_ERROR` client-side).
13
+ - Injectable `http_get` transport (default: stdlib `urllib`), auth via `Authorization: Bearer` against `GET /v1/flags`.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 GreenFlags
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,324 @@
1
+ Metadata-Version: 2.4
2
+ Name: greenflags
3
+ Version: 0.1.0
4
+ Summary: Python SDK for GreenFlags feature flags: snapshot + cache reads, polling, geofence evaluation. Zero dependencies.
5
+ Project-URL: Homepage, https://greenflags.dev
6
+ Project-URL: Documentation, https://greenflags.dev/docs/
7
+ Project-URL: Repository, https://github.com/greenflags-dev/greenflags-python
8
+ Project-URL: Changelog, https://github.com/greenflags-dev/greenflags-python/blob/main/CHANGELOG.md
9
+ License: MIT License
10
+
11
+ Copyright (c) 2026 GreenFlags
12
+
13
+ Permission is hereby granted, free of charge, to any person obtaining a copy
14
+ of this software and associated documentation files (the "Software"), to deal
15
+ in the Software without restriction, including without limitation the rights
16
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17
+ copies of the Software, and to permit persons to whom the Software is
18
+ furnished to do so, subject to the following conditions:
19
+
20
+ The above copyright notice and this permission notice shall be included in all
21
+ copies or substantial portions of the Software.
22
+
23
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29
+ SOFTWARE.
30
+ License-File: LICENSE
31
+ Keywords: feature-flags,feature-toggles,greenflags
32
+ Classifier: Development Status :: 4 - Beta
33
+ Classifier: Intended Audience :: Developers
34
+ Classifier: License :: OSI Approved :: MIT License
35
+ Classifier: Programming Language :: Python :: 3
36
+ Classifier: Programming Language :: Python :: 3 :: Only
37
+ Classifier: Topic :: Software Development :: Libraries
38
+ Classifier: Typing :: Typed
39
+ Requires-Python: >=3.9
40
+ Description-Content-Type: text/markdown
41
+
42
+ # greenflags
43
+
44
+ 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+.
45
+
46
+ **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.
47
+
48
+ > **Status:** `0.1.0`, published on PyPI. Full changelog in [`CHANGELOG.md`](./CHANGELOG.md).
49
+
50
+ ```sh
51
+ pip install greenflags
52
+ ```
53
+
54
+ ---
55
+
56
+ ## Table of Contents
57
+
58
+ - [Why it exists](#why-it-exists)
59
+ - [Features](#features)
60
+ - [Requirements](#requirements)
61
+ - [Quick Start](#quick-start)
62
+ - [Usage Guide](#usage-guide)
63
+ - [API Reference](#api-reference)
64
+ - [Geofence](#geofence)
65
+ - [Types](#types)
66
+ - [Error Handling](#error-handling)
67
+ - [Billing Model](#billing-model)
68
+ - [Handling the `api_token`](#handling-the-api_token)
69
+ - [Framework Recipes](#framework-recipes)
70
+ - [Development](#development)
71
+ - [Versioning](#versioning)
72
+
73
+ ---
74
+
75
+ ## Why it exists
76
+
77
+ 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.
78
+
79
+ `greenflags` solves this with a **snapshot + cache** model:
80
+
81
+ 1. One call (`refresh()`) fetches **every** flag in the `project + environment` tied to your API token.
82
+ 2. That response is stored in memory.
83
+ 3. Every read after that (`get_flag`, `is_enabled`, `get_all_flags`, `get_snapshot`) is local — **zero additional requests**.
84
+
85
+ There is intentionally no method to fetch a single flag over the network — that would break the billing model.
86
+
87
+ ## Features
88
+
89
+ - ✅ Zero dependencies — `urllib` from the standard library, nothing else.
90
+ - ✅ Snapshot + in-memory cache — billing-safe by design.
91
+ - ✅ Thread-safe — one client instance can be shared across WSGI/ASGI workers and threads.
92
+ - ✅ Opt-in polling (`start_polling`) on a daemon thread — you decide if and how often it refreshes.
93
+ - ✅ Fail-open — if the network fails, your app keeps working with the last good snapshot (or the `default` you set).
94
+ - ✅ Client-side geofence evaluation — the end-user's location never leaves your process.
95
+ - ✅ Fully typed (`py.typed`) — mypy/pyright friendly.
96
+ - ✅ Injectable HTTP layer — trivial to mock in tests, or to swap for `requests`/`httpx` if you prefer.
97
+
98
+ ## Requirements
99
+
100
+ - **Python 3.9+**.
101
+ - 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.
102
+
103
+ ## Quick Start
104
+
105
+ ```python
106
+ from greenflags import GreenFlagsClient
107
+
108
+ flags = GreenFlagsClient(
109
+ url="https://app.greenflags.dev",
110
+ api_token="gf_your_token_here",
111
+ )
112
+
113
+ flags.refresh() # 1 billable request — fetches the whole environment
114
+
115
+ if flags.is_enabled("new-checkout"):
116
+ ...
117
+ ```
118
+
119
+ ## Usage Guide
120
+
121
+ ```python
122
+ from greenflags import GreenFlagsClient
123
+
124
+ flags = GreenFlagsClient(url="https://app.greenflags.dev", api_token="gf_...")
125
+
126
+ # 1. Fetch the initial snapshot (required before reading real flag values)
127
+ flags.refresh()
128
+
129
+ # 2. Read flags — always from memory, never hits the network
130
+ enabled = flags.is_enabled("my-feature") # bool sugar
131
+ theme = flags.get_flag("theme", default="light") # str
132
+ limit = flags.get_flag("rate-limit", default=100) # number
133
+ config = flags.get_flag("config", default={}) # json/dict
134
+
135
+ # 3. List everything available
136
+ all_flags = flags.get_all_flags() # list[Flag]
137
+ snapshot = flags.get_snapshot() # dict[str, Flag]
138
+
139
+ # 4. Subscribe to updates (fires on every successful refresh())
140
+ unsubscribe = flags.subscribe(lambda snapshot: print("flags updated"))
141
+
142
+ # 5. Opt-in polling — without this, the SDK NEVER fetches data on its own
143
+ flags.start_polling(60) # seconds; every tick = 1 billable request
144
+
145
+ # 6. Stop polling (the in-memory snapshot is preserved)
146
+ flags.stop_polling()
147
+ unsubscribe()
148
+ ```
149
+
150
+ ### Ground rules
151
+
152
+ - `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.
153
+ - `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.
154
+ - 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.
155
+ - Polling runs on a daemon thread: it never blocks interpreter shutdown, and a failed tick doesn't break the next one.
156
+
157
+ ## API Reference
158
+
159
+ ### `GreenFlagsClient(...)`
160
+
161
+ ```python
162
+ GreenFlagsClient(
163
+ url: str, # API base URL, trailing slash optional
164
+ api_token: str, # token for the environment you're consuming
165
+ coordinates: Coordinates | None = None, # optional — enables geofence evaluation
166
+ http_get = ..., # optional — inject the HTTP layer: (url, headers) -> (status, body)
167
+ )
168
+ ```
169
+
170
+ ### Methods
171
+
172
+ | Method | Signature | Description |
173
+ |---|---|---|
174
+ | `refresh` | `() -> None` | 1 request to `GET /v1/flags`. Replaces the snapshot and notifies subscribers. Raises `GreenFlagsError` on network/API error. |
175
+ | `get_snapshot` | `() -> dict[str, Flag]` | Copy of the current snapshot, keyed by flag key, geofence-evaluated (see [Geofence](#geofence)). |
176
+ | `get_all_flags` | `() -> list[Flag]` | Every flag in the current snapshot, geofence-evaluated. |
177
+ | `get_flag` | `(key, default=None) -> FlagValue` | Evaluated value of one flag. Fail-open: returns `default` if missing. |
178
+ | `is_enabled` | `(key) -> bool` | `True` only when the flag exists and currently evaluates to `True`. |
179
+ | `subscribe` | `(listener) -> unsubscribe` | `listener(snapshot)` runs after every successful `refresh()`. Returns an unsubscribe callable. |
180
+ | `start_polling` | `(interval_seconds) -> None` | Automatic `refresh()` on a daemon thread. Opt-in — no default. Fail-open. |
181
+ | `stop_polling` | `() -> None` | Stops polling. The in-memory snapshot is preserved. |
182
+ | `set_coordinates` | `(Coordinates \| None) -> None` | Sets or clears the end-user location used for geofence evaluation. No network request. |
183
+
184
+ ## Geofence
185
+
186
+ 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:
187
+
188
+ - **Inside the radius** (on-edge counts as inside): the flag's normal value is returned.
189
+ - **Outside the radius**: the flag returns its off value — `False` for `boolean` flags, `None` for `string`/`number`/`json` flags.
190
+ - **No coordinates supplied, or no geofence on the flag**: the flag's normal value is returned, unaffected.
191
+
192
+ > **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.
193
+
194
+ ```python
195
+ from greenflags import Coordinates
196
+
197
+ flags.set_coordinates(Coordinates(latitude=19.4326, longitude=-99.1332))
198
+ flags.is_enabled("store-promo") # evaluated against the geofence, if any
199
+ flags.set_coordinates(None) # back to "ignore geofence" for every flag
200
+ ```
201
+
202
+ ## Types
203
+
204
+ ```python
205
+ FlagType = Literal["boolean", "string", "number", "json"]
206
+ FlagValue = bool | str | int | float | dict | None # None: geofenced-off for non-boolean
207
+
208
+ @dataclass(frozen=True)
209
+ class Coordinates:
210
+ latitude: float
211
+ longitude: float
212
+
213
+ @dataclass(frozen=True)
214
+ class Flag:
215
+ key: str
216
+ type: FlagType
217
+ value: FlagValue
218
+ geofence: Geofence | None
219
+
220
+ class GreenFlagsError(Exception):
221
+ code: str # API error code, or NETWORK_ERROR / PARSE_ERROR
222
+ message: str
223
+ status: int # HTTP status; 0 for network failures
224
+ ```
225
+
226
+ These types mirror the backend contract (`GET /v1/flags`) exactly.
227
+
228
+ ## Error Handling
229
+
230
+ ```python
231
+ from greenflags import GreenFlagsError
232
+
233
+ try:
234
+ flags.refresh()
235
+ except GreenFlagsError as err:
236
+ print(err.code, err.status, err.message)
237
+ ```
238
+
239
+ Codes that `GET /v1/flags` can actually return:
240
+
241
+ | `err.code` | `err.status` | Cause |
242
+ |---|---|---|
243
+ | `INVALID_TOKEN` | 401 | Token missing, invalid, or revoked |
244
+ | `QUOTA_EXCEEDED` | 429 | Monthly read quota exhausted |
245
+ | `BILLING_NO_SUBSCRIPTION` | 429 | The workspace has no active subscription |
246
+ | `BILLING_CANCELED` | 429 | Subscription canceled |
247
+ | `BILLING_PAST_DUE` | 429 | Payment past due |
248
+ | `BILLING_TRIAL_EXPIRED` | 429 | Trial expired |
249
+ | `BILLING_LIMIT_REACHED` | 429 | Billing limit reached |
250
+ | `NETWORK_ERROR` | 0 | The request failed before a response was received |
251
+ | `PARSE_ERROR` | response status | Body wasn't valid JSON, or was missing `data.flags` |
252
+ | `REQUEST_ERROR` | response status | Non-2xx response with no parseable error code |
253
+
254
+ `get_flag()`, `is_enabled()`, `get_all_flags()` and `get_snapshot()` **never** raise any of these — they're always local reads.
255
+
256
+ ## Billing Model
257
+
258
+ 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.
259
+
260
+ 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.
261
+
262
+ ## Handling the `api_token`
263
+
264
+ The token is a secret — treat it like a database password:
265
+
266
+ - **Never hardcode it.** Read it from the environment: `os.environ["GREENFLAGS_API_TOKEN"]`.
267
+ - Keep it out of version control (`.env` + `.gitignore`; ship a `.env.example` without values).
268
+ - 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.
269
+
270
+ ```python
271
+ import os
272
+ from greenflags import GreenFlagsClient
273
+
274
+ flags = GreenFlagsClient(
275
+ url=os.environ.get("GREENFLAGS_URL", "https://app.greenflags.dev"),
276
+ api_token=os.environ["GREENFLAGS_API_TOKEN"],
277
+ )
278
+ ```
279
+
280
+ ## Framework Recipes
281
+
282
+ **FastAPI** — refresh at startup, poll in the background, read anywhere:
283
+
284
+ ```python
285
+ from contextlib import asynccontextmanager
286
+ from fastapi import FastAPI
287
+
288
+ @asynccontextmanager
289
+ async def lifespan(app: FastAPI):
290
+ flags.refresh()
291
+ flags.start_polling(60)
292
+ yield
293
+ flags.stop_polling()
294
+
295
+ app = FastAPI(lifespan=lifespan)
296
+
297
+ @app.get("/checkout")
298
+ def checkout():
299
+ if flags.is_enabled("new-checkout"):
300
+ ...
301
+ ```
302
+
303
+ **Django** — call `flags.refresh()` in `AppConfig.ready()` and share the module-level client. **Celery** — same client per worker process; refresh in `worker_process_init`.
304
+
305
+ ## Development
306
+
307
+ ```sh
308
+ cd sdks/python
309
+ PYTHONPATH=src python3 -m pytest # 12 tests, mocked HTTP — no real network
310
+ ```
311
+
312
+ Tests cover envelope parsing, error mapping, geofence evaluation, fail-open behavior, subscriptions, and polling.
313
+
314
+ ## Versioning
315
+
316
+ 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).
317
+
318
+ ## Related
319
+
320
+ - [API reference](https://greenflags.dev/docs/)
321
+ - [`@greenflags/client`](https://www.npmjs.com/package/@greenflags/client) — JavaScript/TypeScript SDK
322
+ - [`@greenflags/react`](https://www.npmjs.com/package/@greenflags/react) — React hooks
323
+ - [`greenflags`](https://pub.dev/packages/greenflags) — Dart/Flutter SDK
324
+ - [`@greenflags/mcp`](https://www.npmjs.com/package/@greenflags/mcp) — MCP server for AI agents
@@ -0,0 +1,283 @@
1
+ # greenflags
2
+
3
+ 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+.
4
+
5
+ **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.
6
+
7
+ > **Status:** `0.1.0`, published on PyPI. Full changelog in [`CHANGELOG.md`](./CHANGELOG.md).
8
+
9
+ ```sh
10
+ pip install greenflags
11
+ ```
12
+
13
+ ---
14
+
15
+ ## Table of Contents
16
+
17
+ - [Why it exists](#why-it-exists)
18
+ - [Features](#features)
19
+ - [Requirements](#requirements)
20
+ - [Quick Start](#quick-start)
21
+ - [Usage Guide](#usage-guide)
22
+ - [API Reference](#api-reference)
23
+ - [Geofence](#geofence)
24
+ - [Types](#types)
25
+ - [Error Handling](#error-handling)
26
+ - [Billing Model](#billing-model)
27
+ - [Handling the `api_token`](#handling-the-api_token)
28
+ - [Framework Recipes](#framework-recipes)
29
+ - [Development](#development)
30
+ - [Versioning](#versioning)
31
+
32
+ ---
33
+
34
+ ## Why it exists
35
+
36
+ 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.
37
+
38
+ `greenflags` solves this with a **snapshot + cache** model:
39
+
40
+ 1. One call (`refresh()`) fetches **every** flag in the `project + environment` tied to your API token.
41
+ 2. That response is stored in memory.
42
+ 3. Every read after that (`get_flag`, `is_enabled`, `get_all_flags`, `get_snapshot`) is local — **zero additional requests**.
43
+
44
+ There is intentionally no method to fetch a single flag over the network — that would break the billing model.
45
+
46
+ ## Features
47
+
48
+ - ✅ Zero dependencies — `urllib` from the standard library, nothing else.
49
+ - ✅ Snapshot + in-memory cache — billing-safe by design.
50
+ - ✅ Thread-safe — one client instance can be shared across WSGI/ASGI workers and threads.
51
+ - ✅ Opt-in polling (`start_polling`) on a daemon thread — you decide if and how often it refreshes.
52
+ - ✅ Fail-open — if the network fails, your app keeps working with the last good snapshot (or the `default` you set).
53
+ - ✅ Client-side geofence evaluation — the end-user's location never leaves your process.
54
+ - ✅ Fully typed (`py.typed`) — mypy/pyright friendly.
55
+ - ✅ Injectable HTTP layer — trivial to mock in tests, or to swap for `requests`/`httpx` if you prefer.
56
+
57
+ ## Requirements
58
+
59
+ - **Python 3.9+**.
60
+ - 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.
61
+
62
+ ## Quick Start
63
+
64
+ ```python
65
+ from greenflags import GreenFlagsClient
66
+
67
+ flags = GreenFlagsClient(
68
+ url="https://app.greenflags.dev",
69
+ api_token="gf_your_token_here",
70
+ )
71
+
72
+ flags.refresh() # 1 billable request — fetches the whole environment
73
+
74
+ if flags.is_enabled("new-checkout"):
75
+ ...
76
+ ```
77
+
78
+ ## Usage Guide
79
+
80
+ ```python
81
+ from greenflags import GreenFlagsClient
82
+
83
+ flags = GreenFlagsClient(url="https://app.greenflags.dev", api_token="gf_...")
84
+
85
+ # 1. Fetch the initial snapshot (required before reading real flag values)
86
+ flags.refresh()
87
+
88
+ # 2. Read flags — always from memory, never hits the network
89
+ enabled = flags.is_enabled("my-feature") # bool sugar
90
+ theme = flags.get_flag("theme", default="light") # str
91
+ limit = flags.get_flag("rate-limit", default=100) # number
92
+ config = flags.get_flag("config", default={}) # json/dict
93
+
94
+ # 3. List everything available
95
+ all_flags = flags.get_all_flags() # list[Flag]
96
+ snapshot = flags.get_snapshot() # dict[str, Flag]
97
+
98
+ # 4. Subscribe to updates (fires on every successful refresh())
99
+ unsubscribe = flags.subscribe(lambda snapshot: print("flags updated"))
100
+
101
+ # 5. Opt-in polling — without this, the SDK NEVER fetches data on its own
102
+ flags.start_polling(60) # seconds; every tick = 1 billable request
103
+
104
+ # 6. Stop polling (the in-memory snapshot is preserved)
105
+ flags.stop_polling()
106
+ unsubscribe()
107
+ ```
108
+
109
+ ### Ground rules
110
+
111
+ - `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.
112
+ - `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.
113
+ - 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.
114
+ - Polling runs on a daemon thread: it never blocks interpreter shutdown, and a failed tick doesn't break the next one.
115
+
116
+ ## API Reference
117
+
118
+ ### `GreenFlagsClient(...)`
119
+
120
+ ```python
121
+ GreenFlagsClient(
122
+ url: str, # API base URL, trailing slash optional
123
+ api_token: str, # token for the environment you're consuming
124
+ coordinates: Coordinates | None = None, # optional — enables geofence evaluation
125
+ http_get = ..., # optional — inject the HTTP layer: (url, headers) -> (status, body)
126
+ )
127
+ ```
128
+
129
+ ### Methods
130
+
131
+ | Method | Signature | Description |
132
+ |---|---|---|
133
+ | `refresh` | `() -> None` | 1 request to `GET /v1/flags`. Replaces the snapshot and notifies subscribers. Raises `GreenFlagsError` on network/API error. |
134
+ | `get_snapshot` | `() -> dict[str, Flag]` | Copy of the current snapshot, keyed by flag key, geofence-evaluated (see [Geofence](#geofence)). |
135
+ | `get_all_flags` | `() -> list[Flag]` | Every flag in the current snapshot, geofence-evaluated. |
136
+ | `get_flag` | `(key, default=None) -> FlagValue` | Evaluated value of one flag. Fail-open: returns `default` if missing. |
137
+ | `is_enabled` | `(key) -> bool` | `True` only when the flag exists and currently evaluates to `True`. |
138
+ | `subscribe` | `(listener) -> unsubscribe` | `listener(snapshot)` runs after every successful `refresh()`. Returns an unsubscribe callable. |
139
+ | `start_polling` | `(interval_seconds) -> None` | Automatic `refresh()` on a daemon thread. Opt-in — no default. Fail-open. |
140
+ | `stop_polling` | `() -> None` | Stops polling. The in-memory snapshot is preserved. |
141
+ | `set_coordinates` | `(Coordinates \| None) -> None` | Sets or clears the end-user location used for geofence evaluation. No network request. |
142
+
143
+ ## Geofence
144
+
145
+ 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:
146
+
147
+ - **Inside the radius** (on-edge counts as inside): the flag's normal value is returned.
148
+ - **Outside the radius**: the flag returns its off value — `False` for `boolean` flags, `None` for `string`/`number`/`json` flags.
149
+ - **No coordinates supplied, or no geofence on the flag**: the flag's normal value is returned, unaffected.
150
+
151
+ > **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.
152
+
153
+ ```python
154
+ from greenflags import Coordinates
155
+
156
+ flags.set_coordinates(Coordinates(latitude=19.4326, longitude=-99.1332))
157
+ flags.is_enabled("store-promo") # evaluated against the geofence, if any
158
+ flags.set_coordinates(None) # back to "ignore geofence" for every flag
159
+ ```
160
+
161
+ ## Types
162
+
163
+ ```python
164
+ FlagType = Literal["boolean", "string", "number", "json"]
165
+ FlagValue = bool | str | int | float | dict | None # None: geofenced-off for non-boolean
166
+
167
+ @dataclass(frozen=True)
168
+ class Coordinates:
169
+ latitude: float
170
+ longitude: float
171
+
172
+ @dataclass(frozen=True)
173
+ class Flag:
174
+ key: str
175
+ type: FlagType
176
+ value: FlagValue
177
+ geofence: Geofence | None
178
+
179
+ class GreenFlagsError(Exception):
180
+ code: str # API error code, or NETWORK_ERROR / PARSE_ERROR
181
+ message: str
182
+ status: int # HTTP status; 0 for network failures
183
+ ```
184
+
185
+ These types mirror the backend contract (`GET /v1/flags`) exactly.
186
+
187
+ ## Error Handling
188
+
189
+ ```python
190
+ from greenflags import GreenFlagsError
191
+
192
+ try:
193
+ flags.refresh()
194
+ except GreenFlagsError as err:
195
+ print(err.code, err.status, err.message)
196
+ ```
197
+
198
+ Codes that `GET /v1/flags` can actually return:
199
+
200
+ | `err.code` | `err.status` | Cause |
201
+ |---|---|---|
202
+ | `INVALID_TOKEN` | 401 | Token missing, invalid, or revoked |
203
+ | `QUOTA_EXCEEDED` | 429 | Monthly read quota exhausted |
204
+ | `BILLING_NO_SUBSCRIPTION` | 429 | The workspace has no active subscription |
205
+ | `BILLING_CANCELED` | 429 | Subscription canceled |
206
+ | `BILLING_PAST_DUE` | 429 | Payment past due |
207
+ | `BILLING_TRIAL_EXPIRED` | 429 | Trial expired |
208
+ | `BILLING_LIMIT_REACHED` | 429 | Billing limit reached |
209
+ | `NETWORK_ERROR` | 0 | The request failed before a response was received |
210
+ | `PARSE_ERROR` | response status | Body wasn't valid JSON, or was missing `data.flags` |
211
+ | `REQUEST_ERROR` | response status | Non-2xx response with no parseable error code |
212
+
213
+ `get_flag()`, `is_enabled()`, `get_all_flags()` and `get_snapshot()` **never** raise any of these — they're always local reads.
214
+
215
+ ## Billing Model
216
+
217
+ 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.
218
+
219
+ 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.
220
+
221
+ ## Handling the `api_token`
222
+
223
+ The token is a secret — treat it like a database password:
224
+
225
+ - **Never hardcode it.** Read it from the environment: `os.environ["GREENFLAGS_API_TOKEN"]`.
226
+ - Keep it out of version control (`.env` + `.gitignore`; ship a `.env.example` without values).
227
+ - 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.
228
+
229
+ ```python
230
+ import os
231
+ from greenflags import GreenFlagsClient
232
+
233
+ flags = GreenFlagsClient(
234
+ url=os.environ.get("GREENFLAGS_URL", "https://app.greenflags.dev"),
235
+ api_token=os.environ["GREENFLAGS_API_TOKEN"],
236
+ )
237
+ ```
238
+
239
+ ## Framework Recipes
240
+
241
+ **FastAPI** — refresh at startup, poll in the background, read anywhere:
242
+
243
+ ```python
244
+ from contextlib import asynccontextmanager
245
+ from fastapi import FastAPI
246
+
247
+ @asynccontextmanager
248
+ async def lifespan(app: FastAPI):
249
+ flags.refresh()
250
+ flags.start_polling(60)
251
+ yield
252
+ flags.stop_polling()
253
+
254
+ app = FastAPI(lifespan=lifespan)
255
+
256
+ @app.get("/checkout")
257
+ def checkout():
258
+ if flags.is_enabled("new-checkout"):
259
+ ...
260
+ ```
261
+
262
+ **Django** — call `flags.refresh()` in `AppConfig.ready()` and share the module-level client. **Celery** — same client per worker process; refresh in `worker_process_init`.
263
+
264
+ ## Development
265
+
266
+ ```sh
267
+ cd sdks/python
268
+ PYTHONPATH=src python3 -m pytest # 12 tests, mocked HTTP — no real network
269
+ ```
270
+
271
+ Tests cover envelope parsing, error mapping, geofence evaluation, fail-open behavior, subscriptions, and polling.
272
+
273
+ ## Versioning
274
+
275
+ 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).
276
+
277
+ ## Related
278
+
279
+ - [API reference](https://greenflags.dev/docs/)
280
+ - [`@greenflags/client`](https://www.npmjs.com/package/@greenflags/client) — JavaScript/TypeScript SDK
281
+ - [`@greenflags/react`](https://www.npmjs.com/package/@greenflags/react) — React hooks
282
+ - [`greenflags`](https://pub.dev/packages/greenflags) — Dart/Flutter SDK
283
+ - [`@greenflags/mcp`](https://www.npmjs.com/package/@greenflags/mcp) — MCP server for AI agents
@@ -0,0 +1,33 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "greenflags"
7
+ version = "0.1.0"
8
+ description = "Python SDK for GreenFlags feature flags: snapshot + cache reads, polling, geofence evaluation. Zero dependencies."
9
+ readme = "README.md"
10
+ license = { file = "LICENSE" }
11
+ requires-python = ">=3.9"
12
+ keywords = ["feature-flags", "feature-toggles", "greenflags"]
13
+ classifiers = [
14
+ "Development Status :: 4 - Beta",
15
+ "Intended Audience :: Developers",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3 :: Only",
19
+ "Topic :: Software Development :: Libraries",
20
+ "Typing :: Typed",
21
+ ]
22
+
23
+ [project.urls]
24
+ Homepage = "https://greenflags.dev"
25
+ Documentation = "https://greenflags.dev/docs/"
26
+ Repository = "https://github.com/greenflags-dev/greenflags-python"
27
+ Changelog = "https://github.com/greenflags-dev/greenflags-python/blob/main/CHANGELOG.md"
28
+
29
+ [tool.hatch.build.targets.wheel]
30
+ packages = ["src/greenflags"]
31
+
32
+ [tool.pytest.ini_options]
33
+ testpaths = ["tests"]
@@ -0,0 +1,19 @@
1
+ """GreenFlags — feature flags evaluated at the edge.
2
+
3
+ See https://greenflags.dev/docs/ for the API reference.
4
+ """
5
+
6
+ from .client import GreenFlagsClient
7
+ from .types import Coordinates, Flag, FlagType, FlagValue, Geofence, GreenFlagsError
8
+
9
+ __all__ = [
10
+ "Coordinates",
11
+ "Flag",
12
+ "FlagType",
13
+ "FlagValue",
14
+ "Geofence",
15
+ "GreenFlagsClient",
16
+ "GreenFlagsError",
17
+ ]
18
+
19
+ __version__ = "0.1.0"
@@ -0,0 +1,158 @@
1
+ """GreenFlags client: snapshot + cache reads over GET /v1/flags.
2
+
3
+ Thread-safe: reads and refreshes may happen from different threads (WSGI
4
+ workers, background pollers). Every read path goes through geofence
5
+ evaluation — the raw cached snapshot is never exposed.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import threading
11
+ from typing import Callable, Dict, List, Optional
12
+
13
+ from .geo import haversine_meters
14
+ from .transport import HttpGet, _default_http_get, request_flags
15
+ from .types import Coordinates, Flag, FlagValue
16
+
17
+ Listener = Callable[[Dict[str, Flag]], None]
18
+
19
+
20
+ class GreenFlagsClient:
21
+ """Client for the GreenFlags read API.
22
+
23
+ >>> flags = GreenFlagsClient(url="https://app.greenflags.dev", api_token="gf_...")
24
+ >>> flags.refresh()
25
+ >>> if flags.is_enabled("new-checkout"):
26
+ ... ...
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ url: str,
32
+ api_token: str,
33
+ coordinates: Optional[Coordinates] = None,
34
+ http_get: HttpGet = _default_http_get,
35
+ ) -> None:
36
+ self._url = url.strip().rstrip("/")
37
+ self._api_token = api_token
38
+ self._http_get = http_get
39
+ self._coordinates = coordinates
40
+ self._snapshot: Dict[str, Flag] = {}
41
+ self._listeners: List[Listener] = []
42
+ self._lock = threading.RLock()
43
+ self._poll_timer: Optional[threading.Timer] = None
44
+ self._poll_interval: Optional[float] = None
45
+
46
+ # -- network -----------------------------------------------------------
47
+
48
+ def refresh(self) -> None:
49
+ """One billable request: fetches every flag of the token's environment.
50
+
51
+ Raises :class:`GreenFlagsError` on failure; the previous snapshot is
52
+ kept either way.
53
+ """
54
+ flags = request_flags(self._url, self._api_token, self._http_get)
55
+ with self._lock:
56
+ self._snapshot = flags
57
+ listeners = list(self._listeners)
58
+ evaluated = self._evaluated_snapshot()
59
+ for listener in listeners:
60
+ listener(evaluated)
61
+
62
+ # -- local reads (never hit the network) --------------------------------
63
+
64
+ def get_snapshot(self) -> Dict[str, Flag]:
65
+ with self._lock:
66
+ return self._evaluated_snapshot()
67
+
68
+ def get_all_flags(self) -> List[Flag]:
69
+ with self._lock:
70
+ return [self._evaluate(flag) for flag in self._snapshot.values()]
71
+
72
+ def get_flag(self, key: str, default: FlagValue = None) -> FlagValue:
73
+ """Evaluated value of ``key``; ``default`` when the flag is missing.
74
+
75
+ Never raises for missing flags — fail-open by design.
76
+ """
77
+ with self._lock:
78
+ flag = self._snapshot.get(key)
79
+ if flag is None:
80
+ return default
81
+ return self._evaluate(flag).value
82
+
83
+ def is_enabled(self, key: str) -> bool:
84
+ """True only when ``key`` exists and currently evaluates to ``True``."""
85
+ return self.get_flag(key) is True
86
+
87
+ # -- subscriptions -------------------------------------------------------
88
+
89
+ def subscribe(self, listener: Listener) -> Callable[[], None]:
90
+ """Registers ``listener``; called with the evaluated snapshot after
91
+ every successful :meth:`refresh`. Returns an unsubscribe callable."""
92
+ with self._lock:
93
+ self._listeners.append(listener)
94
+
95
+ def unsubscribe() -> None:
96
+ with self._lock:
97
+ if listener in self._listeners:
98
+ self._listeners.remove(listener)
99
+
100
+ return unsubscribe
101
+
102
+ # -- polling -------------------------------------------------------------
103
+
104
+ def start_polling(self, interval_seconds: float) -> None:
105
+ """Refreshes every ``interval_seconds`` on a daemon timer thread until
106
+ :meth:`stop_polling`. Errors are swallowed (previous snapshot stays)."""
107
+ self.stop_polling()
108
+ with self._lock:
109
+ self._poll_interval = interval_seconds
110
+ self._schedule_poll()
111
+
112
+ def stop_polling(self) -> None:
113
+ with self._lock:
114
+ self._poll_interval = None
115
+ if self._poll_timer is not None:
116
+ self._poll_timer.cancel()
117
+ self._poll_timer = None
118
+
119
+ # -- geofence -------------------------------------------------------------
120
+
121
+ def set_coordinates(self, coordinates: Optional[Coordinates]) -> None:
122
+ """Sets (or clears with ``None``) the end-user location used for
123
+ geofence evaluation. No network request is made."""
124
+ with self._lock:
125
+ self._coordinates = coordinates
126
+
127
+ # -- internals -------------------------------------------------------------
128
+
129
+ def _schedule_poll(self) -> None:
130
+ with self._lock:
131
+ interval = self._poll_interval
132
+ if interval is None:
133
+ return
134
+ timer = threading.Timer(interval, self._poll_tick)
135
+ timer.daemon = True
136
+ self._poll_timer = timer
137
+ timer.start()
138
+
139
+ def _poll_tick(self) -> None:
140
+ try:
141
+ self.refresh()
142
+ except Exception: # noqa: BLE001 — polling is fail-open by design
143
+ pass
144
+ self._schedule_poll()
145
+
146
+ def _evaluated_snapshot(self) -> Dict[str, Flag]:
147
+ return {key: self._evaluate(flag) for key, flag in self._snapshot.items()}
148
+
149
+ def _evaluate(self, flag: Flag) -> Flag:
150
+ coords = self._coordinates
151
+ geofence = flag.geofence
152
+ if coords is None or geofence is None:
153
+ return flag
154
+ center = Coordinates(latitude=geofence.latitude, longitude=geofence.longitude)
155
+ outside = haversine_meters(coords, center) > geofence.radius_meters
156
+ if not outside:
157
+ return flag
158
+ return flag.with_value(False if flag.type == "boolean" else None)
@@ -0,0 +1,27 @@
1
+ """Haversine distance — mirrors the JS/Dart SDK implementations exactly.
2
+
3
+ Internal, not part of the public surface.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import math
9
+
10
+ from .types import Coordinates
11
+
12
+ _EARTH_RADIUS_METERS = 6_371_000
13
+
14
+
15
+ def haversine_meters(a: Coordinates, b: Coordinates) -> float:
16
+ phi1 = math.radians(a.latitude)
17
+ phi2 = math.radians(b.latitude)
18
+ delta_phi = math.radians(b.latitude - a.latitude)
19
+ delta_lambda = math.radians(b.longitude - a.longitude)
20
+
21
+ h = (
22
+ math.sin(delta_phi / 2) ** 2
23
+ + math.cos(phi1) * math.cos(phi2) * math.sin(delta_lambda / 2) ** 2
24
+ )
25
+ c = 2 * math.atan2(math.sqrt(h), math.sqrt(1 - h))
26
+
27
+ return _EARTH_RADIUS_METERS * c
File without changes
@@ -0,0 +1,70 @@
1
+ """HTTP transport for GET /v1/flags. Internal.
2
+
3
+ Zero dependencies: urllib from the standard library. An ``http_get`` callable
4
+ can be injected (tests, custom clients); it must return ``(status, body)``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import urllib.error
11
+ import urllib.request
12
+ from typing import Callable, Dict, Tuple
13
+
14
+ from .types import Flag, GreenFlagsError
15
+
16
+ HttpGet = Callable[[str, Dict[str, str]], Tuple[int, str]]
17
+
18
+
19
+ def _default_http_get(url: str, headers: Dict[str, str]) -> Tuple[int, str]:
20
+ request = urllib.request.Request(url, headers=headers)
21
+ try:
22
+ with urllib.request.urlopen(request, timeout=10) as response:
23
+ return response.status, response.read().decode("utf-8")
24
+ except urllib.error.HTTPError as err:
25
+ return err.code, err.read().decode("utf-8", errors="replace")
26
+
27
+
28
+ def request_flags(
29
+ url: str,
30
+ api_token: str,
31
+ http_get: HttpGet = _default_http_get,
32
+ ) -> Dict[str, Flag]:
33
+ base = url.strip().rstrip("/")
34
+ endpoint = f"{base}/v1/flags"
35
+ headers = {"authorization": f"Bearer {api_token}"}
36
+
37
+ try:
38
+ status, raw_body = http_get(endpoint, headers)
39
+ except GreenFlagsError:
40
+ raise
41
+ except Exception as err: # noqa: BLE001 — network layer catch-all by design
42
+ raise GreenFlagsError("NETWORK_ERROR", str(err), 0) from err
43
+
44
+ try:
45
+ body = json.loads(raw_body)
46
+ except ValueError as err:
47
+ raise GreenFlagsError(
48
+ "PARSE_ERROR", "Invalid response from server", status
49
+ ) from err
50
+
51
+ if status < 200 or status >= 300:
52
+ envelope = body if isinstance(body, dict) else {}
53
+ raise GreenFlagsError(
54
+ envelope.get("error") or "REQUEST_ERROR",
55
+ envelope.get("message") or "Request failed",
56
+ status,
57
+ )
58
+
59
+ flags_raw = (
60
+ body.get("data", {}).get("flags") if isinstance(body, dict) else None
61
+ )
62
+ if not isinstance(flags_raw, list):
63
+ raise GreenFlagsError("PARSE_ERROR", "Invalid response from server", status)
64
+
65
+ result: Dict[str, Flag] = {}
66
+ for item in flags_raw:
67
+ if isinstance(item, dict):
68
+ flag = Flag.from_json(item)
69
+ result[flag.key] = flag
70
+ return result
@@ -0,0 +1,69 @@
1
+ """Public types. They mirror the backend contract (GET /v1/flags) exactly."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any, Literal, Optional, Union
7
+
8
+ FlagType = Literal["boolean", "string", "number", "json"]
9
+
10
+ # bool | str | int | float | dict — None is the geofenced-off value for
11
+ # non-boolean flags.
12
+ FlagValue = Union[bool, str, int, float, dict, None]
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class Coordinates:
17
+ latitude: float
18
+ longitude: float
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class Geofence:
23
+ latitude: float
24
+ longitude: float
25
+ radius_meters: float
26
+
27
+ @staticmethod
28
+ def from_json(data: dict) -> "Geofence":
29
+ return Geofence(
30
+ latitude=float(data["latitude"]),
31
+ longitude=float(data["longitude"]),
32
+ radius_meters=float(data["radiusMeters"]),
33
+ )
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class Flag:
38
+ key: str
39
+ type: FlagType
40
+ value: FlagValue
41
+ geofence: Optional[Geofence] = None
42
+
43
+ @staticmethod
44
+ def from_json(data: dict) -> "Flag":
45
+ geofence = data.get("geofence")
46
+ return Flag(
47
+ key=data["key"],
48
+ type=data["type"],
49
+ value=data.get("value"),
50
+ geofence=Geofence.from_json(geofence) if geofence else None,
51
+ )
52
+
53
+ def with_value(self, value: Any) -> "Flag":
54
+ return Flag(key=self.key, type=self.type, value=value, geofence=self.geofence)
55
+
56
+
57
+ class GreenFlagsError(Exception):
58
+ """Raised on network or API failures.
59
+
60
+ ``code`` mirrors the API error codes (e.g. ``INVALID_TOKEN``,
61
+ ``QUOTA_EXCEEDED``) plus the client-side ``NETWORK_ERROR`` and
62
+ ``PARSE_ERROR``. ``status`` is the HTTP status (0 for network failures).
63
+ """
64
+
65
+ def __init__(self, code: str, message: str, status: int) -> None:
66
+ super().__init__(f"{code} ({status}): {message}")
67
+ self.code = code
68
+ self.message = message
69
+ self.status = status
@@ -0,0 +1,175 @@
1
+ import json
2
+ import time
3
+
4
+ import pytest
5
+
6
+ from greenflags import Coordinates, GreenFlagsClient, GreenFlagsError
7
+
8
+
9
+ def flags_server(flags):
10
+ body = json.dumps({"success": True, "data": {"flags": flags}})
11
+
12
+ def http_get(url, headers):
13
+ http_get.calls.append((url, headers))
14
+ return 200, body
15
+
16
+ http_get.calls = []
17
+ return http_get
18
+
19
+
20
+ def test_refresh_and_reads():
21
+ http_get = flags_server(
22
+ [
23
+ {"key": "on", "type": "boolean", "value": True},
24
+ {"key": "banner", "type": "string", "value": "hello"},
25
+ ]
26
+ )
27
+ client = GreenFlagsClient(
28
+ url="https://app.greenflags.dev/", api_token="gf_test", http_get=http_get
29
+ )
30
+ client.refresh()
31
+
32
+ url, headers = http_get.calls[0]
33
+ assert url == "https://app.greenflags.dev/v1/flags"
34
+ assert headers["authorization"] == "Bearer gf_test"
35
+
36
+ assert client.is_enabled("on") is True
37
+ assert client.get_flag("banner") == "hello"
38
+ assert client.get_flag("missing", default=42) == 42
39
+ assert client.is_enabled("missing") is False
40
+ assert len(client.get_all_flags()) == 2
41
+
42
+
43
+ def test_error_envelope_maps_to_exception():
44
+ def http_get(url, headers):
45
+ return 401, json.dumps(
46
+ {"success": False, "error": "INVALID_TOKEN", "message": "Invalid API token."}
47
+ )
48
+
49
+ client = GreenFlagsClient(
50
+ url="https://app.greenflags.dev", api_token="bad", http_get=http_get
51
+ )
52
+ with pytest.raises(GreenFlagsError) as exc:
53
+ client.refresh()
54
+ assert exc.value.code == "INVALID_TOKEN"
55
+ assert exc.value.status == 401
56
+
57
+
58
+ def test_network_error_and_parse_error():
59
+ def network_fail(url, headers):
60
+ raise ConnectionError("connection refused")
61
+
62
+ client = GreenFlagsClient(
63
+ url="https://app.greenflags.dev", api_token="gf", http_get=network_fail
64
+ )
65
+ with pytest.raises(GreenFlagsError) as exc:
66
+ client.refresh()
67
+ assert exc.value.code == "NETWORK_ERROR"
68
+ assert exc.value.status == 0
69
+
70
+ def bad_json(url, headers):
71
+ return 200, "<html>oops</html>"
72
+
73
+ client = GreenFlagsClient(
74
+ url="https://app.greenflags.dev", api_token="gf", http_get=bad_json
75
+ )
76
+ with pytest.raises(GreenFlagsError) as exc:
77
+ client.refresh()
78
+ assert exc.value.code == "PARSE_ERROR"
79
+
80
+
81
+ def test_failed_refresh_keeps_previous_snapshot():
82
+ state = {"fail": False}
83
+
84
+ def http_get(url, headers):
85
+ if state["fail"]:
86
+ return 429, json.dumps(
87
+ {"success": False, "error": "QUOTA_EXCEEDED", "message": "Quota."}
88
+ )
89
+ return 200, json.dumps(
90
+ {"success": True, "data": {"flags": [{"key": "on", "type": "boolean", "value": True}]}}
91
+ )
92
+
93
+ client = GreenFlagsClient(
94
+ url="https://app.greenflags.dev", api_token="gf", http_get=http_get
95
+ )
96
+ client.refresh()
97
+ assert client.is_enabled("on") is True
98
+
99
+ state["fail"] = True
100
+ with pytest.raises(GreenFlagsError):
101
+ client.refresh()
102
+ assert client.is_enabled("on") is True # previous snapshot survives
103
+
104
+
105
+ def test_geofence_evaluation():
106
+ http_get = flags_server(
107
+ [
108
+ {
109
+ "key": "geo-bool",
110
+ "type": "boolean",
111
+ "value": True,
112
+ "geofence": {"latitude": 19.4326, "longitude": -99.1332, "radiusMeters": 1000},
113
+ },
114
+ {
115
+ "key": "geo-string",
116
+ "type": "string",
117
+ "value": "promo",
118
+ "geofence": {"latitude": 19.4326, "longitude": -99.1332, "radiusMeters": 1000},
119
+ },
120
+ ]
121
+ )
122
+ client = GreenFlagsClient(
123
+ url="https://app.greenflags.dev", api_token="gf", http_get=http_get
124
+ )
125
+ client.refresh()
126
+
127
+ # No coordinates: geofence ignored.
128
+ assert client.is_enabled("geo-bool") is True
129
+
130
+ # Inside the radius.
131
+ client.set_coordinates(Coordinates(latitude=19.4327, longitude=-99.1333))
132
+ assert client.is_enabled("geo-bool") is True
133
+ assert client.get_flag("geo-string") == "promo"
134
+
135
+ # Outside (Monterrey, ~700 km away).
136
+ client.set_coordinates(Coordinates(latitude=25.6866, longitude=-100.3161))
137
+ assert client.is_enabled("geo-bool") is False
138
+ assert client.get_flag("geo-string") is None
139
+
140
+ # Clearing restores un-geofenced evaluation.
141
+ client.set_coordinates(None)
142
+ assert client.is_enabled("geo-bool") is True
143
+
144
+
145
+ def test_subscribe_and_unsubscribe():
146
+ http_get = flags_server([{"key": "on", "type": "boolean", "value": True}])
147
+ client = GreenFlagsClient(
148
+ url="https://app.greenflags.dev", api_token="gf", http_get=http_get
149
+ )
150
+ seen = []
151
+ unsubscribe = client.subscribe(lambda snapshot: seen.append(snapshot))
152
+
153
+ client.refresh()
154
+ assert len(seen) == 1
155
+ assert seen[0]["on"].value is True
156
+
157
+ unsubscribe()
158
+ client.refresh()
159
+ assert len(seen) == 1
160
+
161
+
162
+ def test_polling_refreshes_and_stops():
163
+ http_get = flags_server([])
164
+ client = GreenFlagsClient(
165
+ url="https://app.greenflags.dev", api_token="gf", http_get=http_get
166
+ )
167
+
168
+ client.start_polling(0.02)
169
+ time.sleep(0.09)
170
+ client.stop_polling()
171
+ calls_at_stop = len(http_get.calls)
172
+ assert calls_at_stop >= 2
173
+
174
+ time.sleep(0.06)
175
+ assert len(http_get.calls) == calls_at_stop
@@ -0,0 +1,35 @@
1
+ from greenflags import Coordinates
2
+ from greenflags.geo import haversine_meters
3
+
4
+
5
+ def test_same_point_is_zero():
6
+ point = Coordinates(latitude=19.4326, longitude=-99.1332)
7
+ assert abs(haversine_meters(point, point)) < 1e-6
8
+
9
+
10
+ def test_paris_london_within_one_percent():
11
+ paris = Coordinates(latitude=48.8566, longitude=2.3522)
12
+ london = Coordinates(latitude=51.5074, longitude=-0.1278)
13
+ expected = 343_550 # meters, known reference
14
+ distance = haversine_meters(paris, london)
15
+ assert abs(distance - expected) / expected < 0.01
16
+
17
+
18
+ def test_antimeridian_is_small_not_half_earth():
19
+ a = Coordinates(latitude=0, longitude=179.9)
20
+ b = Coordinates(latitude=0, longitude=-179.9)
21
+ distance = haversine_meters(a, b)
22
+ assert 15_000 < distance < 30_000
23
+
24
+
25
+ def test_near_pole_small_finite():
26
+ a = Coordinates(latitude=89.999, longitude=0)
27
+ b = Coordinates(latitude=89.999, longitude=180)
28
+ distance = haversine_meters(a, b)
29
+ assert distance < 300
30
+
31
+
32
+ def test_exact_pole_zero():
33
+ a = Coordinates(latitude=90, longitude=0)
34
+ b = Coordinates(latitude=90, longitude=137)
35
+ assert abs(haversine_meters(a, b)) < 1e-6