moose-lib 0.4.195__tar.gz → 0.4.197__tar.gz

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.
Files changed (27) hide show
  1. {moose_lib-0.4.195 → moose_lib-0.4.197}/PKG-INFO +2 -1
  2. {moose_lib-0.4.195 → moose_lib-0.4.197}/moose_lib/__init__.py +2 -0
  3. moose_lib-0.4.197/moose_lib/clients/redis_client.py +237 -0
  4. moose_lib-0.4.197/moose_lib/streaming/__init__.py +0 -0
  5. {moose_lib-0.4.195 → moose_lib-0.4.197}/moose_lib.egg-info/PKG-INFO +2 -1
  6. {moose_lib-0.4.195 → moose_lib-0.4.197}/moose_lib.egg-info/SOURCES.txt +4 -1
  7. {moose_lib-0.4.195 → moose_lib-0.4.197}/moose_lib.egg-info/requires.txt +1 -0
  8. {moose_lib-0.4.195 → moose_lib-0.4.197}/setup.py +1 -0
  9. moose_lib-0.4.197/tests/test_redis_client.py +127 -0
  10. {moose_lib-0.4.195 → moose_lib-0.4.197}/README.md +0 -0
  11. {moose_lib-0.4.195 → moose_lib-0.4.197}/moose_lib/blocks.py +0 -0
  12. {moose_lib-0.4.195/moose_lib/streaming → moose_lib-0.4.197/moose_lib/clients}/__init__.py +0 -0
  13. {moose_lib-0.4.195 → moose_lib-0.4.197}/moose_lib/commons.py +0 -0
  14. {moose_lib-0.4.195 → moose_lib-0.4.197}/moose_lib/data_models.py +0 -0
  15. {moose_lib-0.4.195 → moose_lib-0.4.197}/moose_lib/dmv2-serializer.py +0 -0
  16. {moose_lib-0.4.195 → moose_lib-0.4.197}/moose_lib/dmv2.py +0 -0
  17. {moose_lib-0.4.195 → moose_lib-0.4.197}/moose_lib/internal.py +0 -0
  18. {moose_lib-0.4.195 → moose_lib-0.4.197}/moose_lib/main.py +0 -0
  19. {moose_lib-0.4.195 → moose_lib-0.4.197}/moose_lib/query_param.py +0 -0
  20. {moose_lib-0.4.195 → moose_lib-0.4.197}/moose_lib/streaming/streaming_function_runner.py +0 -0
  21. {moose_lib-0.4.195 → moose_lib-0.4.197}/moose_lib/tasks.py +0 -0
  22. {moose_lib-0.4.195 → moose_lib-0.4.197}/moose_lib.egg-info/dependency_links.txt +0 -0
  23. {moose_lib-0.4.195 → moose_lib-0.4.197}/moose_lib.egg-info/top_level.txt +0 -0
  24. {moose_lib-0.4.195 → moose_lib-0.4.197}/setup.cfg +0 -0
  25. {moose_lib-0.4.195 → moose_lib-0.4.197}/tests/__init__.py +0 -0
  26. {moose_lib-0.4.195 → moose_lib-0.4.197}/tests/conftest.py +0 -0
  27. {moose_lib-0.4.195 → moose_lib-0.4.197}/tests/test_moose.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: moose_lib
3
- Version: 0.4.195
3
+ Version: 0.4.197
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
@@ -9,3 +9,5 @@ from .tasks import *
9
9
  from .data_models import *
10
10
 
11
11
  from .dmv2 import *
12
+
13
+ from .clients.redis_client import MooseCache
@@ -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")
File without changes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: moose_lib
3
- Version: 0.4.195
3
+ Version: 0.4.197
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
@@ -15,8 +15,11 @@ moose_lib.egg-info/SOURCES.txt
15
15
  moose_lib.egg-info/dependency_links.txt
16
16
  moose_lib.egg-info/requires.txt
17
17
  moose_lib.egg-info/top_level.txt
18
+ moose_lib/clients/__init__.py
19
+ moose_lib/clients/redis_client.py
18
20
  moose_lib/streaming/__init__.py
19
21
  moose_lib/streaming/streaming_function_runner.py
20
22
  tests/__init__.py
21
23
  tests/conftest.py
22
- tests/test_moose.py
24
+ tests/test_moose.py
25
+ tests/test_redis_client.py
@@ -3,3 +3,4 @@ asyncio==3.4.3
3
3
  pydantic==2.10.6
4
4
  temporalio==1.9.0
5
5
  kafka-python-ng==2.2.2
6
+ redis==6.2.0
@@ -28,5 +28,6 @@ setup(
28
28
  "pydantic==2.10.6",
29
29
  "temporalio==1.9.0",
30
30
  "kafka-python-ng==2.2.2",
31
+ "redis==6.2.0",
31
32
  ],
32
33
  )
@@ -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