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,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)
|