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,138 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from typing import Any
5
+
6
+ from pydantic import BaseModel, Field
7
+
8
+ from ..ops import hdel, hget, hincrby_with_expire
9
+ from .utils.serde import loads
10
+ from .utils.tenant_cache import TenantCache
11
+
12
+ logger = logging.getLogger("RedisUser")
13
+
14
+
15
+ class RedisUser(TenantCache):
16
+ """
17
+ Repository for user-specific operations.
18
+
19
+ Extracted from RedisHandler SECTION: User-specific Methods:
20
+ - get_user_data() -> get()
21
+ - get_field() -> get_field()
22
+ - update_user_field() -> update_field()
23
+ - increment_user_field() -> increment_field()
24
+ - append_to_user_list_field() -> append_to_list()
25
+ - create_user_record() -> upsert()
26
+ - find_user_by_field() -> find_by_field()
27
+ - delete_user_record() -> delete()
28
+ - delete_user_hash_field() -> delete_field()
29
+ - user_exists() -> exists()
30
+
31
+ Single Responsibility: User data management only
32
+
33
+ Example usage:
34
+ user = RedisUser(tenant="mimeia", user_id="user123")
35
+ await user.upsert({"name": "Alice", "score": 100})
36
+ data = await user.get()
37
+ name = await user.get_field("name")
38
+ """
39
+
40
+ user_id: str = Field(..., min_length=1)
41
+ redis_alias: str = "user"
42
+
43
+ def _key(self) -> str:
44
+ """Build user key using KeyFactory"""
45
+ return self.keys.user(self.tenant, self.user_id)
46
+
47
+ # ---- Public API extracted from RedisHandler User methods ----------------
48
+ async def get(self, models: type[BaseModel] | None = None) -> dict[str, Any] | None:
49
+ """
50
+ Get full user data hash (was get_user_data)
51
+
52
+ Args:
53
+ models: Optional BaseModel class for full object reconstruction
54
+ e.g., User (will automatically handle nested UserProfile, UserPreferences)
55
+ """
56
+ key = self._key()
57
+ result = await self._get_hash(key, models=models)
58
+ if not result:
59
+ logger.debug(
60
+ f"User data not found for user_id '{self.user_id}' (key: '{key}')"
61
+ )
62
+ return result
63
+
64
+ async def upsert(self, data: dict[str, Any], ttl: int | None = None) -> bool:
65
+ """Create or update user record with multiple fields (Redis HSET upsert behavior)"""
66
+ key = self._key()
67
+ return await self._hset_with_ttl(key, data, ttl)
68
+
69
+ async def update_field(
70
+ self, field: str, value: Any, ttl: int | None = None
71
+ ) -> bool:
72
+ """Update single field in user hash"""
73
+ key = self._key()
74
+ return await self._hset_with_ttl(key, {field: value}, ttl)
75
+
76
+ async def increment_field(
77
+ self, field: str, increment: int = 1, ttl: int | None = None
78
+ ) -> int | None:
79
+ """Atomically increment integer field (was increment_user_field)"""
80
+ key = self._key()
81
+
82
+ new_value, expire_res = await hincrby_with_expire(
83
+ key=key,
84
+ field=field,
85
+ increment=increment,
86
+ ttl=ttl or self.ttl_default,
87
+ alias=self.redis_alias,
88
+ )
89
+
90
+ if new_value is not None and expire_res:
91
+ return new_value
92
+ else:
93
+ logger.warning(
94
+ f"Failed to increment user field '{field}' for user_id '{self.user_id}'"
95
+ )
96
+ return None
97
+
98
+ async def append_to_list(
99
+ self, field: str, value: Any, ttl: int | None = None
100
+ ) -> bool:
101
+ """Append value to list field (was append_to_user_list_field)"""
102
+ key = self._key()
103
+ return await self._append_to_list_field(key, field, value, ttl)
104
+
105
+ async def find_by_field(
106
+ self, field: str, value: Any, models: type[BaseModel] | None = None
107
+ ) -> dict[str, Any] | None:
108
+ """
109
+ Find first user where field matches value (was find_user_by_field)
110
+
111
+ Args:
112
+ field: Field name to search
113
+ value: Value to match
114
+ models: Optional BaseModel class for full object reconstruction
115
+ """
116
+ pattern = self.keys.user(self.tenant, "*")
117
+ return await self._find_by_field(pattern, field, value, models=models)
118
+
119
+ async def delete(self) -> int:
120
+ """Delete entire user record (was delete_user_record)"""
121
+ key = self._key()
122
+ return await self.delete_key(key)
123
+
124
+ async def delete_field(self, field: str) -> int:
125
+ """Delete specific field from user hash (was delete_user_hash_field)"""
126
+ key = self._key()
127
+ return await hdel(key, field, alias=self.redis_alias)
128
+
129
+ async def exists(self) -> bool:
130
+ """Check if user exists (was user_exists)"""
131
+ key = self._key()
132
+ return await self.key_exists(key)
133
+
134
+ async def get_field(self, field: str) -> Any | None:
135
+ """Get a specific field from the user's data"""
136
+ key = self._key()
137
+ raw_value = await hget(key, field, alias=self.redis_alias)
138
+ return loads(raw_value) if raw_value is not None else None
@@ -0,0 +1,12 @@
1
+ """
2
+ Redis Handler Utils
3
+
4
+ Infrastructure and support utilities for Redis repositories.
5
+ Contains key building, serialization, and base functionality.
6
+ """
7
+
8
+ from .key_factory import KeyFactory
9
+ from .serde import dumps, loads
10
+ from .tenant_cache import TenantCache
11
+
12
+ __all__ = ["KeyFactory", "dumps", "loads", "TenantCache"]
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+ logger = logging.getLogger("RedisKeyFactory")
8
+
9
+
10
+ class KeyFactory(BaseModel):
11
+ """Pure stateless helpers for Wappa cache key generation."""
12
+
13
+ user_prefix: str = Field(default="user")
14
+ handler_prefix: str = Field(default="state")
15
+ table_prefix: str = Field(default="df")
16
+ pk_marker: str = Field(default="pkid")
17
+
18
+ # ---- builders ---------------------------------------------------------
19
+ def user(self, tenant: str, user_id: str) -> str:
20
+ return f"{tenant}:{self.user_prefix}:{user_id}"
21
+
22
+ def handler(self, tenant: str, name: str, user_id: str) -> str:
23
+ return f"{tenant}:{self.handler_prefix}:{name}:{user_id}"
24
+
25
+ def table(self, tenant: str, table: str, pkid: str) -> str:
26
+ safe_tbl = table.replace(":", "_")
27
+ safe_pk = pkid.replace(":", "_")
28
+ return f"{tenant}:{self.table_prefix}:{safe_tbl}:{self.pk_marker}:{safe_pk}"
29
+
30
+
31
+ # Default instance for global use
32
+ default_key_factory = KeyFactory()
@@ -0,0 +1,146 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ from datetime import datetime
6
+ from enum import Enum
7
+ from typing import Any
8
+
9
+ from pydantic import BaseModel
10
+
11
+ logger = logging.getLogger("RedisSerde")
12
+
13
+
14
+ def _convert_bools_to_redis(obj: Any) -> Any:
15
+ """Recursively convert boolean values to Redis-optimized "1"/"0" strings"""
16
+ if isinstance(obj, bool):
17
+ return "1" if obj else "0"
18
+ elif isinstance(obj, dict):
19
+ return {k: _convert_bools_to_redis(v) for k, v in obj.items()}
20
+ elif isinstance(obj, list):
21
+ return [_convert_bools_to_redis(item) for item in obj]
22
+ else:
23
+ return obj
24
+
25
+
26
+ def _convert_redis_to_bools(obj: Any) -> Any:
27
+ """Recursively convert Redis "1"/"0" strings back to boolean values"""
28
+ if obj == "1":
29
+ return True
30
+ elif obj == "0":
31
+ return False
32
+ elif isinstance(obj, dict):
33
+ return {k: _convert_redis_to_bools(v) for k, v in obj.items()}
34
+ elif isinstance(obj, list):
35
+ return [_convert_redis_to_bools(item) for item in obj]
36
+ else:
37
+ return obj
38
+
39
+
40
+ def _datetime_handler(obj: Any) -> str:
41
+ """Handle datetime objects during JSON serialization"""
42
+ if isinstance(obj, datetime):
43
+ return obj.isoformat()
44
+ raise TypeError(f"Object of type {type(obj)} is not JSON serializable")
45
+
46
+
47
+ def _convert_iso_strings_to_datetime(obj: Any) -> Any:
48
+ """Recursively convert ISO datetime strings back to datetime objects"""
49
+ if isinstance(obj, str):
50
+ # Try to parse as ISO datetime
51
+ try:
52
+ # Check if it looks like an ISO datetime (basic heuristic)
53
+ if "T" in obj and len(obj) >= 19: # YYYY-MM-DDTHH:MM:SS minimum
54
+ return datetime.fromisoformat(obj.replace("Z", "+00:00"))
55
+ except (ValueError, AttributeError):
56
+ pass
57
+ return obj
58
+ elif isinstance(obj, dict):
59
+ return {k: _convert_iso_strings_to_datetime(v) for k, v in obj.items()}
60
+ elif isinstance(obj, list):
61
+ return [_convert_iso_strings_to_datetime(item) for item in obj]
62
+ else:
63
+ return obj
64
+
65
+
66
+ def dumps(obj: Any) -> str:
67
+ """Serialize Python object to Redis-compatible string"""
68
+ if obj is None:
69
+ return "null"
70
+ if isinstance(obj, bool):
71
+ return str(int(obj))
72
+ if isinstance(obj, int | float):
73
+ return str(obj)
74
+ if isinstance(obj, str):
75
+ return obj
76
+ if isinstance(obj, datetime):
77
+ return obj.isoformat()
78
+ if isinstance(obj, Enum):
79
+ return str(obj.value)
80
+ if isinstance(obj, BaseModel):
81
+ # Convert to dict first, then convert bools to "1"/"0", then to JSON
82
+ model_dict = obj.model_dump()
83
+ redis_dict = _convert_bools_to_redis(model_dict)
84
+ return json.dumps(redis_dict, ensure_ascii=False, default=_datetime_handler)
85
+ try:
86
+ return json.dumps(obj, ensure_ascii=False, default=_datetime_handler)
87
+ except TypeError as e:
88
+ logger.warning(
89
+ f"Could not JSON serialize value of type {type(obj)}. Falling back to str(). Error: {e}. Value: {obj!r}"
90
+ )
91
+ return str(obj)
92
+
93
+
94
+ def loads(raw: str | None, model: type[BaseModel] | None = None) -> Any:
95
+ """Deserialize Redis string back to Python object"""
96
+ if raw in (None, "null"):
97
+ return None
98
+ if raw == "1":
99
+ return True
100
+ if raw == "0":
101
+ return False
102
+ try:
103
+ data = json.loads(raw)
104
+ if model is not None:
105
+ # Convert Redis "1"/"0" back to bools and handle datetime strings
106
+ bool_converted_data = _convert_redis_to_bools(data)
107
+ datetime_converted_data = _convert_iso_strings_to_datetime(
108
+ bool_converted_data
109
+ )
110
+ return model.model_validate(datetime_converted_data)
111
+ return data
112
+ except (json.JSONDecodeError, TypeError):
113
+ return raw
114
+
115
+
116
+ def dumps_hash(data: dict[str, Any] | BaseModel) -> dict[str, str]:
117
+ """Serialize dictionary or BaseModel values for Redis hash storage"""
118
+ if isinstance(data, BaseModel):
119
+ # Convert BaseModel to dict first
120
+ data = data.model_dump()
121
+ return {field: dumps(value) for field, value in data.items()}
122
+
123
+
124
+ def loads_hash(
125
+ raw_data: dict[str, str] | None, models: type[BaseModel] | None = None
126
+ ) -> dict[str, Any] | BaseModel:
127
+ """
128
+ Deserialize Redis hash back to Python dictionary or BaseModel
129
+
130
+ Args:
131
+ raw_data: Raw string data from Redis hash
132
+ models: Optional BaseModel class for full object reconstruction
133
+ e.g., User (will automatically handle nested UserContact, UserLocation)
134
+ """
135
+ if not raw_data:
136
+ return {}
137
+
138
+ # Deserialize all fields normally (no model-specific deserialization)
139
+ data = {field: loads(value_str) for field, value_str in raw_data.items()}
140
+
141
+ if models:
142
+ # Let Pydantic handle nested model reconstruction with preprocessing
143
+ # The model_validate will automatically call @model_validator(mode="before") methods
144
+ return models.model_validate(data)
145
+ else:
146
+ return data
@@ -0,0 +1,268 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from typing import Any
5
+
6
+ from pydantic import BaseModel, Field
7
+
8
+ from ...ops import (
9
+ delete,
10
+ exists,
11
+ expire,
12
+ get_ttl,
13
+ hget,
14
+ hgetall,
15
+ hset_with_expire,
16
+ scan_keys,
17
+ )
18
+ from ...redis_client import PoolAlias
19
+ from .key_factory import KeyFactory
20
+ from .serde import dumps, dumps_hash, loads, loads_hash
21
+
22
+ logger = logging.getLogger("TenantCache")
23
+
24
+
25
+ class TenantCache(BaseModel):
26
+ """
27
+ Base class shared by all Wappa cache repositories.
28
+ Handles tenant context, TTL management, and common Redis operations.
29
+
30
+ Provides common functionality for cache repositories:
31
+ - Tenant key building and namespace management
32
+ - TTL management and automatic expiration
33
+ - Common serialization patterns for cache data
34
+ """
35
+
36
+ tenant: str = Field(..., min_length=1)
37
+ ttl_default: int = 86400 # 24 hours
38
+ redis_alias: PoolAlias = "state_handler" # Default pool
39
+ keys: KeyFactory = Field(default_factory=KeyFactory)
40
+
41
+ model_config = {"arbitrary_types_allowed": True}
42
+
43
+ # --------- Low-level helpers extracted from RedisHandler ------------------
44
+ async def _hset_with_ttl(
45
+ self,
46
+ key: str,
47
+ data: dict[str, Any],
48
+ ttl: int | None = None,
49
+ *,
50
+ alias: PoolAlias | None = None,
51
+ ) -> bool:
52
+ """Helper for atomic hash set with expiration"""
53
+ _alias = alias or self.redis_alias
54
+ payload = dumps_hash(data)
55
+ if not payload:
56
+ logger.warning(f"Setting key '{key}' with empty data. Deleting instead.")
57
+ return await delete(key, alias=_alias) >= 0
58
+
59
+ hset_res, expire_res = await hset_with_expire(
60
+ key, payload, ttl or self.ttl_default, alias=_alias
61
+ )
62
+ success = hset_res is not None and expire_res
63
+ if not success:
64
+ logger.warning(
65
+ f"Failed hset_with_expire for key '{key}'. HSET: {hset_res}, EXPIRE: {expire_res}"
66
+ )
67
+ return success
68
+
69
+ async def _get_hash(
70
+ self,
71
+ key: str,
72
+ models: type[BaseModel] | None = None,
73
+ *,
74
+ alias: PoolAlias | None = None,
75
+ ) -> dict[str, Any] | None:
76
+ """
77
+ Helper to get and deserialize hash data
78
+
79
+ Args:
80
+ key: Redis key
81
+ models: Optional BaseModel class for full object reconstruction
82
+ e.g., User (will automatically handle nested UserProfile, UserSettings)
83
+ alias: Redis pool alias to use (defaults to self.redis_alias)
84
+ """
85
+ _alias = alias or self.redis_alias
86
+ raw_data = await hgetall(key, alias=_alias)
87
+ return loads_hash(raw_data, models=models) if raw_data else None
88
+
89
+ async def _find_by_field(
90
+ self,
91
+ pattern: str,
92
+ field: str,
93
+ value: Any,
94
+ models: type[BaseModel] | None = None,
95
+ *,
96
+ alias: PoolAlias | None = None,
97
+ ) -> dict[str, Any] | None:
98
+ """
99
+ Find first hash matching pattern where field equals value.
100
+ Extracted from _find_hash_by_field_internal in RedisHandler.
101
+
102
+ Args:
103
+ pattern: Redis key pattern to search
104
+ field: Field name to match
105
+ value: Value to match
106
+ models: Optional mapping for BaseModel deserialization
107
+ alias: Redis pool alias to use (defaults to self.redis_alias)
108
+ """
109
+ _alias = alias or self.redis_alias
110
+ compare_value_str = dumps(value)
111
+ logger.debug(
112
+ f"Searching pattern '{pattern}' where field '{field}' == '{compare_value_str}'"
113
+ )
114
+
115
+ cursor = "0"
116
+ try:
117
+ while True:
118
+ next_cursor, keys_batch = await scan_keys(
119
+ match_pattern=pattern, cursor=cursor, count=100, alias=_alias
120
+ )
121
+
122
+ for full_key in keys_batch:
123
+ current_value_str = await hget(full_key, field, alias=_alias)
124
+ if current_value_str == compare_value_str:
125
+ logger.info(
126
+ f"Match found for field '{field}' in key '{full_key}'"
127
+ )
128
+ return await self._get_hash(
129
+ full_key, models=models, alias=alias
130
+ )
131
+
132
+ if next_cursor == "0":
133
+ logger.debug(
134
+ f"SCAN finished for pattern '{pattern}'. No match found."
135
+ )
136
+ return None
137
+ cursor = next_cursor
138
+ except Exception as e:
139
+ logger.error(
140
+ f"Error during find_by_field (pattern='{pattern}', field='{field}'): {e}",
141
+ exc_info=True,
142
+ )
143
+ return None
144
+
145
+ async def _delete_by_pattern(
146
+ self, pattern: str, *, alias: PoolAlias | None = None
147
+ ) -> int:
148
+ """
149
+ Delete all keys matching pattern.
150
+ Extracted from _delete_keys_by_pattern_internal in RedisHandler.
151
+
152
+ Args:
153
+ pattern: Redis key pattern to delete
154
+ alias: Redis pool alias to use (defaults to self.redis_alias)
155
+ """
156
+ _alias = alias or self.redis_alias
157
+ total_deleted = 0
158
+ cursor = "0"
159
+
160
+ logger.debug(f"Deleting keys matching pattern '{pattern}'")
161
+ try:
162
+ while True:
163
+ next_cursor, keys_batch = await scan_keys(
164
+ match_pattern=pattern, cursor=cursor, count=100, alias=_alias
165
+ )
166
+
167
+ if keys_batch:
168
+ logger.debug(f"Deleting batch of {len(keys_batch)} keys")
169
+ deleted_in_batch = await delete(*keys_batch, alias=_alias)
170
+ if deleted_in_batch >= 0:
171
+ total_deleted += deleted_in_batch
172
+
173
+ if next_cursor == "0":
174
+ break
175
+ cursor = next_cursor
176
+
177
+ logger.info(f"Deleted {total_deleted} keys for pattern '{pattern}'")
178
+ except Exception as e:
179
+ logger.error(
180
+ f"Error during delete_by_pattern '{pattern}': {e}", exc_info=True
181
+ )
182
+
183
+ return total_deleted
184
+
185
+ async def _append_to_list_field(
186
+ self,
187
+ key: str,
188
+ field: str,
189
+ value: Any,
190
+ ttl: int | None = None,
191
+ *,
192
+ alias: PoolAlias | None = None,
193
+ ) -> bool:
194
+ """
195
+ Append to a list stored in a hash field.
196
+ Extracted from _append_to_list_in_hash_field in RedisHandler.
197
+
198
+ Args:
199
+ key: Redis key
200
+ field: Hash field name
201
+ value: Value to append to the list
202
+ ttl: Optional TTL override
203
+ alias: Redis pool alias to use (defaults to self.redis_alias)
204
+ """
205
+ _alias = alias or self.redis_alias
206
+ try:
207
+ # Get current value
208
+ current_raw = await hget(key, field, alias=_alias)
209
+ current_list = []
210
+
211
+ if current_raw:
212
+ try:
213
+ deserialized = loads(current_raw)
214
+ if isinstance(deserialized, list):
215
+ current_list = deserialized
216
+ else:
217
+ logger.warning(
218
+ f"Field '{field}' in '{key}' is not a list. Overwriting."
219
+ )
220
+ except Exception as e:
221
+ logger.warning(f"Could not deserialize list field '{field}': {e}")
222
+
223
+ # Append and serialize
224
+ current_list.append(value)
225
+ serialized_list = dumps(current_list)
226
+
227
+ # Write back atomically
228
+ hset_res, expire_res = await hset_with_expire(
229
+ key=key,
230
+ mapping={field: serialized_list},
231
+ ttl=ttl or self.ttl_default,
232
+ alias=_alias,
233
+ )
234
+
235
+ success = hset_res is not None and expire_res
236
+ if not success:
237
+ logger.warning(f"Failed list append for field '{field}' in '{key}'")
238
+ return success
239
+
240
+ except Exception as e:
241
+ logger.error(
242
+ f"Error in append_to_list_field '{field}' in '{key}': {e}",
243
+ exc_info=True,
244
+ )
245
+ return False
246
+
247
+ # --------- Utility methods -----------------------------------------------
248
+ async def key_exists(self, key: str, *, alias: PoolAlias | None = None) -> bool:
249
+ """Check if key exists"""
250
+ _alias = alias or self.redis_alias
251
+ return await exists(key, alias=_alias) > 0
252
+
253
+ async def renew_ttl(
254
+ self, key: str, ttl: int | None = None, *, alias: PoolAlias | None = None
255
+ ) -> bool:
256
+ """Renew TTL for a key"""
257
+ _alias = alias or self.redis_alias
258
+ return await expire(key, ttl or self.ttl_default, alias=_alias)
259
+
260
+ async def get_ttl(self, key: str, *, alias: PoolAlias | None = None) -> int:
261
+ """Get remaining TTL for a key"""
262
+ _alias = alias or self.redis_alias
263
+ return await get_ttl(key, alias=_alias)
264
+
265
+ async def delete_key(self, key: str, *, alias: PoolAlias | None = None) -> int:
266
+ """Delete a key"""
267
+ _alias = alias or self.redis_alias
268
+ return await delete(key, alias=_alias)