litewave-cache-lib 0.1.2__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.
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: litewave-cache-lib
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: A lightweight Python library for Redis-backed tenant configuration storage
|
|
5
|
+
Project-URL: Homepage, https://github.com/aiorch/litewave-cache-lib
|
|
6
|
+
Project-URL: Repository, https://github.com/aiorch/litewave-cache-lib
|
|
7
|
+
Author-email: Nagarjuna Sarvepalli <nagarjuna.s@litewave.ai>
|
|
8
|
+
License: MIT
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
11
|
+
Requires-Dist: redis>=5.0.0
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest-cov>=4.0.0; extra == 'dev'
|
|
14
|
+
Requires-Dist: pytest>=7.0.0; extra == 'dev'
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# litewave-cache-lib
|
|
18
|
+
|
|
19
|
+
A lightweight Python library that provides a connection-pooled, fault-tolerant Redis client with automatic retry logic and JSON-aware key lookup helpers for tenant configuration storage.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Features
|
|
24
|
+
|
|
25
|
+
- Connection-pooled Redis client with automatic retry on failure (5-minute cooldown)
|
|
26
|
+
- Fetch values by raw Redis key, or by `(tenant_id, secret_name)` pair via `get_secret`
|
|
27
|
+
- Automatic JSON deserialization of stored values
|
|
28
|
+
- Attribute-level extraction from stored JSON objects via `get_attribute_value_by_key`
|
|
29
|
+
- Standardised tenant key format: `tenant:{tenant_id}:secret:{secret_name}`
|
|
30
|
+
- Fully configurable via environment variables
|
|
31
|
+
- Zero-boilerplate logging setup
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Installation
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install git+https://github.com/aiorch/litewave-cache-lib
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Or install from source:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
git clone https://github.com/aiorch/litewave-cache-lib.git
|
|
45
|
+
cd litewave-cache-lib
|
|
46
|
+
pip install .
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## Requirements
|
|
52
|
+
|
|
53
|
+
- Python >= 3.10
|
|
54
|
+
- redis >= 5.0.0
|
|
55
|
+
- python-dotenv >= 1.0.0
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## Configuration
|
|
60
|
+
|
|
61
|
+
All settings are read from environment variables at import time. `REDIS_HOST`, `REDIS_PORT`, and `REDIS_PASSWORD` are **required** — the library raises a `ValueError` at startup if any of them are missing.
|
|
62
|
+
|
|
63
|
+
| Environment Variable | Default | Description |
|
|
64
|
+
|--------------------------------|---------|-----------------------------------------------|
|
|
65
|
+
| `REDIS_HOST` | **(required)** | Redis server hostname |
|
|
66
|
+
| `REDIS_PORT` | **(required)** | Redis server port |
|
|
67
|
+
| `REDIS_PASSWORD` | **(required)** | Redis password |
|
|
68
|
+
| `REDIS_DB` | `0` | Default Redis database index |
|
|
69
|
+
| `REDIS_COORDINATION_DB` | `1` | Redis DB reserved for coordination workloads |
|
|
70
|
+
| `REDIS_TENANT_CONFIG_DB` | `2` | Redis DB used for tenant configuration |
|
|
71
|
+
| `REDIS_MAX_CONNECTIONS` | `50` | Connection pool size |
|
|
72
|
+
| `REDIS_SOCKET_CONNECT_TIMEOUT` | `2` | Socket connect timeout in seconds |
|
|
73
|
+
| `REDIS_SOCKET_TIMEOUT` | `2` | Socket read/write timeout in seconds |
|
|
74
|
+
| `LOG_LEVEL` | `INFO` | Logging level (`DEBUG`, `INFO`, `WARNING`, …) |
|
|
75
|
+
|
|
76
|
+
A `.env` file is supported via `python-dotenv`. Load it before importing the library:
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
from dotenv import load_dotenv
|
|
80
|
+
load_dotenv(".env", override=True)
|
|
81
|
+
|
|
82
|
+
from tenant_mgr_cache import get_redis_client
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## Usage
|
|
88
|
+
|
|
89
|
+
### Get the Redis client
|
|
90
|
+
|
|
91
|
+
Returns the shared, connection-pooled Redis client. Returns `None` if the connection is unavailable. A failed connection is retried automatically after a 5-minute cooldown; subsequent calls reuse the existing client.
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
from tenant_mgr_cache import get_redis_client
|
|
95
|
+
|
|
96
|
+
client = get_redis_client()
|
|
97
|
+
if client:
|
|
98
|
+
client.set("tenant:15:secret:satoken", "abc123")
|
|
99
|
+
print(client.get("tenant:15:secret:satoken")) # "abc123"
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
### `get_secret` — fetch by tenant ID and secret name
|
|
105
|
+
|
|
106
|
+
Builds the canonical key `tenant:{tenant_id}:secret:{secret_name}` internally and returns the stored value. JSON values are deserialized automatically; plain strings are returned as-is.
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
from tenant_mgr_cache import get_secret
|
|
110
|
+
|
|
111
|
+
# JSON object stored → returned as dict
|
|
112
|
+
config = get_secret("15", "db_config")
|
|
113
|
+
if isinstance(config, dict):
|
|
114
|
+
print(config["host"]) # e.g. "db.prod.internal"
|
|
115
|
+
print(config["port"]) # e.g. 5432
|
|
116
|
+
|
|
117
|
+
# Plain string stored → returned as-is
|
|
118
|
+
token = get_secret("15", "satoken")
|
|
119
|
+
print(token) # e.g. "abc123"
|
|
120
|
+
|
|
121
|
+
# Key does not exist → None
|
|
122
|
+
missing = get_secret("15", "nonexistent")
|
|
123
|
+
print(missing) # None
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
### `get_secret(tenant_id: str, secret_name: str) -> Optional[Any]`
|
|
128
|
+
|
|
129
|
+
Builds the canonical key `tenant:{tenant_id}:secret:{secret_name}` and returns the stored value. JSON is deserialized automatically. Returns `None` if the key does not exist, Redis is unavailable, or any error occurs.
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
### `get_value_by_key(key: str) -> Optional[Any]`
|
|
134
|
+
|
|
135
|
+
Fetches the value stored at `key` and JSON-decodes it if possible. Returns `None` when the key does not exist, Redis is unavailable, or any error occurs.
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
### `get_attribute_value_by_key(tenant_id: str, secret_name: str, attribute_name: str) -> Optional[Any]`
|
|
140
|
+
|
|
141
|
+
Builds the canonical key for `tenant_id` + `secret_name`, fetches the stored JSON object, and returns `data.get(attribute_name)`. Falls back to `getattr` for non-dict objects. Returns `None` if the key is missing, the value is not dict-like, or the attribute is absent.
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
### `prepare_key(tenant_id: str, secret_name: str) -> str`
|
|
146
|
+
|
|
147
|
+
Builds the canonical Redis key for a tenant secret:
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
from tenant_mgr_cache.cache_client import prepare_key
|
|
151
|
+
|
|
152
|
+
key = prepare_key("15", "satoken")
|
|
153
|
+
print(key) # "tenant:15:secret:satoken"
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
### `settings` — `CacheSettings`
|
|
159
|
+
|
|
160
|
+
A dataclass instance holding all resolved configuration values. Import it to inspect or override settings programmatically:
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
from tenant_mgr_cache import settings
|
|
164
|
+
|
|
165
|
+
print(settings.REDIS_HOST)
|
|
166
|
+
print(settings.REDIS_PORT)
|
|
167
|
+
print(settings.REDIS_TENANT_CONFIG_DB)
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
172
|
+
### `logger` — `logging.Logger`
|
|
173
|
+
|
|
174
|
+
A pre-configured logger (`litewave_cache`) that respects the `LOG_LEVEL` environment variable. Import it to emit log messages consistent with the library's format:
|
|
175
|
+
|
|
176
|
+
```python
|
|
177
|
+
from tenant_mgr_cache import logger
|
|
178
|
+
|
|
179
|
+
logger.info("Custom message from application code")
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
---
|
|
183
|
+
|
|
184
|
+
## Development
|
|
185
|
+
|
|
186
|
+
### Setup
|
|
187
|
+
|
|
188
|
+
```bash
|
|
189
|
+
pip install -r requirements.txt
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
### Running Tests
|
|
193
|
+
|
|
194
|
+
```bash
|
|
195
|
+
pytest
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
With coverage:
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
pytest --cov=tenant_mgr_cache --cov-report=term-missing
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
---
|
|
205
|
+
|
|
206
|
+
## License
|
|
207
|
+
|
|
208
|
+
MIT License. See [LICENSE](LICENSE) for details.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
tenant_mgr_cache/__init__.py,sha256=3OFuIBaDEDrKZy8HnqX8XIYyP8cpUdctn6SufbJjkU0,427
|
|
2
|
+
tenant_mgr_cache/cache_client.py,sha256=HCkG1Tu-k9cFtGU2BizpHdnaPNxRHLO2jmqgysSWTvs,4323
|
|
3
|
+
tenant_mgr_cache/config.py,sha256=hyZOs5L0Lvgqy0AJVHGNfXAVGGrqcgDwEeAr65l1pYQ,1694
|
|
4
|
+
litewave_cache_lib-0.1.2.dist-info/METADATA,sha256=S5TlgTzO9kY25laHHLFh1s2ZuHQAftT6kGvDNZiAFyY,6416
|
|
5
|
+
litewave_cache_lib-0.1.2.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
6
|
+
litewave_cache_lib-0.1.2.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""
|
|
2
|
+
litewave-cache-lib: A lightweight cache library
|
|
3
|
+
for tenant configuration storage.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from tenant_mgr_cache.cache_client import (
|
|
7
|
+
get_redis_client,
|
|
8
|
+
get_secret,
|
|
9
|
+
get_value_by_key,
|
|
10
|
+
get_attribute_value_by_key,
|
|
11
|
+
)
|
|
12
|
+
from tenant_mgr_cache.config import settings, logger
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"get_redis_client",
|
|
16
|
+
"get_secret",
|
|
17
|
+
"get_value_by_key",
|
|
18
|
+
"get_attribute_value_by_key",
|
|
19
|
+
"settings",
|
|
20
|
+
"logger",
|
|
21
|
+
]
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Redis client utilities for tenant configuration storage with 48-hour TTL.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any, Optional
|
|
8
|
+
|
|
9
|
+
import redis
|
|
10
|
+
|
|
11
|
+
from tenant_mgr_cache.config import logger, settings
|
|
12
|
+
|
|
13
|
+
REDIS_RETRY_INTERVAL: int = 300 # Seconds between retry attempts (5 min)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class _ConnectionState: # pylint: disable=too-few-public-methods
|
|
17
|
+
"""Holds mutable Redis connection state to avoid module-level globals."""
|
|
18
|
+
|
|
19
|
+
def __init__(self) -> None:
|
|
20
|
+
self.client: Optional[redis.Redis] = None
|
|
21
|
+
self.connection_failed: bool = False
|
|
22
|
+
self.last_connection_attempt: float = 0
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
_state = _ConnectionState()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_redis_client() -> Optional[redis.Redis]:
|
|
29
|
+
"""
|
|
30
|
+
Returns a singleton Redis client for tenant configuration storage,
|
|
31
|
+
utilizing redis-py connection pooling.
|
|
32
|
+
Reattempts connection only every REDIS_RETRY_INTERVAL seconds
|
|
33
|
+
after a failure.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
redis.Redis or None if connection cannot be established
|
|
37
|
+
"""
|
|
38
|
+
if _state.connection_failed:
|
|
39
|
+
since_last = time.time() - _state.last_connection_attempt
|
|
40
|
+
if since_last < REDIS_RETRY_INTERVAL:
|
|
41
|
+
return None
|
|
42
|
+
logger.info(
|
|
43
|
+
"Retrying Redis connection for tenant config "
|
|
44
|
+
"after %.0fs (interval %ds)",
|
|
45
|
+
since_last, REDIS_RETRY_INTERVAL,
|
|
46
|
+
)
|
|
47
|
+
_state.connection_failed = False
|
|
48
|
+
_state.client = None
|
|
49
|
+
|
|
50
|
+
if _state.client is not None:
|
|
51
|
+
return _state.client
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
pool = redis.ConnectionPool(
|
|
55
|
+
host=settings.REDIS_HOST,
|
|
56
|
+
port=settings.REDIS_PORT,
|
|
57
|
+
db=settings.REDIS_TENANT_CONFIG_DB,
|
|
58
|
+
password=settings.REDIS_PASSWORD or None,
|
|
59
|
+
decode_responses=True,
|
|
60
|
+
max_connections=settings.REDIS_MAX_CONNECTIONS,
|
|
61
|
+
socket_connect_timeout=settings.REDIS_SOCKET_CONNECT_TIMEOUT,
|
|
62
|
+
socket_timeout=settings.REDIS_SOCKET_TIMEOUT,
|
|
63
|
+
)
|
|
64
|
+
client = redis.Redis(connection_pool=pool)
|
|
65
|
+
client.ping()
|
|
66
|
+
_state.client = client
|
|
67
|
+
logger.info(
|
|
68
|
+
"Initialized Redis client for tenant configuration (DB: %s)",
|
|
69
|
+
settings.REDIS_TENANT_CONFIG_DB
|
|
70
|
+
)
|
|
71
|
+
return _state.client
|
|
72
|
+
except redis.RedisError as exc:
|
|
73
|
+
logger.warning("Failed to connect to Redis for tenant config: %s", exc)
|
|
74
|
+
_state.connection_failed = True
|
|
75
|
+
_state.last_connection_attempt = time.time()
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def prepare_key(tenant_id: str, secret_name: str) -> str:
|
|
80
|
+
"""Build the standardized Redis key for a tenant's secret."""
|
|
81
|
+
return f"tenant:{tenant_id}:secret:{secret_name}"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def get_secret(tenant_id: str, secret_name: str) -> Optional[Any]:
|
|
85
|
+
"""
|
|
86
|
+
Flexible interface for retrieving values from Redis:
|
|
87
|
+
- If only tenant_id and secret_name are provided:
|
|
88
|
+
treat as tenant_id and secret_name, fetch object.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
key = prepare_key(tenant_id, secret_name)
|
|
92
|
+
return get_value_by_key(key)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def get_value_by_key(key: str) -> Optional[Any]:
|
|
96
|
+
"""
|
|
97
|
+
Fetch a (possibly JSON) value from Redis by key, parsing if possible.
|
|
98
|
+
"""
|
|
99
|
+
redis_client = get_redis_client()
|
|
100
|
+
if not redis_client:
|
|
101
|
+
logger.warning(
|
|
102
|
+
"Redis client not available, cannot read value for key: %s",
|
|
103
|
+
key,
|
|
104
|
+
)
|
|
105
|
+
return None
|
|
106
|
+
try:
|
|
107
|
+
raw = redis_client.get(key)
|
|
108
|
+
if raw is None:
|
|
109
|
+
logger.debug("Key not found in Redis: %s", key)
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
return json.loads(raw)
|
|
114
|
+
except (json.JSONDecodeError, TypeError):
|
|
115
|
+
return raw
|
|
116
|
+
except redis.RedisError as exc:
|
|
117
|
+
logger.error("Error reading key '%s' from Redis: %s", key, exc)
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def get_attribute_value_by_key(
|
|
122
|
+
tenant_id: str,
|
|
123
|
+
secret_name: str,
|
|
124
|
+
attribute_name: str,
|
|
125
|
+
) -> Optional[Any]:
|
|
126
|
+
"""
|
|
127
|
+
Fetches an object from Redis by key and retrieves the specified
|
|
128
|
+
attribute (dict key or attr).
|
|
129
|
+
Returns None on error or if attribute is missing.
|
|
130
|
+
"""
|
|
131
|
+
key = prepare_key(tenant_id, secret_name)
|
|
132
|
+
data = get_value_by_key(key)
|
|
133
|
+
if data is None:
|
|
134
|
+
return None
|
|
135
|
+
if isinstance(data, dict):
|
|
136
|
+
return data[secret_name].get(attribute_name)
|
|
137
|
+
if hasattr(data, attribute_name): # fallback—rare
|
|
138
|
+
return getattr(data, attribute_name, None)
|
|
139
|
+
return None
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration and logging setup for litewave-cache-lib.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _get_log_level() -> int:
|
|
11
|
+
level = os.environ.get("LOG_LEVEL", "INFO").upper()
|
|
12
|
+
return getattr(logging, level, logging.INFO)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
logging.basicConfig(
|
|
16
|
+
level=_get_log_level(),
|
|
17
|
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
18
|
+
)
|
|
19
|
+
logger = logging.getLogger("litewave_cache")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _require_env(name: str) -> str:
|
|
23
|
+
val = os.environ.get(name)
|
|
24
|
+
if val is None:
|
|
25
|
+
raise ValueError(f"{name} environment variable is required")
|
|
26
|
+
return val
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class CacheSettings: # pylint: disable=invalid-name
|
|
31
|
+
"""Redis connection settings, populated from environment variables."""
|
|
32
|
+
|
|
33
|
+
REDIS_HOST: str = field(
|
|
34
|
+
default_factory=lambda: _require_env("REDIS_HOST")
|
|
35
|
+
)
|
|
36
|
+
REDIS_PORT: int = field(
|
|
37
|
+
default_factory=lambda: int(_require_env("REDIS_PORT"))
|
|
38
|
+
)
|
|
39
|
+
REDIS_PASSWORD: str = field(
|
|
40
|
+
default_factory=lambda: _require_env("REDIS_PASSWORD")
|
|
41
|
+
)
|
|
42
|
+
REDIS_TENANT_CONFIG_DB: int = field(
|
|
43
|
+
default_factory=lambda: int(
|
|
44
|
+
os.environ.get("REDIS_TENANT_CONFIG_DB", "0")
|
|
45
|
+
)
|
|
46
|
+
)
|
|
47
|
+
REDIS_MAX_CONNECTIONS: int = field(
|
|
48
|
+
default_factory=lambda: int(
|
|
49
|
+
os.environ.get("REDIS_MAX_CONNECTIONS", "50")
|
|
50
|
+
)
|
|
51
|
+
)
|
|
52
|
+
REDIS_SOCKET_CONNECT_TIMEOUT: float = field(
|
|
53
|
+
default_factory=lambda: float(
|
|
54
|
+
os.environ.get("REDIS_SOCKET_CONNECT_TIMEOUT", "2")
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
REDIS_SOCKET_TIMEOUT: float = field(
|
|
58
|
+
default_factory=lambda: float(
|
|
59
|
+
os.environ.get("REDIS_SOCKET_TIMEOUT", "2")
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
settings = CacheSettings()
|