flaglite 1.0.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.
flaglite/__init__.py ADDED
@@ -0,0 +1,44 @@
1
+ """FlagLite Python SDK.
2
+
3
+ A lightweight, production-ready Python client for the FlagLite feature flag service.
4
+
5
+ Example:
6
+ ```python
7
+ from flaglite import FlagLite
8
+
9
+ flags = FlagLite() # Auto-init from FLAGLITE_API_KEY env var
10
+
11
+ # Async usage
12
+ if await flags.enabled('new-checkout'):
13
+ show_new_checkout()
14
+
15
+ # With user ID for percentage rollouts
16
+ if await flags.enabled('new-checkout', user_id='user-123'):
17
+ show_new_checkout()
18
+
19
+ # Sync usage
20
+ if flags.enabled_sync('new-checkout'):
21
+ show_new_checkout()
22
+ ```
23
+ """
24
+
25
+ from .client import FlagLite
26
+ from .exceptions import (
27
+ AuthenticationError,
28
+ ConfigurationError,
29
+ FlagLiteError,
30
+ NetworkError,
31
+ RateLimitError,
32
+ )
33
+
34
+ __version__ = "1.0.0"
35
+
36
+ __all__ = [
37
+ "FlagLite",
38
+ "FlagLiteError",
39
+ "AuthenticationError",
40
+ "RateLimitError",
41
+ "NetworkError",
42
+ "ConfigurationError",
43
+ "__version__",
44
+ ]
flaglite/cache.py ADDED
@@ -0,0 +1,137 @@
1
+ """TTL cache for flag evaluations."""
2
+
3
+ import asyncio
4
+ import time
5
+ from dataclasses import dataclass
6
+ from typing import Dict, Optional, Tuple
7
+
8
+
9
+ @dataclass
10
+ class CacheEntry:
11
+ """A cached flag evaluation result."""
12
+
13
+ value: bool
14
+ expires_at: float
15
+
16
+
17
+ class TTLCache:
18
+ """Thread-safe TTL cache for flag evaluations.
19
+
20
+ Keys are stored as (flag_key, user_id) tuples to support
21
+ user-specific flag evaluations.
22
+ """
23
+
24
+ def __init__(self, ttl_seconds: float = 30.0) -> None:
25
+ """Initialize cache with TTL in seconds.
26
+
27
+ Args:
28
+ ttl_seconds: Time-to-live for cache entries. Default 30 seconds.
29
+ """
30
+ self._ttl = ttl_seconds
31
+ self._cache: Dict[Tuple[str, Optional[str]], CacheEntry] = {}
32
+ self._lock = asyncio.Lock()
33
+
34
+ @property
35
+ def ttl(self) -> float:
36
+ """Return the cache TTL in seconds."""
37
+ return self._ttl
38
+
39
+ def _make_key(self, flag_key: str, user_id: Optional[str]) -> Tuple[str, Optional[str]]:
40
+ """Create a cache key from flag key and user ID."""
41
+ return (flag_key, user_id)
42
+
43
+ async def get(self, flag_key: str, user_id: Optional[str] = None) -> Optional[bool]:
44
+ """Get a cached value if it exists and hasn't expired.
45
+
46
+ Args:
47
+ flag_key: The feature flag key.
48
+ user_id: Optional user ID for user-specific evaluations.
49
+
50
+ Returns:
51
+ The cached boolean value, or None if not found/expired.
52
+ """
53
+ key = self._make_key(flag_key, user_id)
54
+ async with self._lock:
55
+ entry = self._cache.get(key)
56
+ if entry is None:
57
+ return None
58
+ if time.monotonic() > entry.expires_at:
59
+ # Entry expired, remove it
60
+ del self._cache[key]
61
+ return None
62
+ return entry.value
63
+
64
+ async def set(
65
+ self, flag_key: str, value: bool, user_id: Optional[str] = None
66
+ ) -> None:
67
+ """Cache a flag evaluation result.
68
+
69
+ Args:
70
+ flag_key: The feature flag key.
71
+ value: The evaluation result.
72
+ user_id: Optional user ID for user-specific evaluations.
73
+ """
74
+ key = self._make_key(flag_key, user_id)
75
+ expires_at = time.monotonic() + self._ttl
76
+ async with self._lock:
77
+ self._cache[key] = CacheEntry(value=value, expires_at=expires_at)
78
+
79
+ async def invalidate(
80
+ self, flag_key: str, user_id: Optional[str] = None
81
+ ) -> None:
82
+ """Remove a specific entry from the cache.
83
+
84
+ Args:
85
+ flag_key: The feature flag key.
86
+ user_id: Optional user ID for user-specific evaluations.
87
+ """
88
+ key = self._make_key(flag_key, user_id)
89
+ async with self._lock:
90
+ self._cache.pop(key, None)
91
+
92
+ async def clear(self) -> None:
93
+ """Clear all entries from the cache."""
94
+ async with self._lock:
95
+ self._cache.clear()
96
+
97
+ async def cleanup_expired(self) -> int:
98
+ """Remove all expired entries from the cache.
99
+
100
+ Returns:
101
+ Number of entries removed.
102
+ """
103
+ now = time.monotonic()
104
+ removed = 0
105
+ async with self._lock:
106
+ expired_keys = [
107
+ key for key, entry in self._cache.items() if now > entry.expires_at
108
+ ]
109
+ for key in expired_keys:
110
+ del self._cache[key]
111
+ removed += 1
112
+ return removed
113
+
114
+ def get_sync(self, flag_key: str, user_id: Optional[str] = None) -> Optional[bool]:
115
+ """Synchronous version of get() for sync wrapper.
116
+
117
+ Note: Not thread-safe with async operations. Only use from sync context.
118
+ """
119
+ key = self._make_key(flag_key, user_id)
120
+ entry = self._cache.get(key)
121
+ if entry is None:
122
+ return None
123
+ if time.monotonic() > entry.expires_at:
124
+ del self._cache[key]
125
+ return None
126
+ return entry.value
127
+
128
+ def set_sync(
129
+ self, flag_key: str, value: bool, user_id: Optional[str] = None
130
+ ) -> None:
131
+ """Synchronous version of set() for sync wrapper.
132
+
133
+ Note: Not thread-safe with async operations. Only use from sync context.
134
+ """
135
+ key = self._make_key(flag_key, user_id)
136
+ expires_at = time.monotonic() + self._ttl
137
+ self._cache[key] = CacheEntry(value=value, expires_at=expires_at)
flaglite/client.py ADDED
@@ -0,0 +1,411 @@
1
+ """FlagLite SDK client implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import os
8
+ from typing import Optional
9
+ from urllib.parse import urlencode, urljoin
10
+
11
+ import httpx
12
+
13
+ from .cache import TTLCache
14
+ from .exceptions import (
15
+ AuthenticationError,
16
+ ConfigurationError,
17
+ FlagLiteError,
18
+ NetworkError,
19
+ RateLimitError,
20
+ )
21
+
22
+ logger = logging.getLogger("flaglite")
23
+
24
+ DEFAULT_BASE_URL = "https://api.flaglite.dev/v1"
25
+ DEFAULT_CACHE_TTL = 30.0 # 30 seconds
26
+ DEFAULT_TIMEOUT = 5.0 # 5 seconds
27
+
28
+
29
+ class FlagLite:
30
+ """FlagLite feature flag client.
31
+
32
+ A lightweight, production-ready client for the FlagLite feature flag service.
33
+ Supports both async and sync usage patterns with built-in caching.
34
+
35
+ Example:
36
+ ```python
37
+ from flaglite import FlagLite
38
+
39
+ # Async usage
40
+ flags = FlagLite() # Auto-init from FLAGLITE_API_KEY env var
41
+
42
+ if await flags.enabled('new-checkout'):
43
+ show_new_checkout()
44
+
45
+ # With user ID for percentage rollouts
46
+ if await flags.enabled('new-checkout', user_id='user-123'):
47
+ show_new_checkout()
48
+
49
+ # Sync usage
50
+ if flags.enabled_sync('new-checkout'):
51
+ show_new_checkout()
52
+ ```
53
+ """
54
+
55
+ def __init__(
56
+ self,
57
+ api_key: Optional[str] = None,
58
+ base_url: Optional[str] = None,
59
+ cache_ttl: float = DEFAULT_CACHE_TTL,
60
+ timeout: float = DEFAULT_TIMEOUT,
61
+ disable_cache: bool = False,
62
+ ) -> None:
63
+ """Initialize the FlagLite client.
64
+
65
+ Args:
66
+ api_key: FlagLite environment API key. If not provided, reads from
67
+ FLAGLITE_API_KEY environment variable.
68
+ base_url: API base URL. Defaults to https://api.flaglite.dev/v1.
69
+ Can also be set via FLAGLITE_BASE_URL env var.
70
+ cache_ttl: Cache time-to-live in seconds. Default 30 seconds.
71
+ Set to 0 or use disable_cache=True to disable caching.
72
+ timeout: HTTP request timeout in seconds. Default 5 seconds.
73
+ disable_cache: If True, disable caching entirely.
74
+
75
+ Raises:
76
+ ConfigurationError: If no API key is provided or found in environment.
77
+ """
78
+ # Resolve API key
79
+ self._api_key = api_key or os.environ.get("FLAGLITE_API_KEY")
80
+ if not self._api_key:
81
+ raise ConfigurationError(
82
+ "API key required. Pass api_key parameter or set FLAGLITE_API_KEY environment variable."
83
+ )
84
+
85
+ # Resolve base URL
86
+ self._base_url = (
87
+ base_url
88
+ or os.environ.get("FLAGLITE_BASE_URL")
89
+ or DEFAULT_BASE_URL
90
+ )
91
+ if not self._base_url.endswith("/"):
92
+ self._base_url += "/"
93
+
94
+ self._timeout = timeout
95
+
96
+ # Initialize cache
97
+ self._cache_enabled = not disable_cache and cache_ttl > 0
98
+ self._cache = TTLCache(ttl_seconds=cache_ttl) if self._cache_enabled else None
99
+
100
+ # HTTP clients (lazy initialized)
101
+ self._async_client: Optional[httpx.AsyncClient] = None
102
+ self._sync_client: Optional[httpx.Client] = None
103
+
104
+ @property
105
+ def cache_ttl(self) -> float:
106
+ """Return the cache TTL in seconds, or 0 if caching is disabled."""
107
+ return self._cache.ttl if self._cache else 0.0
108
+
109
+ def _get_headers(self) -> dict[str, str]:
110
+ """Return headers for API requests."""
111
+ return {
112
+ "Authorization": f"Bearer {self._api_key}",
113
+ "Content-Type": "application/json",
114
+ "User-Agent": "flaglite-python/1.0.0",
115
+ }
116
+
117
+ def _get_async_client(self) -> httpx.AsyncClient:
118
+ """Get or create the async HTTP client."""
119
+ if self._async_client is None:
120
+ self._async_client = httpx.AsyncClient(
121
+ base_url=self._base_url,
122
+ headers=self._get_headers(),
123
+ timeout=self._timeout,
124
+ )
125
+ return self._async_client
126
+
127
+ def _get_sync_client(self) -> httpx.Client:
128
+ """Get or create the sync HTTP client."""
129
+ if self._sync_client is None:
130
+ self._sync_client = httpx.Client(
131
+ base_url=self._base_url,
132
+ headers=self._get_headers(),
133
+ timeout=self._timeout,
134
+ )
135
+ return self._sync_client
136
+
137
+ async def close(self) -> None:
138
+ """Close the HTTP clients and release resources.
139
+
140
+ Should be called when you're done using the client, especially in
141
+ long-running applications.
142
+ """
143
+ if self._async_client:
144
+ await self._async_client.aclose()
145
+ self._async_client = None
146
+ if self._sync_client:
147
+ self._sync_client.close()
148
+ self._sync_client = None
149
+
150
+ def close_sync(self) -> None:
151
+ """Synchronous version of close()."""
152
+ if self._sync_client:
153
+ self._sync_client.close()
154
+ self._sync_client = None
155
+ # Note: Can't close async client from sync context safely
156
+ # It will be garbage collected
157
+
158
+ async def __aenter__(self) -> FlagLite:
159
+ """Async context manager entry."""
160
+ return self
161
+
162
+ async def __aexit__(self, *args: object) -> None:
163
+ """Async context manager exit."""
164
+ await self.close()
165
+
166
+ def __enter__(self) -> FlagLite:
167
+ """Sync context manager entry."""
168
+ return self
169
+
170
+ def __exit__(self, *args: object) -> None:
171
+ """Sync context manager exit."""
172
+ self.close_sync()
173
+
174
+ async def enabled(
175
+ self,
176
+ flag_key: str,
177
+ user_id: Optional[str] = None,
178
+ default: bool = False,
179
+ ) -> bool:
180
+ """Check if a feature flag is enabled.
181
+
182
+ This is the primary method for checking feature flags. Results are cached
183
+ according to the cache_ttl setting.
184
+
185
+ Args:
186
+ flag_key: The unique flag key (e.g., 'new-checkout').
187
+ user_id: Optional user ID for consistent percentage rollouts.
188
+ When provided, the same user always gets the same result
189
+ for a given flag (sticky bucketing).
190
+ default: Value to return on error. Defaults to False (fail closed).
191
+
192
+ Returns:
193
+ True if the flag is enabled, False otherwise.
194
+
195
+ Note:
196
+ This method never raises exceptions. On any error (network, auth, etc.),
197
+ it returns the default value and logs the error.
198
+ """
199
+ # Check cache first
200
+ if self._cache:
201
+ cached = await self._cache.get(flag_key, user_id)
202
+ if cached is not None:
203
+ logger.debug(f"Cache hit for flag '{flag_key}' (user={user_id})")
204
+ return cached
205
+
206
+ # Make API request
207
+ try:
208
+ result = await self._evaluate_flag(flag_key, user_id)
209
+
210
+ # Cache the result
211
+ if self._cache:
212
+ await self._cache.set(flag_key, result, user_id)
213
+
214
+ return result
215
+
216
+ except FlagLiteError as e:
217
+ logger.warning(f"FlagLite error evaluating '{flag_key}': {e.message}")
218
+ return default
219
+ except Exception as e:
220
+ logger.warning(f"Unexpected error evaluating flag '{flag_key}': {e}")
221
+ return default
222
+
223
+ async def _evaluate_flag(
224
+ self, flag_key: str, user_id: Optional[str] = None
225
+ ) -> bool:
226
+ """Make the actual API request to evaluate a flag.
227
+
228
+ Args:
229
+ flag_key: The flag key to evaluate.
230
+ user_id: Optional user ID for percentage rollouts.
231
+
232
+ Returns:
233
+ The flag evaluation result.
234
+
235
+ Raises:
236
+ AuthenticationError: If the API key is invalid.
237
+ RateLimitError: If rate limit is exceeded.
238
+ NetworkError: If a network error occurs.
239
+ FlagLiteError: For other API errors.
240
+ """
241
+ client = self._get_async_client()
242
+
243
+ # Build URL with query params
244
+ url = f"flags/{flag_key}"
245
+ if user_id:
246
+ url = f"{url}?{urlencode({'user_id': user_id})}"
247
+
248
+ try:
249
+ response = await client.get(url)
250
+ except httpx.TimeoutException as e:
251
+ raise NetworkError(f"Request timed out: {e}") from e
252
+ except httpx.NetworkError as e:
253
+ raise NetworkError(f"Network error: {e}") from e
254
+ except httpx.HTTPError as e:
255
+ raise NetworkError(f"HTTP error: {e}") from e
256
+
257
+ # Handle response
258
+ if response.status_code == 200:
259
+ data = response.json()
260
+ return bool(data.get("enabled", False))
261
+
262
+ if response.status_code == 401:
263
+ raise AuthenticationError(
264
+ "Invalid API key", status_code=response.status_code
265
+ )
266
+
267
+ if response.status_code == 404:
268
+ # Flag not found - per spec, return enabled: false
269
+ return False
270
+
271
+ if response.status_code == 429:
272
+ retry_after = response.headers.get("Retry-After")
273
+ raise RateLimitError(
274
+ "Rate limit exceeded",
275
+ retry_after=int(retry_after) if retry_after else None,
276
+ status_code=429,
277
+ )
278
+
279
+ # Other errors
280
+ try:
281
+ error_data = response.json()
282
+ message = error_data.get("message", f"HTTP {response.status_code}")
283
+ except Exception:
284
+ message = f"HTTP {response.status_code}"
285
+
286
+ raise FlagLiteError(message, status_code=response.status_code)
287
+
288
+ def enabled_sync(
289
+ self,
290
+ flag_key: str,
291
+ user_id: Optional[str] = None,
292
+ default: bool = False,
293
+ ) -> bool:
294
+ """Synchronous version of enabled().
295
+
296
+ Use this method in synchronous code paths. Internally creates an event
297
+ loop if needed, or uses httpx sync client for efficiency.
298
+
299
+ Args:
300
+ flag_key: The unique flag key.
301
+ user_id: Optional user ID for consistent percentage rollouts.
302
+ default: Value to return on error. Defaults to False.
303
+
304
+ Returns:
305
+ True if the flag is enabled, False otherwise.
306
+ """
307
+ # Check cache first (sync version)
308
+ if self._cache:
309
+ cached = self._cache.get_sync(flag_key, user_id)
310
+ if cached is not None:
311
+ logger.debug(f"Cache hit for flag '{flag_key}' (user={user_id})")
312
+ return cached
313
+
314
+ # Make sync API request
315
+ try:
316
+ result = self._evaluate_flag_sync(flag_key, user_id)
317
+
318
+ # Cache the result (sync version)
319
+ if self._cache:
320
+ self._cache.set_sync(flag_key, result, user_id)
321
+
322
+ return result
323
+
324
+ except FlagLiteError as e:
325
+ logger.warning(f"FlagLite error evaluating '{flag_key}': {e.message}")
326
+ return default
327
+ except Exception as e:
328
+ logger.warning(f"Unexpected error evaluating flag '{flag_key}': {e}")
329
+ return default
330
+
331
+ def _evaluate_flag_sync(
332
+ self, flag_key: str, user_id: Optional[str] = None
333
+ ) -> bool:
334
+ """Synchronous version of _evaluate_flag().
335
+
336
+ Args:
337
+ flag_key: The flag key to evaluate.
338
+ user_id: Optional user ID for percentage rollouts.
339
+
340
+ Returns:
341
+ The flag evaluation result.
342
+
343
+ Raises:
344
+ AuthenticationError: If the API key is invalid.
345
+ RateLimitError: If rate limit is exceeded.
346
+ NetworkError: If a network error occurs.
347
+ FlagLiteError: For other API errors.
348
+ """
349
+ client = self._get_sync_client()
350
+
351
+ # Build URL with query params
352
+ url = f"flags/{flag_key}"
353
+ if user_id:
354
+ url = f"{url}?{urlencode({'user_id': user_id})}"
355
+
356
+ try:
357
+ response = client.get(url)
358
+ except httpx.TimeoutException as e:
359
+ raise NetworkError(f"Request timed out: {e}") from e
360
+ except httpx.NetworkError as e:
361
+ raise NetworkError(f"Network error: {e}") from e
362
+ except httpx.HTTPError as e:
363
+ raise NetworkError(f"HTTP error: {e}") from e
364
+
365
+ # Handle response
366
+ if response.status_code == 200:
367
+ data = response.json()
368
+ return bool(data.get("enabled", False))
369
+
370
+ if response.status_code == 401:
371
+ raise AuthenticationError(
372
+ "Invalid API key", status_code=response.status_code
373
+ )
374
+
375
+ if response.status_code == 404:
376
+ # Flag not found - per spec, return enabled: false
377
+ return False
378
+
379
+ if response.status_code == 429:
380
+ retry_after = response.headers.get("Retry-After")
381
+ raise RateLimitError(
382
+ "Rate limit exceeded",
383
+ retry_after=int(retry_after) if retry_after else None,
384
+ status_code=429,
385
+ )
386
+
387
+ # Other errors
388
+ try:
389
+ error_data = response.json()
390
+ message = error_data.get("message", f"HTTP {response.status_code}")
391
+ except Exception:
392
+ message = f"HTTP {response.status_code}"
393
+
394
+ raise FlagLiteError(message, status_code=response.status_code)
395
+
396
+ async def invalidate_cache(
397
+ self, flag_key: str, user_id: Optional[str] = None
398
+ ) -> None:
399
+ """Invalidate a cached flag value.
400
+
401
+ Args:
402
+ flag_key: The flag key to invalidate.
403
+ user_id: Optional user ID to invalidate a specific user's cache.
404
+ """
405
+ if self._cache:
406
+ await self._cache.invalidate(flag_key, user_id)
407
+
408
+ async def clear_cache(self) -> None:
409
+ """Clear the entire flag cache."""
410
+ if self._cache:
411
+ await self._cache.clear()
flaglite/exceptions.py ADDED
@@ -0,0 +1,43 @@
1
+ """FlagLite SDK exceptions."""
2
+
3
+ from typing import Optional
4
+
5
+
6
+ class FlagLiteError(Exception):
7
+ """Base exception for FlagLite SDK errors."""
8
+
9
+ def __init__(self, message: str, status_code: Optional[int] = None) -> None:
10
+ super().__init__(message)
11
+ self.message = message
12
+ self.status_code = status_code
13
+
14
+
15
+ class AuthenticationError(FlagLiteError):
16
+ """Raised when API key is invalid or missing."""
17
+
18
+ pass
19
+
20
+
21
+ class RateLimitError(FlagLiteError):
22
+ """Raised when rate limit is exceeded."""
23
+
24
+ def __init__(
25
+ self,
26
+ message: str,
27
+ retry_after: Optional[int] = None,
28
+ status_code: int = 429,
29
+ ) -> None:
30
+ super().__init__(message, status_code)
31
+ self.retry_after = retry_after
32
+
33
+
34
+ class NetworkError(FlagLiteError):
35
+ """Raised when a network error occurs."""
36
+
37
+ pass
38
+
39
+
40
+ class ConfigurationError(FlagLiteError):
41
+ """Raised when SDK is misconfigured."""
42
+
43
+ pass
flaglite/py.typed ADDED
File without changes
@@ -0,0 +1,297 @@
1
+ Metadata-Version: 2.4
2
+ Name: flaglite
3
+ Version: 1.0.0
4
+ Summary: Lightweight Python SDK for FlagLite feature flags
5
+ Project-URL: Homepage, https://flaglite.dev
6
+ Project-URL: Documentation, https://docs.flaglite.dev/sdks/python
7
+ Project-URL: Repository, https://github.com/faiscadev/flaglite-py
8
+ Project-URL: Issues, https://github.com/faiscadev/flaglite-py/issues
9
+ Author-email: FlagLite <support@flaglite.dev>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: feature-flags,feature-toggles,flaglite,sdk
13
+ Classifier: Development Status :: 5 - Production/Stable
14
+ Classifier: Framework :: AsyncIO
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.9
20
+ Classifier: Programming Language :: Python :: 3.10
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Programming Language :: Python :: 3.13
24
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
25
+ Classifier: Typing :: Typed
26
+ Requires-Python: >=3.9
27
+ Requires-Dist: httpx>=0.24.0
28
+ Provides-Extra: dev
29
+ Requires-Dist: mypy>=1.0.0; extra == 'dev'
30
+ Requires-Dist: pytest-asyncio>=0.21.0; extra == 'dev'
31
+ Requires-Dist: pytest-cov>=4.0.0; extra == 'dev'
32
+ Requires-Dist: pytest>=7.0.0; extra == 'dev'
33
+ Requires-Dist: respx>=0.20.0; extra == 'dev'
34
+ Requires-Dist: ruff>=0.1.0; extra == 'dev'
35
+ Description-Content-Type: text/markdown
36
+
37
+ # FlagLite Python SDK
38
+
39
+ A lightweight, production-ready Python client for the [FlagLite](https://flaglite.dev) feature flag service.
40
+
41
+ [![PyPI version](https://badge.fury.io/py/flaglite.svg)](https://badge.fury.io/py/flaglite)
42
+ [![Python versions](https://img.shields.io/pypi/pyversions/flaglite.svg)](https://pypi.org/project/flaglite/)
43
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
44
+
45
+ ## Features
46
+
47
+ - ✅ **Simple API** - One method to check flags: `enabled()`
48
+ - ✅ **Async/Await** - Native asyncio support
49
+ - ✅ **Sync Support** - Use `enabled_sync()` in synchronous code
50
+ - ✅ **Built-in Caching** - 30-second TTL cache (configurable)
51
+ - ✅ **Fail Closed** - Returns `False` on errors by default
52
+ - ✅ **Type Hints** - Full type annotations for IDE support
53
+ - ✅ **Percentage Rollouts** - Sticky bucketing with user IDs
54
+ - ✅ **Production Ready** - Comprehensive error handling
55
+
56
+ ## Installation
57
+
58
+ ```bash
59
+ pip install flaglite
60
+ ```
61
+
62
+ ## Quick Start
63
+
64
+ ### 1. Set your API key
65
+
66
+ ```bash
67
+ export FLAGLITE_API_KEY=ffl_env_your_api_key_here
68
+ ```
69
+
70
+ ### 2. Check feature flags
71
+
72
+ ```python
73
+ from flaglite import FlagLite
74
+
75
+ flags = FlagLite() # Auto-init from FLAGLITE_API_KEY env var
76
+
77
+ # Async usage
78
+ if await flags.enabled('new-checkout'):
79
+ show_new_checkout()
80
+ else:
81
+ show_old_checkout()
82
+ ```
83
+
84
+ ### 3. Percentage rollouts with user IDs
85
+
86
+ ```python
87
+ # User-specific evaluation for consistent rollouts
88
+ if await flags.enabled('new-checkout', user_id='user-123'):
89
+ # Same user always gets the same result (sticky bucketing)
90
+ show_new_checkout()
91
+ ```
92
+
93
+ ## Usage Examples
94
+
95
+ ### Async/Await (Recommended)
96
+
97
+ ```python
98
+ import asyncio
99
+ from flaglite import FlagLite
100
+
101
+ async def main():
102
+ flags = FlagLite()
103
+
104
+ # Simple feature check
105
+ if await flags.enabled('dark-mode'):
106
+ enable_dark_mode()
107
+
108
+ # With user ID for percentage rollouts
109
+ if await flags.enabled('new-dashboard', user_id=current_user.id):
110
+ render_new_dashboard()
111
+
112
+ # Don't forget to close when done
113
+ await flags.close()
114
+
115
+ asyncio.run(main())
116
+ ```
117
+
118
+ ### Context Manager
119
+
120
+ ```python
121
+ async def main():
122
+ async with FlagLite() as flags:
123
+ if await flags.enabled('feature-x'):
124
+ do_something()
125
+ # Client is automatically closed
126
+ ```
127
+
128
+ ### Synchronous Usage
129
+
130
+ ```python
131
+ from flaglite import FlagLite
132
+
133
+ flags = FlagLite()
134
+
135
+ # Use enabled_sync() in synchronous code
136
+ if flags.enabled_sync('new-checkout'):
137
+ show_new_checkout()
138
+
139
+ # Clean up
140
+ flags.close_sync()
141
+ ```
142
+
143
+ ### FastAPI Example
144
+
145
+ ```python
146
+ from fastapi import FastAPI, Depends
147
+ from flaglite import FlagLite
148
+
149
+ app = FastAPI()
150
+
151
+ # Create a shared client instance
152
+ flags = FlagLite()
153
+
154
+ @app.on_event("shutdown")
155
+ async def shutdown():
156
+ await flags.close()
157
+
158
+ @app.get("/checkout")
159
+ async def checkout(user_id: str):
160
+ if await flags.enabled('new-checkout', user_id=user_id):
161
+ return {"version": "new"}
162
+ return {"version": "legacy"}
163
+ ```
164
+
165
+ ### Django Example
166
+
167
+ ```python
168
+ # settings.py
169
+ from flaglite import FlagLite
170
+ FEATURE_FLAGS = FlagLite()
171
+
172
+ # views.py
173
+ from django.conf import settings
174
+
175
+ def my_view(request):
176
+ if settings.FEATURE_FLAGS.enabled_sync('new-feature'):
177
+ return render(request, 'new_template.html')
178
+ return render(request, 'old_template.html')
179
+ ```
180
+
181
+ ## Configuration
182
+
183
+ ### Constructor Options
184
+
185
+ ```python
186
+ flags = FlagLite(
187
+ api_key="ffl_env_...", # Optional: defaults to FLAGLITE_API_KEY env var
188
+ base_url="https://...", # Optional: defaults to FLAGLITE_BASE_URL or production
189
+ cache_ttl=30.0, # Cache TTL in seconds (default: 30)
190
+ timeout=5.0, # HTTP timeout in seconds (default: 5)
191
+ disable_cache=False, # Set True to disable caching
192
+ )
193
+ ```
194
+
195
+ ### Environment Variables
196
+
197
+ | Variable | Description |
198
+ |----------|-------------|
199
+ | `FLAGLITE_API_KEY` | Your environment API key (required if not passed to constructor) |
200
+ | `FLAGLITE_BASE_URL` | API base URL (optional, for self-hosted) |
201
+
202
+ ### Cache Management
203
+
204
+ ```python
205
+ # Invalidate a specific flag
206
+ await flags.invalidate_cache('my-flag')
207
+
208
+ # Invalidate a specific user's flag
209
+ await flags.invalidate_cache('my-flag', user_id='user-123')
210
+
211
+ # Clear entire cache
212
+ await flags.clear_cache()
213
+ ```
214
+
215
+ ## Error Handling
216
+
217
+ The SDK is designed to **fail closed** - it returns `False` on any error by default. This ensures your application continues working even if the flag service is unavailable.
218
+
219
+ ```python
220
+ # Errors are logged but don't raise exceptions
221
+ result = await flags.enabled('my-flag') # Returns False on error
222
+
223
+ # Override default value
224
+ result = await flags.enabled('my-flag', default=True) # Returns True on error
225
+ ```
226
+
227
+ ### Exceptions (for direct API access)
228
+
229
+ If you need to handle errors explicitly, you can catch these exceptions:
230
+
231
+ ```python
232
+ from flaglite import (
233
+ FlagLiteError,
234
+ AuthenticationError,
235
+ RateLimitError,
236
+ NetworkError,
237
+ ConfigurationError,
238
+ )
239
+ ```
240
+
241
+ ## API Reference
242
+
243
+ ### FlagLite
244
+
245
+ #### `async enabled(flag_key: str, user_id: str | None = None, default: bool = False) -> bool`
246
+
247
+ Check if a feature flag is enabled.
248
+
249
+ - **flag_key**: The unique flag key (e.g., 'new-checkout')
250
+ - **user_id**: Optional user ID for consistent percentage rollouts
251
+ - **default**: Value to return on error (default: False)
252
+ - **Returns**: True if enabled, False otherwise
253
+
254
+ #### `enabled_sync(flag_key: str, user_id: str | None = None, default: bool = False) -> bool`
255
+
256
+ Synchronous version of `enabled()`.
257
+
258
+ #### `async close() -> None`
259
+
260
+ Close HTTP clients and release resources.
261
+
262
+ #### `close_sync() -> None`
263
+
264
+ Synchronous version of `close()`.
265
+
266
+ #### `async invalidate_cache(flag_key: str, user_id: str | None = None) -> None`
267
+
268
+ Remove a specific entry from the cache.
269
+
270
+ #### `async clear_cache() -> None`
271
+
272
+ Clear the entire flag cache.
273
+
274
+ ## Requirements
275
+
276
+ - Python 3.9+
277
+ - httpx 0.24+
278
+
279
+ ## CI/CD
280
+
281
+ This package uses automated CI/CD:
282
+
283
+ - **CI**: Runs on every push/PR - linting (ruff), type checking (mypy), tests across Python 3.9-3.13
284
+ - **Release**: Tag with `v*` (e.g., `git tag v1.0.0 && git push --tags`) to publish to PyPI
285
+
286
+ Required repository secrets for releases:
287
+ - `PYPI_TOKEN` - PyPI API token with upload access
288
+
289
+ ## License
290
+
291
+ MIT License - see [LICENSE](LICENSE) for details.
292
+
293
+ ## Support
294
+
295
+ - Documentation: https://docs.flaglite.dev/sdks/python
296
+ - Issues: https://github.com/faiscadev/flaglite-py/issues
297
+ - Email: support@flaglite.dev
@@ -0,0 +1,9 @@
1
+ flaglite/__init__.py,sha256=kvWPM7PZWkLQljZPGZVof_gcps-8nQk4kCYORTAGdJU,916
2
+ flaglite/cache.py,sha256=y_YNBwocQvtGF1HpX-eOwkLPJiFUP_3ScXnpn8wovlw,4369
3
+ flaglite/client.py,sha256=eBOEYSTLvZRH3--HJDEmGtA0HEIRR2WYfCR_nV7vKcs,13612
4
+ flaglite/exceptions.py,sha256=l1IbBKibNp_mMwbglbqhyMykdD5SvZYVtNxZ2tW8S1w,943
5
+ flaglite/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ flaglite-1.0.0.dist-info/METADATA,sha256=3_1BEM4n-uCSSC_vJ_-sehURnDH8Dm16GI8jXwO0SdY,7855
7
+ flaglite-1.0.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
8
+ flaglite-1.0.0.dist-info/licenses/LICENSE,sha256=pYWPQAzDymlHrEFlHLRR-jFc8vM0SpvHBN0TDnx4BZg,1065
9
+ flaglite-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 FlagLite
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.