moose-lib 0.4.195__py3-none-any.whl → 0.4.196__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.
- moose_lib/__init__.py +2 -0
- moose_lib/clients/__init__.py +0 -0
- moose_lib/clients/redis_client.py +237 -0
- {moose_lib-0.4.195.dist-info → moose_lib-0.4.196.dist-info}/METADATA +2 -1
- {moose_lib-0.4.195.dist-info → moose_lib-0.4.196.dist-info}/RECORD +8 -5
- tests/test_redis_client.py +127 -0
- {moose_lib-0.4.195.dist-info → moose_lib-0.4.196.dist-info}/WHEEL +0 -0
- {moose_lib-0.4.195.dist-info → moose_lib-0.4.196.dist-info}/top_level.txt +0 -0
moose_lib/__init__.py
CHANGED
File without changes
|
@@ -0,0 +1,237 @@
|
|
1
|
+
import atexit
|
2
|
+
import json
|
3
|
+
import os
|
4
|
+
import redis
|
5
|
+
import threading
|
6
|
+
from pydantic import BaseModel
|
7
|
+
from typing import Optional, TypeVar, Type, Union, TypeAlias
|
8
|
+
|
9
|
+
|
10
|
+
T = TypeVar('T')
|
11
|
+
SupportedValue: TypeAlias = Union[str, BaseModel]
|
12
|
+
|
13
|
+
class MooseCache:
|
14
|
+
"""
|
15
|
+
A singleton Redis cache client that automatically handles connection management
|
16
|
+
and key prefixing.
|
17
|
+
|
18
|
+
Example:
|
19
|
+
cache = MooseCache() # Gets or creates the singleton instance
|
20
|
+
"""
|
21
|
+
_instance = None
|
22
|
+
_redis_url: str
|
23
|
+
_key_prefix: str
|
24
|
+
_client: Optional[redis.Redis] = None
|
25
|
+
_is_connected: bool = False
|
26
|
+
_disconnect_timer: Optional[threading.Timer] = None
|
27
|
+
_idle_timeout: int
|
28
|
+
|
29
|
+
def __new__(cls):
|
30
|
+
if cls._instance is None:
|
31
|
+
cls._instance = super(MooseCache, cls).__new__(cls)
|
32
|
+
atexit.register(cls._instance.disconnect)
|
33
|
+
return cls._instance
|
34
|
+
|
35
|
+
def __init__(self) -> None:
|
36
|
+
if self._client is not None:
|
37
|
+
return
|
38
|
+
|
39
|
+
self._redis_url = os.getenv('MOOSE_REDIS_CONFIG__URL', 'redis://127.0.0.1:6379')
|
40
|
+
prefix = os.getenv('MOOSE_REDIS_CONFIG__KEY_PREFIX', 'MS')
|
41
|
+
# 30 seconds of inactivity before disconnecting
|
42
|
+
self._idle_timeout = int(os.getenv('MOOSE_REDIS_CONFIG__IDLE_TIMEOUT', '30'))
|
43
|
+
self._key_prefix = f"{prefix}::moosecache::"
|
44
|
+
|
45
|
+
self._ensure_connected()
|
46
|
+
|
47
|
+
def _get_prefixed_key(self, key: str) -> str:
|
48
|
+
"""Internal method to prefix keys with the configured prefix."""
|
49
|
+
return f"{self._key_prefix}{key}"
|
50
|
+
|
51
|
+
def _clear_disconnect_timer(self) -> None:
|
52
|
+
"""Clear the disconnect timer if it exists and create a new one."""
|
53
|
+
if self._disconnect_timer is not None:
|
54
|
+
self._disconnect_timer.cancel()
|
55
|
+
self._disconnect_timer = threading.Timer(self._idle_timeout, self.disconnect)
|
56
|
+
self._disconnect_timer.daemon = True
|
57
|
+
|
58
|
+
def _ensure_connected(self) -> None:
|
59
|
+
"""Ensure the client is connected and reset the disconnect timer."""
|
60
|
+
if not self._is_connected:
|
61
|
+
self._client = redis.from_url(self._redis_url, decode_responses=True)
|
62
|
+
self._is_connected = True
|
63
|
+
print("Python Redis client connected")
|
64
|
+
|
65
|
+
self._clear_disconnect_timer()
|
66
|
+
self._disconnect_timer.start()
|
67
|
+
|
68
|
+
def set(self, key: str, value: SupportedValue, ttl_seconds: Optional[int] = None) -> None:
|
69
|
+
"""
|
70
|
+
Sets a value in the cache. Only accepts strings or Pydantic models.
|
71
|
+
Objects are automatically JSON stringified.
|
72
|
+
|
73
|
+
Args:
|
74
|
+
key: The key to store the value under
|
75
|
+
value: The value to store. Must be a string or Pydantic model
|
76
|
+
ttl_seconds: Optional time-to-live in seconds. If not provided, defaults to 1 hour (3600 seconds).
|
77
|
+
Must be a non-negative number. If 0, the key will expire immediately.
|
78
|
+
|
79
|
+
Example:
|
80
|
+
### Store a string
|
81
|
+
cache.set("foo", "bar")
|
82
|
+
|
83
|
+
### Store a Pydantic model
|
84
|
+
class Config(BaseModel):
|
85
|
+
baz: int
|
86
|
+
qux: bool
|
87
|
+
cache.set("foo:config", Config(baz=123, qux=True))
|
88
|
+
"""
|
89
|
+
try:
|
90
|
+
# Validate value type
|
91
|
+
if not isinstance(value, (str, BaseModel)):
|
92
|
+
raise TypeError(
|
93
|
+
f"Value must be a string or Pydantic model. Got {type(value).__name__}"
|
94
|
+
)
|
95
|
+
|
96
|
+
# Validate TTL
|
97
|
+
if ttl_seconds is not None and ttl_seconds < 0:
|
98
|
+
raise ValueError("ttl_seconds must be a non-negative number")
|
99
|
+
|
100
|
+
self._ensure_connected()
|
101
|
+
prefixed_key = self._get_prefixed_key(key)
|
102
|
+
|
103
|
+
if isinstance(value, str):
|
104
|
+
string_value = value
|
105
|
+
else:
|
106
|
+
string_value = value.model_dump_json()
|
107
|
+
|
108
|
+
# Use provided TTL or default to 1 hour
|
109
|
+
ttl = ttl_seconds if ttl_seconds is not None else 3600
|
110
|
+
self._client.setex(prefixed_key, ttl, string_value)
|
111
|
+
except Exception as e:
|
112
|
+
print(f"Error setting cache key {key}: {e}")
|
113
|
+
raise
|
114
|
+
|
115
|
+
def get(self, key: str, type_hint: Type[T] = str) -> Optional[T]:
|
116
|
+
"""
|
117
|
+
Retrieves a value from the cache. Only supports strings or Pydantic models.
|
118
|
+
The type_hint parameter determines how the value will be parsed and returned.
|
119
|
+
|
120
|
+
Args:
|
121
|
+
key: The key to retrieve
|
122
|
+
type_hint: Type hint for the return value. Must be str or a Pydantic model class.
|
123
|
+
Defaults to str.
|
124
|
+
|
125
|
+
Returns:
|
126
|
+
The value parsed as the specified type. Returns None if key doesn't exist.
|
127
|
+
|
128
|
+
Example:
|
129
|
+
### Get a string (default)
|
130
|
+
value = cache.get("foo")
|
131
|
+
|
132
|
+
### Get and parse as Pydantic model
|
133
|
+
class Config(BaseModel):
|
134
|
+
baz: int
|
135
|
+
qux: bool
|
136
|
+
config = cache.get("foo:config", Config)
|
137
|
+
"""
|
138
|
+
try:
|
139
|
+
# Validate type_hint
|
140
|
+
if not isinstance(type_hint, type):
|
141
|
+
raise TypeError("type_hint must be a type")
|
142
|
+
if not (type_hint is str or issubclass(type_hint, BaseModel)):
|
143
|
+
raise TypeError(
|
144
|
+
"type_hint must be str or a Pydantic model class. "
|
145
|
+
f"Got {type_hint.__name__}"
|
146
|
+
)
|
147
|
+
|
148
|
+
self._ensure_connected()
|
149
|
+
prefixed_key = self._get_prefixed_key(key)
|
150
|
+
value = self._client.get(prefixed_key)
|
151
|
+
|
152
|
+
if value is None:
|
153
|
+
return None
|
154
|
+
elif type_hint is str:
|
155
|
+
return value
|
156
|
+
elif isinstance(type_hint, type) and issubclass(type_hint, BaseModel):
|
157
|
+
try:
|
158
|
+
parsed = json.loads(value)
|
159
|
+
return type_hint.model_validate(parsed)
|
160
|
+
except Exception as e:
|
161
|
+
raise ValueError(f"Failed to validate as {type_hint.__name__}: {e}")
|
162
|
+
else:
|
163
|
+
raise TypeError(f"Unsupported type_hint: {type_hint}")
|
164
|
+
|
165
|
+
except Exception as e:
|
166
|
+
print(f"Error getting cache key {key}: {e}")
|
167
|
+
raise
|
168
|
+
|
169
|
+
def delete(self, key: str) -> None:
|
170
|
+
"""
|
171
|
+
Deletes a specific key from the cache.
|
172
|
+
|
173
|
+
Args:
|
174
|
+
key: The key to delete
|
175
|
+
|
176
|
+
Example:
|
177
|
+
cache.delete("foo")
|
178
|
+
"""
|
179
|
+
try:
|
180
|
+
self._ensure_connected()
|
181
|
+
prefixed_key = self._get_prefixed_key(key)
|
182
|
+
self._client.delete(prefixed_key)
|
183
|
+
except Exception as e:
|
184
|
+
print(f"Error deleting cache key {key}: {e}")
|
185
|
+
raise
|
186
|
+
|
187
|
+
def clear_keys(self, key_prefix: str) -> None:
|
188
|
+
"""
|
189
|
+
Deletes all keys that start with the given prefix.
|
190
|
+
|
191
|
+
Args:
|
192
|
+
key_prefix: The prefix of keys to delete
|
193
|
+
|
194
|
+
Example:
|
195
|
+
# Delete all keys starting with "foo"
|
196
|
+
cache.clear_keys("foo")
|
197
|
+
"""
|
198
|
+
try:
|
199
|
+
self._ensure_connected()
|
200
|
+
prefixed_key = self._get_prefixed_key(key_prefix)
|
201
|
+
keys = self._client.keys(f"{prefixed_key}*")
|
202
|
+
if keys:
|
203
|
+
self._client.delete(*keys)
|
204
|
+
except Exception as e:
|
205
|
+
print(f"Error clearing cache keys with prefix {key_prefix}: {e}")
|
206
|
+
raise
|
207
|
+
|
208
|
+
def clear(self) -> None:
|
209
|
+
"""
|
210
|
+
Deletes all keys in the cache
|
211
|
+
|
212
|
+
Example:
|
213
|
+
cache.clear()
|
214
|
+
"""
|
215
|
+
try:
|
216
|
+
self._ensure_connected()
|
217
|
+
keys = self._client.keys(f"{self._key_prefix}*")
|
218
|
+
if keys:
|
219
|
+
self._client.delete(*keys)
|
220
|
+
except Exception as e:
|
221
|
+
print(f"Error clearing cache: {e}")
|
222
|
+
raise
|
223
|
+
|
224
|
+
def disconnect(self) -> None:
|
225
|
+
"""
|
226
|
+
Manually disconnects the Redis client. The client will automatically reconnect
|
227
|
+
when the next operation is performed.
|
228
|
+
|
229
|
+
Example:
|
230
|
+
cache.disconnect()
|
231
|
+
"""
|
232
|
+
if self._is_connected and self._client:
|
233
|
+
self._client.close()
|
234
|
+
self._is_connected = False
|
235
|
+
self._clear_disconnect_timer()
|
236
|
+
|
237
|
+
print("Python Redis client disconnected")
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: moose_lib
|
3
|
-
Version: 0.4.
|
3
|
+
Version: 0.4.196
|
4
4
|
Home-page: https://www.fiveonefour.com/moose
|
5
5
|
Author: Fiveonefour Labs Inc.
|
6
6
|
Author-email: support@fiveonefour.com
|
@@ -10,6 +10,7 @@ Requires-Dist: asyncio==3.4.3
|
|
10
10
|
Requires-Dist: pydantic==2.10.6
|
11
11
|
Requires-Dist: temporalio==1.9.0
|
12
12
|
Requires-Dist: kafka-python-ng==2.2.2
|
13
|
+
Requires-Dist: redis==6.2.0
|
13
14
|
Dynamic: author
|
14
15
|
Dynamic: author-email
|
15
16
|
Dynamic: description
|
@@ -1,4 +1,4 @@
|
|
1
|
-
moose_lib/__init__.py,sha256=
|
1
|
+
moose_lib/__init__.py,sha256=0MpzYNnjpqcBaXjR5CBr1b0M5YjXXjj9y1RKyNeOJQ8,183
|
2
2
|
moose_lib/blocks.py,sha256=_wdvC2NC_Y3MMEnB71WTgWbeQ--zPNHk19xjToJW0C0,3185
|
3
3
|
moose_lib/commons.py,sha256=BV5X78MuOWHiZV9bsWSN69JIvzTNWUi-gnuMiAtaO8A,2489
|
4
4
|
moose_lib/data_models.py,sha256=R6do1eQqHK6AZ4GTP5tOPtSZaltjZurfx9_Asji7Dwc,8529
|
@@ -8,12 +8,15 @@ moose_lib/internal.py,sha256=gREvC3XxBFN4i7JL5uMj0riCu_JUO2YyiMZvCokg1ME,13101
|
|
8
8
|
moose_lib/main.py,sha256=In-u7yA1FsLDeP_2bhIgBtHY_BkXaZqDwf7BxwyC21c,8471
|
9
9
|
moose_lib/query_param.py,sha256=AB5BKu610Ji-h1iYGMBZKfnEFqt85rS94kzhDwhWJnc,6288
|
10
10
|
moose_lib/tasks.py,sha256=6MXA0j7nhvQILAJVTQHCAsquwrSOi2zAevghAc_7kXs,1554
|
11
|
+
moose_lib/clients/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
12
|
+
moose_lib/clients/redis_client.py,sha256=UBCdxwgZpIOIOy2EnPyxJIAYjw_qmNwGsJQCQ66SxUI,8117
|
11
13
|
moose_lib/streaming/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
12
14
|
moose_lib/streaming/streaming_function_runner.py,sha256=K53lyzGLawAgKgrK3jreJrB7dQfh-Cd0lcJ4je4hGJE,24362
|
13
15
|
tests/__init__.py,sha256=0Gh4yzPkkC3TzBGKhenpMIxJcRhyrrCfxLSfpTZnPMQ,53
|
14
16
|
tests/conftest.py,sha256=ZVJNbnr4DwbcqkTmePW6U01zAzE6QD0kNAEZjPG1f4s,169
|
15
17
|
tests/test_moose.py,sha256=mBsx_OYWmL8ppDzL_7Bd7xR6qf_i3-pCIO3wm2iQNaA,2136
|
16
|
-
|
17
|
-
moose_lib-0.4.
|
18
|
-
moose_lib-0.4.
|
19
|
-
moose_lib-0.4.
|
18
|
+
tests/test_redis_client.py,sha256=d9_MLYsJ4ecVil_jPB2gW3Q5aWnavxmmjZg2uYI3LVo,3256
|
19
|
+
moose_lib-0.4.196.dist-info/METADATA,sha256=hEY2gVgCdaCXTS84IF_gjUqikTfSm3FVnl9qhedH5Pc,603
|
20
|
+
moose_lib-0.4.196.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
21
|
+
moose_lib-0.4.196.dist-info/top_level.txt,sha256=XEns2-4aCmGp2XjJAeEH9TAUcGONLnSLy6ycT9FSJh8,16
|
22
|
+
moose_lib-0.4.196.dist-info/RECORD,,
|
@@ -0,0 +1,127 @@
|
|
1
|
+
import os
|
2
|
+
import sys
|
3
|
+
import time
|
4
|
+
import subprocess
|
5
|
+
import pytest
|
6
|
+
from pydantic import BaseModel
|
7
|
+
from moose_lib import MooseCache
|
8
|
+
|
9
|
+
class Config(BaseModel):
|
10
|
+
baz: int
|
11
|
+
qux: bool
|
12
|
+
|
13
|
+
@pytest.mark.integration
|
14
|
+
def test_cache_strings():
|
15
|
+
cache = MooseCache()
|
16
|
+
|
17
|
+
# Test setting and getting strings
|
18
|
+
cache.set("test:string", "hello")
|
19
|
+
value = cache.get("test:string")
|
20
|
+
assert value == "hello"
|
21
|
+
|
22
|
+
# Test with explicit str type hint
|
23
|
+
value = cache.get("test:string", str)
|
24
|
+
assert value == "hello"
|
25
|
+
|
26
|
+
# Clean up
|
27
|
+
cache.clear_keys("test")
|
28
|
+
|
29
|
+
@pytest.mark.integration
|
30
|
+
def test_cache_pydantic():
|
31
|
+
cache = MooseCache()
|
32
|
+
|
33
|
+
# Test setting and getting Pydantic models
|
34
|
+
config = Config(baz=123, qux=True)
|
35
|
+
cache.set("test:config", config)
|
36
|
+
|
37
|
+
retrieved = cache.get("test:config", Config)
|
38
|
+
assert retrieved is not None
|
39
|
+
assert retrieved.baz == 123
|
40
|
+
assert retrieved.qux is True
|
41
|
+
|
42
|
+
# Test invalid JSON
|
43
|
+
cache.set("test:invalid", "not json")
|
44
|
+
with pytest.raises(ValueError):
|
45
|
+
cache.get("test:invalid", Config)
|
46
|
+
|
47
|
+
# Clean up
|
48
|
+
cache.clear_keys("test")
|
49
|
+
|
50
|
+
@pytest.mark.integration
|
51
|
+
def test_cache_ttl():
|
52
|
+
cache = MooseCache()
|
53
|
+
|
54
|
+
# Test setting and getting with TTL
|
55
|
+
cache.set("test:ttl", "hello", ttl_seconds=3)
|
56
|
+
value = cache.get("test:ttl")
|
57
|
+
assert value == "hello"
|
58
|
+
time.sleep(5)
|
59
|
+
value = cache.get("test:ttl")
|
60
|
+
assert value is None
|
61
|
+
|
62
|
+
# Test negative TTL
|
63
|
+
with pytest.raises(ValueError):
|
64
|
+
cache.set("test:negative_ttl", "hello", ttl_seconds=-1)
|
65
|
+
|
66
|
+
# Clean up
|
67
|
+
cache.clear_keys("test")
|
68
|
+
|
69
|
+
@pytest.mark.integration
|
70
|
+
def test_cache_nonexistent():
|
71
|
+
cache = MooseCache()
|
72
|
+
|
73
|
+
# Test getting nonexistent keys
|
74
|
+
assert cache.get("nonexistent") is None
|
75
|
+
assert cache.get("nonexistent", str) is None
|
76
|
+
assert cache.get("nonexistent", Config) is None
|
77
|
+
|
78
|
+
@pytest.mark.integration
|
79
|
+
def test_cache_invalid_type():
|
80
|
+
cache = MooseCache()
|
81
|
+
|
82
|
+
# Test invalid type hints
|
83
|
+
with pytest.raises(TypeError):
|
84
|
+
cache.get("test", int)
|
85
|
+
|
86
|
+
with pytest.raises(TypeError):
|
87
|
+
cache.get("test", dict)
|
88
|
+
|
89
|
+
@pytest.mark.integration
|
90
|
+
def test_atexit_cleanup():
|
91
|
+
# Create a test script that will be run in a separate process
|
92
|
+
test_script = """
|
93
|
+
import sys
|
94
|
+
from moose_lib.clients.redis_client import MooseCache
|
95
|
+
|
96
|
+
# Create instance
|
97
|
+
cache = MooseCache()
|
98
|
+
print("Created cache instance")
|
99
|
+
|
100
|
+
# Force exit without calling disconnect
|
101
|
+
sys.exit(0)
|
102
|
+
"""
|
103
|
+
|
104
|
+
# Write the test script to a temporary file
|
105
|
+
with open("test_atexit.py", "w") as f:
|
106
|
+
f.write(test_script)
|
107
|
+
|
108
|
+
try:
|
109
|
+
# Run the script and capture output
|
110
|
+
result = subprocess.run([sys.executable, "test_atexit.py"],
|
111
|
+
capture_output=True,
|
112
|
+
text=True)
|
113
|
+
|
114
|
+
# Check if we see both the connection and disconnection messages
|
115
|
+
output = result.stdout + result.stderr
|
116
|
+
print("\nTest output:")
|
117
|
+
print("------------")
|
118
|
+
print(output)
|
119
|
+
print("------------")
|
120
|
+
|
121
|
+
assert "Python Redis client connected" in output
|
122
|
+
assert "Python Redis client disconnected" in output
|
123
|
+
print("\nTest passed! Verified atexit cleanup is working.")
|
124
|
+
|
125
|
+
finally:
|
126
|
+
# Clean up the test script
|
127
|
+
os.remove("test_atexit.py")
|
File without changes
|
File without changes
|