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.

Files changed (211) hide show
  1. wappa/__init__.py +85 -0
  2. wappa/api/__init__.py +1 -0
  3. wappa/api/controllers/__init__.py +10 -0
  4. wappa/api/controllers/webhook_controller.py +441 -0
  5. wappa/api/dependencies/__init__.py +15 -0
  6. wappa/api/dependencies/whatsapp_dependencies.py +220 -0
  7. wappa/api/dependencies/whatsapp_media_dependencies.py +26 -0
  8. wappa/api/middleware/__init__.py +7 -0
  9. wappa/api/middleware/error_handler.py +158 -0
  10. wappa/api/middleware/owner.py +99 -0
  11. wappa/api/middleware/request_logging.py +184 -0
  12. wappa/api/routes/__init__.py +6 -0
  13. wappa/api/routes/health.py +102 -0
  14. wappa/api/routes/webhooks.py +211 -0
  15. wappa/api/routes/whatsapp/__init__.py +15 -0
  16. wappa/api/routes/whatsapp/whatsapp_interactive.py +429 -0
  17. wappa/api/routes/whatsapp/whatsapp_media.py +440 -0
  18. wappa/api/routes/whatsapp/whatsapp_messages.py +195 -0
  19. wappa/api/routes/whatsapp/whatsapp_specialized.py +516 -0
  20. wappa/api/routes/whatsapp/whatsapp_templates.py +431 -0
  21. wappa/api/routes/whatsapp_combined.py +35 -0
  22. wappa/cli/__init__.py +9 -0
  23. wappa/cli/main.py +199 -0
  24. wappa/core/__init__.py +6 -0
  25. wappa/core/config/__init__.py +5 -0
  26. wappa/core/config/settings.py +161 -0
  27. wappa/core/events/__init__.py +41 -0
  28. wappa/core/events/default_handlers.py +642 -0
  29. wappa/core/events/event_dispatcher.py +244 -0
  30. wappa/core/events/event_handler.py +247 -0
  31. wappa/core/events/webhook_factory.py +219 -0
  32. wappa/core/factory/__init__.py +15 -0
  33. wappa/core/factory/plugin.py +68 -0
  34. wappa/core/factory/wappa_builder.py +326 -0
  35. wappa/core/logging/__init__.py +5 -0
  36. wappa/core/logging/context.py +100 -0
  37. wappa/core/logging/logger.py +343 -0
  38. wappa/core/plugins/__init__.py +34 -0
  39. wappa/core/plugins/auth_plugin.py +169 -0
  40. wappa/core/plugins/cors_plugin.py +128 -0
  41. wappa/core/plugins/custom_middleware_plugin.py +182 -0
  42. wappa/core/plugins/database_plugin.py +235 -0
  43. wappa/core/plugins/rate_limit_plugin.py +183 -0
  44. wappa/core/plugins/redis_plugin.py +224 -0
  45. wappa/core/plugins/wappa_core_plugin.py +261 -0
  46. wappa/core/plugins/webhook_plugin.py +253 -0
  47. wappa/core/types.py +108 -0
  48. wappa/core/wappa_app.py +546 -0
  49. wappa/database/__init__.py +18 -0
  50. wappa/database/adapter.py +107 -0
  51. wappa/database/adapters/__init__.py +17 -0
  52. wappa/database/adapters/mysql_adapter.py +187 -0
  53. wappa/database/adapters/postgresql_adapter.py +169 -0
  54. wappa/database/adapters/sqlite_adapter.py +174 -0
  55. wappa/domain/__init__.py +28 -0
  56. wappa/domain/builders/__init__.py +5 -0
  57. wappa/domain/builders/message_builder.py +189 -0
  58. wappa/domain/entities/__init__.py +5 -0
  59. wappa/domain/enums/messenger_platform.py +123 -0
  60. wappa/domain/factories/__init__.py +6 -0
  61. wappa/domain/factories/media_factory.py +450 -0
  62. wappa/domain/factories/message_factory.py +497 -0
  63. wappa/domain/factories/messenger_factory.py +244 -0
  64. wappa/domain/interfaces/__init__.py +32 -0
  65. wappa/domain/interfaces/base_repository.py +94 -0
  66. wappa/domain/interfaces/cache_factory.py +85 -0
  67. wappa/domain/interfaces/cache_interface.py +199 -0
  68. wappa/domain/interfaces/expiry_repository.py +68 -0
  69. wappa/domain/interfaces/media_interface.py +311 -0
  70. wappa/domain/interfaces/messaging_interface.py +523 -0
  71. wappa/domain/interfaces/pubsub_repository.py +151 -0
  72. wappa/domain/interfaces/repository_factory.py +108 -0
  73. wappa/domain/interfaces/shared_state_repository.py +122 -0
  74. wappa/domain/interfaces/state_repository.py +123 -0
  75. wappa/domain/interfaces/tables_repository.py +215 -0
  76. wappa/domain/interfaces/user_repository.py +114 -0
  77. wappa/domain/interfaces/webhooks/__init__.py +1 -0
  78. wappa/domain/models/media_result.py +110 -0
  79. wappa/domain/models/platforms/__init__.py +15 -0
  80. wappa/domain/models/platforms/platform_config.py +104 -0
  81. wappa/domain/services/__init__.py +11 -0
  82. wappa/domain/services/tenant_credentials_service.py +56 -0
  83. wappa/messaging/__init__.py +7 -0
  84. wappa/messaging/whatsapp/__init__.py +1 -0
  85. wappa/messaging/whatsapp/client/__init__.py +5 -0
  86. wappa/messaging/whatsapp/client/whatsapp_client.py +417 -0
  87. wappa/messaging/whatsapp/handlers/__init__.py +13 -0
  88. wappa/messaging/whatsapp/handlers/whatsapp_interactive_handler.py +653 -0
  89. wappa/messaging/whatsapp/handlers/whatsapp_media_handler.py +579 -0
  90. wappa/messaging/whatsapp/handlers/whatsapp_specialized_handler.py +434 -0
  91. wappa/messaging/whatsapp/handlers/whatsapp_template_handler.py +416 -0
  92. wappa/messaging/whatsapp/messenger/__init__.py +5 -0
  93. wappa/messaging/whatsapp/messenger/whatsapp_messenger.py +904 -0
  94. wappa/messaging/whatsapp/models/__init__.py +61 -0
  95. wappa/messaging/whatsapp/models/basic_models.py +65 -0
  96. wappa/messaging/whatsapp/models/interactive_models.py +287 -0
  97. wappa/messaging/whatsapp/models/media_models.py +215 -0
  98. wappa/messaging/whatsapp/models/specialized_models.py +304 -0
  99. wappa/messaging/whatsapp/models/template_models.py +261 -0
  100. wappa/persistence/cache_factory.py +93 -0
  101. wappa/persistence/json/__init__.py +14 -0
  102. wappa/persistence/json/cache_adapters.py +271 -0
  103. wappa/persistence/json/handlers/__init__.py +1 -0
  104. wappa/persistence/json/handlers/state_handler.py +250 -0
  105. wappa/persistence/json/handlers/table_handler.py +263 -0
  106. wappa/persistence/json/handlers/user_handler.py +213 -0
  107. wappa/persistence/json/handlers/utils/__init__.py +1 -0
  108. wappa/persistence/json/handlers/utils/file_manager.py +153 -0
  109. wappa/persistence/json/handlers/utils/key_factory.py +11 -0
  110. wappa/persistence/json/handlers/utils/serialization.py +121 -0
  111. wappa/persistence/json/json_cache_factory.py +76 -0
  112. wappa/persistence/json/storage_manager.py +285 -0
  113. wappa/persistence/memory/__init__.py +14 -0
  114. wappa/persistence/memory/cache_adapters.py +271 -0
  115. wappa/persistence/memory/handlers/__init__.py +1 -0
  116. wappa/persistence/memory/handlers/state_handler.py +250 -0
  117. wappa/persistence/memory/handlers/table_handler.py +280 -0
  118. wappa/persistence/memory/handlers/user_handler.py +213 -0
  119. wappa/persistence/memory/handlers/utils/__init__.py +1 -0
  120. wappa/persistence/memory/handlers/utils/key_factory.py +11 -0
  121. wappa/persistence/memory/handlers/utils/memory_store.py +317 -0
  122. wappa/persistence/memory/handlers/utils/ttl_manager.py +235 -0
  123. wappa/persistence/memory/memory_cache_factory.py +76 -0
  124. wappa/persistence/memory/storage_manager.py +235 -0
  125. wappa/persistence/redis/README.md +699 -0
  126. wappa/persistence/redis/__init__.py +11 -0
  127. wappa/persistence/redis/cache_adapters.py +285 -0
  128. wappa/persistence/redis/ops.py +880 -0
  129. wappa/persistence/redis/redis_cache_factory.py +71 -0
  130. wappa/persistence/redis/redis_client.py +231 -0
  131. wappa/persistence/redis/redis_handler/__init__.py +26 -0
  132. wappa/persistence/redis/redis_handler/state_handler.py +176 -0
  133. wappa/persistence/redis/redis_handler/table.py +158 -0
  134. wappa/persistence/redis/redis_handler/user.py +138 -0
  135. wappa/persistence/redis/redis_handler/utils/__init__.py +12 -0
  136. wappa/persistence/redis/redis_handler/utils/key_factory.py +32 -0
  137. wappa/persistence/redis/redis_handler/utils/serde.py +146 -0
  138. wappa/persistence/redis/redis_handler/utils/tenant_cache.py +268 -0
  139. wappa/persistence/redis/redis_manager.py +189 -0
  140. wappa/processors/__init__.py +6 -0
  141. wappa/processors/base_processor.py +262 -0
  142. wappa/processors/factory.py +550 -0
  143. wappa/processors/whatsapp_processor.py +810 -0
  144. wappa/schemas/__init__.py +6 -0
  145. wappa/schemas/core/__init__.py +71 -0
  146. wappa/schemas/core/base_message.py +499 -0
  147. wappa/schemas/core/base_status.py +322 -0
  148. wappa/schemas/core/base_webhook.py +312 -0
  149. wappa/schemas/core/types.py +253 -0
  150. wappa/schemas/core/webhook_interfaces/__init__.py +48 -0
  151. wappa/schemas/core/webhook_interfaces/base_components.py +293 -0
  152. wappa/schemas/core/webhook_interfaces/universal_webhooks.py +348 -0
  153. wappa/schemas/factory.py +754 -0
  154. wappa/schemas/webhooks/__init__.py +3 -0
  155. wappa/schemas/whatsapp/__init__.py +6 -0
  156. wappa/schemas/whatsapp/base_models.py +285 -0
  157. wappa/schemas/whatsapp/message_types/__init__.py +93 -0
  158. wappa/schemas/whatsapp/message_types/audio.py +350 -0
  159. wappa/schemas/whatsapp/message_types/button.py +267 -0
  160. wappa/schemas/whatsapp/message_types/contact.py +464 -0
  161. wappa/schemas/whatsapp/message_types/document.py +421 -0
  162. wappa/schemas/whatsapp/message_types/errors.py +195 -0
  163. wappa/schemas/whatsapp/message_types/image.py +424 -0
  164. wappa/schemas/whatsapp/message_types/interactive.py +430 -0
  165. wappa/schemas/whatsapp/message_types/location.py +416 -0
  166. wappa/schemas/whatsapp/message_types/order.py +372 -0
  167. wappa/schemas/whatsapp/message_types/reaction.py +271 -0
  168. wappa/schemas/whatsapp/message_types/sticker.py +328 -0
  169. wappa/schemas/whatsapp/message_types/system.py +317 -0
  170. wappa/schemas/whatsapp/message_types/text.py +411 -0
  171. wappa/schemas/whatsapp/message_types/unsupported.py +273 -0
  172. wappa/schemas/whatsapp/message_types/video.py +344 -0
  173. wappa/schemas/whatsapp/status_models.py +479 -0
  174. wappa/schemas/whatsapp/validators.py +454 -0
  175. wappa/schemas/whatsapp/webhook_container.py +438 -0
  176. wappa/webhooks/__init__.py +17 -0
  177. wappa/webhooks/core/__init__.py +71 -0
  178. wappa/webhooks/core/base_message.py +499 -0
  179. wappa/webhooks/core/base_status.py +322 -0
  180. wappa/webhooks/core/base_webhook.py +312 -0
  181. wappa/webhooks/core/types.py +253 -0
  182. wappa/webhooks/core/webhook_interfaces/__init__.py +48 -0
  183. wappa/webhooks/core/webhook_interfaces/base_components.py +293 -0
  184. wappa/webhooks/core/webhook_interfaces/universal_webhooks.py +441 -0
  185. wappa/webhooks/factory.py +754 -0
  186. wappa/webhooks/whatsapp/__init__.py +6 -0
  187. wappa/webhooks/whatsapp/base_models.py +285 -0
  188. wappa/webhooks/whatsapp/message_types/__init__.py +93 -0
  189. wappa/webhooks/whatsapp/message_types/audio.py +350 -0
  190. wappa/webhooks/whatsapp/message_types/button.py +267 -0
  191. wappa/webhooks/whatsapp/message_types/contact.py +464 -0
  192. wappa/webhooks/whatsapp/message_types/document.py +421 -0
  193. wappa/webhooks/whatsapp/message_types/errors.py +195 -0
  194. wappa/webhooks/whatsapp/message_types/image.py +424 -0
  195. wappa/webhooks/whatsapp/message_types/interactive.py +430 -0
  196. wappa/webhooks/whatsapp/message_types/location.py +416 -0
  197. wappa/webhooks/whatsapp/message_types/order.py +372 -0
  198. wappa/webhooks/whatsapp/message_types/reaction.py +271 -0
  199. wappa/webhooks/whatsapp/message_types/sticker.py +328 -0
  200. wappa/webhooks/whatsapp/message_types/system.py +317 -0
  201. wappa/webhooks/whatsapp/message_types/text.py +411 -0
  202. wappa/webhooks/whatsapp/message_types/unsupported.py +273 -0
  203. wappa/webhooks/whatsapp/message_types/video.py +344 -0
  204. wappa/webhooks/whatsapp/status_models.py +479 -0
  205. wappa/webhooks/whatsapp/validators.py +454 -0
  206. wappa/webhooks/whatsapp/webhook_container.py +438 -0
  207. wappa-0.1.0.dist-info/METADATA +269 -0
  208. wappa-0.1.0.dist-info/RECORD +211 -0
  209. wappa-0.1.0.dist-info/WHEEL +4 -0
  210. wappa-0.1.0.dist-info/entry_points.txt +2 -0
  211. 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"]