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.
- greenflags-0.1.0/.gitignore +5 -0
- greenflags-0.1.0/CHANGELOG.md +13 -0
- greenflags-0.1.0/LICENSE +21 -0
- greenflags-0.1.0/PKG-INFO +324 -0
- greenflags-0.1.0/README.md +283 -0
- greenflags-0.1.0/pyproject.toml +33 -0
- greenflags-0.1.0/src/greenflags/__init__.py +19 -0
- greenflags-0.1.0/src/greenflags/client.py +158 -0
- greenflags-0.1.0/src/greenflags/geo.py +27 -0
- greenflags-0.1.0/src/greenflags/py.typed +0 -0
- greenflags-0.1.0/src/greenflags/transport.py +70 -0
- greenflags-0.1.0/src/greenflags/types.py +69 -0
- greenflags-0.1.0/tests/test_client.py +175 -0
- greenflags-0.1.0/tests/test_geo.py +35 -0
|
@@ -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`.
|
greenflags-0.1.0/LICENSE
ADDED
|
@@ -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
|