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 +44 -0
- flaglite/cache.py +137 -0
- flaglite/client.py +411 -0
- flaglite/exceptions.py +43 -0
- flaglite/py.typed +0 -0
- flaglite-1.0.0.dist-info/METADATA +297 -0
- flaglite-1.0.0.dist-info/RECORD +9 -0
- flaglite-1.0.0.dist-info/WHEEL +4 -0
- flaglite-1.0.0.dist-info/licenses/LICENSE +21 -0
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
|
+
[](https://badge.fury.io/py/flaglite)
|
|
42
|
+
[](https://pypi.org/project/flaglite/)
|
|
43
|
+
[](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,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.
|