togul 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,65 @@
1
+ name: CI
2
+
3
+ on:
4
+ pull_request:
5
+ push:
6
+ branches: [main]
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ fail-fast: false
13
+ matrix:
14
+ python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+
18
+ - uses: actions/setup-python@v5
19
+ with:
20
+ python-version: ${{ matrix.python-version }}
21
+
22
+ - name: Install
23
+ run: |
24
+ python -m pip install --upgrade pip
25
+ pip install -e ".[dev,django,fastapi]"
26
+
27
+ - name: Test
28
+ run: python -m pytest -v
29
+
30
+ lint:
31
+ runs-on: ubuntu-latest
32
+ steps:
33
+ - uses: actions/checkout@v4
34
+
35
+ - uses: actions/setup-python@v5
36
+ with:
37
+ python-version: "3.12"
38
+
39
+ - name: Install
40
+ run: |
41
+ python -m pip install --upgrade pip
42
+ pip install -e ".[dev,django,fastapi]"
43
+
44
+ - name: Ruff
45
+ run: |
46
+ ruff check src tests
47
+ ruff format --check src tests
48
+
49
+ - name: Mypy
50
+ run: mypy
51
+
52
+ package:
53
+ runs-on: ubuntu-latest
54
+ steps:
55
+ - uses: actions/checkout@v4
56
+
57
+ - uses: actions/setup-python@v5
58
+ with:
59
+ python-version: "3.12"
60
+
61
+ - name: Build and check the distribution
62
+ run: |
63
+ python -m pip install --upgrade pip build twine
64
+ python -m build
65
+ twine check dist/*
@@ -0,0 +1,31 @@
1
+ name: Release
2
+
3
+ on:
4
+ workflow_dispatch:
5
+ inputs:
6
+ publish:
7
+ description: "Upload to PyPI (leave false for a dry run)"
8
+ type: boolean
9
+ default: false
10
+
11
+ jobs:
12
+ build:
13
+ runs-on: ubuntu-latest
14
+ permissions:
15
+ id-token: write
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+
19
+ - uses: actions/setup-python@v5
20
+ with:
21
+ python-version: "3.12"
22
+
23
+ - name: Build
24
+ run: |
25
+ python -m pip install --upgrade pip build twine
26
+ python -m build
27
+ twine check dist/*
28
+
29
+ - name: Publish to PyPI
30
+ if: ${{ inputs.publish }}
31
+ uses: pypa/gh-action-pypi-publish@release/v1
togul-0.1.0/.gitignore ADDED
@@ -0,0 +1,11 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ venv/
5
+ dist/
6
+ build/
7
+ *.egg-info/
8
+ .pytest_cache/
9
+ .mypy_cache/
10
+ .ruff_cache/
11
+ .coverage
togul-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Togul
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.
togul-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,318 @@
1
+ Metadata-Version: 2.5
2
+ Name: togul
3
+ Version: 0.1.0
4
+ Summary: Official Python SDK for Togul feature flags and remote config
5
+ Project-URL: Homepage, https://togul.io
6
+ Project-URL: Documentation, https://docs.togul.io
7
+ Project-URL: Source, https://github.com/togulapp/togul-python
8
+ Author: Togul
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: feature-flags,feature-toggle,remote-config,togul
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.9
23
+ Requires-Dist: httpx<1,>=0.24
24
+ Provides-Extra: dev
25
+ Requires-Dist: build>=1.0; extra == 'dev'
26
+ Requires-Dist: mypy>=1.8; extra == 'dev'
27
+ Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
28
+ Requires-Dist: pytest>=7.0; extra == 'dev'
29
+ Requires-Dist: ruff>=0.4; extra == 'dev'
30
+ Requires-Dist: twine>=5.0; extra == 'dev'
31
+ Provides-Extra: django
32
+ Requires-Dist: django>=4.2; extra == 'django'
33
+ Provides-Extra: fastapi
34
+ Requires-Dist: fastapi>=0.100; extra == 'fastapi'
35
+ Description-Content-Type: text/markdown
36
+
37
+ # Togul Python SDK
38
+
39
+ Official Python SDK for Togul feature flags.
40
+
41
+ ## Install
42
+
43
+ ```bash
44
+ pip install togul
45
+ ```
46
+
47
+ Framework integrations are extras:
48
+
49
+ ```bash
50
+ pip install "togul[django]"
51
+ pip install "togul[fastapi]"
52
+ ```
53
+
54
+ ## Quick start (sync)
55
+
56
+ ```python
57
+ """Minimal synchronous usage."""
58
+
59
+ from __future__ import annotations
60
+
61
+ import os
62
+
63
+ from togul import Config, TogulClient
64
+
65
+ config = Config(
66
+ api_key=os.environ["TOGUL_API_KEY"],
67
+ environment=os.environ.get("TOGUL_ENVIRONMENT", "production"),
68
+ )
69
+
70
+ with TogulClient(config) as client:
71
+ result = client.evaluate("new-dashboard", {"user_id": "u-123", "plan": "premium"})
72
+ print(f"{result.flag_key}: enabled={result.enabled} value={result.value!r} ({result.reason})")
73
+ ```
74
+
75
+ `TogulClient` is blocking. Use it from Django views, Celery tasks, scripts, or
76
+ anywhere else that isn't running an event loop. The `with` block closes the
77
+ underlying HTTP connection pool on exit; if you pass your own `httpx.Client`,
78
+ the SDK leaves it open and you own its lifecycle.
79
+
80
+ See [`examples/basic.py`](examples/basic.py).
81
+
82
+ ## Async
83
+
84
+ For FastAPI, Starlette, or any asyncio application, use `AsyncTogulClient`:
85
+
86
+ ```python
87
+ from togul import AsyncTogulClient, Config
88
+
89
+ config = Config(api_key="...", environment="production")
90
+
91
+ async with AsyncTogulClient(config) as client:
92
+ result = await client.evaluate("new-dashboard", {"user_id": "u-123"})
93
+ ```
94
+
95
+ The API mirrors `TogulClient` exactly, `evaluate` just needs an `await`.
96
+ `invalidate_cache()` and `invalidate_flag()` stay synchronous on both clients:
97
+ they only touch the in-memory cache under a lock, so there's nothing to await.
98
+
99
+ ## Real-time updates
100
+
101
+ Both clients cache evaluation results for `cache_ttl` seconds. A stream
102
+ client keeps that cache fresh by invalidating entries as flags change,
103
+ instead of waiting for the TTL to expire.
104
+
105
+ Sync, run on a background thread:
106
+
107
+ ```python
108
+ import threading
109
+
110
+ from togul import Config, TogulClient, TogulStreamClient
111
+
112
+ config = Config(api_key="...", environment="production")
113
+ client = TogulClient(config)
114
+ stream = TogulStreamClient(config, client.cache)
115
+
116
+ threading.Thread(target=stream.connect, daemon=True).start()
117
+ # ... use client.evaluate(...) as usual; stream.stop() to shut it down.
118
+ ```
119
+
120
+ Async, run as a task:
121
+
122
+ ```python
123
+ import asyncio
124
+
125
+ from togul import AsyncTogulClient, AsyncTogulStreamClient, Config
126
+
127
+ config = Config(api_key="...", environment="production")
128
+ client = AsyncTogulClient(config)
129
+ stream = AsyncTogulStreamClient(config, client.cache)
130
+
131
+ task = asyncio.create_task(stream.connect())
132
+ # ... await client.evaluate(...) as usual; stream.stop() then cancel the task.
133
+ ```
134
+
135
+ `connect()` never returns on its own. It reconnects with exponential backoff
136
+ (starting at 1s, doubling up to a 30s cap, reset after a clean disconnect)
137
+ until `stop()` is called. A 401 or 403 response is the one exception: it is
138
+ re-raised out of `connect()` immediately, because reconnecting with a
139
+ rejected key cannot fix anything.
140
+
141
+ The FastAPI integration below wires the async stream client into the
142
+ application lifespan automatically; you don't need to manage the task by
143
+ hand there.
144
+
145
+ ## Django
146
+
147
+ Add the middleware and a `TOGUL` settings block:
148
+
149
+ ```python
150
+ MIDDLEWARE = [
151
+ # ... your other middleware ...
152
+ "togul.contrib.django.TogulMiddleware",
153
+ ]
154
+
155
+ TOGUL = {
156
+ "API_KEY": os.environ["TOGUL_API_KEY"],
157
+ "ENVIRONMENT": os.environ.get("TOGUL_ENVIRONMENT", "production"),
158
+ "CACHE_TTL": 30.0,
159
+ # "CONTEXT_BUILDER": "myapp.flags.build_context",
160
+ }
161
+ ```
162
+
163
+ | Key | Default | Notes |
164
+ |---|---|---|
165
+ | `API_KEY` | required | |
166
+ | `ENVIRONMENT` | required | |
167
+ | `TIMEOUT` | `5.0` | HTTP timeout in seconds. |
168
+ | `CACHE_TTL` | `30.0` | Seconds an evaluation stays cached. |
169
+ | `RETRY_COUNT` | `2` | Total request attempts (see below), not extra retries. |
170
+ | `CONTEXT_BUILDER` | the built-in `build_context` | Dotted path or callable; see below. |
171
+
172
+ There is deliberately no `BASE_URL` key. This matches `togul-laravel`'s
173
+ `config/togul.php`, which exposes the same narrow set of framework-layer
174
+ options. If you need a non-production origin, construct a `Config` directly
175
+ (see [`examples/basic.py`](examples/basic.py)) rather than going through
176
+ Django settings.
177
+
178
+ `TogulMiddleware` attaches `request.togul` (a `TogulClient`) and
179
+ `request.togul_context` (built by `CONTEXT_BUILDER`, or the default
180
+ `build_context` if unset) to every request. The default `build_context` maps
181
+ *only* the authenticated user's primary key to `user_id`, and omits it
182
+ entirely for an anonymous request rather than sending a blank value. If your
183
+ flags need anything else — a plan, a country, an experiment cohort — point
184
+ `CONTEXT_BUILDER` at your own function; don't edit the default.
185
+
186
+ ```python
187
+ def dashboard(request):
188
+ result = request.togul.evaluate("new-dashboard", request.togul_context)
189
+ if result.enabled:
190
+ return render(request, "dashboard/new.html")
191
+ return render(request, "dashboard/legacy.html")
192
+ ```
193
+
194
+ For gating an entire view, use the `@togul_flag("key")` decorator instead.
195
+ It raises `Http404` when the flag is disabled, and works with or without the
196
+ middleware installed (it builds context on demand if `request.togul_context`
197
+ isn't there):
198
+
199
+ ```python
200
+ from togul.contrib.django import togul_flag
201
+
202
+
203
+ @togul_flag("new-dashboard")
204
+ def beta_dashboard(request):
205
+ return render(request, "dashboard/new.html")
206
+ ```
207
+
208
+ See [`examples/django_settings.py`](examples/django_settings.py).
209
+
210
+ ## FastAPI
211
+
212
+ ```python
213
+ from fastapi import Depends, FastAPI
214
+
215
+ from togul import AsyncTogulClient, Config
216
+ from togul.contrib.fastapi import create_lifespan, get_togul
217
+
218
+ config = Config(api_key="...", environment="production")
219
+
220
+ app = FastAPI(lifespan=create_lifespan(config))
221
+
222
+
223
+ @app.get("/dashboard")
224
+ async def dashboard(
225
+ user_id: str,
226
+ togul: AsyncTogulClient = Depends(get_togul),
227
+ ) -> dict:
228
+ result = await togul.evaluate("new-dashboard", {"user_id": user_id})
229
+ return {"variant": "new" if result.enabled else "legacy", "reason": result.reason}
230
+ ```
231
+
232
+ `create_lifespan(config, *, stream=True, client=None)` builds an
233
+ `AsyncTogulClient`, stores it on `app.state.togul`, and — since `stream`
234
+ defaults to `True` — starts an `AsyncTogulStreamClient` as a background task
235
+ so SSE events invalidate the cache in real time. Both are shut down cleanly
236
+ on application exit. Pass `client=` to supply your own pre-built
237
+ `AsyncTogulClient` (for tests, or a custom `httpx.AsyncClient`); the lifespan
238
+ still closes it on shutdown. Pass `stream=False` to skip the background
239
+ connection and rely on `cache_ttl` alone.
240
+
241
+ `get_togul` is a plain `Depends()` provider that reads `app.state.togul`.
242
+
243
+ See [`examples/fastapi_app.py`](examples/fastapi_app.py).
244
+
245
+ ## Configuration reference
246
+
247
+ Every field of `Config`:
248
+
249
+ | Field | Type | Default | Meaning |
250
+ |---|---|---|---|
251
+ | `api_key` | `str` | required | Environment-scoped API key. |
252
+ | `environment` | `str` | required | Environment key, e.g. `"production"`. |
253
+ | `timeout` | `float` | `5.0` | HTTP request timeout, in seconds. |
254
+ | `cache_ttl` | `float` | `30.0` | How long an evaluation result stays cached, in seconds. |
255
+ | `retry_count` | `int` | `2` | **Total** request attempts, not extra retries — `2` means the SDK issues at most two requests, i.e. one retry. |
256
+ | `base_url` | `str` | `"https://api.togul.io"` | API origin. Trailing slashes are stripped. |
257
+
258
+ `Config` is a frozen dataclass: build a new one to change settings.
259
+
260
+ `evaluate()` is the only evaluation method — there are no typed
261
+ `evaluate_string` / `evaluate_boolean` helpers. Read `result.value` and
262
+ interpret it according to `result.value_type`.
263
+
264
+ ## Caching and invalidation
265
+
266
+ Both clients keep an in-memory, thread-safe TTL cache of `EvaluateResult`s.
267
+ An entry is dropped lazily, the first time it's read after `cache_ttl`
268
+ seconds have passed, or if it carries a malformed (empty) `value_type`.
269
+
270
+ The cache key is `flag_key:environment`, followed by `:key=value` for every
271
+ context pair, with context keys sorted so call order never changes the key.
272
+ For example, `evaluate("new-dashboard", {"plan": "premium", "user_id": "u-123"})`
273
+ against environment `production` produces
274
+ `new-dashboard:production:plan=premium:user_id=u-123`.
275
+
276
+ `invalidate_cache()` clears the whole cache; `invalidate_flag(flag_key)`
277
+ clears only entries for that flag. A `TogulStreamClient` /
278
+ `AsyncTogulStreamClient` calls these automatically as SSE events arrive. A
279
+ line that is not a well-formed `data:` JSON object — a heartbeat, a blank
280
+ line, or a malformed payload — is ignored entirely: no invalidation, no
281
+ flush. A well-formed event naming a `flag_key` invalidates just that flag; a
282
+ well-formed event that omits it flushes the whole cache. Register
283
+ `on_cache_invalidated(...)` on the stream client to observe SSE-driven
284
+ invalidations, e.g. for logging — the stream client invalidates the cache
285
+ directly and only notifies its own listeners, so it never routes through the
286
+ evaluation client. A listener registered on the evaluation client instead
287
+ fires only for invalidations *you* trigger by calling that client's own
288
+ `invalidate_cache()` / `invalidate_flag()` directly; it will not fire for SSE
289
+ events.
290
+
291
+ ## Error handling
292
+
293
+ Every error the SDK raises is a `TogulError`. `TogulAPIError` is the subclass
294
+ raised when the API itself returns a non-2xx response; it carries
295
+ `status_code` and, when the response body has one, `error_code`.
296
+
297
+ Retries apply only to HTTP `429` and any `5xx` response, plus network-level
298
+ failures. Every other `4xx` (400, 401, 403, 404, ...) raises immediately with
299
+ no retry. Each retry waits `attempt × 100ms` before trying again, so with the
300
+ default `retry_count` of 2 the single retry is delayed by 100ms. When
301
+ retries are exhausted, the client raises a `TogulError` wrapping the last
302
+ failure.
303
+
304
+ **There is no fail-open fallback.** Unlike SDKs that silently return a
305
+ default value when evaluation fails, this SDK never guesses on your behalf:
306
+ a failed `evaluate()` call raises, and it's up to your code to decide what
307
+ happens next — return a default, log and re-raise, fall back to cached
308
+ behavior, whatever fits your product. If you want fail-open behavior, wrap
309
+ `evaluate()` in a `try`/`except TogulError` yourself.
310
+
311
+ ## Requirements
312
+
313
+ Python 3.9+. Django integration requires Django 4.2+. FastAPI integration
314
+ requires FastAPI 0.100+.
315
+
316
+ ## License
317
+
318
+ MIT. See [LICENSE](LICENSE).