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.
Files changed (221) hide show
  1. {django_nativemojo-0.1.15.dist-info → django_nativemojo-0.1.17.dist-info}/METADATA +3 -2
  2. django_nativemojo-0.1.17.dist-info/RECORD +302 -0
  3. mojo/__init__.py +1 -1
  4. mojo/apps/account/management/commands/serializer_admin.py +121 -1
  5. mojo/apps/account/migrations/0006_add_device_tracking_models.py +72 -0
  6. mojo/apps/account/migrations/0007_delete_userdevicelocation.py +16 -0
  7. mojo/apps/account/migrations/0008_userdevicelocation.py +33 -0
  8. mojo/apps/account/migrations/0009_geolocatedip_subnet.py +18 -0
  9. mojo/apps/account/migrations/0010_group_avatar.py +20 -0
  10. mojo/apps/account/migrations/0011_user_org_registereddevice_pushconfig_and_more.py +118 -0
  11. mojo/apps/account/migrations/0012_remove_pushconfig_apns_key_file_and_more.py +21 -0
  12. mojo/apps/account/migrations/0013_pushconfig_test_mode_alter_pushconfig_apns_enabled_and_more.py +28 -0
  13. mojo/apps/account/migrations/0014_notificationdelivery_data_payload_and_more.py +48 -0
  14. mojo/apps/account/models/__init__.py +2 -0
  15. mojo/apps/account/models/device.py +279 -0
  16. mojo/apps/account/models/group.py +294 -8
  17. mojo/apps/account/models/member.py +14 -1
  18. mojo/apps/account/models/push/__init__.py +4 -0
  19. mojo/apps/account/models/push/config.py +112 -0
  20. mojo/apps/account/models/push/delivery.py +93 -0
  21. mojo/apps/account/models/push/device.py +66 -0
  22. mojo/apps/account/models/push/template.py +99 -0
  23. mojo/apps/account/models/user.py +190 -17
  24. mojo/apps/account/rest/__init__.py +2 -0
  25. mojo/apps/account/rest/device.py +39 -0
  26. mojo/apps/account/rest/group.py +8 -0
  27. mojo/apps/account/rest/push.py +187 -0
  28. mojo/apps/account/rest/user.py +95 -5
  29. mojo/apps/account/services/__init__.py +1 -0
  30. mojo/apps/account/services/push.py +363 -0
  31. mojo/apps/aws/migrations/0001_initial.py +206 -0
  32. mojo/apps/aws/migrations/0002_emaildomain_can_recv_emaildomain_can_send_and_more.py +28 -0
  33. mojo/apps/aws/migrations/0003_mailbox_is_domain_default_mailbox_is_system_default_and_more.py +31 -0
  34. mojo/apps/aws/migrations/0004_s3bucket.py +39 -0
  35. mojo/apps/aws/migrations/0005_alter_emaildomain_region_delete_s3bucket.py +21 -0
  36. mojo/apps/aws/models/__init__.py +19 -0
  37. mojo/apps/aws/models/email_attachment.py +99 -0
  38. mojo/apps/aws/models/email_domain.py +218 -0
  39. mojo/apps/aws/models/email_template.py +132 -0
  40. mojo/apps/aws/models/incoming_email.py +197 -0
  41. mojo/apps/aws/models/mailbox.py +288 -0
  42. mojo/apps/aws/models/sent_message.py +175 -0
  43. mojo/apps/aws/rest/__init__.py +6 -0
  44. mojo/apps/aws/rest/email.py +33 -0
  45. mojo/apps/aws/rest/email_ops.py +183 -0
  46. mojo/apps/aws/rest/messages.py +32 -0
  47. mojo/apps/aws/rest/send.py +101 -0
  48. mojo/apps/aws/rest/sns.py +403 -0
  49. mojo/apps/aws/rest/templates.py +19 -0
  50. mojo/apps/aws/services/__init__.py +32 -0
  51. mojo/apps/aws/services/email.py +390 -0
  52. mojo/apps/aws/services/email_ops.py +548 -0
  53. mojo/apps/docit/__init__.py +6 -0
  54. mojo/apps/docit/markdown_plugins/syntax_highlight.py +25 -0
  55. mojo/apps/docit/markdown_plugins/toc.py +12 -0
  56. mojo/apps/docit/migrations/0001_initial.py +113 -0
  57. mojo/apps/docit/migrations/0002_alter_book_modified_by_alter_page_modified_by.py +26 -0
  58. mojo/apps/docit/migrations/0003_alter_book_group.py +20 -0
  59. mojo/apps/docit/models/__init__.py +17 -0
  60. mojo/apps/docit/models/asset.py +231 -0
  61. mojo/apps/docit/models/book.py +227 -0
  62. mojo/apps/docit/models/page.py +319 -0
  63. mojo/apps/docit/models/page_revision.py +203 -0
  64. mojo/apps/docit/rest/__init__.py +10 -0
  65. mojo/apps/docit/rest/asset.py +17 -0
  66. mojo/apps/docit/rest/book.py +22 -0
  67. mojo/apps/docit/rest/page.py +22 -0
  68. mojo/apps/docit/rest/page_revision.py +17 -0
  69. mojo/apps/docit/services/__init__.py +11 -0
  70. mojo/apps/docit/services/docit.py +315 -0
  71. mojo/apps/docit/services/markdown.py +44 -0
  72. mojo/apps/fileman/backends/s3.py +209 -0
  73. mojo/apps/fileman/models/file.py +45 -9
  74. mojo/apps/fileman/models/manager.py +269 -3
  75. mojo/apps/incident/migrations/0007_event_uid.py +18 -0
  76. mojo/apps/incident/migrations/0008_ticket_ticketnote.py +55 -0
  77. mojo/apps/incident/migrations/0009_incident_status.py +18 -0
  78. mojo/apps/incident/migrations/0010_event_country_code.py +18 -0
  79. mojo/apps/incident/migrations/0011_incident_country_code.py +18 -0
  80. mojo/apps/incident/migrations/0012_alter_incident_status.py +18 -0
  81. mojo/apps/incident/models/__init__.py +1 -0
  82. mojo/apps/incident/models/event.py +35 -0
  83. mojo/apps/incident/models/incident.py +2 -0
  84. mojo/apps/incident/models/ticket.py +62 -0
  85. mojo/apps/incident/reporter.py +21 -3
  86. mojo/apps/incident/rest/__init__.py +1 -0
  87. mojo/apps/incident/rest/ticket.py +43 -0
  88. mojo/apps/jobs/__init__.py +489 -0
  89. mojo/apps/jobs/adapters.py +24 -0
  90. mojo/apps/jobs/cli.py +616 -0
  91. mojo/apps/jobs/daemon.py +370 -0
  92. mojo/apps/jobs/examples/sample_jobs.py +376 -0
  93. mojo/apps/jobs/examples/webhook_examples.py +203 -0
  94. mojo/apps/jobs/handlers/__init__.py +5 -0
  95. mojo/apps/jobs/handlers/webhook.py +317 -0
  96. mojo/apps/jobs/job_engine.py +734 -0
  97. mojo/apps/jobs/keys.py +203 -0
  98. mojo/apps/jobs/local_queue.py +363 -0
  99. mojo/apps/jobs/management/__init__.py +3 -0
  100. mojo/apps/jobs/management/commands/__init__.py +3 -0
  101. mojo/apps/jobs/manager.py +1327 -0
  102. mojo/apps/jobs/migrations/0001_initial.py +97 -0
  103. mojo/apps/jobs/migrations/0002_alter_job_max_retries_joblog.py +39 -0
  104. mojo/apps/jobs/models/__init__.py +6 -0
  105. mojo/apps/jobs/models/job.py +441 -0
  106. mojo/apps/jobs/rest/__init__.py +2 -0
  107. mojo/apps/jobs/rest/control.py +466 -0
  108. mojo/apps/jobs/rest/jobs.py +421 -0
  109. mojo/apps/jobs/scheduler.py +571 -0
  110. mojo/apps/jobs/services/__init__.py +6 -0
  111. mojo/apps/jobs/services/job_actions.py +465 -0
  112. mojo/apps/jobs/settings.py +209 -0
  113. mojo/apps/logit/models/log.py +3 -0
  114. mojo/apps/metrics/__init__.py +8 -1
  115. mojo/apps/metrics/redis_metrics.py +198 -0
  116. mojo/apps/metrics/rest/__init__.py +3 -0
  117. mojo/apps/metrics/rest/categories.py +266 -0
  118. mojo/apps/metrics/rest/helpers.py +48 -0
  119. mojo/apps/metrics/rest/permissions.py +99 -0
  120. mojo/apps/metrics/rest/values.py +277 -0
  121. mojo/apps/metrics/utils.py +17 -0
  122. mojo/decorators/http.py +40 -1
  123. mojo/helpers/aws/__init__.py +11 -7
  124. mojo/helpers/aws/inbound_email.py +309 -0
  125. mojo/helpers/aws/kms.py +413 -0
  126. mojo/helpers/aws/ses_domain.py +959 -0
  127. mojo/helpers/crypto/__init__.py +1 -1
  128. mojo/helpers/crypto/utils.py +15 -0
  129. mojo/helpers/location/__init__.py +2 -0
  130. mojo/helpers/location/countries.py +262 -0
  131. mojo/helpers/location/geolocation.py +196 -0
  132. mojo/helpers/logit.py +37 -0
  133. mojo/helpers/redis/__init__.py +2 -0
  134. mojo/helpers/redis/adapter.py +606 -0
  135. mojo/helpers/redis/client.py +48 -0
  136. mojo/helpers/redis/pool.py +225 -0
  137. mojo/helpers/request.py +8 -0
  138. mojo/helpers/response.py +8 -0
  139. mojo/middleware/auth.py +1 -1
  140. mojo/middleware/cors.py +40 -0
  141. mojo/middleware/logging.py +131 -12
  142. mojo/middleware/mojo.py +5 -0
  143. mojo/models/rest.py +271 -57
  144. mojo/models/secrets.py +86 -0
  145. mojo/serializers/__init__.py +16 -10
  146. mojo/serializers/core/__init__.py +90 -0
  147. mojo/serializers/core/cache/__init__.py +121 -0
  148. mojo/serializers/core/cache/backends.py +518 -0
  149. mojo/serializers/core/cache/base.py +102 -0
  150. mojo/serializers/core/cache/disabled.py +181 -0
  151. mojo/serializers/core/cache/memory.py +287 -0
  152. mojo/serializers/core/cache/redis.py +533 -0
  153. mojo/serializers/core/cache/utils.py +454 -0
  154. mojo/serializers/{manager.py → core/manager.py} +53 -4
  155. mojo/serializers/core/serializer.py +475 -0
  156. mojo/serializers/{advanced/formats → formats}/csv.py +116 -139
  157. mojo/serializers/suggested_improvements.md +388 -0
  158. testit/client.py +1 -1
  159. testit/helpers.py +14 -0
  160. testit/runner.py +23 -6
  161. django_nativemojo-0.1.15.dist-info/RECORD +0 -234
  162. mojo/apps/notify/README.md +0 -91
  163. mojo/apps/notify/README_NOTIFICATIONS.md +0 -566
  164. mojo/apps/notify/admin.py +0 -52
  165. mojo/apps/notify/handlers/example_handlers.py +0 -516
  166. mojo/apps/notify/handlers/ses/__init__.py +0 -25
  167. mojo/apps/notify/handlers/ses/complaint.py +0 -25
  168. mojo/apps/notify/handlers/ses/message.py +0 -86
  169. mojo/apps/notify/management/commands/__init__.py +0 -1
  170. mojo/apps/notify/management/commands/process_notifications.py +0 -370
  171. mojo/apps/notify/mod +0 -0
  172. mojo/apps/notify/models/__init__.py +0 -12
  173. mojo/apps/notify/models/account.py +0 -128
  174. mojo/apps/notify/models/attachment.py +0 -24
  175. mojo/apps/notify/models/bounce.py +0 -68
  176. mojo/apps/notify/models/complaint.py +0 -40
  177. mojo/apps/notify/models/inbox.py +0 -113
  178. mojo/apps/notify/models/inbox_message.py +0 -173
  179. mojo/apps/notify/models/outbox.py +0 -129
  180. mojo/apps/notify/models/outbox_message.py +0 -288
  181. mojo/apps/notify/models/template.py +0 -30
  182. mojo/apps/notify/providers/aws.py +0 -73
  183. mojo/apps/notify/rest/ses.py +0 -0
  184. mojo/apps/notify/utils/__init__.py +0 -2
  185. mojo/apps/notify/utils/notifications.py +0 -404
  186. mojo/apps/notify/utils/parsing.py +0 -202
  187. mojo/apps/notify/utils/render.py +0 -144
  188. mojo/apps/tasks/README.md +0 -118
  189. mojo/apps/tasks/__init__.py +0 -44
  190. mojo/apps/tasks/manager.py +0 -644
  191. mojo/apps/tasks/rest/__init__.py +0 -2
  192. mojo/apps/tasks/rest/hooks.py +0 -0
  193. mojo/apps/tasks/rest/tasks.py +0 -76
  194. mojo/apps/tasks/runner.py +0 -439
  195. mojo/apps/tasks/task.py +0 -99
  196. mojo/apps/tasks/tq_handlers.py +0 -132
  197. mojo/helpers/crypto/__pycache__/hash.cpython-310.pyc +0 -0
  198. mojo/helpers/crypto/__pycache__/sign.cpython-310.pyc +0 -0
  199. mojo/helpers/crypto/__pycache__/utils.cpython-310.pyc +0 -0
  200. mojo/helpers/redis.py +0 -10
  201. mojo/models/meta.py +0 -262
  202. mojo/serializers/advanced/README.md +0 -363
  203. mojo/serializers/advanced/__init__.py +0 -247
  204. mojo/serializers/advanced/formats/__init__.py +0 -28
  205. mojo/serializers/advanced/formats/excel.py +0 -516
  206. mojo/serializers/advanced/formats/json.py +0 -239
  207. mojo/serializers/advanced/formats/response.py +0 -485
  208. mojo/serializers/advanced/serializer.py +0 -568
  209. mojo/serializers/optimized.py +0 -618
  210. {django_nativemojo-0.1.15.dist-info → django_nativemojo-0.1.17.dist-info}/LICENSE +0 -0
  211. {django_nativemojo-0.1.15.dist-info → django_nativemojo-0.1.17.dist-info}/NOTICE +0 -0
  212. {django_nativemojo-0.1.15.dist-info → django_nativemojo-0.1.17.dist-info}/WHEEL +0 -0
  213. /mojo/apps/{notify → aws/migrations}/__init__.py +0 -0
  214. /mojo/apps/{notify/handlers → docit/markdown_plugins}/__init__.py +0 -0
  215. /mojo/apps/{notify/management → docit/migrations}/__init__.py +0 -0
  216. /mojo/apps/{notify/providers → jobs/examples}/__init__.py +0 -0
  217. /mojo/apps/{notify/rest → jobs/migrations}/__init__.py +0 -0
  218. /mojo/{serializers → rest}/openapi.py +0 -0
  219. /mojo/serializers/{settings_example.py → examples/settings.py} +0 -0
  220. /mojo/{apps/notify/handlers/ses/bounce.py → serializers/formats/__init__.py} +0 -0
  221. /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)