django-nativemojo 0.1.15__py3-none-any.whl → 0.1.17__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.
- {django_nativemojo-0.1.15.dist-info → django_nativemojo-0.1.17.dist-info}/METADATA +3 -2
- django_nativemojo-0.1.17.dist-info/RECORD +302 -0
- mojo/__init__.py +1 -1
- mojo/apps/account/management/commands/serializer_admin.py +121 -1
- mojo/apps/account/migrations/0006_add_device_tracking_models.py +72 -0
- mojo/apps/account/migrations/0007_delete_userdevicelocation.py +16 -0
- mojo/apps/account/migrations/0008_userdevicelocation.py +33 -0
- mojo/apps/account/migrations/0009_geolocatedip_subnet.py +18 -0
- mojo/apps/account/migrations/0010_group_avatar.py +20 -0
- mojo/apps/account/migrations/0011_user_org_registereddevice_pushconfig_and_more.py +118 -0
- mojo/apps/account/migrations/0012_remove_pushconfig_apns_key_file_and_more.py +21 -0
- mojo/apps/account/migrations/0013_pushconfig_test_mode_alter_pushconfig_apns_enabled_and_more.py +28 -0
- mojo/apps/account/migrations/0014_notificationdelivery_data_payload_and_more.py +48 -0
- mojo/apps/account/models/__init__.py +2 -0
- mojo/apps/account/models/device.py +279 -0
- mojo/apps/account/models/group.py +294 -8
- mojo/apps/account/models/member.py +14 -1
- mojo/apps/account/models/push/__init__.py +4 -0
- mojo/apps/account/models/push/config.py +112 -0
- mojo/apps/account/models/push/delivery.py +93 -0
- mojo/apps/account/models/push/device.py +66 -0
- mojo/apps/account/models/push/template.py +99 -0
- mojo/apps/account/models/user.py +190 -17
- mojo/apps/account/rest/__init__.py +2 -0
- mojo/apps/account/rest/device.py +39 -0
- mojo/apps/account/rest/group.py +8 -0
- mojo/apps/account/rest/push.py +187 -0
- mojo/apps/account/rest/user.py +95 -5
- mojo/apps/account/services/__init__.py +1 -0
- mojo/apps/account/services/push.py +363 -0
- mojo/apps/aws/migrations/0001_initial.py +206 -0
- mojo/apps/aws/migrations/0002_emaildomain_can_recv_emaildomain_can_send_and_more.py +28 -0
- mojo/apps/aws/migrations/0003_mailbox_is_domain_default_mailbox_is_system_default_and_more.py +31 -0
- mojo/apps/aws/migrations/0004_s3bucket.py +39 -0
- mojo/apps/aws/migrations/0005_alter_emaildomain_region_delete_s3bucket.py +21 -0
- mojo/apps/aws/models/__init__.py +19 -0
- mojo/apps/aws/models/email_attachment.py +99 -0
- mojo/apps/aws/models/email_domain.py +218 -0
- mojo/apps/aws/models/email_template.py +132 -0
- mojo/apps/aws/models/incoming_email.py +197 -0
- mojo/apps/aws/models/mailbox.py +288 -0
- mojo/apps/aws/models/sent_message.py +175 -0
- mojo/apps/aws/rest/__init__.py +6 -0
- mojo/apps/aws/rest/email.py +33 -0
- mojo/apps/aws/rest/email_ops.py +183 -0
- mojo/apps/aws/rest/messages.py +32 -0
- mojo/apps/aws/rest/send.py +101 -0
- mojo/apps/aws/rest/sns.py +403 -0
- mojo/apps/aws/rest/templates.py +19 -0
- mojo/apps/aws/services/__init__.py +32 -0
- mojo/apps/aws/services/email.py +390 -0
- mojo/apps/aws/services/email_ops.py +548 -0
- mojo/apps/docit/__init__.py +6 -0
- mojo/apps/docit/markdown_plugins/syntax_highlight.py +25 -0
- mojo/apps/docit/markdown_plugins/toc.py +12 -0
- mojo/apps/docit/migrations/0001_initial.py +113 -0
- mojo/apps/docit/migrations/0002_alter_book_modified_by_alter_page_modified_by.py +26 -0
- mojo/apps/docit/migrations/0003_alter_book_group.py +20 -0
- mojo/apps/docit/models/__init__.py +17 -0
- mojo/apps/docit/models/asset.py +231 -0
- mojo/apps/docit/models/book.py +227 -0
- mojo/apps/docit/models/page.py +319 -0
- mojo/apps/docit/models/page_revision.py +203 -0
- mojo/apps/docit/rest/__init__.py +10 -0
- mojo/apps/docit/rest/asset.py +17 -0
- mojo/apps/docit/rest/book.py +22 -0
- mojo/apps/docit/rest/page.py +22 -0
- mojo/apps/docit/rest/page_revision.py +17 -0
- mojo/apps/docit/services/__init__.py +11 -0
- mojo/apps/docit/services/docit.py +315 -0
- mojo/apps/docit/services/markdown.py +44 -0
- mojo/apps/fileman/backends/s3.py +209 -0
- mojo/apps/fileman/models/file.py +45 -9
- mojo/apps/fileman/models/manager.py +269 -3
- mojo/apps/incident/migrations/0007_event_uid.py +18 -0
- mojo/apps/incident/migrations/0008_ticket_ticketnote.py +55 -0
- mojo/apps/incident/migrations/0009_incident_status.py +18 -0
- mojo/apps/incident/migrations/0010_event_country_code.py +18 -0
- mojo/apps/incident/migrations/0011_incident_country_code.py +18 -0
- mojo/apps/incident/migrations/0012_alter_incident_status.py +18 -0
- mojo/apps/incident/models/__init__.py +1 -0
- mojo/apps/incident/models/event.py +35 -0
- mojo/apps/incident/models/incident.py +2 -0
- mojo/apps/incident/models/ticket.py +62 -0
- mojo/apps/incident/reporter.py +21 -3
- mojo/apps/incident/rest/__init__.py +1 -0
- mojo/apps/incident/rest/ticket.py +43 -0
- mojo/apps/jobs/__init__.py +489 -0
- mojo/apps/jobs/adapters.py +24 -0
- mojo/apps/jobs/cli.py +616 -0
- mojo/apps/jobs/daemon.py +370 -0
- mojo/apps/jobs/examples/sample_jobs.py +376 -0
- mojo/apps/jobs/examples/webhook_examples.py +203 -0
- mojo/apps/jobs/handlers/__init__.py +5 -0
- mojo/apps/jobs/handlers/webhook.py +317 -0
- mojo/apps/jobs/job_engine.py +734 -0
- mojo/apps/jobs/keys.py +203 -0
- mojo/apps/jobs/local_queue.py +363 -0
- mojo/apps/jobs/management/__init__.py +3 -0
- mojo/apps/jobs/management/commands/__init__.py +3 -0
- mojo/apps/jobs/manager.py +1327 -0
- mojo/apps/jobs/migrations/0001_initial.py +97 -0
- mojo/apps/jobs/migrations/0002_alter_job_max_retries_joblog.py +39 -0
- mojo/apps/jobs/models/__init__.py +6 -0
- mojo/apps/jobs/models/job.py +441 -0
- mojo/apps/jobs/rest/__init__.py +2 -0
- mojo/apps/jobs/rest/control.py +466 -0
- mojo/apps/jobs/rest/jobs.py +421 -0
- mojo/apps/jobs/scheduler.py +571 -0
- mojo/apps/jobs/services/__init__.py +6 -0
- mojo/apps/jobs/services/job_actions.py +465 -0
- mojo/apps/jobs/settings.py +209 -0
- mojo/apps/logit/models/log.py +3 -0
- mojo/apps/metrics/__init__.py +8 -1
- mojo/apps/metrics/redis_metrics.py +198 -0
- mojo/apps/metrics/rest/__init__.py +3 -0
- mojo/apps/metrics/rest/categories.py +266 -0
- mojo/apps/metrics/rest/helpers.py +48 -0
- mojo/apps/metrics/rest/permissions.py +99 -0
- mojo/apps/metrics/rest/values.py +277 -0
- mojo/apps/metrics/utils.py +17 -0
- mojo/decorators/http.py +40 -1
- mojo/helpers/aws/__init__.py +11 -7
- mojo/helpers/aws/inbound_email.py +309 -0
- mojo/helpers/aws/kms.py +413 -0
- mojo/helpers/aws/ses_domain.py +959 -0
- mojo/helpers/crypto/__init__.py +1 -1
- mojo/helpers/crypto/utils.py +15 -0
- mojo/helpers/location/__init__.py +2 -0
- mojo/helpers/location/countries.py +262 -0
- mojo/helpers/location/geolocation.py +196 -0
- mojo/helpers/logit.py +37 -0
- mojo/helpers/redis/__init__.py +2 -0
- mojo/helpers/redis/adapter.py +606 -0
- mojo/helpers/redis/client.py +48 -0
- mojo/helpers/redis/pool.py +225 -0
- mojo/helpers/request.py +8 -0
- mojo/helpers/response.py +8 -0
- mojo/middleware/auth.py +1 -1
- mojo/middleware/cors.py +40 -0
- mojo/middleware/logging.py +131 -12
- mojo/middleware/mojo.py +5 -0
- mojo/models/rest.py +271 -57
- mojo/models/secrets.py +86 -0
- mojo/serializers/__init__.py +16 -10
- mojo/serializers/core/__init__.py +90 -0
- mojo/serializers/core/cache/__init__.py +121 -0
- mojo/serializers/core/cache/backends.py +518 -0
- mojo/serializers/core/cache/base.py +102 -0
- mojo/serializers/core/cache/disabled.py +181 -0
- mojo/serializers/core/cache/memory.py +287 -0
- mojo/serializers/core/cache/redis.py +533 -0
- mojo/serializers/core/cache/utils.py +454 -0
- mojo/serializers/{manager.py → core/manager.py} +53 -4
- mojo/serializers/core/serializer.py +475 -0
- mojo/serializers/{advanced/formats → formats}/csv.py +116 -139
- mojo/serializers/suggested_improvements.md +388 -0
- testit/client.py +1 -1
- testit/helpers.py +14 -0
- testit/runner.py +23 -6
- django_nativemojo-0.1.15.dist-info/RECORD +0 -234
- mojo/apps/notify/README.md +0 -91
- mojo/apps/notify/README_NOTIFICATIONS.md +0 -566
- mojo/apps/notify/admin.py +0 -52
- mojo/apps/notify/handlers/example_handlers.py +0 -516
- mojo/apps/notify/handlers/ses/__init__.py +0 -25
- mojo/apps/notify/handlers/ses/complaint.py +0 -25
- mojo/apps/notify/handlers/ses/message.py +0 -86
- mojo/apps/notify/management/commands/__init__.py +0 -1
- mojo/apps/notify/management/commands/process_notifications.py +0 -370
- mojo/apps/notify/mod +0 -0
- mojo/apps/notify/models/__init__.py +0 -12
- mojo/apps/notify/models/account.py +0 -128
- mojo/apps/notify/models/attachment.py +0 -24
- mojo/apps/notify/models/bounce.py +0 -68
- mojo/apps/notify/models/complaint.py +0 -40
- mojo/apps/notify/models/inbox.py +0 -113
- mojo/apps/notify/models/inbox_message.py +0 -173
- mojo/apps/notify/models/outbox.py +0 -129
- mojo/apps/notify/models/outbox_message.py +0 -288
- mojo/apps/notify/models/template.py +0 -30
- mojo/apps/notify/providers/aws.py +0 -73
- mojo/apps/notify/rest/ses.py +0 -0
- mojo/apps/notify/utils/__init__.py +0 -2
- mojo/apps/notify/utils/notifications.py +0 -404
- mojo/apps/notify/utils/parsing.py +0 -202
- mojo/apps/notify/utils/render.py +0 -144
- mojo/apps/tasks/README.md +0 -118
- mojo/apps/tasks/__init__.py +0 -44
- mojo/apps/tasks/manager.py +0 -644
- mojo/apps/tasks/rest/__init__.py +0 -2
- mojo/apps/tasks/rest/hooks.py +0 -0
- mojo/apps/tasks/rest/tasks.py +0 -76
- mojo/apps/tasks/runner.py +0 -439
- mojo/apps/tasks/task.py +0 -99
- mojo/apps/tasks/tq_handlers.py +0 -132
- mojo/helpers/crypto/__pycache__/hash.cpython-310.pyc +0 -0
- mojo/helpers/crypto/__pycache__/sign.cpython-310.pyc +0 -0
- mojo/helpers/crypto/__pycache__/utils.cpython-310.pyc +0 -0
- mojo/helpers/redis.py +0 -10
- mojo/models/meta.py +0 -262
- mojo/serializers/advanced/README.md +0 -363
- mojo/serializers/advanced/__init__.py +0 -247
- mojo/serializers/advanced/formats/__init__.py +0 -28
- mojo/serializers/advanced/formats/excel.py +0 -516
- mojo/serializers/advanced/formats/json.py +0 -239
- mojo/serializers/advanced/formats/response.py +0 -485
- mojo/serializers/advanced/serializer.py +0 -568
- mojo/serializers/optimized.py +0 -618
- {django_nativemojo-0.1.15.dist-info → django_nativemojo-0.1.17.dist-info}/LICENSE +0 -0
- {django_nativemojo-0.1.15.dist-info → django_nativemojo-0.1.17.dist-info}/NOTICE +0 -0
- {django_nativemojo-0.1.15.dist-info → django_nativemojo-0.1.17.dist-info}/WHEEL +0 -0
- /mojo/apps/{notify → aws/migrations}/__init__.py +0 -0
- /mojo/apps/{notify/handlers → docit/markdown_plugins}/__init__.py +0 -0
- /mojo/apps/{notify/management → docit/migrations}/__init__.py +0 -0
- /mojo/apps/{notify/providers → jobs/examples}/__init__.py +0 -0
- /mojo/apps/{notify/rest → jobs/migrations}/__init__.py +0 -0
- /mojo/{serializers → rest}/openapi.py +0 -0
- /mojo/serializers/{settings_example.py → examples/settings.py} +0 -0
- /mojo/{apps/notify/handlers/ses/bounce.py → serializers/formats/__init__.py} +0 -0
- /mojo/serializers/{advanced/formats → formats}/localizers.py +0 -0
@@ -0,0 +1,606 @@
|
|
1
|
+
"""
|
2
|
+
Redis connection pooling and typed operations for the Mojo framework.
|
3
|
+
Provides both simple connections and a full-featured adapter with type safety.
|
4
|
+
"""
|
5
|
+
import json
|
6
|
+
from typing import Any, Dict, List, Optional, Tuple, Union
|
7
|
+
from contextlib import contextmanager
|
8
|
+
|
9
|
+
import redis
|
10
|
+
|
11
|
+
from mojo.helpers import logit
|
12
|
+
from .client import get_connection
|
13
|
+
|
14
|
+
|
15
|
+
class RedisAdapter:
|
16
|
+
"""
|
17
|
+
Redis adapter with typed operations and automatic serialization.
|
18
|
+
Uses the framework's connection pooling via get_connection().
|
19
|
+
"""
|
20
|
+
|
21
|
+
def get_client(self):
|
22
|
+
"""
|
23
|
+
Get a Redis client instance using framework connection pooling.
|
24
|
+
|
25
|
+
Returns:
|
26
|
+
Redis client
|
27
|
+
"""
|
28
|
+
return get_connection()
|
29
|
+
|
30
|
+
@contextmanager
|
31
|
+
def pipeline(self, transaction: bool = True):
|
32
|
+
"""
|
33
|
+
Context manager for Redis pipeline operations.
|
34
|
+
|
35
|
+
Args:
|
36
|
+
transaction: Whether to use MULTI/EXEC transaction
|
37
|
+
|
38
|
+
Yields:
|
39
|
+
Redis pipeline object
|
40
|
+
"""
|
41
|
+
pipe = self.get_client().pipeline(transaction=transaction)
|
42
|
+
try:
|
43
|
+
yield pipe
|
44
|
+
pipe.execute()
|
45
|
+
except Exception as e:
|
46
|
+
logit.error(f"Pipeline execution failed: {e}")
|
47
|
+
raise
|
48
|
+
finally:
|
49
|
+
pipe.reset()
|
50
|
+
|
51
|
+
# Stream operations
|
52
|
+
def xadd(self, stream: str, fields: Dict[str, Any], id: str = '*',
|
53
|
+
maxlen: Optional[int] = None) -> str:
|
54
|
+
"""
|
55
|
+
Add entry to a stream.
|
56
|
+
|
57
|
+
Args:
|
58
|
+
stream: Stream key
|
59
|
+
fields: Field-value pairs
|
60
|
+
id: Entry ID (default '*' for auto-generation)
|
61
|
+
maxlen: Trim stream to approximately this length
|
62
|
+
|
63
|
+
Returns:
|
64
|
+
Stream entry ID
|
65
|
+
"""
|
66
|
+
# Serialize complex values to JSON
|
67
|
+
serialized = {}
|
68
|
+
for k, v in fields.items():
|
69
|
+
if isinstance(v, (dict, list)):
|
70
|
+
serialized[k] = json.dumps(v)
|
71
|
+
else:
|
72
|
+
serialized[k] = v
|
73
|
+
|
74
|
+
return self.get_client().xadd(
|
75
|
+
stream, serialized, id=id, maxlen=maxlen, approximate=False
|
76
|
+
)
|
77
|
+
|
78
|
+
def xreadgroup(self, group: str, consumer: str, streams: Dict[str, str],
|
79
|
+
count: Optional[int] = None, block: Optional[int] = None) -> List[Tuple[str, List]]:
|
80
|
+
"""
|
81
|
+
Read from streams as part of a consumer group.
|
82
|
+
|
83
|
+
Args:
|
84
|
+
group: Consumer group name
|
85
|
+
consumer: Consumer name
|
86
|
+
streams: Dict of stream names to IDs (use '>' for new messages)
|
87
|
+
count: Max messages to return
|
88
|
+
block: Block for this many milliseconds (None = don't block)
|
89
|
+
|
90
|
+
Returns:
|
91
|
+
List of (stream_name, messages) tuples with decoded strings
|
92
|
+
"""
|
93
|
+
return self.get_client().xreadgroup(
|
94
|
+
group, consumer, streams, count=count, block=block
|
95
|
+
)
|
96
|
+
|
97
|
+
def xack(self, stream: str, group: str, *ids) -> int:
|
98
|
+
"""
|
99
|
+
Acknowledge messages in a stream.
|
100
|
+
|
101
|
+
Args:
|
102
|
+
stream: Stream key
|
103
|
+
group: Consumer group name
|
104
|
+
*ids: Message IDs to acknowledge
|
105
|
+
|
106
|
+
Returns:
|
107
|
+
Number of messages acknowledged
|
108
|
+
"""
|
109
|
+
return self.get_client().xack(stream, group, *ids)
|
110
|
+
|
111
|
+
def xclaim(self, stream: str, group: str, consumer: str, min_idle: int,
|
112
|
+
*ids, **kwargs) -> List:
|
113
|
+
"""
|
114
|
+
Claim pending messages.
|
115
|
+
|
116
|
+
Args:
|
117
|
+
stream: Stream key
|
118
|
+
group: Consumer group name
|
119
|
+
consumer: Consumer claiming the messages
|
120
|
+
min_idle: Minimum idle time in milliseconds
|
121
|
+
*ids: Message IDs to claim
|
122
|
+
**kwargs: Additional options
|
123
|
+
|
124
|
+
Returns:
|
125
|
+
List of claimed messages with decoded strings
|
126
|
+
"""
|
127
|
+
return self.get_client().xclaim(
|
128
|
+
stream, group, consumer, min_idle, *ids, **kwargs
|
129
|
+
)
|
130
|
+
|
131
|
+
def xpending(self, stream: str, group: str, start: Optional[str] = None, end: Optional[str] = None,
|
132
|
+
count: Optional[int] = None, consumer: Optional[str] = None) -> Union[Dict[str, Any], List[Dict[str, Any]]]:
|
133
|
+
"""
|
134
|
+
Get pending message info for a consumer group.
|
135
|
+
|
136
|
+
Args:
|
137
|
+
stream: Stream key
|
138
|
+
group: Consumer group name
|
139
|
+
start: Start ID for range query (optional, enables detailed mode)
|
140
|
+
end: End ID for range query (optional)
|
141
|
+
count: Max messages to return (optional)
|
142
|
+
consumer: Filter by consumer (optional)
|
143
|
+
|
144
|
+
Returns:
|
145
|
+
Summary dict if no range specified, or list of detailed pending message dicts
|
146
|
+
"""
|
147
|
+
client = self.get_client()
|
148
|
+
if start is not None:
|
149
|
+
# Detailed pending info with range
|
150
|
+
# Prefer redis-py's xpending_range when available for structured output
|
151
|
+
detailed_messages: List[Dict[str, Any]] = []
|
152
|
+
used_structured_api = False
|
153
|
+
try:
|
154
|
+
if hasattr(client, "xpending_range"):
|
155
|
+
# redis-py >= 4 provides xpending_range(name, groupname, min, max, count, consumername=None)
|
156
|
+
res = client.xpending_range(
|
157
|
+
stream, group, start, end or '+', count or 10,
|
158
|
+
consumername=consumer
|
159
|
+
)
|
160
|
+
used_structured_api = True
|
161
|
+
# Normalize to our schema
|
162
|
+
for item in res or []:
|
163
|
+
# redis-py uses keys like 'message_id', 'consumer', 'idle', 'times_delivered'
|
164
|
+
msg_id = item.get('message_id')
|
165
|
+
cons = item.get('consumer')
|
166
|
+
idle = item.get('idle')
|
167
|
+
deliveries = item.get('times_delivered')
|
168
|
+
detailed_messages.append({
|
169
|
+
'message_id': msg_id,
|
170
|
+
'consumer': cons,
|
171
|
+
'idle_time': int(idle or 0),
|
172
|
+
'delivery_count': int(deliveries or 0),
|
173
|
+
})
|
174
|
+
except Exception as e:
|
175
|
+
logit.debug(f"xpending_range failed for {stream}/{group}: {e}")
|
176
|
+
used_structured_api = False
|
177
|
+
detailed_messages = []
|
178
|
+
|
179
|
+
if not used_structured_api:
|
180
|
+
# Fallback: raw XPENDING command returning list entries
|
181
|
+
args = [stream, group, start, end or '+', count or 10]
|
182
|
+
if consumer:
|
183
|
+
args.append(consumer)
|
184
|
+
try:
|
185
|
+
result = client.execute_command('XPENDING', *args)
|
186
|
+
except Exception as e:
|
187
|
+
# Handle case where stream/group doesn't exist or other Redis errors
|
188
|
+
logit.debug(f"XPENDING detailed query failed for {stream}/{group}: {e}")
|
189
|
+
return []
|
190
|
+
|
191
|
+
# Convert detailed response to structured format
|
192
|
+
# Each item in result is: [message_id, consumer, idle_time, delivery_count]
|
193
|
+
if result:
|
194
|
+
for item in result:
|
195
|
+
try:
|
196
|
+
if isinstance(item, (list, tuple)) and len(item) >= 4:
|
197
|
+
msg_id = item[0]
|
198
|
+
cons = item[1]
|
199
|
+
idle = int(item[2] or 0)
|
200
|
+
deliveries = int(item[3] or 0)
|
201
|
+
detailed_messages.append({
|
202
|
+
'message_id': msg_id,
|
203
|
+
'consumer': cons,
|
204
|
+
'idle_time': idle,
|
205
|
+
'delivery_count': deliveries
|
206
|
+
})
|
207
|
+
except Exception as ie:
|
208
|
+
logit.debug(f"Failed to parse XPENDING detailed item {item}: {ie}")
|
209
|
+
|
210
|
+
return detailed_messages
|
211
|
+
else:
|
212
|
+
# Basic pending summary
|
213
|
+
try:
|
214
|
+
result = client.xpending(stream, group)
|
215
|
+
return result
|
216
|
+
except Exception as e:
|
217
|
+
# Handle case where stream/group doesn't exist or other Redis errors
|
218
|
+
logit.debug(f"XPENDING summary query failed for {stream}/{group}: {e}")
|
219
|
+
return {'pending': 0, 'min_idle_time': 0, 'max_idle_time': 0, 'consumers': []}
|
220
|
+
|
221
|
+
def xinfo_stream(self, stream: str) -> Dict[str, Any]:
|
222
|
+
"""
|
223
|
+
Get stream information.
|
224
|
+
|
225
|
+
Args:
|
226
|
+
stream: Stream key
|
227
|
+
|
228
|
+
Returns:
|
229
|
+
Stream info dict with decoded strings
|
230
|
+
"""
|
231
|
+
return self.get_client().xinfo_stream(stream)
|
232
|
+
|
233
|
+
def xgroup_create(self, stream: str, group: str, id: str = '0',
|
234
|
+
mkstream: bool = True) -> bool:
|
235
|
+
"""
|
236
|
+
Create a consumer group.
|
237
|
+
|
238
|
+
Args:
|
239
|
+
stream: Stream key
|
240
|
+
group: Consumer group name
|
241
|
+
id: Starting message ID
|
242
|
+
mkstream: Create stream if it doesn't exist
|
243
|
+
|
244
|
+
Returns:
|
245
|
+
True if created, False if already exists
|
246
|
+
"""
|
247
|
+
try:
|
248
|
+
self.get_client().xgroup_create(
|
249
|
+
stream, group, id=id, mkstream=mkstream
|
250
|
+
)
|
251
|
+
return True
|
252
|
+
except Exception as e:
|
253
|
+
if "BUSYGROUP" in str(e):
|
254
|
+
# Group already exists
|
255
|
+
return False
|
256
|
+
raise
|
257
|
+
|
258
|
+
# ZSET operations
|
259
|
+
def zadd(self, key: str, mapping: Dict[str, float], **kwargs) -> int:
|
260
|
+
"""
|
261
|
+
Add members to a sorted set.
|
262
|
+
|
263
|
+
Args:
|
264
|
+
key: ZSET key
|
265
|
+
mapping: Dict of member -> score
|
266
|
+
**kwargs: Additional options (NX, XX, CH, INCR)
|
267
|
+
|
268
|
+
Returns:
|
269
|
+
Number of elements added
|
270
|
+
"""
|
271
|
+
return self.get_client().zadd(key, mapping, **kwargs)
|
272
|
+
|
273
|
+
def zpopmin(self, key: str, count: int = 1) -> List[Tuple[str, float]]:
|
274
|
+
"""
|
275
|
+
Pop members with lowest scores.
|
276
|
+
|
277
|
+
Args:
|
278
|
+
key: ZSET key
|
279
|
+
count: Number of members to pop
|
280
|
+
|
281
|
+
Returns:
|
282
|
+
List of (member, score) tuples with decoded member names
|
283
|
+
"""
|
284
|
+
return self.get_client().zpopmin(key, count)
|
285
|
+
|
286
|
+
def zcard(self, key: str) -> int:
|
287
|
+
"""
|
288
|
+
Get sorted set cardinality.
|
289
|
+
|
290
|
+
Args:
|
291
|
+
key: ZSET key
|
292
|
+
|
293
|
+
Returns:
|
294
|
+
Number of members
|
295
|
+
"""
|
296
|
+
return self.get_client().zcard(key)
|
297
|
+
|
298
|
+
def zscore(self, key: str, member: str) -> Optional[float]:
|
299
|
+
"""
|
300
|
+
Get the score of a member in a sorted set.
|
301
|
+
|
302
|
+
Args:
|
303
|
+
key: ZSET key
|
304
|
+
member: Member whose score to retrieve
|
305
|
+
|
306
|
+
Returns:
|
307
|
+
The score as a float, or None if the member does not exist
|
308
|
+
"""
|
309
|
+
return self.get_client().zscore(key, member)
|
310
|
+
|
311
|
+
# List operations (Plan B)
|
312
|
+
def rpush(self, key: str, *values: Any) -> int:
|
313
|
+
"""
|
314
|
+
Push one or more values to the right end of a list.
|
315
|
+
|
316
|
+
Args:
|
317
|
+
key: List key
|
318
|
+
*values: One or more values to push
|
319
|
+
|
320
|
+
Returns:
|
321
|
+
The length of the list after the push operations
|
322
|
+
"""
|
323
|
+
return self.get_client().rpush(key, *values)
|
324
|
+
|
325
|
+
def brpop(self, keys: List[str], timeout: int = 1) -> Optional[Tuple[str, str]]:
|
326
|
+
"""
|
327
|
+
Blocking right pop on one or more lists.
|
328
|
+
|
329
|
+
Args:
|
330
|
+
keys: List of keys to BRPOP from (first non-empty wins)
|
331
|
+
timeout: Timeout in seconds (0 = block indefinitely)
|
332
|
+
|
333
|
+
Returns:
|
334
|
+
(key, value) tuple as strings, or None if timed out
|
335
|
+
"""
|
336
|
+
return self.get_client().brpop(keys, timeout=timeout)
|
337
|
+
|
338
|
+
def llen(self, key: str) -> int:
|
339
|
+
"""
|
340
|
+
Get the length of a list.
|
341
|
+
|
342
|
+
Args:
|
343
|
+
key: List key
|
344
|
+
|
345
|
+
Returns:
|
346
|
+
Length of the list
|
347
|
+
"""
|
348
|
+
return self.get_client().llen(key)
|
349
|
+
|
350
|
+
# ZSET helpers (Plan B)
|
351
|
+
def zrem(self, key: str, member: str) -> int:
|
352
|
+
"""
|
353
|
+
Remove a member from a sorted set.
|
354
|
+
|
355
|
+
Args:
|
356
|
+
key: ZSET key
|
357
|
+
member: Member to remove
|
358
|
+
|
359
|
+
Returns:
|
360
|
+
Number of members removed (0 or 1)
|
361
|
+
"""
|
362
|
+
return self.get_client().zrem(key, member)
|
363
|
+
|
364
|
+
def zrangebyscore(self, key: str, min_score: float, max_score: float, limit: Optional[int] = None) -> List[str]:
|
365
|
+
"""
|
366
|
+
Return members in a sorted set within the given scores.
|
367
|
+
|
368
|
+
Args:
|
369
|
+
key: ZSET key
|
370
|
+
min_score: Minimum score (inclusive)
|
371
|
+
max_score: Maximum score (inclusive)
|
372
|
+
limit: Optional maximum number of members to return
|
373
|
+
|
374
|
+
Returns:
|
375
|
+
List of members as strings
|
376
|
+
"""
|
377
|
+
client = self.get_client()
|
378
|
+
if limit is not None:
|
379
|
+
return client.zrangebyscore(key, min_score, max_score, start=0, num=int(limit))
|
380
|
+
else:
|
381
|
+
return client.zrangebyscore(key, min_score, max_score)
|
382
|
+
|
383
|
+
# Hash operations
|
384
|
+
def hset(self, key: str, mapping: Dict[str, Any]) -> int:
|
385
|
+
"""
|
386
|
+
Set hash fields.
|
387
|
+
|
388
|
+
Args:
|
389
|
+
key: Hash key
|
390
|
+
mapping: Field-value pairs
|
391
|
+
|
392
|
+
Returns:
|
393
|
+
Number of fields added
|
394
|
+
"""
|
395
|
+
# Serialize complex values
|
396
|
+
serialized = {}
|
397
|
+
for k, v in mapping.items():
|
398
|
+
if v is None:
|
399
|
+
serialized[k] = ''
|
400
|
+
elif isinstance(v, bool):
|
401
|
+
serialized[k] = '1' if v else '0'
|
402
|
+
elif isinstance(v, (dict, list)):
|
403
|
+
serialized[k] = json.dumps(v)
|
404
|
+
else:
|
405
|
+
serialized[k] = str(v)
|
406
|
+
|
407
|
+
return self.get_client().hset(key, mapping=serialized)
|
408
|
+
|
409
|
+
def hget(self, key: str, field: str) -> Optional[str]:
|
410
|
+
"""
|
411
|
+
Get hash field value.
|
412
|
+
|
413
|
+
Args:
|
414
|
+
key: Hash key
|
415
|
+
field: Field name
|
416
|
+
|
417
|
+
Returns:
|
418
|
+
Field value or None
|
419
|
+
"""
|
420
|
+
return self.get_client().hget(key, field)
|
421
|
+
|
422
|
+
def hgetall(self, key: str) -> Dict[str, str]:
|
423
|
+
"""
|
424
|
+
Get all hash fields.
|
425
|
+
|
426
|
+
Args:
|
427
|
+
key: Hash key
|
428
|
+
|
429
|
+
Returns:
|
430
|
+
Dict of field -> value
|
431
|
+
"""
|
432
|
+
return self.get_client().hgetall(key)
|
433
|
+
|
434
|
+
def hdel(self, key: str, *fields) -> int:
|
435
|
+
"""
|
436
|
+
Delete hash fields.
|
437
|
+
|
438
|
+
Args:
|
439
|
+
key: Hash key
|
440
|
+
*fields: Field names to delete
|
441
|
+
|
442
|
+
Returns:
|
443
|
+
Number of fields deleted
|
444
|
+
"""
|
445
|
+
return self.get_client().hdel(key, *fields)
|
446
|
+
|
447
|
+
# Key operations
|
448
|
+
def set(self, key: str, value: Any, ex: Optional[int] = None,
|
449
|
+
px: Optional[int] = None, nx: bool = False, xx: bool = False) -> bool:
|
450
|
+
"""
|
451
|
+
Set a key value.
|
452
|
+
|
453
|
+
Args:
|
454
|
+
key: Key name
|
455
|
+
value: Value to set
|
456
|
+
ex: Expire time in seconds
|
457
|
+
px: Expire time in milliseconds
|
458
|
+
nx: Only set if key doesn't exist
|
459
|
+
xx: Only set if key exists
|
460
|
+
|
461
|
+
Returns:
|
462
|
+
True if set, False otherwise
|
463
|
+
"""
|
464
|
+
if isinstance(value, (dict, list)):
|
465
|
+
value = json.dumps(value)
|
466
|
+
|
467
|
+
result = self.get_client().set(
|
468
|
+
key, value, ex=ex, px=px, nx=nx, xx=xx
|
469
|
+
)
|
470
|
+
return result is True or (isinstance(result, bytes) and result == b'OK')
|
471
|
+
|
472
|
+
def get(self, key: str) -> Optional[str]:
|
473
|
+
"""
|
474
|
+
Get a key value.
|
475
|
+
|
476
|
+
Args:
|
477
|
+
key: Key name
|
478
|
+
|
479
|
+
Returns:
|
480
|
+
Value or None
|
481
|
+
"""
|
482
|
+
return self.get_client().get(key)
|
483
|
+
|
484
|
+
def delete(self, *keys) -> int:
|
485
|
+
"""
|
486
|
+
Delete keys.
|
487
|
+
|
488
|
+
Args:
|
489
|
+
*keys: Key names to delete
|
490
|
+
|
491
|
+
Returns:
|
492
|
+
Number of keys deleted
|
493
|
+
"""
|
494
|
+
return self.get_client().delete(*keys)
|
495
|
+
|
496
|
+
def expire(self, key: str, seconds: int) -> bool:
|
497
|
+
"""
|
498
|
+
Set key expiration.
|
499
|
+
|
500
|
+
Args:
|
501
|
+
key: Key name
|
502
|
+
seconds: TTL in seconds
|
503
|
+
|
504
|
+
Returns:
|
505
|
+
True if expiration was set
|
506
|
+
"""
|
507
|
+
return self.get_client().expire(key, seconds)
|
508
|
+
|
509
|
+
def pexpire(self, key: str, milliseconds: int) -> bool:
|
510
|
+
"""
|
511
|
+
Set key expiration in milliseconds.
|
512
|
+
|
513
|
+
Args:
|
514
|
+
key: Key name
|
515
|
+
milliseconds: TTL in milliseconds
|
516
|
+
|
517
|
+
Returns:
|
518
|
+
True if expiration was set
|
519
|
+
"""
|
520
|
+
return self.get_client().pexpire(key, milliseconds)
|
521
|
+
|
522
|
+
def ttl(self, key: str) -> int:
|
523
|
+
"""
|
524
|
+
Get key TTL in seconds.
|
525
|
+
|
526
|
+
Args:
|
527
|
+
key: Key name
|
528
|
+
|
529
|
+
Returns:
|
530
|
+
TTL in seconds (-2 if doesn't exist, -1 if no expiry)
|
531
|
+
"""
|
532
|
+
return self.get_client().ttl(key)
|
533
|
+
|
534
|
+
def exists(self, *keys) -> int:
|
535
|
+
"""
|
536
|
+
Check if keys exist.
|
537
|
+
|
538
|
+
Args:
|
539
|
+
*keys: Key names to check
|
540
|
+
|
541
|
+
Returns:
|
542
|
+
Number of keys that exist
|
543
|
+
"""
|
544
|
+
return self.get_client().exists(*keys)
|
545
|
+
|
546
|
+
# Pub/Sub operations
|
547
|
+
def publish(self, channel: str, message: Union[str, Dict]) -> int:
|
548
|
+
"""
|
549
|
+
Publish message to a channel.
|
550
|
+
|
551
|
+
Args:
|
552
|
+
channel: Channel name
|
553
|
+
message: Message to publish
|
554
|
+
|
555
|
+
Returns:
|
556
|
+
Number of subscribers that received the message
|
557
|
+
"""
|
558
|
+
if isinstance(message, dict):
|
559
|
+
message = json.dumps(message)
|
560
|
+
|
561
|
+
return self.get_client().publish(channel, message)
|
562
|
+
|
563
|
+
def pubsub(self):
|
564
|
+
"""
|
565
|
+
Get a pub/sub connection.
|
566
|
+
|
567
|
+
Returns:
|
568
|
+
PubSub object
|
569
|
+
"""
|
570
|
+
return self.get_client().pubsub()
|
571
|
+
|
572
|
+
# Utility methods
|
573
|
+
def ping(self) -> bool:
|
574
|
+
"""
|
575
|
+
Test Redis connection.
|
576
|
+
|
577
|
+
Returns:
|
578
|
+
True if connected
|
579
|
+
"""
|
580
|
+
try:
|
581
|
+
return self.get_client().ping()
|
582
|
+
except Exception:
|
583
|
+
return False
|
584
|
+
|
585
|
+
|
586
|
+
# Framework-level singleton
|
587
|
+
_default_adapter = None
|
588
|
+
|
589
|
+
|
590
|
+
def get_adapter() -> RedisAdapter:
|
591
|
+
"""
|
592
|
+
Get the default Redis adapter instance.
|
593
|
+
|
594
|
+
Returns:
|
595
|
+
RedisAdapter instance
|
596
|
+
"""
|
597
|
+
global _default_adapter
|
598
|
+
if not _default_adapter:
|
599
|
+
_default_adapter = RedisAdapter()
|
600
|
+
return _default_adapter
|
601
|
+
|
602
|
+
|
603
|
+
def reset_adapter():
|
604
|
+
"""Reset the default adapter (useful for testing)."""
|
605
|
+
global _default_adapter
|
606
|
+
_default_adapter = None
|
@@ -0,0 +1,48 @@
|
|
1
|
+
import redis
|
2
|
+
from mojo.helpers.settings import settings
|
3
|
+
|
4
|
+
def get_redis_config():
|
5
|
+
"""Get Redis configuration with decode_responses always enabled"""
|
6
|
+
# Start with safe defaults
|
7
|
+
config = {
|
8
|
+
'host': 'localhost',
|
9
|
+
'port': 6379,
|
10
|
+
'db': 0,
|
11
|
+
'decode_responses': True, # Always decode responses to strings
|
12
|
+
}
|
13
|
+
|
14
|
+
# Check if complete REDIS_DB dict is provided
|
15
|
+
redis_db_setting = settings.get('REDIS_DB', None)
|
16
|
+
|
17
|
+
if redis_db_setting and isinstance(redis_db_setting, dict):
|
18
|
+
# Use the provided dictionary, but ensure decode_responses=True
|
19
|
+
config.update(redis_db_setting)
|
20
|
+
config['decode_responses'] = True
|
21
|
+
else:
|
22
|
+
# Use individual settings
|
23
|
+
config.update({
|
24
|
+
'host': settings.get('REDIS_HOST', 'localhost'),
|
25
|
+
'port': settings.get('REDIS_PORT', 6379),
|
26
|
+
'db': settings.get('REDIS_DATABASE', 0),
|
27
|
+
})
|
28
|
+
|
29
|
+
# Add password if provided
|
30
|
+
password = settings.get('REDIS_PASSWORD', None)
|
31
|
+
if password:
|
32
|
+
config['password'] = password
|
33
|
+
|
34
|
+
return config
|
35
|
+
REDIS_POOL = None
|
36
|
+
|
37
|
+
def get_connection():
|
38
|
+
"""
|
39
|
+
Get a Redis connection using shared connection pooling.
|
40
|
+
|
41
|
+
Returns:
|
42
|
+
Redis client instance
|
43
|
+
"""
|
44
|
+
global REDIS_POOL
|
45
|
+
if REDIS_POOL is None:
|
46
|
+
config = get_redis_config()
|
47
|
+
REDIS_POOL = redis.ConnectionPool(**config)
|
48
|
+
return redis.Redis(connection_pool=REDIS_POOL)
|