togul 0.1.0__py3-none-any.whl
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.
- togul/__init__.py +25 -0
- togul/_sse.py +29 -0
- togul/_transport.py +77 -0
- togul/async_client.py +120 -0
- togul/cache.py +53 -0
- togul/client.py +125 -0
- togul/config.py +32 -0
- togul/contrib/__init__.py +3 -0
- togul/contrib/django.py +138 -0
- togul/contrib/fastapi.py +93 -0
- togul/errors.py +22 -0
- togul/py.typed +0 -0
- togul/result.py +26 -0
- togul/stream.py +179 -0
- togul-0.1.0.dist-info/METADATA +318 -0
- togul-0.1.0.dist-info/RECORD +18 -0
- togul-0.1.0.dist-info/WHEEL +4 -0
- togul-0.1.0.dist-info/licenses/LICENSE +21 -0
togul/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Official Python SDK for Togul feature flags and remote config."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .async_client import AsyncTogulClient
|
|
6
|
+
from .client import TogulClient
|
|
7
|
+
from .config import DEFAULT_BASE_URL, Config
|
|
8
|
+
from .errors import TogulAPIError, TogulError
|
|
9
|
+
from .result import EvaluateResult, ValueType
|
|
10
|
+
from .stream import AsyncTogulStreamClient, TogulStreamClient
|
|
11
|
+
|
|
12
|
+
__version__ = "0.1.0"
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"AsyncTogulClient",
|
|
16
|
+
"AsyncTogulStreamClient",
|
|
17
|
+
"Config",
|
|
18
|
+
"DEFAULT_BASE_URL",
|
|
19
|
+
"EvaluateResult",
|
|
20
|
+
"TogulAPIError",
|
|
21
|
+
"TogulClient",
|
|
22
|
+
"TogulError",
|
|
23
|
+
"TogulStreamClient",
|
|
24
|
+
"ValueType",
|
|
25
|
+
]
|
togul/_sse.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any, Dict, Optional
|
|
5
|
+
|
|
6
|
+
_DATA_PREFIX = "data: "
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def parse_sse_event(line: str) -> Optional[Dict[str, Any]]:
|
|
10
|
+
"""Decode one SSE line into an event object.
|
|
11
|
+
|
|
12
|
+
Returns ``None`` for anything that is not a ``data:`` line carrying a
|
|
13
|
+
JSON object: heartbeat comments (``: heartbeat``), blank lines, other
|
|
14
|
+
SSE fields, malformed JSON, and JSON that is not an object. The Go and
|
|
15
|
+
PHP SDKs skip those cases identically.
|
|
16
|
+
"""
|
|
17
|
+
if not line.startswith(_DATA_PREFIX):
|
|
18
|
+
return None
|
|
19
|
+
|
|
20
|
+
raw = line[len(_DATA_PREFIX) :].strip()
|
|
21
|
+
if not raw:
|
|
22
|
+
return None
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
event = json.loads(raw)
|
|
26
|
+
except ValueError:
|
|
27
|
+
return None
|
|
28
|
+
|
|
29
|
+
return event if isinstance(event, dict) else None
|
togul/_transport.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, Mapping, Optional
|
|
4
|
+
|
|
5
|
+
from .config import Config
|
|
6
|
+
from .errors import TogulAPIError, TogulError
|
|
7
|
+
from .result import EvaluateResult
|
|
8
|
+
|
|
9
|
+
EVALUATE_PATH = "/api/v1/evaluate"
|
|
10
|
+
STREAM_PATH = "/api/v1/stream"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def cache_key_for(
|
|
14
|
+
flag_key: str,
|
|
15
|
+
environment: str,
|
|
16
|
+
context: Optional[Mapping[str, str]],
|
|
17
|
+
) -> str:
|
|
18
|
+
"""Build the cache key for one evaluation.
|
|
19
|
+
|
|
20
|
+
Format: ``flag_key:environment`` followed by ``:key=value`` for every
|
|
21
|
+
context pair, with keys sorted so call order never changes the key.
|
|
22
|
+
Identical to ``cacheKeyFor`` (Go), ``cacheKey`` (JS) and
|
|
23
|
+
``buildCacheKey`` (PHP).
|
|
24
|
+
"""
|
|
25
|
+
parts = [flag_key, environment]
|
|
26
|
+
if context:
|
|
27
|
+
for key in sorted(context):
|
|
28
|
+
parts.append(f"{key}={context[key]}")
|
|
29
|
+
return ":".join(parts)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def build_request(
|
|
33
|
+
config: Config,
|
|
34
|
+
flag_key: str,
|
|
35
|
+
context: Optional[Mapping[str, str]],
|
|
36
|
+
) -> Dict[str, Any]:
|
|
37
|
+
return {
|
|
38
|
+
"flag_key": flag_key,
|
|
39
|
+
"environment_key": config.environment,
|
|
40
|
+
"context": dict(context) if context else {},
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def should_retry(status_code: int) -> bool:
|
|
45
|
+
return status_code == 429 or status_code >= 500
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def backoff_seconds(attempt: int) -> float:
|
|
49
|
+
return attempt * 0.1
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def parse_result(payload: Any, flag_key: str) -> EvaluateResult:
|
|
53
|
+
if not isinstance(payload, dict):
|
|
54
|
+
payload = {}
|
|
55
|
+
return EvaluateResult(
|
|
56
|
+
flag_key=str(payload.get("flag_key") or flag_key),
|
|
57
|
+
enabled=bool(payload.get("enabled", False)),
|
|
58
|
+
value_type=str(payload.get("value_type") or ""),
|
|
59
|
+
value=payload.get("value"),
|
|
60
|
+
reason=str(payload.get("reason") or ""),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def api_error_from(status_code: int, body: Any) -> TogulAPIError:
|
|
65
|
+
message = f"Unexpected status {status_code}"
|
|
66
|
+
error_code: Optional[str] = None
|
|
67
|
+
if isinstance(body, dict):
|
|
68
|
+
if body.get("message"):
|
|
69
|
+
message = str(body["message"])
|
|
70
|
+
if body.get("code"):
|
|
71
|
+
error_code = str(body["code"])
|
|
72
|
+
return TogulAPIError(message, status_code=status_code, error_code=error_code)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def require_api_key(config: Config) -> None:
|
|
76
|
+
if not config.api_key.strip():
|
|
77
|
+
raise TogulError("togul: api_key is required")
|
togul/async_client.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from types import TracebackType
|
|
5
|
+
from typing import List, Mapping, Optional, Type
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from . import _transport as t
|
|
10
|
+
from .cache import TTLCache
|
|
11
|
+
from .client import CacheListener, safe_json
|
|
12
|
+
from .config import Config
|
|
13
|
+
from .errors import TogulError
|
|
14
|
+
from .result import EvaluateResult
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AsyncTogulClient:
|
|
18
|
+
"""Asyncio Togul client for FastAPI, Starlette and other ASGI apps.
|
|
19
|
+
|
|
20
|
+
``invalidate_cache`` and ``invalidate_flag`` stay synchronous: they only
|
|
21
|
+
touch the in-memory cache under a lock, so awaiting them would buy
|
|
22
|
+
nothing.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
config: Config,
|
|
28
|
+
http_client: Optional[httpx.AsyncClient] = None,
|
|
29
|
+
) -> None:
|
|
30
|
+
self._config = config
|
|
31
|
+
self._owns_http = http_client is None
|
|
32
|
+
self._http = http_client or httpx.AsyncClient(
|
|
33
|
+
base_url=config.base_url,
|
|
34
|
+
timeout=config.timeout,
|
|
35
|
+
)
|
|
36
|
+
self._cache = TTLCache(config.cache_ttl)
|
|
37
|
+
self._listeners: List[CacheListener] = []
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def config(self) -> Config:
|
|
41
|
+
return self._config
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def cache(self) -> TTLCache:
|
|
45
|
+
return self._cache
|
|
46
|
+
|
|
47
|
+
async def evaluate(
|
|
48
|
+
self,
|
|
49
|
+
flag_key: str,
|
|
50
|
+
context: Optional[Mapping[str, str]] = None,
|
|
51
|
+
) -> EvaluateResult:
|
|
52
|
+
key = t.cache_key_for(flag_key, self._config.environment, context)
|
|
53
|
+
cached = self._cache.get(key)
|
|
54
|
+
if cached is not None:
|
|
55
|
+
return cached
|
|
56
|
+
|
|
57
|
+
result = await self._fetch(flag_key, context)
|
|
58
|
+
self._cache.set(key, result)
|
|
59
|
+
return result
|
|
60
|
+
|
|
61
|
+
def invalidate_cache(self) -> None:
|
|
62
|
+
self._cache.flush()
|
|
63
|
+
self._notify("")
|
|
64
|
+
|
|
65
|
+
def invalidate_flag(self, flag_key: str) -> None:
|
|
66
|
+
self._cache.invalidate_flag(flag_key)
|
|
67
|
+
self._notify(flag_key)
|
|
68
|
+
|
|
69
|
+
def on_cache_invalidated(self, listener: CacheListener) -> None:
|
|
70
|
+
self._listeners.append(listener)
|
|
71
|
+
|
|
72
|
+
async def aclose(self) -> None:
|
|
73
|
+
if self._owns_http:
|
|
74
|
+
await self._http.aclose()
|
|
75
|
+
|
|
76
|
+
async def __aenter__(self) -> AsyncTogulClient:
|
|
77
|
+
return self
|
|
78
|
+
|
|
79
|
+
async def __aexit__(
|
|
80
|
+
self,
|
|
81
|
+
exc_type: Optional[Type[BaseException]],
|
|
82
|
+
exc: Optional[BaseException],
|
|
83
|
+
tb: Optional[TracebackType],
|
|
84
|
+
) -> None:
|
|
85
|
+
await self.aclose()
|
|
86
|
+
|
|
87
|
+
async def _fetch(
|
|
88
|
+
self,
|
|
89
|
+
flag_key: str,
|
|
90
|
+
context: Optional[Mapping[str, str]],
|
|
91
|
+
) -> EvaluateResult:
|
|
92
|
+
t.require_api_key(self._config)
|
|
93
|
+
payload = t.build_request(self._config, flag_key, context)
|
|
94
|
+
headers = {"X-API-Key": self._config.api_key}
|
|
95
|
+
last_error: Optional[BaseException] = None
|
|
96
|
+
|
|
97
|
+
for attempt in range(self._config.retry_count):
|
|
98
|
+
if attempt > 0:
|
|
99
|
+
await asyncio.sleep(t.backoff_seconds(attempt))
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
response = await self._http.post(t.EVALUATE_PATH, json=payload, headers=headers)
|
|
103
|
+
except httpx.HTTPError as exc:
|
|
104
|
+
last_error = exc
|
|
105
|
+
continue
|
|
106
|
+
|
|
107
|
+
if response.status_code >= 400:
|
|
108
|
+
error = t.api_error_from(response.status_code, safe_json(response))
|
|
109
|
+
if not t.should_retry(response.status_code):
|
|
110
|
+
raise error
|
|
111
|
+
last_error = error
|
|
112
|
+
continue
|
|
113
|
+
|
|
114
|
+
return t.parse_result(safe_json(response), flag_key)
|
|
115
|
+
|
|
116
|
+
raise TogulError(f"togul: all retries failed: {last_error}") from last_error
|
|
117
|
+
|
|
118
|
+
def _notify(self, flag_key: str) -> None:
|
|
119
|
+
for listener in self._listeners:
|
|
120
|
+
listener(flag_key)
|
togul/cache.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
from typing import Dict, NamedTuple, Optional
|
|
6
|
+
|
|
7
|
+
from .result import EvaluateResult
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class _Entry(NamedTuple):
|
|
11
|
+
result: EvaluateResult
|
|
12
|
+
expires_at: float
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TTLCache:
|
|
16
|
+
"""An in-memory, thread-safe cache of evaluation results.
|
|
17
|
+
|
|
18
|
+
Eviction is lazy: an entry is dropped when it is read after expiry, or
|
|
19
|
+
when it carries a blank ``value_type`` (a malformed or legacy payload).
|
|
20
|
+
This mirrors the Go, JS and PHP SDKs exactly.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, ttl: float) -> None:
|
|
24
|
+
self._ttl = ttl
|
|
25
|
+
self._store: Dict[str, _Entry] = {}
|
|
26
|
+
self._lock = threading.RLock()
|
|
27
|
+
|
|
28
|
+
def get(self, key: str) -> Optional[EvaluateResult]:
|
|
29
|
+
with self._lock:
|
|
30
|
+
entry = self._store.get(key)
|
|
31
|
+
if entry is None:
|
|
32
|
+
return None
|
|
33
|
+
if time.monotonic() > entry.expires_at:
|
|
34
|
+
del self._store[key]
|
|
35
|
+
return None
|
|
36
|
+
if entry.result.value_type == "":
|
|
37
|
+
del self._store[key]
|
|
38
|
+
return None
|
|
39
|
+
return entry.result
|
|
40
|
+
|
|
41
|
+
def set(self, key: str, result: EvaluateResult) -> None:
|
|
42
|
+
with self._lock:
|
|
43
|
+
self._store[key] = _Entry(result, time.monotonic() + self._ttl)
|
|
44
|
+
|
|
45
|
+
def flush(self) -> None:
|
|
46
|
+
with self._lock:
|
|
47
|
+
self._store.clear()
|
|
48
|
+
|
|
49
|
+
def invalidate_flag(self, flag_key: str) -> None:
|
|
50
|
+
prefix = flag_key + ":"
|
|
51
|
+
with self._lock:
|
|
52
|
+
for key in [k for k in self._store if k.startswith(prefix)]:
|
|
53
|
+
del self._store[key]
|
togul/client.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from types import TracebackType
|
|
5
|
+
from typing import Any, Callable, List, Mapping, Optional, Type
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from . import _transport as t
|
|
10
|
+
from .cache import TTLCache
|
|
11
|
+
from .config import Config
|
|
12
|
+
from .errors import TogulError
|
|
13
|
+
from .result import EvaluateResult
|
|
14
|
+
|
|
15
|
+
CacheListener = Callable[[str], None]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def safe_json(response: httpx.Response) -> Any:
|
|
19
|
+
"""Decode a response body, returning None when it is not JSON."""
|
|
20
|
+
try:
|
|
21
|
+
return response.json()
|
|
22
|
+
except ValueError:
|
|
23
|
+
return None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TogulClient:
|
|
27
|
+
"""Synchronous Togul client.
|
|
28
|
+
|
|
29
|
+
Use this from Django, Celery, scripts, and any other blocking context.
|
|
30
|
+
For asyncio applications (FastAPI, Starlette) use ``AsyncTogulClient``.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, config: Config, http_client: Optional[httpx.Client] = None) -> None:
|
|
34
|
+
self._config = config
|
|
35
|
+
self._owns_http = http_client is None
|
|
36
|
+
self._http = http_client or httpx.Client(
|
|
37
|
+
base_url=config.base_url,
|
|
38
|
+
timeout=config.timeout,
|
|
39
|
+
)
|
|
40
|
+
self._cache = TTLCache(config.cache_ttl)
|
|
41
|
+
self._listeners: List[CacheListener] = []
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def config(self) -> Config:
|
|
45
|
+
return self._config
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def cache(self) -> TTLCache:
|
|
49
|
+
"""The cache this client reads. A stream client invalidates it."""
|
|
50
|
+
return self._cache
|
|
51
|
+
|
|
52
|
+
def evaluate(
|
|
53
|
+
self,
|
|
54
|
+
flag_key: str,
|
|
55
|
+
context: Optional[Mapping[str, str]] = None,
|
|
56
|
+
) -> EvaluateResult:
|
|
57
|
+
key = t.cache_key_for(flag_key, self._config.environment, context)
|
|
58
|
+
cached = self._cache.get(key)
|
|
59
|
+
if cached is not None:
|
|
60
|
+
return cached
|
|
61
|
+
|
|
62
|
+
result = self._fetch(flag_key, context)
|
|
63
|
+
self._cache.set(key, result)
|
|
64
|
+
return result
|
|
65
|
+
|
|
66
|
+
def invalidate_cache(self) -> None:
|
|
67
|
+
self._cache.flush()
|
|
68
|
+
self._notify("")
|
|
69
|
+
|
|
70
|
+
def invalidate_flag(self, flag_key: str) -> None:
|
|
71
|
+
self._cache.invalidate_flag(flag_key)
|
|
72
|
+
self._notify(flag_key)
|
|
73
|
+
|
|
74
|
+
def on_cache_invalidated(self, listener: CacheListener) -> None:
|
|
75
|
+
self._listeners.append(listener)
|
|
76
|
+
|
|
77
|
+
def close(self) -> None:
|
|
78
|
+
if self._owns_http:
|
|
79
|
+
self._http.close()
|
|
80
|
+
|
|
81
|
+
def __enter__(self) -> TogulClient:
|
|
82
|
+
return self
|
|
83
|
+
|
|
84
|
+
def __exit__(
|
|
85
|
+
self,
|
|
86
|
+
exc_type: Optional[Type[BaseException]],
|
|
87
|
+
exc: Optional[BaseException],
|
|
88
|
+
tb: Optional[TracebackType],
|
|
89
|
+
) -> None:
|
|
90
|
+
self.close()
|
|
91
|
+
|
|
92
|
+
def _fetch(
|
|
93
|
+
self,
|
|
94
|
+
flag_key: str,
|
|
95
|
+
context: Optional[Mapping[str, str]],
|
|
96
|
+
) -> EvaluateResult:
|
|
97
|
+
t.require_api_key(self._config)
|
|
98
|
+
payload = t.build_request(self._config, flag_key, context)
|
|
99
|
+
headers = {"X-API-Key": self._config.api_key}
|
|
100
|
+
last_error: Optional[BaseException] = None
|
|
101
|
+
|
|
102
|
+
for attempt in range(self._config.retry_count):
|
|
103
|
+
if attempt > 0:
|
|
104
|
+
time.sleep(t.backoff_seconds(attempt))
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
response = self._http.post(t.EVALUATE_PATH, json=payload, headers=headers)
|
|
108
|
+
except httpx.HTTPError as exc:
|
|
109
|
+
last_error = exc
|
|
110
|
+
continue
|
|
111
|
+
|
|
112
|
+
if response.status_code >= 400:
|
|
113
|
+
error = t.api_error_from(response.status_code, safe_json(response))
|
|
114
|
+
if not t.should_retry(response.status_code):
|
|
115
|
+
raise error
|
|
116
|
+
last_error = error
|
|
117
|
+
continue
|
|
118
|
+
|
|
119
|
+
return t.parse_result(safe_json(response), flag_key)
|
|
120
|
+
|
|
121
|
+
raise TogulError(f"togul: all retries failed: {last_error}") from last_error
|
|
122
|
+
|
|
123
|
+
def _notify(self, flag_key: str) -> None:
|
|
124
|
+
for listener in self._listeners:
|
|
125
|
+
listener(flag_key)
|
togul/config.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import dataclasses
|
|
4
|
+
|
|
5
|
+
DEFAULT_BASE_URL = "https://api.togul.io"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclasses.dataclass(frozen=True)
|
|
9
|
+
class Config:
|
|
10
|
+
"""Connection and caching settings for a Togul client.
|
|
11
|
+
|
|
12
|
+
Args:
|
|
13
|
+
api_key: Environment-scoped API key (``server`` or ``sdk`` scope to
|
|
14
|
+
evaluate, ``sdk`` or ``stream`` scope to stream).
|
|
15
|
+
environment: Environment key, e.g. ``"production"``.
|
|
16
|
+
timeout: HTTP request timeout in seconds.
|
|
17
|
+
cache_ttl: How long an evaluation result stays cached, in seconds.
|
|
18
|
+
retry_count: Total request attempts, not extra retries. ``2`` means
|
|
19
|
+
the SDK issues at most two requests.
|
|
20
|
+
base_url: API origin. Trailing slashes are stripped.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
api_key: str
|
|
24
|
+
environment: str
|
|
25
|
+
timeout: float = 5.0
|
|
26
|
+
cache_ttl: float = 30.0
|
|
27
|
+
retry_count: int = 2
|
|
28
|
+
base_url: str = DEFAULT_BASE_URL
|
|
29
|
+
|
|
30
|
+
def __post_init__(self) -> None:
|
|
31
|
+
normalized = self.base_url.rstrip("/") or DEFAULT_BASE_URL
|
|
32
|
+
object.__setattr__(self, "base_url", normalized)
|
togul/contrib/django.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
from typing import Any, Callable, Dict, Optional, TypeVar, cast
|
|
5
|
+
|
|
6
|
+
from django.conf import settings
|
|
7
|
+
from django.core.exceptions import ImproperlyConfigured
|
|
8
|
+
from django.http import Http404
|
|
9
|
+
from django.utils.module_loading import import_string
|
|
10
|
+
|
|
11
|
+
from ..client import TogulClient
|
|
12
|
+
from ..config import Config
|
|
13
|
+
|
|
14
|
+
_client: Optional[TogulClient] = None
|
|
15
|
+
|
|
16
|
+
ContextBuilder = Callable[[Any], Dict[str, str]]
|
|
17
|
+
ViewFunc = TypeVar("ViewFunc", bound=Callable[..., Any])
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def build_context(request: Any) -> Dict[str, str]:
|
|
21
|
+
"""Map a Django request onto the flag evaluation context.
|
|
22
|
+
|
|
23
|
+
Maps the authenticated user's primary key to ``user_id`` and nothing
|
|
24
|
+
else, matching ``togul-laravel``'s middleware. An anonymous request
|
|
25
|
+
yields an empty context rather than a blank ``user_id``, so its cache
|
|
26
|
+
key stays ``flag:environment`` and rule matching never sees a
|
|
27
|
+
present-but-empty attribute.
|
|
28
|
+
|
|
29
|
+
Applications that need more — a country, a plan, an experiment cohort —
|
|
30
|
+
point ``settings.TOGUL["CONTEXT_BUILDER"]`` at their own function
|
|
31
|
+
instead of editing this one. Every value must be a string: the API
|
|
32
|
+
types ``context`` as ``additionalProperties: {type: string}``.
|
|
33
|
+
"""
|
|
34
|
+
context: Dict[str, str] = {}
|
|
35
|
+
|
|
36
|
+
user = getattr(request, "user", None)
|
|
37
|
+
if user is not None and getattr(user, "is_authenticated", False):
|
|
38
|
+
context["user_id"] = str(user.pk)
|
|
39
|
+
|
|
40
|
+
return context
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _settings() -> Dict[str, Any]:
|
|
44
|
+
raw = getattr(settings, "TOGUL", None)
|
|
45
|
+
if not raw or not raw.get("API_KEY") or not raw.get("ENVIRONMENT"):
|
|
46
|
+
raise ImproperlyConfigured("settings.TOGUL must define at least API_KEY and ENVIRONMENT")
|
|
47
|
+
return dict(raw)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def get_client() -> TogulClient:
|
|
51
|
+
"""Return the process-wide Togul client, building it on first use.
|
|
52
|
+
|
|
53
|
+
``base_url`` is intentionally not read from settings: like
|
|
54
|
+
``togul-laravel/config/togul.php``, the framework layer exposes only the
|
|
55
|
+
key, environment, timeout, TTL and retry count. Construct a ``Config``
|
|
56
|
+
directly to point at a non-production origin.
|
|
57
|
+
"""
|
|
58
|
+
global _client
|
|
59
|
+
if _client is None:
|
|
60
|
+
raw = _settings()
|
|
61
|
+
_client = TogulClient(
|
|
62
|
+
Config(
|
|
63
|
+
api_key=raw["API_KEY"],
|
|
64
|
+
environment=raw["ENVIRONMENT"],
|
|
65
|
+
timeout=raw.get("TIMEOUT", 5.0),
|
|
66
|
+
cache_ttl=raw.get("CACHE_TTL", 30.0),
|
|
67
|
+
retry_count=raw.get("RETRY_COUNT", 2),
|
|
68
|
+
)
|
|
69
|
+
)
|
|
70
|
+
return _client
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def reset_client() -> None:
|
|
74
|
+
"""Drop the cached client. Useful in tests and after a settings reload."""
|
|
75
|
+
global _client
|
|
76
|
+
if _client is not None:
|
|
77
|
+
_client.close()
|
|
78
|
+
_client = None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _resolve_context_builder() -> ContextBuilder:
|
|
82
|
+
raw = getattr(settings, "TOGUL", None) or {}
|
|
83
|
+
builder = raw.get("CONTEXT_BUILDER")
|
|
84
|
+
if isinstance(builder, str):
|
|
85
|
+
resolved: ContextBuilder = import_string(builder)
|
|
86
|
+
return resolved
|
|
87
|
+
if callable(builder):
|
|
88
|
+
return cast(ContextBuilder, builder)
|
|
89
|
+
return build_context
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class TogulMiddleware:
|
|
93
|
+
"""Attach ``request.togul`` and ``request.togul_context`` to every request.
|
|
94
|
+
|
|
95
|
+
Add ``"togul.contrib.django.TogulMiddleware"`` to ``MIDDLEWARE``.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
def __init__(self, get_response: Callable[[Any], Any]) -> None:
|
|
99
|
+
self.get_response = get_response
|
|
100
|
+
self.build_context = _resolve_context_builder()
|
|
101
|
+
|
|
102
|
+
def __call__(self, request: Any) -> Any:
|
|
103
|
+
request.togul = get_client()
|
|
104
|
+
request.togul_context = self.build_context(request)
|
|
105
|
+
return self.get_response(request)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def togul_flag(flag_key: str) -> Callable[[ViewFunc], ViewFunc]:
|
|
109
|
+
"""Gate a view behind a flag, raising ``Http404`` when it is disabled.
|
|
110
|
+
|
|
111
|
+
This is the Django form of ``togul-laravel``'s route guard
|
|
112
|
+
(``->middleware('togul:new-dashboard')`` → ``abort(404)``). Django
|
|
113
|
+
middleware takes no per-route arguments, so the guard is a decorator::
|
|
114
|
+
|
|
115
|
+
@togul_flag("new-dashboard")
|
|
116
|
+
def dashboard(request):
|
|
117
|
+
...
|
|
118
|
+
|
|
119
|
+
The context comes from ``request.togul_context`` when ``TogulMiddleware``
|
|
120
|
+
already built it, and is built on demand otherwise — so the decorator
|
|
121
|
+
works with or without the middleware installed.
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
def decorator(view: ViewFunc) -> ViewFunc:
|
|
125
|
+
@functools.wraps(view)
|
|
126
|
+
def wrapped(request: Any, *args: Any, **kwargs: Any) -> Any:
|
|
127
|
+
context = getattr(request, "togul_context", None)
|
|
128
|
+
if context is None:
|
|
129
|
+
context = _resolve_context_builder()(request)
|
|
130
|
+
|
|
131
|
+
if not get_client().evaluate(flag_key, context).enabled:
|
|
132
|
+
raise Http404(f"togul: flag {flag_key!r} is disabled")
|
|
133
|
+
|
|
134
|
+
return view(request, *args, **kwargs)
|
|
135
|
+
|
|
136
|
+
return cast(ViewFunc, wrapped)
|
|
137
|
+
|
|
138
|
+
return decorator
|
togul/contrib/fastapi.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Any, AsyncIterator, Callable, Optional, cast
|
|
7
|
+
|
|
8
|
+
from starlette.requests import Request
|
|
9
|
+
|
|
10
|
+
from ..async_client import AsyncTogulClient
|
|
11
|
+
from ..config import Config
|
|
12
|
+
from ..stream import AsyncTogulStreamClient
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger("togul")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def create_lifespan(
|
|
18
|
+
config: Config,
|
|
19
|
+
*,
|
|
20
|
+
stream: bool = True,
|
|
21
|
+
client: Optional[AsyncTogulClient] = None,
|
|
22
|
+
) -> Callable[[Any], Any]:
|
|
23
|
+
"""Build a lifespan context manager for FastAPI or Starlette.
|
|
24
|
+
|
|
25
|
+
The client is stored on ``app.state.togul``. When ``stream`` is true an
|
|
26
|
+
``AsyncTogulStreamClient`` runs as a background task (on
|
|
27
|
+
``app.state.togul_stream_task``) so SSE events invalidate the cache in
|
|
28
|
+
real time. Both are shut down cleanly on application exit — including an
|
|
29
|
+
injected ``client``, which the lifespan owns for the app's lifetime.
|
|
30
|
+
|
|
31
|
+
Usage::
|
|
32
|
+
|
|
33
|
+
app = FastAPI(lifespan=create_lifespan(Config(...)))
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
@contextlib.asynccontextmanager
|
|
37
|
+
async def lifespan(app: Any) -> AsyncIterator[None]:
|
|
38
|
+
togul = client or AsyncTogulClient(config)
|
|
39
|
+
app.state.togul = togul
|
|
40
|
+
|
|
41
|
+
stream_client: Optional[AsyncTogulStreamClient] = None
|
|
42
|
+
task: Optional[asyncio.Task[None]] = None
|
|
43
|
+
|
|
44
|
+
if stream:
|
|
45
|
+
stream_client = AsyncTogulStreamClient(config, togul.cache)
|
|
46
|
+
task = asyncio.create_task(stream_client.connect())
|
|
47
|
+
task.add_done_callback(_log_stream_failure)
|
|
48
|
+
app.state.togul_stream_task = task
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
yield
|
|
52
|
+
finally:
|
|
53
|
+
if task is not None:
|
|
54
|
+
task.cancel()
|
|
55
|
+
try:
|
|
56
|
+
await task
|
|
57
|
+
except asyncio.CancelledError:
|
|
58
|
+
pass
|
|
59
|
+
except Exception:
|
|
60
|
+
# The stream task already failed on its own — for example a 401
|
|
61
|
+
# from an API key without `stream` scope. Shutdown must still
|
|
62
|
+
# close both pools.
|
|
63
|
+
pass
|
|
64
|
+
if stream_client is not None:
|
|
65
|
+
await stream_client.aclose()
|
|
66
|
+
await togul.aclose()
|
|
67
|
+
|
|
68
|
+
return lifespan
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _log_stream_failure(task: asyncio.Task[None]) -> None:
|
|
72
|
+
"""Surface an ``AsyncTogulStreamClient`` background task that died on its own.
|
|
73
|
+
|
|
74
|
+
Without this, a failed stream task (a 401/403 from an API key that lacks
|
|
75
|
+
``stream`` scope, for example) fails silently: real-time invalidation
|
|
76
|
+
stops, the cache quietly degrades to TTL-only, and nothing is reported
|
|
77
|
+
until Python's default handler prints "Task exception was never
|
|
78
|
+
retrieved" at garbage-collection time.
|
|
79
|
+
"""
|
|
80
|
+
if task.cancelled():
|
|
81
|
+
return
|
|
82
|
+
exc = task.exception()
|
|
83
|
+
if exc is not None:
|
|
84
|
+
logger.error(
|
|
85
|
+
"togul: stream task failed, real-time cache invalidation has stopped "
|
|
86
|
+
"(falling back to TTL-only caching): %r",
|
|
87
|
+
exc,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def get_togul(request: Request) -> AsyncTogulClient:
|
|
92
|
+
"""FastAPI dependency: ``togul = Depends(get_togul)``."""
|
|
93
|
+
return cast(AsyncTogulClient, request.app.state.togul)
|
togul/errors.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TogulError(Exception):
|
|
7
|
+
"""Base class for every error raised by the Togul SDK."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TogulAPIError(TogulError):
|
|
11
|
+
"""Raised when the Togul API returns a non-2xx response."""
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
message: str,
|
|
16
|
+
status_code: int = 0,
|
|
17
|
+
error_code: Optional[str] = None,
|
|
18
|
+
) -> None:
|
|
19
|
+
super().__init__(message)
|
|
20
|
+
self.message = message
|
|
21
|
+
self.status_code = status_code
|
|
22
|
+
self.error_code = error_code
|
togul/py.typed
ADDED
|
File without changes
|
togul/result.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import dataclasses
|
|
4
|
+
from typing import Any, Literal
|
|
5
|
+
|
|
6
|
+
ValueType = Literal["boolean", "string", "number", "json"]
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclasses.dataclass(frozen=True)
|
|
10
|
+
class EvaluateResult:
|
|
11
|
+
"""A single flag evaluation, mirroring the API's ``EvaluateResponse``.
|
|
12
|
+
|
|
13
|
+
Attributes:
|
|
14
|
+
flag_key: The evaluated flag key.
|
|
15
|
+
enabled: Whether the flag itself is enabled.
|
|
16
|
+
value_type: One of ``boolean``, ``string``, ``number``, ``json``.
|
|
17
|
+
An empty string means the API response was malformed.
|
|
18
|
+
value: The flag value. Its Python type follows ``value_type``.
|
|
19
|
+
reason: One of ``disabled``, ``rule_match``, ``default``.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
flag_key: str
|
|
23
|
+
enabled: bool
|
|
24
|
+
value_type: str
|
|
25
|
+
value: Any
|
|
26
|
+
reason: str
|
togul/stream.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import time
|
|
5
|
+
from typing import List, Optional
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from . import _transport as t
|
|
10
|
+
from ._sse import parse_sse_event
|
|
11
|
+
from .cache import TTLCache
|
|
12
|
+
from .client import CacheListener, safe_json
|
|
13
|
+
from .config import Config
|
|
14
|
+
from .errors import TogulAPIError
|
|
15
|
+
|
|
16
|
+
INITIAL_BACKOFF = 1.0
|
|
17
|
+
MAX_BACKOFF = 30.0
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _is_auth_error(exc: BaseException) -> bool:
|
|
21
|
+
return isinstance(exc, TogulAPIError) and exc.status_code in (401, 403)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _stream_timeout(config: Config) -> httpx.Timeout:
|
|
25
|
+
"""No read deadline: an SSE connection is intentionally long-lived."""
|
|
26
|
+
return httpx.Timeout(None, connect=config.timeout)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class _BaseStreamClient:
|
|
30
|
+
def __init__(self, config: Config, cache: TTLCache) -> None:
|
|
31
|
+
self._config = config
|
|
32
|
+
self._cache = cache
|
|
33
|
+
self._listeners: List[CacheListener] = []
|
|
34
|
+
self._stop = False
|
|
35
|
+
#: Internal test hook: end connect() after one stream attempt.
|
|
36
|
+
self.stop_after_first_stream = False
|
|
37
|
+
|
|
38
|
+
def on_cache_invalidated(self, listener: CacheListener) -> None:
|
|
39
|
+
self._listeners.append(listener)
|
|
40
|
+
|
|
41
|
+
def stop(self) -> None:
|
|
42
|
+
"""Ask the reconnect loop to exit at the next opportunity."""
|
|
43
|
+
self._stop = True
|
|
44
|
+
|
|
45
|
+
def _handle_line(self, line: str) -> None:
|
|
46
|
+
event = parse_sse_event(line)
|
|
47
|
+
if event is None:
|
|
48
|
+
return
|
|
49
|
+
|
|
50
|
+
flag_key = str(event.get("flag_key") or "")
|
|
51
|
+
if flag_key:
|
|
52
|
+
self._cache.invalidate_flag(flag_key)
|
|
53
|
+
else:
|
|
54
|
+
self._cache.flush()
|
|
55
|
+
|
|
56
|
+
for listener in self._listeners:
|
|
57
|
+
listener(flag_key)
|
|
58
|
+
|
|
59
|
+
def _next_backoff(self, backoff: float) -> float:
|
|
60
|
+
return min(backoff * 2, MAX_BACKOFF)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class TogulStreamClient(_BaseStreamClient):
|
|
64
|
+
"""Blocking SSE client that keeps a cache fresh.
|
|
65
|
+
|
|
66
|
+
``connect()`` never returns on its own: it reconnects with exponential
|
|
67
|
+
backoff until ``stop()`` is called, or until the API rejects the key
|
|
68
|
+
with 401/403 — which is re-raised, because retrying cannot fix it.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
def __init__(
|
|
72
|
+
self,
|
|
73
|
+
config: Config,
|
|
74
|
+
cache: TTLCache,
|
|
75
|
+
http_client: Optional[httpx.Client] = None,
|
|
76
|
+
) -> None:
|
|
77
|
+
super().__init__(config, cache)
|
|
78
|
+
self._owns_http = http_client is None
|
|
79
|
+
self._http = http_client or httpx.Client(
|
|
80
|
+
base_url=config.base_url,
|
|
81
|
+
timeout=_stream_timeout(config),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def _sleep(self, seconds: float) -> None:
|
|
85
|
+
time.sleep(seconds)
|
|
86
|
+
|
|
87
|
+
def connect(self) -> None:
|
|
88
|
+
backoff = INITIAL_BACKOFF
|
|
89
|
+
while not self._stop:
|
|
90
|
+
try:
|
|
91
|
+
self._stream_once()
|
|
92
|
+
backoff = INITIAL_BACKOFF
|
|
93
|
+
except Exception as exc:
|
|
94
|
+
if _is_auth_error(exc):
|
|
95
|
+
raise
|
|
96
|
+
if self._stop:
|
|
97
|
+
return
|
|
98
|
+
self._sleep(backoff)
|
|
99
|
+
backoff = self._next_backoff(backoff)
|
|
100
|
+
if self.stop_after_first_stream:
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
def close(self) -> None:
|
|
104
|
+
self.stop()
|
|
105
|
+
if self._owns_http:
|
|
106
|
+
self._http.close()
|
|
107
|
+
|
|
108
|
+
def _stream_once(self) -> None:
|
|
109
|
+
t.require_api_key(self._config)
|
|
110
|
+
headers = {
|
|
111
|
+
"Accept": "text/event-stream",
|
|
112
|
+
"X-API-Key": self._config.api_key,
|
|
113
|
+
}
|
|
114
|
+
with self._http.stream("GET", t.STREAM_PATH, headers=headers) as response:
|
|
115
|
+
if response.status_code != 200:
|
|
116
|
+
response.read()
|
|
117
|
+
raise t.api_error_from(response.status_code, safe_json(response))
|
|
118
|
+
for line in response.iter_lines():
|
|
119
|
+
if self._stop:
|
|
120
|
+
return
|
|
121
|
+
self._handle_line(line)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class AsyncTogulStreamClient(_BaseStreamClient):
|
|
125
|
+
"""Asyncio SSE client. Run it as a background task."""
|
|
126
|
+
|
|
127
|
+
def __init__(
|
|
128
|
+
self,
|
|
129
|
+
config: Config,
|
|
130
|
+
cache: TTLCache,
|
|
131
|
+
http_client: Optional[httpx.AsyncClient] = None,
|
|
132
|
+
) -> None:
|
|
133
|
+
super().__init__(config, cache)
|
|
134
|
+
self._owns_http = http_client is None
|
|
135
|
+
self._http = http_client or httpx.AsyncClient(
|
|
136
|
+
base_url=config.base_url,
|
|
137
|
+
timeout=_stream_timeout(config),
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
async def _sleep(self, seconds: float) -> None:
|
|
141
|
+
await asyncio.sleep(seconds)
|
|
142
|
+
|
|
143
|
+
async def connect(self) -> None:
|
|
144
|
+
backoff = INITIAL_BACKOFF
|
|
145
|
+
while not self._stop:
|
|
146
|
+
try:
|
|
147
|
+
await self._stream_once()
|
|
148
|
+
backoff = INITIAL_BACKOFF
|
|
149
|
+
except asyncio.CancelledError:
|
|
150
|
+
raise
|
|
151
|
+
except Exception as exc:
|
|
152
|
+
if _is_auth_error(exc):
|
|
153
|
+
raise
|
|
154
|
+
if self._stop:
|
|
155
|
+
return
|
|
156
|
+
await self._sleep(backoff)
|
|
157
|
+
backoff = self._next_backoff(backoff)
|
|
158
|
+
if self.stop_after_first_stream:
|
|
159
|
+
return
|
|
160
|
+
|
|
161
|
+
async def aclose(self) -> None:
|
|
162
|
+
self.stop()
|
|
163
|
+
if self._owns_http:
|
|
164
|
+
await self._http.aclose()
|
|
165
|
+
|
|
166
|
+
async def _stream_once(self) -> None:
|
|
167
|
+
t.require_api_key(self._config)
|
|
168
|
+
headers = {
|
|
169
|
+
"Accept": "text/event-stream",
|
|
170
|
+
"X-API-Key": self._config.api_key,
|
|
171
|
+
}
|
|
172
|
+
async with self._http.stream("GET", t.STREAM_PATH, headers=headers) as response:
|
|
173
|
+
if response.status_code != 200:
|
|
174
|
+
await response.aread()
|
|
175
|
+
raise t.api_error_from(response.status_code, safe_json(response))
|
|
176
|
+
async for line in response.aiter_lines():
|
|
177
|
+
if self._stop:
|
|
178
|
+
return
|
|
179
|
+
self._handle_line(line)
|
|
@@ -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).
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
togul/__init__.py,sha256=InLsdeJHm84g3Cr4V1BwLkRpYKt9ZI0JZdD64TQKtxE,632
|
|
2
|
+
togul/_sse.py,sha256=ArGL2zl68bG86om6UNuB3HI7YTYD5QlBl-nFTHee8LE,781
|
|
3
|
+
togul/_transport.py,sha256=U-Btvcr6upezqiloeg18C7jaQBfxg6MxChiKx_ZPg0o,2236
|
|
4
|
+
togul/async_client.py,sha256=h-PY-hwrMrNEo7KRaRNwYGmkw1poPb1eR4tsrrSrz58,3609
|
|
5
|
+
togul/cache.py,sha256=Xkp9QVuBq3uJrCstS7EbaH2zgNmfsF49K8D--Maexqc,1580
|
|
6
|
+
togul/client.py,sha256=UldslGFUfZ37oRXpRf1js-dJYCg3zPlxs-q4lh56pQQ,3725
|
|
7
|
+
togul/config.py,sha256=7NRqJVZ8IFRY8jozdtxf8gddQgoL6Vf6Hh0f3j310Mg,1054
|
|
8
|
+
togul/errors.py,sha256=ZERnVTafBHav-bQNUBB5F4RG4p4SheEdh8E2sVkd1us,539
|
|
9
|
+
togul/result.py,sha256=GruUnEEgSTMABXhnvyyFz6PmBDyT4S0hsFeDTNcz6iw,767
|
|
10
|
+
togul/stream.py,sha256=OD8_xou7EprmmmYRnUlFia_jclvUHW-fF3d0Mc_y06A,5616
|
|
11
|
+
togul/contrib/__init__.py,sha256=5bQYr8MLpreK-0m-V1j1P4b3Rv5YHVOMnRfCPCbSE8c,108
|
|
12
|
+
togul/contrib/django.py,sha256=SbN2PasPPcdUYmlKYML52uqYLatvg6FMKFQ-JbiHQKo,4800
|
|
13
|
+
togul/contrib/fastapi.py,sha256=2RWYxrqmwFkWX4nL-spN2JebveGdV8sD8MzDNcqNIrc,3142
|
|
14
|
+
togul/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
15
|
+
togul-0.1.0.dist-info/METADATA,sha256=9vpqSBUp3dbUnFjv7FFJkMYi9n1_1pgFFZ4h2cALrRw,11649
|
|
16
|
+
togul-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
17
|
+
togul-0.1.0.dist-info/licenses/LICENSE,sha256=qqlwhIgv1axiq5bM0dq8U_Ti4bJ-2_hZH8X1mKRjW1U,1062
|
|
18
|
+
togul-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|