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 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)
@@ -0,0 +1,3 @@
1
+ """Framework integrations. Each module imports its framework lazily."""
2
+
3
+ from __future__ import annotations
@@ -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
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.