wappa 0.1.0__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 +85 -0
- wappa/api/__init__.py +1 -0
- wappa/api/controllers/__init__.py +10 -0
- wappa/api/controllers/webhook_controller.py +441 -0
- wappa/api/dependencies/__init__.py +15 -0
- wappa/api/dependencies/whatsapp_dependencies.py +220 -0
- wappa/api/dependencies/whatsapp_media_dependencies.py +26 -0
- wappa/api/middleware/__init__.py +7 -0
- wappa/api/middleware/error_handler.py +158 -0
- wappa/api/middleware/owner.py +99 -0
- wappa/api/middleware/request_logging.py +184 -0
- wappa/api/routes/__init__.py +6 -0
- wappa/api/routes/health.py +102 -0
- wappa/api/routes/webhooks.py +211 -0
- wappa/api/routes/whatsapp/__init__.py +15 -0
- wappa/api/routes/whatsapp/whatsapp_interactive.py +429 -0
- wappa/api/routes/whatsapp/whatsapp_media.py +440 -0
- wappa/api/routes/whatsapp/whatsapp_messages.py +195 -0
- wappa/api/routes/whatsapp/whatsapp_specialized.py +516 -0
- wappa/api/routes/whatsapp/whatsapp_templates.py +431 -0
- wappa/api/routes/whatsapp_combined.py +35 -0
- wappa/cli/__init__.py +9 -0
- wappa/cli/main.py +199 -0
- wappa/core/__init__.py +6 -0
- wappa/core/config/__init__.py +5 -0
- wappa/core/config/settings.py +161 -0
- wappa/core/events/__init__.py +41 -0
- wappa/core/events/default_handlers.py +642 -0
- wappa/core/events/event_dispatcher.py +244 -0
- wappa/core/events/event_handler.py +247 -0
- wappa/core/events/webhook_factory.py +219 -0
- wappa/core/factory/__init__.py +15 -0
- wappa/core/factory/plugin.py +68 -0
- wappa/core/factory/wappa_builder.py +326 -0
- wappa/core/logging/__init__.py +5 -0
- wappa/core/logging/context.py +100 -0
- wappa/core/logging/logger.py +343 -0
- wappa/core/plugins/__init__.py +34 -0
- wappa/core/plugins/auth_plugin.py +169 -0
- wappa/core/plugins/cors_plugin.py +128 -0
- wappa/core/plugins/custom_middleware_plugin.py +182 -0
- wappa/core/plugins/database_plugin.py +235 -0
- wappa/core/plugins/rate_limit_plugin.py +183 -0
- wappa/core/plugins/redis_plugin.py +224 -0
- wappa/core/plugins/wappa_core_plugin.py +261 -0
- wappa/core/plugins/webhook_plugin.py +253 -0
- wappa/core/types.py +108 -0
- wappa/core/wappa_app.py +546 -0
- wappa/database/__init__.py +18 -0
- wappa/database/adapter.py +107 -0
- wappa/database/adapters/__init__.py +17 -0
- wappa/database/adapters/mysql_adapter.py +187 -0
- wappa/database/adapters/postgresql_adapter.py +169 -0
- wappa/database/adapters/sqlite_adapter.py +174 -0
- wappa/domain/__init__.py +28 -0
- wappa/domain/builders/__init__.py +5 -0
- wappa/domain/builders/message_builder.py +189 -0
- wappa/domain/entities/__init__.py +5 -0
- wappa/domain/enums/messenger_platform.py +123 -0
- wappa/domain/factories/__init__.py +6 -0
- wappa/domain/factories/media_factory.py +450 -0
- wappa/domain/factories/message_factory.py +497 -0
- wappa/domain/factories/messenger_factory.py +244 -0
- wappa/domain/interfaces/__init__.py +32 -0
- wappa/domain/interfaces/base_repository.py +94 -0
- wappa/domain/interfaces/cache_factory.py +85 -0
- wappa/domain/interfaces/cache_interface.py +199 -0
- wappa/domain/interfaces/expiry_repository.py +68 -0
- wappa/domain/interfaces/media_interface.py +311 -0
- wappa/domain/interfaces/messaging_interface.py +523 -0
- wappa/domain/interfaces/pubsub_repository.py +151 -0
- wappa/domain/interfaces/repository_factory.py +108 -0
- wappa/domain/interfaces/shared_state_repository.py +122 -0
- wappa/domain/interfaces/state_repository.py +123 -0
- wappa/domain/interfaces/tables_repository.py +215 -0
- wappa/domain/interfaces/user_repository.py +114 -0
- wappa/domain/interfaces/webhooks/__init__.py +1 -0
- wappa/domain/models/media_result.py +110 -0
- wappa/domain/models/platforms/__init__.py +15 -0
- wappa/domain/models/platforms/platform_config.py +104 -0
- wappa/domain/services/__init__.py +11 -0
- wappa/domain/services/tenant_credentials_service.py +56 -0
- wappa/messaging/__init__.py +7 -0
- wappa/messaging/whatsapp/__init__.py +1 -0
- wappa/messaging/whatsapp/client/__init__.py +5 -0
- wappa/messaging/whatsapp/client/whatsapp_client.py +417 -0
- wappa/messaging/whatsapp/handlers/__init__.py +13 -0
- wappa/messaging/whatsapp/handlers/whatsapp_interactive_handler.py +653 -0
- wappa/messaging/whatsapp/handlers/whatsapp_media_handler.py +579 -0
- wappa/messaging/whatsapp/handlers/whatsapp_specialized_handler.py +434 -0
- wappa/messaging/whatsapp/handlers/whatsapp_template_handler.py +416 -0
- wappa/messaging/whatsapp/messenger/__init__.py +5 -0
- wappa/messaging/whatsapp/messenger/whatsapp_messenger.py +904 -0
- wappa/messaging/whatsapp/models/__init__.py +61 -0
- wappa/messaging/whatsapp/models/basic_models.py +65 -0
- wappa/messaging/whatsapp/models/interactive_models.py +287 -0
- wappa/messaging/whatsapp/models/media_models.py +215 -0
- wappa/messaging/whatsapp/models/specialized_models.py +304 -0
- wappa/messaging/whatsapp/models/template_models.py +261 -0
- wappa/persistence/cache_factory.py +93 -0
- wappa/persistence/json/__init__.py +14 -0
- wappa/persistence/json/cache_adapters.py +271 -0
- wappa/persistence/json/handlers/__init__.py +1 -0
- wappa/persistence/json/handlers/state_handler.py +250 -0
- wappa/persistence/json/handlers/table_handler.py +263 -0
- wappa/persistence/json/handlers/user_handler.py +213 -0
- wappa/persistence/json/handlers/utils/__init__.py +1 -0
- wappa/persistence/json/handlers/utils/file_manager.py +153 -0
- wappa/persistence/json/handlers/utils/key_factory.py +11 -0
- wappa/persistence/json/handlers/utils/serialization.py +121 -0
- wappa/persistence/json/json_cache_factory.py +76 -0
- wappa/persistence/json/storage_manager.py +285 -0
- wappa/persistence/memory/__init__.py +14 -0
- wappa/persistence/memory/cache_adapters.py +271 -0
- wappa/persistence/memory/handlers/__init__.py +1 -0
- wappa/persistence/memory/handlers/state_handler.py +250 -0
- wappa/persistence/memory/handlers/table_handler.py +280 -0
- wappa/persistence/memory/handlers/user_handler.py +213 -0
- wappa/persistence/memory/handlers/utils/__init__.py +1 -0
- wappa/persistence/memory/handlers/utils/key_factory.py +11 -0
- wappa/persistence/memory/handlers/utils/memory_store.py +317 -0
- wappa/persistence/memory/handlers/utils/ttl_manager.py +235 -0
- wappa/persistence/memory/memory_cache_factory.py +76 -0
- wappa/persistence/memory/storage_manager.py +235 -0
- wappa/persistence/redis/README.md +699 -0
- wappa/persistence/redis/__init__.py +11 -0
- wappa/persistence/redis/cache_adapters.py +285 -0
- wappa/persistence/redis/ops.py +880 -0
- wappa/persistence/redis/redis_cache_factory.py +71 -0
- wappa/persistence/redis/redis_client.py +231 -0
- wappa/persistence/redis/redis_handler/__init__.py +26 -0
- wappa/persistence/redis/redis_handler/state_handler.py +176 -0
- wappa/persistence/redis/redis_handler/table.py +158 -0
- wappa/persistence/redis/redis_handler/user.py +138 -0
- wappa/persistence/redis/redis_handler/utils/__init__.py +12 -0
- wappa/persistence/redis/redis_handler/utils/key_factory.py +32 -0
- wappa/persistence/redis/redis_handler/utils/serde.py +146 -0
- wappa/persistence/redis/redis_handler/utils/tenant_cache.py +268 -0
- wappa/persistence/redis/redis_manager.py +189 -0
- wappa/processors/__init__.py +6 -0
- wappa/processors/base_processor.py +262 -0
- wappa/processors/factory.py +550 -0
- wappa/processors/whatsapp_processor.py +810 -0
- wappa/schemas/__init__.py +6 -0
- wappa/schemas/core/__init__.py +71 -0
- wappa/schemas/core/base_message.py +499 -0
- wappa/schemas/core/base_status.py +322 -0
- wappa/schemas/core/base_webhook.py +312 -0
- wappa/schemas/core/types.py +253 -0
- wappa/schemas/core/webhook_interfaces/__init__.py +48 -0
- wappa/schemas/core/webhook_interfaces/base_components.py +293 -0
- wappa/schemas/core/webhook_interfaces/universal_webhooks.py +348 -0
- wappa/schemas/factory.py +754 -0
- wappa/schemas/webhooks/__init__.py +3 -0
- wappa/schemas/whatsapp/__init__.py +6 -0
- wappa/schemas/whatsapp/base_models.py +285 -0
- wappa/schemas/whatsapp/message_types/__init__.py +93 -0
- wappa/schemas/whatsapp/message_types/audio.py +350 -0
- wappa/schemas/whatsapp/message_types/button.py +267 -0
- wappa/schemas/whatsapp/message_types/contact.py +464 -0
- wappa/schemas/whatsapp/message_types/document.py +421 -0
- wappa/schemas/whatsapp/message_types/errors.py +195 -0
- wappa/schemas/whatsapp/message_types/image.py +424 -0
- wappa/schemas/whatsapp/message_types/interactive.py +430 -0
- wappa/schemas/whatsapp/message_types/location.py +416 -0
- wappa/schemas/whatsapp/message_types/order.py +372 -0
- wappa/schemas/whatsapp/message_types/reaction.py +271 -0
- wappa/schemas/whatsapp/message_types/sticker.py +328 -0
- wappa/schemas/whatsapp/message_types/system.py +317 -0
- wappa/schemas/whatsapp/message_types/text.py +411 -0
- wappa/schemas/whatsapp/message_types/unsupported.py +273 -0
- wappa/schemas/whatsapp/message_types/video.py +344 -0
- wappa/schemas/whatsapp/status_models.py +479 -0
- wappa/schemas/whatsapp/validators.py +454 -0
- wappa/schemas/whatsapp/webhook_container.py +438 -0
- wappa/webhooks/__init__.py +17 -0
- wappa/webhooks/core/__init__.py +71 -0
- wappa/webhooks/core/base_message.py +499 -0
- wappa/webhooks/core/base_status.py +322 -0
- wappa/webhooks/core/base_webhook.py +312 -0
- wappa/webhooks/core/types.py +253 -0
- wappa/webhooks/core/webhook_interfaces/__init__.py +48 -0
- wappa/webhooks/core/webhook_interfaces/base_components.py +293 -0
- wappa/webhooks/core/webhook_interfaces/universal_webhooks.py +441 -0
- wappa/webhooks/factory.py +754 -0
- wappa/webhooks/whatsapp/__init__.py +6 -0
- wappa/webhooks/whatsapp/base_models.py +285 -0
- wappa/webhooks/whatsapp/message_types/__init__.py +93 -0
- wappa/webhooks/whatsapp/message_types/audio.py +350 -0
- wappa/webhooks/whatsapp/message_types/button.py +267 -0
- wappa/webhooks/whatsapp/message_types/contact.py +464 -0
- wappa/webhooks/whatsapp/message_types/document.py +421 -0
- wappa/webhooks/whatsapp/message_types/errors.py +195 -0
- wappa/webhooks/whatsapp/message_types/image.py +424 -0
- wappa/webhooks/whatsapp/message_types/interactive.py +430 -0
- wappa/webhooks/whatsapp/message_types/location.py +416 -0
- wappa/webhooks/whatsapp/message_types/order.py +372 -0
- wappa/webhooks/whatsapp/message_types/reaction.py +271 -0
- wappa/webhooks/whatsapp/message_types/sticker.py +328 -0
- wappa/webhooks/whatsapp/message_types/system.py +317 -0
- wappa/webhooks/whatsapp/message_types/text.py +411 -0
- wappa/webhooks/whatsapp/message_types/unsupported.py +273 -0
- wappa/webhooks/whatsapp/message_types/video.py +344 -0
- wappa/webhooks/whatsapp/status_models.py +479 -0
- wappa/webhooks/whatsapp/validators.py +454 -0
- wappa/webhooks/whatsapp/webhook_container.py +438 -0
- wappa-0.1.0.dist-info/METADATA +269 -0
- wappa-0.1.0.dist-info/RECORD +211 -0
- wappa-0.1.0.dist-info/WHEEL +4 -0
- wappa-0.1.0.dist-info/entry_points.txt +2 -0
- wappa-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""
|
|
2
|
+
JSON Table handler - mirrors Redis table handler functionality.
|
|
3
|
+
|
|
4
|
+
Provides table cache operations using JSON file storage.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from typing import Any, Dict, Optional
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel
|
|
11
|
+
|
|
12
|
+
from ..storage_manager import storage_manager
|
|
13
|
+
from .utils.key_factory import default_key_factory
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger("JSONTable")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class JSONTable:
|
|
19
|
+
"""
|
|
20
|
+
JSON-based table cache handler.
|
|
21
|
+
|
|
22
|
+
Mirrors RedisTable functionality using file-based JSON storage.
|
|
23
|
+
Maintains the same API for seamless cache backend switching.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, tenant: str):
|
|
27
|
+
"""
|
|
28
|
+
Initialize JSON table handler.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
tenant: Tenant identifier
|
|
32
|
+
"""
|
|
33
|
+
if not tenant:
|
|
34
|
+
raise ValueError(f"Missing required parameter: tenant={tenant}")
|
|
35
|
+
|
|
36
|
+
self.tenant = tenant
|
|
37
|
+
self.keys = default_key_factory
|
|
38
|
+
|
|
39
|
+
def _key(self, table_name: str, pkid: str) -> str:
|
|
40
|
+
"""Build table key using KeyFactory (same as Redis)."""
|
|
41
|
+
return self.keys.table(self.tenant, table_name, pkid)
|
|
42
|
+
|
|
43
|
+
# ---- Public API matching RedisTable ----
|
|
44
|
+
async def get(
|
|
45
|
+
self,
|
|
46
|
+
table_name: str,
|
|
47
|
+
pkid: str,
|
|
48
|
+
models: type[BaseModel] | None = None,
|
|
49
|
+
) -> dict[str, Any] | None:
|
|
50
|
+
"""
|
|
51
|
+
Get table row data.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
table_name: Table name
|
|
55
|
+
pkid: Primary key ID
|
|
56
|
+
models: Optional BaseModel class for deserialization
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
Table row data or None if not found
|
|
60
|
+
"""
|
|
61
|
+
key = self._key(table_name, pkid)
|
|
62
|
+
return await storage_manager.get("tables", self.tenant, None, key, models)
|
|
63
|
+
|
|
64
|
+
async def upsert(
|
|
65
|
+
self,
|
|
66
|
+
table_name: str,
|
|
67
|
+
pkid: str,
|
|
68
|
+
data: dict[str, Any] | BaseModel,
|
|
69
|
+
ttl: int | None = None,
|
|
70
|
+
) -> bool:
|
|
71
|
+
"""
|
|
72
|
+
Create or update table row data.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
table_name: Table name
|
|
76
|
+
pkid: Primary key ID
|
|
77
|
+
data: Data to store
|
|
78
|
+
ttl: Time to live in seconds
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
True if successful, False otherwise
|
|
82
|
+
"""
|
|
83
|
+
key = self._key(table_name, pkid)
|
|
84
|
+
return await storage_manager.set("tables", self.tenant, None, key, data, ttl)
|
|
85
|
+
|
|
86
|
+
async def delete(self, table_name: str, pkid: str) -> int:
|
|
87
|
+
"""
|
|
88
|
+
Delete table row data.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
table_name: Table name
|
|
92
|
+
pkid: Primary key ID
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
1 if deleted, 0 if didn't exist
|
|
96
|
+
"""
|
|
97
|
+
key = self._key(table_name, pkid)
|
|
98
|
+
success = await storage_manager.delete("tables", self.tenant, None, key)
|
|
99
|
+
return 1 if success else 0
|
|
100
|
+
|
|
101
|
+
async def exists(self, table_name: str, pkid: str) -> bool:
|
|
102
|
+
"""
|
|
103
|
+
Check if table row exists.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
table_name: Table name
|
|
107
|
+
pkid: Primary key ID
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
True if exists, False otherwise
|
|
111
|
+
"""
|
|
112
|
+
key = self._key(table_name, pkid)
|
|
113
|
+
return await storage_manager.exists("tables", self.tenant, None, key)
|
|
114
|
+
|
|
115
|
+
async def get_field(self, table_name: str, pkid: str, field: str) -> Any | None:
|
|
116
|
+
"""
|
|
117
|
+
Get a specific field from table row.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
table_name: Table name
|
|
121
|
+
pkid: Primary key ID
|
|
122
|
+
field: Field name
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
Field value or None if not found
|
|
126
|
+
"""
|
|
127
|
+
row_data = await self.get(table_name, pkid)
|
|
128
|
+
if row_data is None:
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
if isinstance(row_data, dict):
|
|
132
|
+
return row_data.get(field)
|
|
133
|
+
else:
|
|
134
|
+
# BaseModel instance
|
|
135
|
+
return getattr(row_data, field, None)
|
|
136
|
+
|
|
137
|
+
async def update_field(
|
|
138
|
+
self,
|
|
139
|
+
table_name: str,
|
|
140
|
+
pkid: str,
|
|
141
|
+
field: str,
|
|
142
|
+
value: Any,
|
|
143
|
+
ttl: int | None = None,
|
|
144
|
+
) -> bool:
|
|
145
|
+
"""
|
|
146
|
+
Update a specific field in table row.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
table_name: Table name
|
|
150
|
+
pkid: Primary key ID
|
|
151
|
+
field: Field name
|
|
152
|
+
value: New value
|
|
153
|
+
ttl: Time to live in seconds
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
True if successful, False otherwise
|
|
157
|
+
"""
|
|
158
|
+
row_data = await self.get(table_name, pkid)
|
|
159
|
+
if row_data is None:
|
|
160
|
+
row_data = {}
|
|
161
|
+
|
|
162
|
+
if isinstance(row_data, BaseModel):
|
|
163
|
+
row_data = row_data.model_dump()
|
|
164
|
+
|
|
165
|
+
row_data[field] = value
|
|
166
|
+
return await self.upsert(table_name, pkid, row_data, ttl)
|
|
167
|
+
|
|
168
|
+
async def increment_field(
|
|
169
|
+
self,
|
|
170
|
+
table_name: str,
|
|
171
|
+
pkid: str,
|
|
172
|
+
field: str,
|
|
173
|
+
increment: int = 1,
|
|
174
|
+
ttl: int | None = None,
|
|
175
|
+
) -> int | None:
|
|
176
|
+
"""
|
|
177
|
+
Atomically increment an integer field in table row.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
table_name: Table name
|
|
181
|
+
pkid: Primary key ID
|
|
182
|
+
field: Field name
|
|
183
|
+
increment: Amount to increment by
|
|
184
|
+
ttl: Time to live in seconds
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
New value after increment or None on error
|
|
188
|
+
"""
|
|
189
|
+
row_data = await self.get(table_name, pkid)
|
|
190
|
+
if row_data is None:
|
|
191
|
+
row_data = {}
|
|
192
|
+
|
|
193
|
+
if isinstance(row_data, BaseModel):
|
|
194
|
+
row_data = row_data.model_dump()
|
|
195
|
+
|
|
196
|
+
current_value = row_data.get(field, 0)
|
|
197
|
+
if not isinstance(current_value, (int, float)):
|
|
198
|
+
logger.warning(f"Cannot increment non-numeric field '{field}': {current_value}")
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
new_value = int(current_value) + increment
|
|
202
|
+
row_data[field] = new_value
|
|
203
|
+
|
|
204
|
+
success = await self.upsert(table_name, pkid, row_data, ttl)
|
|
205
|
+
return new_value if success else None
|
|
206
|
+
|
|
207
|
+
async def append_to_list(
|
|
208
|
+
self,
|
|
209
|
+
table_name: str,
|
|
210
|
+
pkid: str,
|
|
211
|
+
field: str,
|
|
212
|
+
value: Any,
|
|
213
|
+
ttl: int | None = None,
|
|
214
|
+
) -> bool:
|
|
215
|
+
"""
|
|
216
|
+
Append value to a list field in table row.
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
table_name: Table name
|
|
220
|
+
pkid: Primary key ID
|
|
221
|
+
field: Field name containing list
|
|
222
|
+
value: Value to append
|
|
223
|
+
ttl: Time to live in seconds
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
True if successful, False otherwise
|
|
227
|
+
"""
|
|
228
|
+
row_data = await self.get(table_name, pkid)
|
|
229
|
+
if row_data is None:
|
|
230
|
+
row_data = {}
|
|
231
|
+
|
|
232
|
+
if isinstance(row_data, BaseModel):
|
|
233
|
+
row_data = row_data.model_dump()
|
|
234
|
+
|
|
235
|
+
current_list = row_data.get(field, [])
|
|
236
|
+
if not isinstance(current_list, list):
|
|
237
|
+
current_list = []
|
|
238
|
+
|
|
239
|
+
current_list.append(value)
|
|
240
|
+
row_data[field] = current_list
|
|
241
|
+
|
|
242
|
+
return await self.upsert(table_name, pkid, row_data, ttl)
|
|
243
|
+
|
|
244
|
+
async def get_ttl(self, key: str) -> int:
|
|
245
|
+
"""
|
|
246
|
+
Get remaining time to live for table cache.
|
|
247
|
+
|
|
248
|
+
Returns:
|
|
249
|
+
Remaining TTL in seconds, -1 if no expiry, -2 if doesn't exist
|
|
250
|
+
"""
|
|
251
|
+
return await storage_manager.get_ttl("tables", self.tenant, None)
|
|
252
|
+
|
|
253
|
+
async def renew_ttl(self, key: str, ttl: int) -> bool:
|
|
254
|
+
"""
|
|
255
|
+
Renew time to live for table cache.
|
|
256
|
+
|
|
257
|
+
Args:
|
|
258
|
+
ttl: New time to live in seconds
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
True if successful, False otherwise
|
|
262
|
+
"""
|
|
263
|
+
return await storage_manager.set_ttl("tables", self.tenant, None, ttl)
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""
|
|
2
|
+
JSON User handler - mirrors Redis user handler functionality.
|
|
3
|
+
|
|
4
|
+
Provides user-specific cache operations using JSON file storage.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from typing import Any, Dict, Optional
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, Field
|
|
11
|
+
|
|
12
|
+
from ..storage_manager import storage_manager
|
|
13
|
+
from .utils.key_factory import default_key_factory
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger("JSONUser")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class JSONUser:
|
|
19
|
+
"""
|
|
20
|
+
JSON-based user cache handler.
|
|
21
|
+
|
|
22
|
+
Mirrors RedisUser functionality using file-based JSON storage.
|
|
23
|
+
Maintains the same API for seamless cache backend switching.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, tenant: str, user_id: str):
|
|
27
|
+
"""
|
|
28
|
+
Initialize JSON user handler.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
tenant: Tenant identifier
|
|
32
|
+
user_id: User identifier
|
|
33
|
+
"""
|
|
34
|
+
if not tenant or not user_id:
|
|
35
|
+
raise ValueError(f"Missing required parameters: tenant={tenant}, user_id={user_id}")
|
|
36
|
+
|
|
37
|
+
self.tenant = tenant
|
|
38
|
+
self.user_id = user_id
|
|
39
|
+
self.keys = default_key_factory
|
|
40
|
+
|
|
41
|
+
def _key(self) -> str:
|
|
42
|
+
"""Build user key using KeyFactory (same as Redis)."""
|
|
43
|
+
return self.keys.user(self.tenant, self.user_id)
|
|
44
|
+
|
|
45
|
+
# ---- Public API matching RedisUser ----
|
|
46
|
+
async def get(self, models: type[BaseModel] | None = None) -> dict[str, Any] | None:
|
|
47
|
+
"""
|
|
48
|
+
Get full user data.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
models: Optional BaseModel class for deserialization
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
User data dictionary or BaseModel instance, None if not found
|
|
55
|
+
"""
|
|
56
|
+
key = self._key()
|
|
57
|
+
return await storage_manager.get("users", self.tenant, self.user_id, key, models)
|
|
58
|
+
|
|
59
|
+
async def upsert(self, data: dict[str, Any] | BaseModel, ttl: int | None = None) -> bool:
|
|
60
|
+
"""
|
|
61
|
+
Create or update user data.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
data: User data to store
|
|
65
|
+
ttl: Time to live in seconds
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
True if successful, False otherwise
|
|
69
|
+
"""
|
|
70
|
+
key = self._key()
|
|
71
|
+
return await storage_manager.set("users", self.tenant, self.user_id, key, data, ttl)
|
|
72
|
+
|
|
73
|
+
async def delete(self) -> int:
|
|
74
|
+
"""
|
|
75
|
+
Delete user data.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
1 if deleted, 0 if didn't exist
|
|
79
|
+
"""
|
|
80
|
+
key = self._key()
|
|
81
|
+
success = await storage_manager.delete("users", self.tenant, self.user_id, key)
|
|
82
|
+
return 1 if success else 0
|
|
83
|
+
|
|
84
|
+
async def exists(self) -> bool:
|
|
85
|
+
"""
|
|
86
|
+
Check if user data exists.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
True if exists, False otherwise
|
|
90
|
+
"""
|
|
91
|
+
key = self._key()
|
|
92
|
+
return await storage_manager.exists("users", self.tenant, self.user_id, key)
|
|
93
|
+
|
|
94
|
+
async def get_field(self, field: str) -> Any | None:
|
|
95
|
+
"""
|
|
96
|
+
Get a specific field from user data.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
field: Field name
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
Field value or None if not found
|
|
103
|
+
"""
|
|
104
|
+
user_data = await self.get()
|
|
105
|
+
if user_data is None:
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
if isinstance(user_data, dict):
|
|
109
|
+
return user_data.get(field)
|
|
110
|
+
else:
|
|
111
|
+
# BaseModel instance
|
|
112
|
+
return getattr(user_data, field, None)
|
|
113
|
+
|
|
114
|
+
async def update_field(self, field: str, value: Any, ttl: int | None = None) -> bool:
|
|
115
|
+
"""
|
|
116
|
+
Update a specific field in user data.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
field: Field name
|
|
120
|
+
value: New value
|
|
121
|
+
ttl: Time to live in seconds
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
True if successful, False otherwise
|
|
125
|
+
"""
|
|
126
|
+
user_data = await self.get()
|
|
127
|
+
if user_data is None:
|
|
128
|
+
user_data = {}
|
|
129
|
+
|
|
130
|
+
if isinstance(user_data, BaseModel):
|
|
131
|
+
user_data = user_data.model_dump()
|
|
132
|
+
|
|
133
|
+
user_data[field] = value
|
|
134
|
+
return await self.upsert(user_data, ttl)
|
|
135
|
+
|
|
136
|
+
async def increment_field(self, field: str, increment: int = 1, ttl: int | None = None) -> int | None:
|
|
137
|
+
"""
|
|
138
|
+
Atomically increment an integer field.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
field: Field name
|
|
142
|
+
increment: Amount to increment by
|
|
143
|
+
ttl: Time to live in seconds
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
New value after increment or None on error
|
|
147
|
+
"""
|
|
148
|
+
user_data = await self.get()
|
|
149
|
+
if user_data is None:
|
|
150
|
+
user_data = {}
|
|
151
|
+
|
|
152
|
+
if isinstance(user_data, BaseModel):
|
|
153
|
+
user_data = user_data.model_dump()
|
|
154
|
+
|
|
155
|
+
current_value = user_data.get(field, 0)
|
|
156
|
+
if not isinstance(current_value, (int, float)):
|
|
157
|
+
logger.warning(f"Cannot increment non-numeric field '{field}': {current_value}")
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
new_value = int(current_value) + increment
|
|
161
|
+
user_data[field] = new_value
|
|
162
|
+
|
|
163
|
+
success = await self.upsert(user_data, ttl)
|
|
164
|
+
return new_value if success else None
|
|
165
|
+
|
|
166
|
+
async def append_to_list(self, field: str, value: Any, ttl: int | None = None) -> bool:
|
|
167
|
+
"""
|
|
168
|
+
Append value to a list field.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
field: Field name containing list
|
|
172
|
+
value: Value to append
|
|
173
|
+
ttl: Time to live in seconds
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
True if successful, False otherwise
|
|
177
|
+
"""
|
|
178
|
+
user_data = await self.get()
|
|
179
|
+
if user_data is None:
|
|
180
|
+
user_data = {}
|
|
181
|
+
|
|
182
|
+
if isinstance(user_data, BaseModel):
|
|
183
|
+
user_data = user_data.model_dump()
|
|
184
|
+
|
|
185
|
+
current_list = user_data.get(field, [])
|
|
186
|
+
if not isinstance(current_list, list):
|
|
187
|
+
current_list = []
|
|
188
|
+
|
|
189
|
+
current_list.append(value)
|
|
190
|
+
user_data[field] = current_list
|
|
191
|
+
|
|
192
|
+
return await self.upsert(user_data, ttl)
|
|
193
|
+
|
|
194
|
+
async def get_ttl(self, key: str) -> int:
|
|
195
|
+
"""
|
|
196
|
+
Get remaining time to live.
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
Remaining TTL in seconds, -1 if no expiry, -2 if doesn't exist
|
|
200
|
+
"""
|
|
201
|
+
return await storage_manager.get_ttl("users", self.tenant, self.user_id)
|
|
202
|
+
|
|
203
|
+
async def renew_ttl(self, key: str, ttl: int) -> bool:
|
|
204
|
+
"""
|
|
205
|
+
Renew time to live.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
ttl: New time to live in seconds
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
True if successful, False otherwise
|
|
212
|
+
"""
|
|
213
|
+
return await storage_manager.set_ttl("users", self.tenant, self.user_id, ttl)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""JSON cache utilities package."""
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""
|
|
2
|
+
File system operations for JSON cache.
|
|
3
|
+
|
|
4
|
+
Handles cache directory creation, file I/O, and project root detection.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Dict, Optional
|
|
13
|
+
|
|
14
|
+
from .serialization import from_json_string, to_json_string
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger("JSONFileManager")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class FileManager:
|
|
20
|
+
"""Manages file operations for JSON cache."""
|
|
21
|
+
|
|
22
|
+
def __init__(self):
|
|
23
|
+
self._cache_root: Optional[Path] = None
|
|
24
|
+
self._file_locks: Dict[str, asyncio.Lock] = {}
|
|
25
|
+
|
|
26
|
+
def _get_file_lock(self, file_path: str) -> asyncio.Lock:
|
|
27
|
+
"""Get or create a lock for a specific file path."""
|
|
28
|
+
if file_path not in self._file_locks:
|
|
29
|
+
self._file_locks[file_path] = asyncio.Lock()
|
|
30
|
+
return self._file_locks[file_path]
|
|
31
|
+
|
|
32
|
+
def get_cache_root(self) -> Path:
|
|
33
|
+
"""Get or detect the cache root directory."""
|
|
34
|
+
if self._cache_root is None:
|
|
35
|
+
self._cache_root = self._detect_project_root()
|
|
36
|
+
return self._cache_root
|
|
37
|
+
|
|
38
|
+
def _detect_project_root(self) -> Path:
|
|
39
|
+
"""
|
|
40
|
+
Detect project root by looking for main.py with Wappa.run().
|
|
41
|
+
|
|
42
|
+
Searches from current working directory upwards.
|
|
43
|
+
Falls back to current directory if not found.
|
|
44
|
+
"""
|
|
45
|
+
current_dir = Path.cwd()
|
|
46
|
+
|
|
47
|
+
# Search upwards for main.py containing Wappa.run()
|
|
48
|
+
for directory in [current_dir] + list(current_dir.parents):
|
|
49
|
+
main_py = directory / "main.py"
|
|
50
|
+
if main_py.exists():
|
|
51
|
+
try:
|
|
52
|
+
content = main_py.read_text(encoding='utf-8')
|
|
53
|
+
if "Wappa" in content and (".run()" in content or "app.run()" in content):
|
|
54
|
+
cache_dir = directory / "cache"
|
|
55
|
+
logger.info(f"Detected project root: {directory}")
|
|
56
|
+
return cache_dir
|
|
57
|
+
except (IOError, UnicodeDecodeError):
|
|
58
|
+
continue
|
|
59
|
+
|
|
60
|
+
# Fallback to current directory + cache
|
|
61
|
+
fallback_cache = current_dir / "cache"
|
|
62
|
+
logger.info(f"Project root not detected, using fallback: {fallback_cache}")
|
|
63
|
+
return fallback_cache
|
|
64
|
+
|
|
65
|
+
def ensure_cache_directories(self) -> None:
|
|
66
|
+
"""Create cache directory structure if it doesn't exist."""
|
|
67
|
+
cache_root = self.get_cache_root()
|
|
68
|
+
cache_root.mkdir(exist_ok=True)
|
|
69
|
+
|
|
70
|
+
# Create subdirectories
|
|
71
|
+
(cache_root / "users").mkdir(exist_ok=True)
|
|
72
|
+
(cache_root / "tables").mkdir(exist_ok=True)
|
|
73
|
+
(cache_root / "states").mkdir(exist_ok=True)
|
|
74
|
+
|
|
75
|
+
logger.debug(f"Cache directories ensured at: {cache_root}")
|
|
76
|
+
|
|
77
|
+
def get_cache_file_path(self, cache_type: str, tenant_id: str, user_id: str = None) -> Path:
|
|
78
|
+
"""
|
|
79
|
+
Get the file path for a cache file.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
cache_type: "users", "tables", or "states"
|
|
83
|
+
tenant_id: Tenant identifier
|
|
84
|
+
user_id: User identifier (required for users and states)
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
Path to cache file
|
|
88
|
+
"""
|
|
89
|
+
cache_root = self.get_cache_root()
|
|
90
|
+
|
|
91
|
+
if cache_type == "users":
|
|
92
|
+
if not user_id:
|
|
93
|
+
raise ValueError("user_id is required for users cache")
|
|
94
|
+
return cache_root / "users" / f"{tenant_id}_{user_id}.json"
|
|
95
|
+
elif cache_type == "tables":
|
|
96
|
+
return cache_root / "tables" / f"{tenant_id}_tables.json"
|
|
97
|
+
elif cache_type == "states":
|
|
98
|
+
if not user_id:
|
|
99
|
+
raise ValueError("user_id is required for states cache")
|
|
100
|
+
return cache_root / "states" / f"{tenant_id}_{user_id}_state.json"
|
|
101
|
+
else:
|
|
102
|
+
raise ValueError(f"Invalid cache_type: {cache_type}")
|
|
103
|
+
|
|
104
|
+
async def read_file(self, file_path: Path) -> Dict[str, Any]:
|
|
105
|
+
"""Read and parse JSON file with file locking."""
|
|
106
|
+
async with self._get_file_lock(str(file_path)):
|
|
107
|
+
if not file_path.exists():
|
|
108
|
+
return {}
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
content = await asyncio.to_thread(file_path.read_text, encoding='utf-8')
|
|
112
|
+
return from_json_string(content)
|
|
113
|
+
except (IOError, json.JSONDecodeError) as e:
|
|
114
|
+
logger.error(f"Failed to read file {file_path}: {e}")
|
|
115
|
+
return {}
|
|
116
|
+
|
|
117
|
+
async def write_file(self, file_path: Path, data: Dict[str, Any]) -> bool:
|
|
118
|
+
"""Write data to JSON file with file locking."""
|
|
119
|
+
async with self._get_file_lock(str(file_path)):
|
|
120
|
+
try:
|
|
121
|
+
# Ensure parent directory exists
|
|
122
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
123
|
+
|
|
124
|
+
# Write to temporary file first, then rename (atomic operation)
|
|
125
|
+
temp_file = file_path.with_suffix(file_path.suffix + '.tmp')
|
|
126
|
+
content = to_json_string(data)
|
|
127
|
+
|
|
128
|
+
await asyncio.to_thread(temp_file.write_text, content, encoding='utf-8')
|
|
129
|
+
await asyncio.to_thread(temp_file.replace, file_path)
|
|
130
|
+
|
|
131
|
+
return True
|
|
132
|
+
except IOError as e:
|
|
133
|
+
logger.error(f"Failed to write file {file_path}: {e}")
|
|
134
|
+
return False
|
|
135
|
+
|
|
136
|
+
async def delete_file(self, file_path: Path) -> bool:
|
|
137
|
+
"""Delete file with file locking."""
|
|
138
|
+
async with self._get_file_lock(str(file_path)):
|
|
139
|
+
try:
|
|
140
|
+
if file_path.exists():
|
|
141
|
+
await asyncio.to_thread(file_path.unlink)
|
|
142
|
+
return True
|
|
143
|
+
except IOError as e:
|
|
144
|
+
logger.error(f"Failed to delete file {file_path}: {e}")
|
|
145
|
+
return False
|
|
146
|
+
|
|
147
|
+
async def file_exists(self, file_path: Path) -> bool:
|
|
148
|
+
"""Check if file exists."""
|
|
149
|
+
return await asyncio.to_thread(file_path.exists)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# Global file manager instance
|
|
153
|
+
file_manager = FileManager()
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Key factory for JSON cache using Redis patterns.
|
|
3
|
+
|
|
4
|
+
Reuses the existing KeyFactory from Redis to maintain consistency
|
|
5
|
+
across all cache implementations.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from ....redis.redis_handler.utils.key_factory import KeyFactory, default_key_factory
|
|
9
|
+
|
|
10
|
+
# Export the same key factory used by Redis for consistency
|
|
11
|
+
__all__ = ["KeyFactory", "default_key_factory"]
|