wappa 0.1.8__py3-none-any.whl → 0.1.10__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.
Potentially problematic release.
This version of wappa might be problematic. Click here for more details.
- wappa/__init__.py +4 -5
- wappa/api/controllers/webhook_controller.py +5 -2
- wappa/api/dependencies/__init__.py +0 -5
- wappa/api/middleware/error_handler.py +4 -4
- wappa/api/middleware/owner.py +11 -5
- wappa/api/routes/webhooks.py +2 -2
- wappa/cli/__init__.py +1 -1
- wappa/cli/examples/init/.env.example +33 -0
- wappa/cli/examples/init/app/__init__.py +0 -0
- wappa/cli/examples/init/app/main.py +9 -0
- wappa/cli/examples/init/app/master_event.py +10 -0
- wappa/cli/examples/json_cache_example/.env.example +33 -0
- wappa/cli/examples/json_cache_example/app/__init__.py +1 -0
- wappa/cli/examples/json_cache_example/app/main.py +247 -0
- wappa/cli/examples/json_cache_example/app/master_event.py +455 -0
- wappa/cli/examples/json_cache_example/app/models/__init__.py +1 -0
- wappa/cli/examples/json_cache_example/app/models/json_demo_models.py +256 -0
- wappa/cli/examples/json_cache_example/app/scores/__init__.py +35 -0
- wappa/cli/examples/json_cache_example/app/scores/score_base.py +192 -0
- wappa/cli/examples/json_cache_example/app/scores/score_cache_statistics.py +256 -0
- wappa/cli/examples/json_cache_example/app/scores/score_message_history.py +187 -0
- wappa/cli/examples/json_cache_example/app/scores/score_state_commands.py +272 -0
- wappa/cli/examples/json_cache_example/app/scores/score_user_management.py +239 -0
- wappa/cli/examples/json_cache_example/app/utils/__init__.py +26 -0
- wappa/cli/examples/json_cache_example/app/utils/cache_utils.py +174 -0
- wappa/cli/examples/json_cache_example/app/utils/message_utils.py +251 -0
- wappa/cli/examples/openai_transcript/.gitignore +63 -4
- wappa/cli/examples/openai_transcript/app/__init__.py +0 -0
- wappa/cli/examples/openai_transcript/app/main.py +9 -0
- wappa/cli/examples/openai_transcript/app/master_event.py +62 -0
- wappa/cli/examples/openai_transcript/app/openai_utils/__init__.py +3 -0
- wappa/cli/examples/openai_transcript/app/openai_utils/audio_processing.py +89 -0
- wappa/cli/examples/redis_cache_example/.env.example +33 -0
- wappa/cli/examples/redis_cache_example/app/__init__.py +6 -0
- wappa/cli/examples/redis_cache_example/app/main.py +246 -0
- wappa/cli/examples/redis_cache_example/app/master_event.py +455 -0
- wappa/cli/examples/redis_cache_example/app/models/redis_demo_models.py +256 -0
- wappa/cli/examples/redis_cache_example/app/scores/__init__.py +35 -0
- wappa/cli/examples/redis_cache_example/app/scores/score_base.py +192 -0
- wappa/cli/examples/redis_cache_example/app/scores/score_cache_statistics.py +256 -0
- wappa/cli/examples/redis_cache_example/app/scores/score_message_history.py +187 -0
- wappa/cli/examples/redis_cache_example/app/scores/score_state_commands.py +272 -0
- wappa/cli/examples/redis_cache_example/app/scores/score_user_management.py +239 -0
- wappa/cli/examples/redis_cache_example/app/utils/__init__.py +26 -0
- wappa/cli/examples/redis_cache_example/app/utils/cache_utils.py +174 -0
- wappa/cli/examples/redis_cache_example/app/utils/message_utils.py +251 -0
- wappa/cli/examples/simple_echo_example/.env.example +33 -0
- wappa/cli/examples/simple_echo_example/app/__init__.py +7 -0
- wappa/cli/examples/simple_echo_example/app/main.py +191 -0
- wappa/cli/examples/simple_echo_example/app/master_event.py +230 -0
- wappa/cli/examples/wappa_full_example/.env.example +33 -0
- wappa/cli/examples/wappa_full_example/.gitignore +63 -4
- wappa/cli/examples/wappa_full_example/app/__init__.py +6 -0
- wappa/cli/examples/wappa_full_example/app/handlers/__init__.py +5 -0
- wappa/cli/examples/wappa_full_example/app/handlers/command_handlers.py +492 -0
- wappa/cli/examples/wappa_full_example/app/handlers/message_handlers.py +559 -0
- wappa/cli/examples/wappa_full_example/app/handlers/state_handlers.py +514 -0
- wappa/cli/examples/wappa_full_example/app/main.py +269 -0
- wappa/cli/examples/wappa_full_example/app/master_event.py +504 -0
- wappa/cli/examples/wappa_full_example/app/media/README.md +54 -0
- wappa/cli/examples/wappa_full_example/app/media/buttons/README.md +62 -0
- wappa/cli/examples/wappa_full_example/app/media/buttons/kitty.png +0 -0
- wappa/cli/examples/wappa_full_example/app/media/buttons/puppy.png +0 -0
- wappa/cli/examples/wappa_full_example/app/media/list/README.md +110 -0
- wappa/cli/examples/wappa_full_example/app/media/list/audio.mp3 +0 -0
- wappa/cli/examples/wappa_full_example/app/media/list/document.pdf +0 -0
- wappa/cli/examples/wappa_full_example/app/media/list/image.png +0 -0
- wappa/cli/examples/wappa_full_example/app/media/list/video.mp4 +0 -0
- wappa/cli/examples/wappa_full_example/app/models/__init__.py +5 -0
- wappa/cli/examples/wappa_full_example/app/models/state_models.py +434 -0
- wappa/cli/examples/wappa_full_example/app/models/user_models.py +303 -0
- wappa/cli/examples/wappa_full_example/app/models/webhook_metadata.py +327 -0
- wappa/cli/examples/wappa_full_example/app/utils/__init__.py +5 -0
- wappa/cli/examples/wappa_full_example/app/utils/cache_utils.py +502 -0
- wappa/cli/examples/wappa_full_example/app/utils/media_handler.py +516 -0
- wappa/cli/examples/wappa_full_example/app/utils/metadata_extractor.py +337 -0
- wappa/cli/main.py +14 -5
- wappa/core/__init__.py +18 -23
- wappa/core/config/settings.py +7 -5
- wappa/core/events/default_handlers.py +1 -1
- wappa/core/factory/wappa_builder.py +38 -25
- wappa/core/plugins/redis_plugin.py +1 -3
- wappa/core/plugins/wappa_core_plugin.py +7 -6
- wappa/core/types.py +12 -12
- wappa/core/wappa_app.py +10 -8
- wappa/database/__init__.py +3 -4
- wappa/domain/enums/messenger_platform.py +1 -2
- wappa/domain/factories/media_factory.py +5 -20
- wappa/domain/factories/message_factory.py +5 -20
- wappa/domain/factories/messenger_factory.py +2 -4
- wappa/domain/interfaces/cache_interface.py +7 -7
- wappa/domain/interfaces/media_interface.py +2 -5
- wappa/domain/models/media_result.py +1 -3
- wappa/domain/models/platforms/platform_config.py +1 -3
- wappa/messaging/__init__.py +9 -12
- wappa/messaging/whatsapp/handlers/whatsapp_media_handler.py +20 -22
- wappa/models/__init__.py +27 -35
- wappa/persistence/__init__.py +12 -15
- wappa/persistence/cache_factory.py +0 -1
- wappa/persistence/json/__init__.py +1 -1
- wappa/persistence/json/cache_adapters.py +37 -25
- wappa/persistence/json/handlers/state_handler.py +60 -52
- wappa/persistence/json/handlers/table_handler.py +51 -49
- wappa/persistence/json/handlers/user_handler.py +71 -55
- wappa/persistence/json/handlers/utils/file_manager.py +42 -39
- wappa/persistence/json/handlers/utils/key_factory.py +1 -1
- wappa/persistence/json/handlers/utils/serialization.py +13 -11
- wappa/persistence/json/json_cache_factory.py +4 -8
- wappa/persistence/json/storage_manager.py +66 -79
- wappa/persistence/memory/__init__.py +1 -1
- wappa/persistence/memory/cache_adapters.py +37 -25
- wappa/persistence/memory/handlers/state_handler.py +62 -52
- wappa/persistence/memory/handlers/table_handler.py +59 -53
- wappa/persistence/memory/handlers/user_handler.py +75 -55
- wappa/persistence/memory/handlers/utils/key_factory.py +1 -1
- wappa/persistence/memory/handlers/utils/memory_store.py +75 -71
- wappa/persistence/memory/handlers/utils/ttl_manager.py +59 -67
- wappa/persistence/memory/memory_cache_factory.py +3 -7
- wappa/persistence/memory/storage_manager.py +52 -62
- wappa/persistence/redis/cache_adapters.py +27 -21
- wappa/persistence/redis/ops.py +11 -11
- wappa/persistence/redis/redis_client.py +4 -6
- wappa/persistence/redis/redis_manager.py +12 -4
- wappa/processors/factory.py +5 -5
- wappa/schemas/factory.py +2 -5
- wappa/schemas/whatsapp/message_types/errors.py +3 -12
- wappa/schemas/whatsapp/validators.py +3 -3
- wappa/webhooks/__init__.py +17 -18
- wappa/webhooks/factory.py +3 -5
- wappa/webhooks/whatsapp/__init__.py +10 -13
- wappa/webhooks/whatsapp/message_types/audio.py +0 -4
- wappa/webhooks/whatsapp/message_types/document.py +1 -9
- wappa/webhooks/whatsapp/message_types/errors.py +3 -12
- wappa/webhooks/whatsapp/message_types/location.py +1 -21
- wappa/webhooks/whatsapp/message_types/sticker.py +1 -5
- wappa/webhooks/whatsapp/message_types/text.py +0 -6
- wappa/webhooks/whatsapp/message_types/video.py +1 -20
- wappa/webhooks/whatsapp/status_models.py +2 -2
- wappa/webhooks/whatsapp/validators.py +3 -3
- {wappa-0.1.8.dist-info → wappa-0.1.10.dist-info}/METADATA +362 -8
- {wappa-0.1.8.dist-info → wappa-0.1.10.dist-info}/RECORD +144 -80
- wappa/cli/examples/init/pyproject.toml +0 -7
- wappa/cli/examples/simple_echo_example/.python-version +0 -1
- wappa/cli/examples/simple_echo_example/pyproject.toml +0 -9
- {wappa-0.1.8.dist-info → wappa-0.1.10.dist-info}/WHEEL +0 -0
- {wappa-0.1.8.dist-info → wappa-0.1.10.dist-info}/entry_points.txt +0 -0
- {wappa-0.1.8.dist-info → wappa-0.1.10.dist-info}/licenses/LICENSE +0 -0
|
@@ -5,7 +5,7 @@ Provides user-specific cache operations using in-memory storage.
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
import logging
|
|
8
|
-
from typing import Any
|
|
8
|
+
from typing import Any
|
|
9
9
|
|
|
10
10
|
from pydantic import BaseModel
|
|
11
11
|
|
|
@@ -18,196 +18,216 @@ logger = logging.getLogger("MemoryUser")
|
|
|
18
18
|
class MemoryUser:
|
|
19
19
|
"""
|
|
20
20
|
Memory-based user cache handler.
|
|
21
|
-
|
|
21
|
+
|
|
22
22
|
Mirrors RedisUser functionality using in-memory storage.
|
|
23
23
|
Maintains the same API for seamless cache backend switching.
|
|
24
24
|
"""
|
|
25
|
-
|
|
25
|
+
|
|
26
26
|
def __init__(self, tenant: str, user_id: str):
|
|
27
27
|
"""
|
|
28
28
|
Initialize Memory user handler.
|
|
29
|
-
|
|
29
|
+
|
|
30
30
|
Args:
|
|
31
31
|
tenant: Tenant identifier
|
|
32
32
|
user_id: User identifier
|
|
33
33
|
"""
|
|
34
34
|
if not tenant or not user_id:
|
|
35
|
-
raise ValueError(
|
|
36
|
-
|
|
35
|
+
raise ValueError(
|
|
36
|
+
f"Missing required parameters: tenant={tenant}, user_id={user_id}"
|
|
37
|
+
)
|
|
38
|
+
|
|
37
39
|
self.tenant = tenant
|
|
38
40
|
self.user_id = user_id
|
|
39
41
|
self.keys = default_key_factory
|
|
40
|
-
|
|
42
|
+
|
|
41
43
|
def _key(self) -> str:
|
|
42
44
|
"""Build user key using KeyFactory (same as Redis)."""
|
|
43
45
|
return self.keys.user(self.tenant, self.user_id)
|
|
44
|
-
|
|
46
|
+
|
|
45
47
|
# ---- Public API matching RedisUser ----
|
|
46
48
|
async def get(self, models: type[BaseModel] | None = None) -> dict[str, Any] | None:
|
|
47
49
|
"""
|
|
48
50
|
Get full user data.
|
|
49
|
-
|
|
51
|
+
|
|
50
52
|
Args:
|
|
51
53
|
models: Optional BaseModel class for deserialization
|
|
52
|
-
|
|
54
|
+
|
|
53
55
|
Returns:
|
|
54
56
|
User data dictionary or BaseModel instance, None if not found
|
|
55
57
|
"""
|
|
56
58
|
key = self._key()
|
|
57
|
-
return await storage_manager.get(
|
|
58
|
-
|
|
59
|
-
|
|
59
|
+
return await storage_manager.get(
|
|
60
|
+
"users", self.tenant, self.user_id, key, models
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
async def upsert(
|
|
64
|
+
self, data: dict[str, Any] | BaseModel, ttl: int | None = None
|
|
65
|
+
) -> bool:
|
|
60
66
|
"""
|
|
61
67
|
Create or update user data.
|
|
62
|
-
|
|
68
|
+
|
|
63
69
|
Args:
|
|
64
70
|
data: User data to store
|
|
65
71
|
ttl: Time to live in seconds
|
|
66
|
-
|
|
72
|
+
|
|
67
73
|
Returns:
|
|
68
74
|
True if successful, False otherwise
|
|
69
75
|
"""
|
|
70
76
|
key = self._key()
|
|
71
|
-
return await storage_manager.set(
|
|
72
|
-
|
|
77
|
+
return await storage_manager.set(
|
|
78
|
+
"users", self.tenant, self.user_id, key, data, ttl
|
|
79
|
+
)
|
|
80
|
+
|
|
73
81
|
async def delete(self) -> int:
|
|
74
82
|
"""
|
|
75
83
|
Delete user data.
|
|
76
|
-
|
|
84
|
+
|
|
77
85
|
Returns:
|
|
78
86
|
1 if deleted, 0 if didn't exist
|
|
79
87
|
"""
|
|
80
88
|
key = self._key()
|
|
81
89
|
success = await storage_manager.delete("users", self.tenant, self.user_id, key)
|
|
82
90
|
return 1 if success else 0
|
|
83
|
-
|
|
91
|
+
|
|
84
92
|
async def exists(self) -> bool:
|
|
85
93
|
"""
|
|
86
94
|
Check if user data exists.
|
|
87
|
-
|
|
95
|
+
|
|
88
96
|
Returns:
|
|
89
97
|
True if exists, False otherwise
|
|
90
98
|
"""
|
|
91
99
|
key = self._key()
|
|
92
100
|
return await storage_manager.exists("users", self.tenant, self.user_id, key)
|
|
93
|
-
|
|
101
|
+
|
|
94
102
|
async def get_field(self, field: str) -> Any | None:
|
|
95
103
|
"""
|
|
96
104
|
Get a specific field from user data.
|
|
97
|
-
|
|
105
|
+
|
|
98
106
|
Args:
|
|
99
107
|
field: Field name
|
|
100
|
-
|
|
108
|
+
|
|
101
109
|
Returns:
|
|
102
110
|
Field value or None if not found
|
|
103
111
|
"""
|
|
104
112
|
user_data = await self.get()
|
|
105
113
|
if user_data is None:
|
|
106
114
|
return None
|
|
107
|
-
|
|
115
|
+
|
|
108
116
|
if isinstance(user_data, dict):
|
|
109
117
|
return user_data.get(field)
|
|
110
118
|
else:
|
|
111
119
|
# BaseModel instance
|
|
112
120
|
return getattr(user_data, field, None)
|
|
113
|
-
|
|
114
|
-
async def update_field(
|
|
121
|
+
|
|
122
|
+
async def update_field(
|
|
123
|
+
self, field: str, value: Any, ttl: int | None = None
|
|
124
|
+
) -> bool:
|
|
115
125
|
"""
|
|
116
126
|
Update a specific field in user data.
|
|
117
|
-
|
|
127
|
+
|
|
118
128
|
Args:
|
|
119
129
|
field: Field name
|
|
120
130
|
value: New value
|
|
121
131
|
ttl: Time to live in seconds
|
|
122
|
-
|
|
132
|
+
|
|
123
133
|
Returns:
|
|
124
134
|
True if successful, False otherwise
|
|
125
135
|
"""
|
|
126
136
|
user_data = await self.get()
|
|
127
137
|
if user_data is None:
|
|
128
138
|
user_data = {}
|
|
129
|
-
|
|
139
|
+
|
|
130
140
|
if isinstance(user_data, BaseModel):
|
|
131
141
|
user_data = user_data.model_dump()
|
|
132
|
-
|
|
142
|
+
|
|
133
143
|
user_data[field] = value
|
|
134
144
|
return await self.upsert(user_data, ttl)
|
|
135
|
-
|
|
136
|
-
async def increment_field(
|
|
145
|
+
|
|
146
|
+
async def increment_field(
|
|
147
|
+
self, field: str, increment: int = 1, ttl: int | None = None
|
|
148
|
+
) -> int | None:
|
|
137
149
|
"""
|
|
138
150
|
Atomically increment an integer field.
|
|
139
|
-
|
|
151
|
+
|
|
140
152
|
Args:
|
|
141
153
|
field: Field name
|
|
142
154
|
increment: Amount to increment by
|
|
143
155
|
ttl: Time to live in seconds
|
|
144
|
-
|
|
156
|
+
|
|
145
157
|
Returns:
|
|
146
158
|
New value after increment or None on error
|
|
147
159
|
"""
|
|
148
160
|
user_data = await self.get()
|
|
149
161
|
if user_data is None:
|
|
150
162
|
user_data = {}
|
|
151
|
-
|
|
163
|
+
|
|
152
164
|
if isinstance(user_data, BaseModel):
|
|
153
165
|
user_data = user_data.model_dump()
|
|
154
|
-
|
|
166
|
+
|
|
155
167
|
current_value = user_data.get(field, 0)
|
|
156
|
-
if not isinstance(current_value,
|
|
157
|
-
logger.warning(
|
|
168
|
+
if not isinstance(current_value, int | float):
|
|
169
|
+
logger.warning(
|
|
170
|
+
f"Cannot increment non-numeric field '{field}': {current_value}"
|
|
171
|
+
)
|
|
158
172
|
return None
|
|
159
|
-
|
|
173
|
+
|
|
160
174
|
new_value = int(current_value) + increment
|
|
161
175
|
user_data[field] = new_value
|
|
162
|
-
|
|
176
|
+
|
|
163
177
|
success = await self.upsert(user_data, ttl)
|
|
164
178
|
return new_value if success else None
|
|
165
|
-
|
|
166
|
-
async def append_to_list(
|
|
179
|
+
|
|
180
|
+
async def append_to_list(
|
|
181
|
+
self, field: str, value: Any, ttl: int | None = None
|
|
182
|
+
) -> bool:
|
|
167
183
|
"""
|
|
168
184
|
Append value to a list field.
|
|
169
|
-
|
|
185
|
+
|
|
170
186
|
Args:
|
|
171
187
|
field: Field name containing list
|
|
172
188
|
value: Value to append
|
|
173
189
|
ttl: Time to live in seconds
|
|
174
|
-
|
|
190
|
+
|
|
175
191
|
Returns:
|
|
176
192
|
True if successful, False otherwise
|
|
177
193
|
"""
|
|
178
194
|
user_data = await self.get()
|
|
179
195
|
if user_data is None:
|
|
180
196
|
user_data = {}
|
|
181
|
-
|
|
197
|
+
|
|
182
198
|
if isinstance(user_data, BaseModel):
|
|
183
199
|
user_data = user_data.model_dump()
|
|
184
|
-
|
|
200
|
+
|
|
185
201
|
current_list = user_data.get(field, [])
|
|
186
202
|
if not isinstance(current_list, list):
|
|
187
203
|
current_list = []
|
|
188
|
-
|
|
204
|
+
|
|
189
205
|
current_list.append(value)
|
|
190
206
|
user_data[field] = current_list
|
|
191
|
-
|
|
207
|
+
|
|
192
208
|
return await self.upsert(user_data, ttl)
|
|
193
|
-
|
|
209
|
+
|
|
194
210
|
async def get_ttl(self, key: str) -> int:
|
|
195
211
|
"""
|
|
196
212
|
Get remaining time to live.
|
|
197
|
-
|
|
213
|
+
|
|
198
214
|
Returns:
|
|
199
215
|
Remaining TTL in seconds, -1 if no expiry, -2 if doesn't exist
|
|
200
216
|
"""
|
|
201
|
-
return await storage_manager.get_ttl(
|
|
202
|
-
|
|
217
|
+
return await storage_manager.get_ttl(
|
|
218
|
+
"users", self.tenant, self.user_id, self._key()
|
|
219
|
+
)
|
|
220
|
+
|
|
203
221
|
async def renew_ttl(self, key: str, ttl: int) -> bool:
|
|
204
222
|
"""
|
|
205
223
|
Renew time to live.
|
|
206
|
-
|
|
224
|
+
|
|
207
225
|
Args:
|
|
208
226
|
ttl: New time to live in seconds
|
|
209
|
-
|
|
227
|
+
|
|
210
228
|
Returns:
|
|
211
229
|
True if successful, False otherwise
|
|
212
230
|
"""
|
|
213
|
-
return await storage_manager.set_ttl(
|
|
231
|
+
return await storage_manager.set_ttl(
|
|
232
|
+
"users", self.tenant, self.user_id, self._key(), ttl
|
|
233
|
+
)
|
|
@@ -8,4 +8,4 @@ across all cache implementations.
|
|
|
8
8
|
from ....redis.redis_handler.utils.key_factory import KeyFactory, default_key_factory
|
|
9
9
|
|
|
10
10
|
# Export the same key factory used by Redis for consistency
|
|
11
|
-
__all__ = ["KeyFactory", "default_key_factory"]
|
|
11
|
+
__all__ = ["KeyFactory", "default_key_factory"]
|
|
@@ -8,7 +8,7 @@ and automatic expiration cleanup.
|
|
|
8
8
|
import asyncio
|
|
9
9
|
import logging
|
|
10
10
|
from datetime import datetime, timedelta
|
|
11
|
-
from typing import Any
|
|
11
|
+
from typing import Any
|
|
12
12
|
|
|
13
13
|
logger = logging.getLogger("MemoryStore")
|
|
14
14
|
|
|
@@ -16,62 +16,62 @@ logger = logging.getLogger("MemoryStore")
|
|
|
16
16
|
class MemoryStore:
|
|
17
17
|
"""
|
|
18
18
|
Thread-safe in-memory store with TTL support.
|
|
19
|
-
|
|
19
|
+
|
|
20
20
|
Storage Structure:
|
|
21
21
|
{
|
|
22
22
|
"users": {context_key: {key: (data, expires_at)}},
|
|
23
23
|
"tables": {context_key: {key: (data, expires_at)}},
|
|
24
24
|
"states": {context_key: {key: (data, expires_at)}}
|
|
25
25
|
}
|
|
26
|
-
|
|
26
|
+
|
|
27
27
|
Where context_key is typically "{tenant_id}_{user_id}" for isolation.
|
|
28
28
|
"""
|
|
29
|
-
|
|
29
|
+
|
|
30
30
|
def __init__(self):
|
|
31
|
-
self._store:
|
|
31
|
+
self._store: dict[str, dict[str, dict[str, tuple[Any, datetime | None]]]] = {
|
|
32
32
|
"users": {},
|
|
33
33
|
"tables": {},
|
|
34
|
-
"states": {}
|
|
34
|
+
"states": {},
|
|
35
35
|
}
|
|
36
36
|
self._locks = {
|
|
37
37
|
"users": asyncio.Lock(),
|
|
38
38
|
"tables": asyncio.Lock(),
|
|
39
|
-
"states": asyncio.Lock()
|
|
39
|
+
"states": asyncio.Lock(),
|
|
40
40
|
}
|
|
41
|
-
self._cleanup_task:
|
|
41
|
+
self._cleanup_task: asyncio.Task | None = None
|
|
42
42
|
self._cleanup_interval = 300 # 5 minutes
|
|
43
|
-
|
|
43
|
+
|
|
44
44
|
def start_cleanup_task(self):
|
|
45
45
|
"""Start background TTL cleanup task."""
|
|
46
46
|
if self._cleanup_task is None or self._cleanup_task.done():
|
|
47
47
|
self._cleanup_task = asyncio.create_task(self._cleanup_expired_entries())
|
|
48
48
|
logger.info("Started memory store TTL cleanup task")
|
|
49
|
-
|
|
49
|
+
|
|
50
50
|
def stop_cleanup_task(self):
|
|
51
51
|
"""Stop background TTL cleanup task."""
|
|
52
52
|
if self._cleanup_task and not self._cleanup_task.done():
|
|
53
53
|
self._cleanup_task.cancel()
|
|
54
54
|
logger.info("Stopped memory store TTL cleanup task")
|
|
55
|
-
|
|
55
|
+
|
|
56
56
|
async def get(self, namespace: str, context_key: str, key: str) -> Any:
|
|
57
57
|
"""
|
|
58
58
|
Get value with automatic expiration check.
|
|
59
|
-
|
|
59
|
+
|
|
60
60
|
Args:
|
|
61
61
|
namespace: Cache namespace ("users", "tables", "states")
|
|
62
62
|
context_key: Context identifier (e.g., "{tenant_id}_{user_id}")
|
|
63
63
|
key: Cache key
|
|
64
|
-
|
|
64
|
+
|
|
65
65
|
Returns:
|
|
66
66
|
Cached value or None if not found/expired
|
|
67
67
|
"""
|
|
68
68
|
if namespace not in self._locks:
|
|
69
69
|
raise ValueError(f"Invalid namespace: {namespace}")
|
|
70
|
-
|
|
70
|
+
|
|
71
71
|
async with self._locks[namespace]:
|
|
72
72
|
store = self._store[namespace]
|
|
73
73
|
context_store = store.get(context_key, {})
|
|
74
|
-
|
|
74
|
+
|
|
75
75
|
if key in context_store:
|
|
76
76
|
data, expires_at = context_store[key]
|
|
77
77
|
if expires_at and datetime.now() > expires_at:
|
|
@@ -80,64 +80,64 @@ class MemoryStore:
|
|
|
80
80
|
return None
|
|
81
81
|
return data
|
|
82
82
|
return None
|
|
83
|
-
|
|
83
|
+
|
|
84
84
|
async def set(
|
|
85
|
-
self,
|
|
86
|
-
namespace: str,
|
|
87
|
-
context_key: str,
|
|
88
|
-
key: str,
|
|
89
|
-
data: Any,
|
|
90
|
-
ttl:
|
|
85
|
+
self,
|
|
86
|
+
namespace: str,
|
|
87
|
+
context_key: str,
|
|
88
|
+
key: str,
|
|
89
|
+
data: Any,
|
|
90
|
+
ttl: int | None = None,
|
|
91
91
|
) -> bool:
|
|
92
92
|
"""
|
|
93
93
|
Set value with optional TTL.
|
|
94
|
-
|
|
94
|
+
|
|
95
95
|
Args:
|
|
96
96
|
namespace: Cache namespace
|
|
97
97
|
context_key: Context identifier
|
|
98
98
|
key: Cache key
|
|
99
99
|
data: Value to store
|
|
100
100
|
ttl: Time to live in seconds
|
|
101
|
-
|
|
101
|
+
|
|
102
102
|
Returns:
|
|
103
103
|
True if successful, False otherwise
|
|
104
104
|
"""
|
|
105
105
|
if namespace not in self._locks:
|
|
106
106
|
raise ValueError(f"Invalid namespace: {namespace}")
|
|
107
|
-
|
|
107
|
+
|
|
108
108
|
expires_at = None
|
|
109
109
|
if ttl:
|
|
110
110
|
expires_at = datetime.now() + timedelta(seconds=ttl)
|
|
111
|
-
|
|
111
|
+
|
|
112
112
|
try:
|
|
113
113
|
async with self._locks[namespace]:
|
|
114
114
|
store = self._store[namespace]
|
|
115
115
|
if context_key not in store:
|
|
116
116
|
store[context_key] = {}
|
|
117
117
|
store[context_key][key] = (data, expires_at)
|
|
118
|
-
|
|
118
|
+
|
|
119
119
|
# Start cleanup task if not running
|
|
120
120
|
self.start_cleanup_task()
|
|
121
121
|
return True
|
|
122
122
|
except Exception as e:
|
|
123
123
|
logger.error(f"Failed to set key '{key}' in {namespace}: {e}")
|
|
124
124
|
return False
|
|
125
|
-
|
|
125
|
+
|
|
126
126
|
async def delete(self, namespace: str, context_key: str, key: str) -> bool:
|
|
127
127
|
"""
|
|
128
128
|
Delete key from store.
|
|
129
|
-
|
|
129
|
+
|
|
130
130
|
Args:
|
|
131
131
|
namespace: Cache namespace
|
|
132
132
|
context_key: Context identifier
|
|
133
133
|
key: Cache key
|
|
134
|
-
|
|
134
|
+
|
|
135
135
|
Returns:
|
|
136
136
|
True if deleted or didn't exist, False on error
|
|
137
137
|
"""
|
|
138
138
|
if namespace not in self._locks:
|
|
139
139
|
raise ValueError(f"Invalid namespace: {namespace}")
|
|
140
|
-
|
|
140
|
+
|
|
141
141
|
try:
|
|
142
142
|
async with self._locks[namespace]:
|
|
143
143
|
store = self._store[namespace]
|
|
@@ -151,73 +151,75 @@ class MemoryStore:
|
|
|
151
151
|
except Exception as e:
|
|
152
152
|
logger.error(f"Failed to delete key '{key}' from {namespace}: {e}")
|
|
153
153
|
return False
|
|
154
|
-
|
|
154
|
+
|
|
155
155
|
async def exists(self, namespace: str, context_key: str, key: str) -> bool:
|
|
156
156
|
"""
|
|
157
157
|
Check if key exists and is not expired.
|
|
158
|
-
|
|
158
|
+
|
|
159
159
|
Args:
|
|
160
160
|
namespace: Cache namespace
|
|
161
161
|
context_key: Context identifier
|
|
162
162
|
key: Cache key
|
|
163
|
-
|
|
163
|
+
|
|
164
164
|
Returns:
|
|
165
165
|
True if exists and not expired, False otherwise
|
|
166
166
|
"""
|
|
167
167
|
value = await self.get(namespace, context_key, key)
|
|
168
168
|
return value is not None
|
|
169
|
-
|
|
169
|
+
|
|
170
170
|
async def get_ttl(self, namespace: str, context_key: str, key: str) -> int:
|
|
171
171
|
"""
|
|
172
172
|
Get remaining TTL for key.
|
|
173
|
-
|
|
173
|
+
|
|
174
174
|
Returns:
|
|
175
175
|
Remaining TTL in seconds, -1 if no expiry, -2 if doesn't exist
|
|
176
176
|
"""
|
|
177
177
|
if namespace not in self._locks:
|
|
178
178
|
return -2
|
|
179
|
-
|
|
179
|
+
|
|
180
180
|
async with self._locks[namespace]:
|
|
181
181
|
store = self._store[namespace]
|
|
182
182
|
context_store = store.get(context_key, {})
|
|
183
|
-
|
|
183
|
+
|
|
184
184
|
if key not in context_store:
|
|
185
185
|
return -2 # Doesn't exist
|
|
186
|
-
|
|
186
|
+
|
|
187
187
|
data, expires_at = context_store[key]
|
|
188
|
-
|
|
188
|
+
|
|
189
189
|
if expires_at is None:
|
|
190
190
|
return -1 # No expiry
|
|
191
|
-
|
|
191
|
+
|
|
192
192
|
now = datetime.now()
|
|
193
193
|
if now >= expires_at:
|
|
194
194
|
# Already expired, clean up
|
|
195
195
|
del context_store[key]
|
|
196
196
|
return -2
|
|
197
|
-
|
|
197
|
+
|
|
198
198
|
return int((expires_at - now).total_seconds())
|
|
199
|
-
|
|
200
|
-
async def set_ttl(
|
|
199
|
+
|
|
200
|
+
async def set_ttl(
|
|
201
|
+
self, namespace: str, context_key: str, key: str, ttl: int
|
|
202
|
+
) -> bool:
|
|
201
203
|
"""
|
|
202
204
|
Set TTL for existing key.
|
|
203
|
-
|
|
205
|
+
|
|
204
206
|
Args:
|
|
205
207
|
ttl: Time to live in seconds
|
|
206
|
-
|
|
208
|
+
|
|
207
209
|
Returns:
|
|
208
210
|
True if successful, False if key doesn't exist or error
|
|
209
211
|
"""
|
|
210
212
|
if namespace not in self._locks:
|
|
211
213
|
return False
|
|
212
|
-
|
|
214
|
+
|
|
213
215
|
try:
|
|
214
216
|
async with self._locks[namespace]:
|
|
215
217
|
store = self._store[namespace]
|
|
216
218
|
context_store = store.get(context_key, {})
|
|
217
|
-
|
|
219
|
+
|
|
218
220
|
if key not in context_store:
|
|
219
221
|
return False # Key doesn't exist
|
|
220
|
-
|
|
222
|
+
|
|
221
223
|
data, _ = context_store[key] # Get existing data, ignore old TTL
|
|
222
224
|
expires_at = datetime.now() + timedelta(seconds=ttl)
|
|
223
225
|
context_store[key] = (data, expires_at)
|
|
@@ -225,78 +227,80 @@ class MemoryStore:
|
|
|
225
227
|
except Exception as e:
|
|
226
228
|
logger.error(f"Failed to set TTL for key '{key}' in {namespace}: {e}")
|
|
227
229
|
return False
|
|
228
|
-
|
|
229
|
-
async def get_all_keys(self, namespace: str, context_key: str) ->
|
|
230
|
+
|
|
231
|
+
async def get_all_keys(self, namespace: str, context_key: str) -> dict[str, Any]:
|
|
230
232
|
"""
|
|
231
233
|
Get all non-expired keys for a context.
|
|
232
|
-
|
|
234
|
+
|
|
233
235
|
Args:
|
|
234
236
|
namespace: Cache namespace
|
|
235
237
|
context_key: Context identifier
|
|
236
|
-
|
|
238
|
+
|
|
237
239
|
Returns:
|
|
238
240
|
Dictionary of all non-expired key-value pairs
|
|
239
241
|
"""
|
|
240
242
|
if namespace not in self._locks:
|
|
241
243
|
return {}
|
|
242
|
-
|
|
244
|
+
|
|
243
245
|
async with self._locks[namespace]:
|
|
244
246
|
store = self._store[namespace]
|
|
245
247
|
context_store = store.get(context_key, {})
|
|
246
|
-
|
|
248
|
+
|
|
247
249
|
result = {}
|
|
248
250
|
now = datetime.now()
|
|
249
251
|
expired_keys = []
|
|
250
|
-
|
|
252
|
+
|
|
251
253
|
for key, (data, expires_at) in context_store.items():
|
|
252
254
|
if expires_at and now > expires_at:
|
|
253
255
|
expired_keys.append(key)
|
|
254
256
|
else:
|
|
255
257
|
result[key] = data
|
|
256
|
-
|
|
258
|
+
|
|
257
259
|
# Clean up expired keys
|
|
258
260
|
for key in expired_keys:
|
|
259
261
|
del context_store[key]
|
|
260
|
-
|
|
262
|
+
|
|
261
263
|
return result
|
|
262
|
-
|
|
264
|
+
|
|
263
265
|
async def _cleanup_expired_entries(self):
|
|
264
266
|
"""Background task to clean up expired entries."""
|
|
265
267
|
while True:
|
|
266
268
|
try:
|
|
267
269
|
await asyncio.sleep(self._cleanup_interval)
|
|
268
|
-
|
|
270
|
+
|
|
269
271
|
now = datetime.now()
|
|
270
272
|
total_cleaned = 0
|
|
271
|
-
|
|
273
|
+
|
|
272
274
|
for namespace in ["users", "tables", "states"]:
|
|
273
275
|
async with self._locks[namespace]:
|
|
274
276
|
store = self._store[namespace]
|
|
275
277
|
empty_contexts = []
|
|
276
|
-
|
|
278
|
+
|
|
277
279
|
for context_key, context_store in store.items():
|
|
278
280
|
expired_keys = []
|
|
279
|
-
|
|
281
|
+
|
|
280
282
|
for key, (_, expires_at) in context_store.items():
|
|
281
283
|
if expires_at and now > expires_at:
|
|
282
284
|
expired_keys.append(key)
|
|
283
|
-
|
|
285
|
+
|
|
284
286
|
# Remove expired keys
|
|
285
287
|
for key in expired_keys:
|
|
286
288
|
del context_store[key]
|
|
287
289
|
total_cleaned += 1
|
|
288
|
-
|
|
290
|
+
|
|
289
291
|
# Mark empty contexts for cleanup
|
|
290
292
|
if not context_store:
|
|
291
293
|
empty_contexts.append(context_key)
|
|
292
|
-
|
|
294
|
+
|
|
293
295
|
# Remove empty contexts
|
|
294
296
|
for context_key in empty_contexts:
|
|
295
297
|
del store[context_key]
|
|
296
|
-
|
|
298
|
+
|
|
297
299
|
if total_cleaned > 0:
|
|
298
|
-
logger.debug(
|
|
299
|
-
|
|
300
|
+
logger.debug(
|
|
301
|
+
f"Cleaned up {total_cleaned} expired entries from memory store"
|
|
302
|
+
)
|
|
303
|
+
|
|
300
304
|
except asyncio.CancelledError:
|
|
301
305
|
logger.info("Memory store cleanup task cancelled")
|
|
302
306
|
break
|
|
@@ -306,7 +310,7 @@ class MemoryStore:
|
|
|
306
310
|
|
|
307
311
|
|
|
308
312
|
# Global singleton memory store instance
|
|
309
|
-
_global_memory_store:
|
|
313
|
+
_global_memory_store: MemoryStore | None = None
|
|
310
314
|
|
|
311
315
|
|
|
312
316
|
def get_memory_store() -> MemoryStore:
|
|
@@ -314,4 +318,4 @@ def get_memory_store() -> MemoryStore:
|
|
|
314
318
|
global _global_memory_store
|
|
315
319
|
if _global_memory_store is None:
|
|
316
320
|
_global_memory_store = MemoryStore()
|
|
317
|
-
return _global_memory_store
|
|
321
|
+
return _global_memory_store
|