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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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()