django-cfg 1.4.62__py3-none-any.whl → 1.4.63__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 django-cfg might be problematic. Click here for more details.

Files changed (181) hide show
  1. django_cfg/__init__.py +1 -1
  2. django_cfg/apps/accounts/services/otp_service.py +3 -14
  3. django_cfg/apps/centrifugo/__init__.py +57 -0
  4. django_cfg/apps/centrifugo/admin/__init__.py +13 -0
  5. django_cfg/apps/centrifugo/admin/centrifugo_log.py +249 -0
  6. django_cfg/apps/centrifugo/admin/config.py +82 -0
  7. django_cfg/apps/centrifugo/apps.py +31 -0
  8. django_cfg/apps/centrifugo/codegen/IMPLEMENTATION_SUMMARY.md +475 -0
  9. django_cfg/apps/centrifugo/codegen/README.md +242 -0
  10. django_cfg/apps/centrifugo/codegen/USAGE.md +616 -0
  11. django_cfg/apps/centrifugo/codegen/__init__.py +19 -0
  12. django_cfg/apps/centrifugo/codegen/discovery.py +246 -0
  13. django_cfg/apps/centrifugo/codegen/generators/go_thin/__init__.py +5 -0
  14. django_cfg/apps/centrifugo/codegen/generators/go_thin/generator.py +174 -0
  15. django_cfg/apps/centrifugo/codegen/generators/go_thin/templates/README.md.j2 +182 -0
  16. django_cfg/apps/centrifugo/codegen/generators/go_thin/templates/client.go.j2 +64 -0
  17. django_cfg/apps/centrifugo/codegen/generators/go_thin/templates/go.mod.j2 +10 -0
  18. django_cfg/apps/centrifugo/codegen/generators/go_thin/templates/rpc_client.go.j2 +300 -0
  19. django_cfg/apps/centrifugo/codegen/generators/go_thin/templates/rpc_client.go.j2.old +267 -0
  20. django_cfg/apps/centrifugo/codegen/generators/go_thin/templates/types.go.j2 +16 -0
  21. django_cfg/apps/centrifugo/codegen/generators/python_thin/__init__.py +7 -0
  22. django_cfg/apps/centrifugo/codegen/generators/python_thin/generator.py +241 -0
  23. django_cfg/apps/centrifugo/codegen/generators/python_thin/templates/README.md.j2 +128 -0
  24. django_cfg/apps/centrifugo/codegen/generators/python_thin/templates/__init__.py.j2 +22 -0
  25. django_cfg/apps/centrifugo/codegen/generators/python_thin/templates/client.py.j2 +73 -0
  26. django_cfg/apps/centrifugo/codegen/generators/python_thin/templates/models.py.j2 +19 -0
  27. django_cfg/apps/centrifugo/codegen/generators/python_thin/templates/requirements.txt.j2 +8 -0
  28. django_cfg/apps/centrifugo/codegen/generators/python_thin/templates/rpc_client.py.j2 +193 -0
  29. django_cfg/apps/centrifugo/codegen/generators/typescript_thin/__init__.py +5 -0
  30. django_cfg/apps/centrifugo/codegen/generators/typescript_thin/generator.py +124 -0
  31. django_cfg/apps/centrifugo/codegen/generators/typescript_thin/templates/README.md.j2 +38 -0
  32. django_cfg/apps/centrifugo/codegen/generators/typescript_thin/templates/client.ts.j2 +25 -0
  33. django_cfg/apps/centrifugo/codegen/generators/typescript_thin/templates/index.ts.j2 +12 -0
  34. django_cfg/apps/centrifugo/codegen/generators/typescript_thin/templates/package.json.j2 +13 -0
  35. django_cfg/apps/centrifugo/codegen/generators/typescript_thin/templates/rpc-client.ts.j2 +137 -0
  36. django_cfg/apps/centrifugo/codegen/generators/typescript_thin/templates/tsconfig.json.j2 +14 -0
  37. django_cfg/apps/centrifugo/codegen/generators/typescript_thin/templates/types.ts.j2 +9 -0
  38. django_cfg/apps/centrifugo/codegen/utils/__init__.py +37 -0
  39. django_cfg/apps/centrifugo/codegen/utils/naming.py +155 -0
  40. django_cfg/apps/centrifugo/codegen/utils/type_converter.py +349 -0
  41. django_cfg/apps/centrifugo/decorators.py +137 -0
  42. django_cfg/apps/centrifugo/management/__init__.py +1 -0
  43. django_cfg/apps/centrifugo/management/commands/__init__.py +1 -0
  44. django_cfg/apps/centrifugo/management/commands/generate_centrifugo_clients.py +254 -0
  45. django_cfg/apps/centrifugo/managers/__init__.py +12 -0
  46. django_cfg/apps/centrifugo/managers/centrifugo_log.py +264 -0
  47. django_cfg/apps/centrifugo/migrations/0001_initial.py +164 -0
  48. django_cfg/apps/centrifugo/migrations/__init__.py +3 -0
  49. django_cfg/apps/centrifugo/models/__init__.py +11 -0
  50. django_cfg/apps/centrifugo/models/centrifugo_log.py +210 -0
  51. django_cfg/apps/centrifugo/registry.py +106 -0
  52. django_cfg/apps/centrifugo/router.py +125 -0
  53. django_cfg/apps/centrifugo/serializers/__init__.py +40 -0
  54. django_cfg/apps/centrifugo/serializers/admin_api.py +264 -0
  55. django_cfg/apps/centrifugo/serializers/channels.py +26 -0
  56. django_cfg/apps/centrifugo/serializers/health.py +17 -0
  57. django_cfg/apps/centrifugo/serializers/publishes.py +16 -0
  58. django_cfg/apps/centrifugo/serializers/stats.py +21 -0
  59. django_cfg/apps/centrifugo/services/__init__.py +12 -0
  60. django_cfg/apps/centrifugo/services/client/__init__.py +29 -0
  61. django_cfg/apps/centrifugo/services/client/client.py +577 -0
  62. django_cfg/apps/centrifugo/services/client/config.py +228 -0
  63. django_cfg/apps/centrifugo/services/client/exceptions.py +212 -0
  64. django_cfg/apps/centrifugo/services/config_helper.py +63 -0
  65. django_cfg/apps/centrifugo/services/dashboard_notifier.py +157 -0
  66. django_cfg/apps/centrifugo/services/logging.py +677 -0
  67. django_cfg/apps/centrifugo/static/django_cfg_centrifugo/css/dashboard.css +260 -0
  68. django_cfg/apps/centrifugo/static/django_cfg_centrifugo/js/dashboard/live_channels.mjs +313 -0
  69. django_cfg/apps/centrifugo/static/django_cfg_centrifugo/js/dashboard/live_testing.mjs +803 -0
  70. django_cfg/apps/centrifugo/static/django_cfg_centrifugo/js/dashboard/main.mjs +333 -0
  71. django_cfg/apps/centrifugo/static/django_cfg_centrifugo/js/dashboard/overview.mjs +432 -0
  72. django_cfg/apps/centrifugo/static/django_cfg_centrifugo/js/dashboard/testing.mjs +33 -0
  73. django_cfg/apps/centrifugo/static/django_cfg_centrifugo/js/dashboard/websocket.mjs +210 -0
  74. django_cfg/apps/centrifugo/templates/django_cfg_centrifugo/components/channels_content.html +46 -0
  75. django_cfg/apps/centrifugo/templates/django_cfg_centrifugo/components/live_channels_content.html +123 -0
  76. django_cfg/apps/centrifugo/templates/django_cfg_centrifugo/components/overview_content.html +45 -0
  77. django_cfg/apps/centrifugo/templates/django_cfg_centrifugo/components/publishes_content.html +84 -0
  78. django_cfg/apps/{ipc/templates/django_cfg_ipc → centrifugo/templates/django_cfg_centrifugo}/components/stat_cards.html +23 -20
  79. django_cfg/apps/centrifugo/templates/django_cfg_centrifugo/components/system_status.html +91 -0
  80. django_cfg/apps/{ipc/templates/django_cfg_ipc → centrifugo/templates/django_cfg_centrifugo}/components/tab_navigation.html +15 -15
  81. django_cfg/apps/centrifugo/templates/django_cfg_centrifugo/components/testing_tools.html +415 -0
  82. django_cfg/apps/centrifugo/templates/django_cfg_centrifugo/layout/base.html +61 -0
  83. django_cfg/apps/centrifugo/templates/django_cfg_centrifugo/pages/dashboard.html +58 -0
  84. django_cfg/apps/centrifugo/templates/django_cfg_centrifugo/tags/connection_script.html +48 -0
  85. django_cfg/apps/centrifugo/templatetags/__init__.py +1 -0
  86. django_cfg/apps/centrifugo/templatetags/centrifugo_tags.py +81 -0
  87. django_cfg/apps/centrifugo/urls.py +31 -0
  88. django_cfg/apps/{ipc → centrifugo}/urls_admin.py +4 -4
  89. django_cfg/apps/centrifugo/views/__init__.py +15 -0
  90. django_cfg/apps/centrifugo/views/admin_api.py +374 -0
  91. django_cfg/apps/centrifugo/views/dashboard.py +15 -0
  92. django_cfg/apps/centrifugo/views/monitoring.py +286 -0
  93. django_cfg/apps/centrifugo/views/testing_api.py +422 -0
  94. django_cfg/apps/support/utils/support_email_service.py +5 -18
  95. django_cfg/apps/tasks/templates/tasks/layout/base.html +0 -2
  96. django_cfg/apps/urls.py +5 -5
  97. django_cfg/core/base/config_model.py +4 -44
  98. django_cfg/core/builders/apps_builder.py +2 -2
  99. django_cfg/core/generation/integration_generators/third_party.py +8 -8
  100. django_cfg/core/utils/__init__.py +5 -0
  101. django_cfg/core/utils/url_helpers.py +73 -0
  102. django_cfg/modules/base.py +7 -7
  103. django_cfg/modules/django_client/core/__init__.py +2 -1
  104. django_cfg/modules/django_client/core/config/config.py +8 -0
  105. django_cfg/modules/django_client/core/generator/__init__.py +42 -2
  106. django_cfg/modules/django_client/core/generator/go/__init__.py +14 -0
  107. django_cfg/modules/django_client/core/generator/go/client_generator.py +124 -0
  108. django_cfg/modules/django_client/core/generator/go/files_generator.py +133 -0
  109. django_cfg/modules/django_client/core/generator/go/generator.py +203 -0
  110. django_cfg/modules/django_client/core/generator/go/models_generator.py +304 -0
  111. django_cfg/modules/django_client/core/generator/go/naming.py +193 -0
  112. django_cfg/modules/django_client/core/generator/go/operations_generator.py +134 -0
  113. django_cfg/modules/django_client/core/generator/go/templates/Makefile.j2 +38 -0
  114. django_cfg/modules/django_client/core/generator/go/templates/README.md.j2 +55 -0
  115. django_cfg/modules/django_client/core/generator/go/templates/client.go.j2 +122 -0
  116. django_cfg/modules/django_client/core/generator/go/templates/enums.go.j2 +49 -0
  117. django_cfg/modules/django_client/core/generator/go/templates/errors.go.j2 +182 -0
  118. django_cfg/modules/django_client/core/generator/go/templates/go.mod.j2 +6 -0
  119. django_cfg/modules/django_client/core/generator/go/templates/main_client.go.j2 +60 -0
  120. django_cfg/modules/django_client/core/generator/go/templates/middleware.go.j2 +388 -0
  121. django_cfg/modules/django_client/core/generator/go/templates/models.go.j2 +28 -0
  122. django_cfg/modules/django_client/core/generator/go/templates/operations_client.go.j2 +142 -0
  123. django_cfg/modules/django_client/core/generator/go/templates/validation.go.j2 +217 -0
  124. django_cfg/modules/django_client/core/generator/go/type_mapper.py +380 -0
  125. django_cfg/modules/django_client/management/commands/generate_client.py +53 -3
  126. django_cfg/modules/django_client/system/generate_mjs_clients.py +3 -1
  127. django_cfg/modules/django_client/system/schema_parser.py +5 -1
  128. django_cfg/modules/django_tailwind/templates/django_tailwind/base.html +1 -0
  129. django_cfg/modules/django_twilio/sendgrid_service.py +7 -4
  130. django_cfg/modules/django_unfold/dashboard.py +25 -19
  131. django_cfg/pyproject.toml +1 -1
  132. django_cfg/registry/core.py +2 -0
  133. django_cfg/registry/modules.py +2 -2
  134. django_cfg/static/js/api/centrifugo/client.mjs +164 -0
  135. django_cfg/static/js/api/centrifugo/index.mjs +13 -0
  136. django_cfg/static/js/api/index.mjs +5 -5
  137. django_cfg/static/js/api/types.mjs +89 -26
  138. {django_cfg-1.4.62.dist-info → django_cfg-1.4.63.dist-info}/METADATA +1 -1
  139. {django_cfg-1.4.62.dist-info → django_cfg-1.4.63.dist-info}/RECORD +142 -70
  140. django_cfg/apps/ipc/README.md +0 -346
  141. django_cfg/apps/ipc/RPC_LOGGING.md +0 -321
  142. django_cfg/apps/ipc/TESTING.md +0 -539
  143. django_cfg/apps/ipc/__init__.py +0 -60
  144. django_cfg/apps/ipc/admin.py +0 -232
  145. django_cfg/apps/ipc/apps.py +0 -98
  146. django_cfg/apps/ipc/migrations/0001_initial.py +0 -137
  147. django_cfg/apps/ipc/migrations/0002_rpclog_is_event.py +0 -23
  148. django_cfg/apps/ipc/migrations/__init__.py +0 -0
  149. django_cfg/apps/ipc/models.py +0 -229
  150. django_cfg/apps/ipc/serializers/__init__.py +0 -29
  151. django_cfg/apps/ipc/serializers/serializers.py +0 -343
  152. django_cfg/apps/ipc/services/__init__.py +0 -7
  153. django_cfg/apps/ipc/services/client/__init__.py +0 -23
  154. django_cfg/apps/ipc/services/client/client.py +0 -621
  155. django_cfg/apps/ipc/services/client/config.py +0 -214
  156. django_cfg/apps/ipc/services/client/exceptions.py +0 -201
  157. django_cfg/apps/ipc/services/logging.py +0 -239
  158. django_cfg/apps/ipc/services/monitor.py +0 -466
  159. django_cfg/apps/ipc/services/rpc_log_consumer.py +0 -330
  160. django_cfg/apps/ipc/static/django_cfg_ipc/js/dashboard/main.mjs +0 -269
  161. django_cfg/apps/ipc/static/django_cfg_ipc/js/dashboard/overview.mjs +0 -259
  162. django_cfg/apps/ipc/static/django_cfg_ipc/js/dashboard/testing.mjs +0 -375
  163. django_cfg/apps/ipc/static/django_cfg_ipc/js/dashboard.mjs.old +0 -441
  164. django_cfg/apps/ipc/templates/django_cfg_ipc/components/methods_content.html +0 -22
  165. django_cfg/apps/ipc/templates/django_cfg_ipc/components/notifications_content.html +0 -9
  166. django_cfg/apps/ipc/templates/django_cfg_ipc/components/overview_content.html +0 -9
  167. django_cfg/apps/ipc/templates/django_cfg_ipc/components/requests_content.html +0 -23
  168. django_cfg/apps/ipc/templates/django_cfg_ipc/components/system_status.html +0 -47
  169. django_cfg/apps/ipc/templates/django_cfg_ipc/components/testing_tools.html +0 -184
  170. django_cfg/apps/ipc/templates/django_cfg_ipc/layout/base.html +0 -71
  171. django_cfg/apps/ipc/templates/django_cfg_ipc/pages/dashboard.html +0 -56
  172. django_cfg/apps/ipc/urls.py +0 -23
  173. django_cfg/apps/ipc/views/__init__.py +0 -13
  174. django_cfg/apps/ipc/views/dashboard.py +0 -15
  175. django_cfg/apps/ipc/views/monitoring.py +0 -251
  176. django_cfg/apps/ipc/views/testing.py +0 -285
  177. django_cfg/static/js/api/ipc/client.mjs +0 -114
  178. django_cfg/static/js/api/ipc/index.mjs +0 -13
  179. {django_cfg-1.4.62.dist-info → django_cfg-1.4.63.dist-info}/WHEEL +0 -0
  180. {django_cfg-1.4.62.dist-info → django_cfg-1.4.63.dist-info}/entry_points.txt +0 -0
  181. {django_cfg-1.4.62.dist-info → django_cfg-1.4.63.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,577 @@
1
+ """
2
+ Django-CFG Centrifugo Client.
3
+
4
+ Async client enabling Django applications to publish messages
5
+ to Centrifugo via Python Wrapper with ACK tracking.
6
+
7
+ Mirrors DjangoCfgRPCClient interface for easy migration from django-ipc.
8
+ """
9
+
10
+ import asyncio
11
+ import json
12
+ import time
13
+ from typing import Any, Optional, Type, TypeVar
14
+ from uuid import uuid4
15
+
16
+ import httpx
17
+ from django_cfg.modules.django_logging import get_logger
18
+ from pydantic import BaseModel
19
+
20
+ from .exceptions import (
21
+ CentrifugoConfigurationError,
22
+ CentrifugoConnectionError,
23
+ CentrifugoPublishError,
24
+ CentrifugoTimeoutError,
25
+ CentrifugoValidationError,
26
+ )
27
+
28
+ logger = get_logger("centrifugo.client")
29
+
30
+ TData = TypeVar("TData", bound=BaseModel)
31
+
32
+
33
+ class PublishResponse(BaseModel):
34
+ """Response from publish operation."""
35
+
36
+ message_id: str
37
+ published: bool
38
+ delivered: bool = False
39
+ acks_received: int = 0
40
+ timeout: bool = False
41
+
42
+
43
+ class CentrifugoClient:
44
+ """
45
+ Async Centrifugo client for Django to communicate with Centrifugo server.
46
+
47
+ Features:
48
+ - Async/await API for Django 5.2+ async views
49
+ - Publishes messages via Python Wrapper HTTP API
50
+ - Supports ACK tracking for delivery confirmation
51
+ - Type-safe API with Pydantic models
52
+ - Connection pooling for performance
53
+ - Automatic logging with CentrifugoLogger
54
+ - Mirrors DjangoCfgRPCClient interface for migration
55
+
56
+ Example:
57
+ >>> from django_cfg.apps.centrifugo import get_centrifugo_client
58
+ >>>
59
+ >>> client = get_centrifugo_client()
60
+ >>>
61
+ >>> # Simple publish (fire-and-forget)
62
+ >>> result = await client.publish(
63
+ ... channel="broadcast",
64
+ ... data={"message": "Hello everyone"}
65
+ ... )
66
+ >>>
67
+ >>> # Publish with ACK tracking
68
+ >>> result = await client.publish_with_ack(
69
+ ... channel="user#123",
70
+ ... data={"title": "Notification", "message": "Test"},
71
+ ... ack_timeout=10
72
+ ... )
73
+ >>> if result.delivered:
74
+ ... print(f"Delivered to {result.acks_received} clients")
75
+ """
76
+
77
+ def __init__(
78
+ self,
79
+ wrapper_url: Optional[str] = None,
80
+ wrapper_api_key: Optional[str] = None,
81
+ default_timeout: int = 30,
82
+ ack_timeout: int = 10,
83
+ http_timeout: int = 35,
84
+ max_retries: int = 3,
85
+ retry_delay: float = 1.0,
86
+ ):
87
+ """
88
+ Initialize Centrifugo client.
89
+
90
+ Args:
91
+ wrapper_url: Python Wrapper HTTP API URL
92
+ wrapper_api_key: Optional API key for wrapper authentication
93
+ default_timeout: Default publish timeout (seconds)
94
+ ack_timeout: Default ACK timeout (seconds)
95
+ http_timeout: HTTP request timeout (seconds)
96
+ max_retries: Maximum retry attempts
97
+ retry_delay: Delay between retries (seconds)
98
+ """
99
+ self.wrapper_url = wrapper_url or self._get_wrapper_url_from_settings()
100
+ self.wrapper_api_key = wrapper_api_key
101
+ self.default_timeout = default_timeout
102
+ self.ack_timeout = ack_timeout
103
+ self.http_timeout = http_timeout
104
+ self.max_retries = max_retries
105
+ self.retry_delay = retry_delay
106
+
107
+ # Create HTTP client with connection pooling
108
+ headers = {"Content-Type": "application/json"}
109
+ if self.wrapper_api_key:
110
+ headers["X-API-Key"] = self.wrapper_api_key
111
+
112
+ self._http_client = httpx.AsyncClient(
113
+ base_url=self.wrapper_url,
114
+ headers=headers,
115
+ timeout=httpx.Timeout(self.http_timeout),
116
+ limits=httpx.Limits(max_keepalive_connections=20, max_connections=50),
117
+ )
118
+
119
+ logger.info(f"Centrifugo client initialized: {self.wrapper_url}")
120
+
121
+ def _get_wrapper_url_from_settings(self) -> str:
122
+ """
123
+ Get Wrapper URL from django-cfg config.
124
+
125
+ Returns:
126
+ Wrapper URL string
127
+
128
+ Raises:
129
+ CentrifugoConfigurationError: If settings not configured
130
+ """
131
+ from ..config_helper import get_centrifugo_config
132
+
133
+ config = get_centrifugo_config()
134
+
135
+ if config and config.wrapper_url:
136
+ return config.wrapper_url
137
+
138
+ raise CentrifugoConfigurationError(
139
+ "Centrifugo config not found in django-cfg. "
140
+ "Configure DjangoCfgCentrifugoConfig in DjangoConfig.",
141
+ config_key="centrifugo.wrapper_url",
142
+ )
143
+
144
+ async def publish(
145
+ self,
146
+ channel: str,
147
+ data: BaseModel | dict,
148
+ user: Optional[Any] = None,
149
+ caller_ip: Optional[str] = None,
150
+ user_agent: Optional[str] = None,
151
+ ) -> PublishResponse:
152
+ """
153
+ Publish message to Centrifugo channel (fire-and-forget).
154
+
155
+ Does not wait for client acknowledgment.
156
+
157
+ Args:
158
+ channel: Centrifugo channel (e.g., "user#123", "broadcast")
159
+ data: Pydantic model or dict with message data
160
+ user: Django User instance for logging (optional)
161
+ caller_ip: IP address for logging (optional)
162
+ user_agent: User agent for logging (optional)
163
+
164
+ Returns:
165
+ PublishResponse with published=True if sent successfully
166
+
167
+ Raises:
168
+ CentrifugoPublishError: If publish fails
169
+ CentrifugoValidationError: If data validation fails
170
+
171
+ Example:
172
+ >>> result = await client.publish(
173
+ ... channel="broadcast",
174
+ ... data={"message": "Hello everyone"}
175
+ ... )
176
+ >>> print(result.published) # True
177
+ """
178
+ return await self._publish_internal(
179
+ channel=channel,
180
+ data=data,
181
+ wait_for_ack=False,
182
+ ack_timeout=0,
183
+ user=user,
184
+ caller_ip=caller_ip,
185
+ user_agent=user_agent,
186
+ )
187
+
188
+ async def publish_with_ack(
189
+ self,
190
+ channel: str,
191
+ data: BaseModel | dict,
192
+ ack_timeout: Optional[int] = None,
193
+ user: Optional[Any] = None,
194
+ caller_ip: Optional[str] = None,
195
+ user_agent: Optional[str] = None,
196
+ ) -> PublishResponse:
197
+ """
198
+ Publish message with ACK tracking (delivery confirmation).
199
+
200
+ Waits for client(s) to acknowledge receipt.
201
+
202
+ Args:
203
+ channel: Centrifugo channel (e.g., "user#123")
204
+ data: Pydantic model or dict with message data
205
+ ack_timeout: ACK timeout override (seconds)
206
+ user: Django User instance for logging (optional)
207
+ caller_ip: IP address for logging (optional)
208
+ user_agent: User agent for logging (optional)
209
+
210
+ Returns:
211
+ PublishResponse with delivered=True and acks_received count
212
+
213
+ Raises:
214
+ CentrifugoTimeoutError: If ACK timeout exceeded
215
+ CentrifugoPublishError: If publish fails
216
+ CentrifugoValidationError: If data validation fails
217
+
218
+ Example:
219
+ >>> result = await client.publish_with_ack(
220
+ ... channel="user#123",
221
+ ... data={"title": "Alert", "message": "Important"},
222
+ ... ack_timeout=10
223
+ ... )
224
+ >>> if result.delivered:
225
+ ... print(f"Delivered to {result.acks_received} clients")
226
+ """
227
+ timeout = ack_timeout or self.ack_timeout
228
+
229
+ return await self._publish_internal(
230
+ channel=channel,
231
+ data=data,
232
+ wait_for_ack=True,
233
+ ack_timeout=timeout,
234
+ user=user,
235
+ caller_ip=caller_ip,
236
+ user_agent=user_agent,
237
+ )
238
+
239
+ async def _publish_internal(
240
+ self,
241
+ channel: str,
242
+ data: BaseModel | dict,
243
+ wait_for_ack: bool,
244
+ ack_timeout: int,
245
+ user: Optional[Any] = None,
246
+ caller_ip: Optional[str] = None,
247
+ user_agent: Optional[str] = None,
248
+ ) -> PublishResponse:
249
+ """
250
+ Internal publish implementation with retry logic.
251
+
252
+ Args:
253
+ channel: Centrifugo channel
254
+ data: Message data
255
+ wait_for_ack: Whether to wait for ACK
256
+ ack_timeout: ACK timeout in seconds
257
+ user: Django User for logging
258
+ caller_ip: Caller IP for logging
259
+ user_agent: User agent for logging
260
+
261
+ Returns:
262
+ PublishResponse
263
+
264
+ Raises:
265
+ CentrifugoPublishError: If all retries fail
266
+ CentrifugoTimeoutError: If ACK timeout
267
+ """
268
+ message_id = str(uuid4())
269
+ start_time = time.time()
270
+
271
+ # Serialize data
272
+ if isinstance(data, BaseModel):
273
+ try:
274
+ data_dict = data.model_dump()
275
+ except Exception as e:
276
+ raise CentrifugoValidationError(
277
+ f"Failed to serialize Pydantic model: {e}",
278
+ validation_errors=[str(e)],
279
+ )
280
+ elif isinstance(data, dict):
281
+ data_dict = data
282
+ else:
283
+ raise CentrifugoValidationError(
284
+ f"data must be BaseModel or dict, got {type(data).__name__}"
285
+ )
286
+
287
+ # Create log entry (async)
288
+ log_entry = None
289
+ try:
290
+ from ..logging import CentrifugoLogger
291
+
292
+ log_entry = await CentrifugoLogger.create_log_async(
293
+ message_id=message_id,
294
+ channel=channel,
295
+ data=data_dict,
296
+ wait_for_ack=wait_for_ack,
297
+ ack_timeout=ack_timeout if wait_for_ack else None,
298
+ user=user,
299
+ caller_ip=caller_ip,
300
+ user_agent=user_agent,
301
+ )
302
+ except Exception as e:
303
+ logger.warning(f"Failed to create log entry: {e}", exc_info=True)
304
+
305
+ # Prepare request payload
306
+ payload = {
307
+ "channel": channel,
308
+ "data": data_dict,
309
+ "wait_for_ack": wait_for_ack,
310
+ "ack_timeout": ack_timeout if wait_for_ack else 0,
311
+ }
312
+
313
+ # Retry logic
314
+ last_error = None
315
+ for attempt in range(self.max_retries):
316
+ try:
317
+ response = await self._http_client.post("/api/publish", json=payload)
318
+
319
+ if response.status_code == 200:
320
+ result_data = response.json()
321
+ duration_ms = int((time.time() - start_time) * 1000)
322
+
323
+ # Update log entry (async)
324
+ if log_entry:
325
+ try:
326
+ from ..logging import CentrifugoLogger
327
+
328
+ if result_data.get("delivered", False):
329
+ await CentrifugoLogger.mark_success_async(
330
+ log_entry,
331
+ acks_received=result_data.get("acks_received", 0),
332
+ duration_ms=duration_ms,
333
+ )
334
+ elif wait_for_ack:
335
+ await CentrifugoLogger.mark_timeout_async(
336
+ log_entry,
337
+ acks_received=result_data.get("acks_received", 0),
338
+ duration_ms=duration_ms,
339
+ )
340
+ else:
341
+ await CentrifugoLogger.mark_success_async(
342
+ log_entry, acks_received=0, duration_ms=duration_ms
343
+ )
344
+ except Exception as e:
345
+ logger.warning(f"Failed to update log entry: {e}", exc_info=True)
346
+
347
+ return PublishResponse(
348
+ message_id=result_data.get("message_id", message_id),
349
+ published=result_data.get("published", True),
350
+ delivered=result_data.get("delivered", False),
351
+ acks_received=result_data.get("acks_received", 0),
352
+ timeout=not result_data.get("delivered", False) and wait_for_ack,
353
+ )
354
+
355
+ else:
356
+ last_error = CentrifugoPublishError(
357
+ f"Wrapper returned HTTP {response.status_code}",
358
+ channel=channel,
359
+ status_code=response.status_code,
360
+ response_data=response.json() if response.text else None,
361
+ )
362
+
363
+ except httpx.TimeoutException as e:
364
+ last_error = CentrifugoTimeoutError(
365
+ f"HTTP timeout to wrapper: {e}",
366
+ channel=channel,
367
+ timeout_seconds=self.http_timeout,
368
+ )
369
+
370
+ except httpx.ConnectError as e:
371
+ last_error = CentrifugoConnectionError(
372
+ f"Failed to connect to wrapper: {e}",
373
+ wrapper_url=self.wrapper_url,
374
+ )
375
+
376
+ except Exception as e:
377
+ last_error = CentrifugoPublishError(
378
+ f"Unexpected error: {e}",
379
+ channel=channel,
380
+ )
381
+
382
+ # Retry delay
383
+ if attempt < self.max_retries - 1:
384
+ await asyncio.sleep(self.retry_delay)
385
+
386
+ # All retries failed
387
+ duration_ms = int((time.time() - start_time) * 1000)
388
+
389
+ if log_entry:
390
+ try:
391
+ from ..logging import CentrifugoLogger
392
+
393
+ error_code = type(last_error).__name__ if last_error else "unknown"
394
+ error_message = str(last_error) if last_error else "Unknown error"
395
+ CentrifugoLogger.mark_failed(
396
+ log_entry,
397
+ error_code=error_code,
398
+ error_message=error_message,
399
+ duration_ms=duration_ms,
400
+ )
401
+ except Exception as e:
402
+ logger.warning(f"Failed to update log entry: {e}")
403
+
404
+ raise last_error if last_error else CentrifugoPublishError(
405
+ "All retries failed", channel=channel
406
+ )
407
+
408
+ async def fire_and_forget(
409
+ self,
410
+ channel: str,
411
+ data: BaseModel | dict,
412
+ ) -> str:
413
+ """
414
+ Send message without waiting for response (alias for publish).
415
+
416
+ Args:
417
+ channel: Centrifugo channel
418
+ data: Message data
419
+
420
+ Returns:
421
+ Message ID
422
+
423
+ Example:
424
+ >>> message_id = await client.fire_and_forget(
425
+ ... channel="logs",
426
+ ... data={"event": "user_login", "user_id": "123"}
427
+ ... )
428
+ """
429
+ result = await self.publish(channel=channel, data=data)
430
+ return result.message_id
431
+
432
+ async def health_check(self, timeout: int = 5) -> bool:
433
+ """
434
+ Check if wrapper is healthy.
435
+
436
+ Args:
437
+ timeout: Health check timeout (seconds)
438
+
439
+ Returns:
440
+ True if healthy, False otherwise
441
+
442
+ Example:
443
+ >>> if await client.health_check():
444
+ ... print("Wrapper healthy")
445
+ """
446
+ try:
447
+ response = await self._http_client.get(
448
+ "/health",
449
+ timeout=httpx.Timeout(timeout),
450
+ )
451
+
452
+ if response.status_code == 200:
453
+ health_data = response.json()
454
+ return health_data.get("status") == "healthy"
455
+
456
+ return False
457
+
458
+ except Exception as e:
459
+ logger.error(f"Health check failed: {e}")
460
+ return False
461
+
462
+ def get_connection_info(self) -> dict:
463
+ """
464
+ Get connection information.
465
+
466
+ Returns:
467
+ Dictionary with connection details
468
+
469
+ Example:
470
+ >>> info = client.get_connection_info()
471
+ >>> print(info["wrapper_url"])
472
+ """
473
+ return {
474
+ "wrapper_url": self.wrapper_url,
475
+ "has_api_key": self.wrapper_api_key is not None,
476
+ "default_timeout": self.default_timeout,
477
+ "ack_timeout": self.ack_timeout,
478
+ "http_timeout": self.http_timeout,
479
+ "max_retries": self.max_retries,
480
+ "retry_delay": self.retry_delay,
481
+ }
482
+
483
+ async def close(self):
484
+ """
485
+ Close HTTP client connections.
486
+
487
+ Call this when shutting down application to clean up resources.
488
+
489
+ Example:
490
+ >>> await client.close()
491
+ """
492
+ await self._http_client.aclose()
493
+ logger.info("Centrifugo client closed")
494
+
495
+ async def __aenter__(self):
496
+ """Async context manager entry."""
497
+ return self
498
+
499
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
500
+ """Async context manager exit."""
501
+ await self.close()
502
+
503
+
504
+ # ==================== Singleton Pattern ====================
505
+
506
+ _centrifugo_client: Optional[CentrifugoClient] = None
507
+ _centrifugo_client_lock = None
508
+
509
+
510
+ def get_centrifugo_client(force_new: bool = False) -> CentrifugoClient:
511
+ """
512
+ Get global Centrifugo client instance (singleton).
513
+
514
+ Creates client from Django settings on first call.
515
+ Subsequent calls return the same instance (thread-safe).
516
+
517
+ Args:
518
+ force_new: Force create new instance (for testing)
519
+
520
+ Returns:
521
+ CentrifugoClient instance
522
+
523
+ Example:
524
+ >>> from django_cfg.apps.centrifugo import get_centrifugo_client
525
+ >>> client = get_centrifugo_client()
526
+ >>> result = await client.publish_with_ack(...)
527
+ """
528
+ global _centrifugo_client, _centrifugo_client_lock
529
+
530
+ if force_new:
531
+ return _create_client_from_settings()
532
+
533
+ if _centrifugo_client is None:
534
+ # Thread-safe singleton creation
535
+ import threading
536
+
537
+ if _centrifugo_client_lock is None:
538
+ _centrifugo_client_lock = threading.Lock()
539
+
540
+ with _centrifugo_client_lock:
541
+ if _centrifugo_client is None:
542
+ _centrifugo_client = _create_client_from_settings()
543
+
544
+ return _centrifugo_client
545
+
546
+
547
+ def _create_client_from_settings() -> CentrifugoClient:
548
+ """
549
+ Create Centrifugo client from django-cfg config.
550
+
551
+ Returns:
552
+ CentrifugoClient instance
553
+
554
+ Raises:
555
+ CentrifugoConfigurationError: If settings not configured
556
+ """
557
+ from ..config_helper import get_centrifugo_config_or_default
558
+
559
+ cfg = get_centrifugo_config_or_default()
560
+ logger.debug(f"Creating Centrifugo client from config: {cfg.wrapper_url}")
561
+
562
+ return CentrifugoClient(
563
+ wrapper_url=cfg.wrapper_url,
564
+ wrapper_api_key=cfg.wrapper_api_key,
565
+ default_timeout=cfg.default_timeout,
566
+ ack_timeout=cfg.ack_timeout,
567
+ http_timeout=cfg.http_timeout,
568
+ max_retries=cfg.max_retries,
569
+ retry_delay=cfg.retry_delay,
570
+ )
571
+
572
+
573
+ __all__ = [
574
+ "CentrifugoClient",
575
+ "get_centrifugo_client",
576
+ "PublishResponse",
577
+ ]