django-cfg 1.5.20__py3-none-any.whl → 1.5.31__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 (98) hide show
  1. django_cfg/__init__.py +1 -1
  2. django_cfg/apps/integrations/centrifugo/__init__.py +2 -0
  3. django_cfg/apps/integrations/centrifugo/services/client/client.py +1 -1
  4. django_cfg/apps/integrations/centrifugo/services/logging.py +90 -14
  5. django_cfg/apps/integrations/centrifugo/views/admin_api.py +29 -32
  6. django_cfg/apps/integrations/centrifugo/views/testing_api.py +47 -43
  7. django_cfg/apps/integrations/centrifugo/views/wrapper.py +41 -29
  8. django_cfg/apps/integrations/grpc/auth/api_key_auth.py +11 -10
  9. django_cfg/apps/integrations/grpc/management/commands/generate_protos.py +1 -1
  10. django_cfg/apps/integrations/grpc/management/commands/rungrpc.py +22 -36
  11. django_cfg/apps/integrations/grpc/managers/grpc_request_log.py +84 -0
  12. django_cfg/apps/integrations/grpc/managers/grpc_server_status.py +126 -3
  13. django_cfg/apps/integrations/grpc/models/grpc_api_key.py +7 -1
  14. django_cfg/apps/integrations/grpc/models/grpc_server_status.py +22 -3
  15. django_cfg/apps/integrations/grpc/services/__init__.py +102 -17
  16. django_cfg/apps/integrations/grpc/services/centrifugo/bridge.py +469 -0
  17. django_cfg/apps/integrations/grpc/{centrifugo → services/centrifugo}/demo.py +1 -1
  18. django_cfg/apps/integrations/grpc/{centrifugo → services/centrifugo}/test_publish.py +4 -4
  19. django_cfg/apps/integrations/grpc/services/client/__init__.py +26 -0
  20. django_cfg/apps/integrations/grpc/services/commands/IMPLEMENTATION.md +456 -0
  21. django_cfg/apps/integrations/grpc/services/commands/README.md +252 -0
  22. django_cfg/apps/integrations/grpc/services/commands/__init__.py +93 -0
  23. django_cfg/apps/integrations/grpc/services/commands/base.py +243 -0
  24. django_cfg/apps/integrations/grpc/services/commands/examples/__init__.py +22 -0
  25. django_cfg/apps/integrations/grpc/services/commands/examples/base_client.py +228 -0
  26. django_cfg/apps/integrations/grpc/services/commands/examples/client.py +272 -0
  27. django_cfg/apps/integrations/grpc/services/commands/examples/config.py +177 -0
  28. django_cfg/apps/integrations/grpc/services/commands/examples/start.py +125 -0
  29. django_cfg/apps/integrations/grpc/services/commands/examples/stop.py +101 -0
  30. django_cfg/apps/integrations/grpc/services/commands/registry.py +170 -0
  31. django_cfg/apps/integrations/grpc/services/discovery/__init__.py +39 -0
  32. django_cfg/apps/integrations/grpc/services/{discovery.py → discovery/discovery.py} +62 -55
  33. django_cfg/apps/integrations/grpc/services/{service_registry.py → discovery/registry.py} +216 -5
  34. django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/metrics.py +3 -3
  35. django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/request_logger.py +10 -13
  36. django_cfg/apps/integrations/grpc/services/management/__init__.py +37 -0
  37. django_cfg/apps/integrations/grpc/services/monitoring/__init__.py +38 -0
  38. django_cfg/apps/integrations/grpc/services/{monitoring_service.py → monitoring/monitoring.py} +2 -2
  39. django_cfg/apps/integrations/grpc/services/{testing_service.py → monitoring/testing.py} +5 -5
  40. django_cfg/apps/integrations/grpc/services/rendering/__init__.py +27 -0
  41. django_cfg/apps/integrations/grpc/services/{chart_generator.py → rendering/charts.py} +1 -1
  42. django_cfg/apps/integrations/grpc/services/routing/__init__.py +59 -0
  43. django_cfg/apps/integrations/grpc/services/routing/config.py +76 -0
  44. django_cfg/apps/integrations/grpc/services/routing/router.py +430 -0
  45. django_cfg/apps/integrations/grpc/services/streaming/__init__.py +117 -0
  46. django_cfg/apps/integrations/grpc/services/streaming/config.py +451 -0
  47. django_cfg/apps/integrations/grpc/services/streaming/service.py +651 -0
  48. django_cfg/apps/integrations/grpc/services/streaming/types.py +367 -0
  49. django_cfg/apps/integrations/grpc/utils/__init__.py +58 -1
  50. django_cfg/apps/integrations/grpc/utils/converters.py +565 -0
  51. django_cfg/apps/integrations/grpc/utils/handlers.py +242 -0
  52. django_cfg/apps/integrations/grpc/utils/proto_gen.py +1 -1
  53. django_cfg/apps/integrations/grpc/utils/streaming_logger.py +55 -8
  54. django_cfg/apps/integrations/grpc/views/charts.py +1 -1
  55. django_cfg/apps/integrations/grpc/views/config.py +1 -1
  56. django_cfg/core/base/config_model.py +11 -0
  57. django_cfg/core/builders/middleware_builder.py +5 -0
  58. django_cfg/management/commands/pool_status.py +153 -0
  59. django_cfg/middleware/pool_cleanup.py +261 -0
  60. django_cfg/models/api/grpc/config.py +2 -2
  61. django_cfg/models/infrastructure/database/config.py +16 -0
  62. django_cfg/models/infrastructure/database/converters.py +2 -0
  63. django_cfg/modules/django_admin/utils/html/composition.py +57 -13
  64. django_cfg/modules/django_admin/utils/html_builder.py +1 -0
  65. django_cfg/modules/django_client/core/generator/typescript/files_generator.py +12 -0
  66. django_cfg/modules/django_client/core/generator/typescript/generator.py +8 -0
  67. django_cfg/modules/django_client/core/generator/typescript/templates/fetchers/function.ts.jinja +22 -0
  68. django_cfg/modules/django_client/core/generator/typescript/templates/main_index.ts.jinja +4 -0
  69. django_cfg/modules/django_client/core/generator/typescript/templates/utils/validation-events.ts.jinja +133 -0
  70. django_cfg/modules/django_client/core/groups/manager.py +25 -18
  71. django_cfg/modules/django_client/management/commands/generate_client.py +9 -5
  72. django_cfg/modules/django_client/urls.py +38 -5
  73. django_cfg/modules/django_logging/django_logger.py +58 -19
  74. django_cfg/modules/django_twilio/email_otp.py +3 -1
  75. django_cfg/modules/django_twilio/sms.py +3 -1
  76. django_cfg/modules/django_twilio/unified.py +6 -2
  77. django_cfg/modules/django_twilio/whatsapp.py +3 -1
  78. django_cfg/pyproject.toml +3 -3
  79. django_cfg/static/frontend/admin.zip +0 -0
  80. django_cfg/templates/admin/index.html +17 -57
  81. django_cfg/utils/pool_monitor.py +320 -0
  82. django_cfg/utils/smart_defaults.py +233 -7
  83. {django_cfg-1.5.20.dist-info → django_cfg-1.5.31.dist-info}/METADATA +75 -5
  84. {django_cfg-1.5.20.dist-info → django_cfg-1.5.31.dist-info}/RECORD +97 -68
  85. django_cfg/apps/integrations/grpc/centrifugo/bridge.py +0 -277
  86. /django_cfg/apps/integrations/grpc/{centrifugo → services/centrifugo}/__init__.py +0 -0
  87. /django_cfg/apps/integrations/grpc/{centrifugo → services/centrifugo}/config.py +0 -0
  88. /django_cfg/apps/integrations/grpc/{centrifugo → services/centrifugo}/transformers.py +0 -0
  89. /django_cfg/apps/integrations/grpc/services/{grpc_client.py → client/client.py} +0 -0
  90. /django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/__init__.py +0 -0
  91. /django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/centrifugo.py +0 -0
  92. /django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/errors.py +0 -0
  93. /django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/logging.py +0 -0
  94. /django_cfg/apps/integrations/grpc/services/{config_helper.py → management/config_helper.py} +0 -0
  95. /django_cfg/apps/integrations/grpc/services/{proto_files_manager.py → management/proto_manager.py} +0 -0
  96. {django_cfg-1.5.20.dist-info → django_cfg-1.5.31.dist-info}/WHEEL +0 -0
  97. {django_cfg-1.5.20.dist-info → django_cfg-1.5.31.dist-info}/entry_points.txt +0 -0
  98. {django_cfg-1.5.20.dist-info → django_cfg-1.5.31.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,261 @@
1
+ """
2
+ Connection Pool Cleanup Middleware.
3
+
4
+ Ensures database connections are properly returned to the pool after each
5
+ request, including error cases. Prevents connection leaks in production.
6
+
7
+ Usage:
8
+ Add to MIDDLEWARE in settings.py:
9
+ MIDDLEWARE = [
10
+ # ... other middleware ...
11
+ 'django_cfg.middleware.pool_cleanup.ConnectionPoolCleanupMiddleware',
12
+ ]
13
+
14
+ Note:
15
+ This middleware should be placed AFTER all other middleware to ensure
16
+ cleanup happens regardless of what other middleware does.
17
+ """
18
+
19
+ import logging
20
+ import time
21
+ from typing import Callable
22
+
23
+ from django.db import connection, connections, transaction
24
+ from django.http import HttpRequest, HttpResponse
25
+ from django.utils.deprecation import MiddlewareMixin
26
+
27
+ logger = logging.getLogger('django_cfg.middleware.pool_cleanup')
28
+
29
+
30
+ class ConnectionPoolCleanupMiddleware(MiddlewareMixin):
31
+ """
32
+ Middleware to ensure database connections are cleaned up after each request.
33
+
34
+ Features:
35
+ - Closes connections after successful responses
36
+ - Closes connections after exceptions
37
+ - Rolls back uncommitted transactions on errors
38
+ - Works with both sync and async views
39
+ - Minimal performance overhead (<1ms)
40
+
41
+ This middleware is critical when using connection pooling with
42
+ ATOMIC_REQUESTS=False, as it ensures connections don't leak.
43
+ """
44
+
45
+ def __init__(self, get_response: Callable):
46
+ """
47
+ Initialize middleware.
48
+
49
+ Args:
50
+ get_response: The next middleware or view to call
51
+ """
52
+ super().__init__(get_response)
53
+ self.get_response = get_response
54
+ self._enable_logging = False # Set to True for debug logging
55
+
56
+ def __call__(self, request: HttpRequest) -> HttpResponse:
57
+ """
58
+ Process request through middleware chain.
59
+
60
+ Args:
61
+ request: The HTTP request
62
+
63
+ Returns:
64
+ HttpResponse from view or next middleware
65
+ """
66
+ start_time = time.time() if self._enable_logging else None
67
+
68
+ try:
69
+ response = self.get_response(request)
70
+ return response
71
+ finally:
72
+ # Cleanup happens in finally block to ensure it runs
73
+ self._cleanup_connections(request, rollback_on_error=False)
74
+
75
+ if self._enable_logging and start_time:
76
+ duration_ms = (time.time() - start_time) * 1000
77
+ logger.debug(f"Pool cleanup overhead: {duration_ms:.2f}ms")
78
+
79
+ def process_response(self, request: HttpRequest, response: HttpResponse) -> HttpResponse:
80
+ """
81
+ Process response before returning to client.
82
+
83
+ Called after view execution for successful responses.
84
+
85
+ Args:
86
+ request: The HTTP request
87
+ response: The HTTP response from view
88
+
89
+ Returns:
90
+ The HTTP response (unchanged)
91
+ """
92
+ # Cleanup handled in __call__ finally block
93
+ return response
94
+
95
+ def process_exception(self, request: HttpRequest, exception: Exception) -> None:
96
+ """
97
+ Process exception raised during request handling.
98
+
99
+ Called when view raises an exception. Rolls back any pending
100
+ transactions and closes connections.
101
+
102
+ Args:
103
+ request: The HTTP request
104
+ exception: The exception that was raised
105
+
106
+ Returns:
107
+ None (allows exception to propagate)
108
+ """
109
+ logger.warning(
110
+ f"Exception in request, rolling back transactions: {exception.__class__.__name__}",
111
+ extra={
112
+ 'path': request.path,
113
+ 'method': request.method,
114
+ 'exception': str(exception),
115
+ }
116
+ )
117
+
118
+ # Rollback all open transactions
119
+ self._cleanup_connections(request, rollback_on_error=True)
120
+
121
+ # Return None to allow exception to propagate
122
+ return None
123
+
124
+ def _cleanup_connections(self, request: HttpRequest, rollback_on_error: bool = False) -> None:
125
+ """
126
+ Clean up all database connections.
127
+
128
+ Args:
129
+ request: The HTTP request
130
+ rollback_on_error: If True, rollback uncommitted transactions
131
+ """
132
+ for db_alias in connections:
133
+ try:
134
+ conn = connections[db_alias]
135
+
136
+ # Check if connection is open
137
+ if conn.connection is None:
138
+ continue
139
+
140
+ # Rollback uncommitted transactions if requested
141
+ if rollback_on_error:
142
+ self._rollback_transaction(conn, db_alias)
143
+
144
+ # Close the connection to return it to pool
145
+ # Django's close() is safe to call multiple times
146
+ conn.close()
147
+
148
+ except Exception as e:
149
+ logger.error(
150
+ f"Error cleaning up connection '{db_alias}': {e}",
151
+ exc_info=True,
152
+ extra={
153
+ 'database': db_alias,
154
+ 'path': request.path,
155
+ }
156
+ )
157
+
158
+ def _rollback_transaction(self, conn, db_alias: str) -> None:
159
+ """
160
+ Rollback any uncommitted transaction on a connection.
161
+
162
+ Args:
163
+ conn: Database connection
164
+ db_alias: Database alias for logging
165
+ """
166
+ try:
167
+ # Check if there's an open transaction
168
+ if conn.in_atomic_block:
169
+ logger.debug(f"Rolling back transaction for database '{db_alias}'")
170
+ conn.rollback()
171
+ except Exception as e:
172
+ logger.error(
173
+ f"Error rolling back transaction for '{db_alias}': {e}",
174
+ exc_info=True
175
+ )
176
+
177
+
178
+ class AsyncConnectionPoolCleanupMiddleware:
179
+ """
180
+ Async version of ConnectionPoolCleanupMiddleware.
181
+
182
+ Use this middleware in ASGI deployments for better async compatibility.
183
+
184
+ Usage:
185
+ MIDDLEWARE = [
186
+ 'django_cfg.middleware.pool_cleanup.AsyncConnectionPoolCleanupMiddleware',
187
+ ]
188
+ """
189
+
190
+ def __init__(self, get_response: Callable):
191
+ """
192
+ Initialize async middleware.
193
+
194
+ Args:
195
+ get_response: The next middleware or view to call
196
+ """
197
+ self.get_response = get_response
198
+ self._enable_logging = False
199
+
200
+ async def __call__(self, request: HttpRequest) -> HttpResponse:
201
+ """
202
+ Process request through middleware chain (async).
203
+
204
+ Args:
205
+ request: The HTTP request
206
+
207
+ Returns:
208
+ HttpResponse from view or next middleware
209
+ """
210
+ start_time = time.time() if self._enable_logging else None
211
+
212
+ try:
213
+ response = await self.get_response(request)
214
+ return response
215
+ except Exception as e:
216
+ # Rollback on exception
217
+ logger.warning(
218
+ f"Exception in async request, rolling back: {e.__class__.__name__}",
219
+ extra={'path': request.path, 'exception': str(e)}
220
+ )
221
+ self._cleanup_connections(request, rollback_on_error=True)
222
+ raise
223
+ finally:
224
+ # Always cleanup connections
225
+ self._cleanup_connections(request, rollback_on_error=False)
226
+
227
+ if self._enable_logging and start_time:
228
+ duration_ms = (time.time() - start_time) * 1000
229
+ logger.debug(f"Async pool cleanup overhead: {duration_ms:.2f}ms")
230
+
231
+ def _cleanup_connections(self, request: HttpRequest, rollback_on_error: bool = False) -> None:
232
+ """
233
+ Clean up all database connections (sync code in async middleware).
234
+
235
+ Args:
236
+ request: The HTTP request
237
+ rollback_on_error: If True, rollback uncommitted transactions
238
+ """
239
+ for db_alias in connections:
240
+ try:
241
+ conn = connections[db_alias]
242
+
243
+ if conn.connection is None:
244
+ continue
245
+
246
+ if rollback_on_error and conn.in_atomic_block:
247
+ logger.debug(f"Rolling back async transaction for '{db_alias}'")
248
+ conn.rollback()
249
+
250
+ conn.close()
251
+
252
+ except Exception as e:
253
+ logger.error(
254
+ f"Error cleaning up async connection '{db_alias}': {e}",
255
+ exc_info=True,
256
+ extra={'database': db_alias, 'path': request.path}
257
+ )
258
+
259
+
260
+ # Default export - use sync middleware
261
+ __all__ = ['ConnectionPoolCleanupMiddleware', 'AsyncConnectionPoolCleanupMiddleware']
@@ -370,9 +370,9 @@ class GRPCConfig(BaseConfig):
370
370
  description="Proto generation configuration (optional, use flatten fields above for common settings)",
371
371
  )
372
372
 
373
- handlers_hook: str = Field(
373
+ handlers_hook: str | List[str] = Field(
374
374
  default="",
375
- description="Import path to grpc_handlers function (optional, e.g., '{ROOT_URLCONF}.grpc_handlers')",
375
+ description="Import path(s) to grpc_handlers function (optional, e.g., '{ROOT_URLCONF}.grpc_handlers' or list of paths)",
376
376
  )
377
377
 
378
378
  auto_register_apps: bool = Field(
@@ -81,6 +81,18 @@ class DatabaseConfig(BaseModel):
81
81
  description="Additional database-specific options",
82
82
  )
83
83
 
84
+ # Connection pooling options (CRITICAL for preventing connection exhaustion)
85
+ conn_max_age: int = Field(
86
+ default=0, # 0 disables persistent connections when using connection pooling
87
+ description="Maximum age of database connections in seconds. Must be 0 when using connection pooling.",
88
+ ge=0,
89
+ )
90
+
91
+ conn_health_checks: bool = Field(
92
+ default=True,
93
+ description="Enable database connection health checks",
94
+ )
95
+
84
96
  # Database routing configuration
85
97
  apps: List[str] = Field(
86
98
  default_factory=list,
@@ -223,6 +235,8 @@ class DatabaseConfig(BaseModel):
223
235
  apps: Optional[List[str]] = None,
224
236
  operations: Optional[List[Literal["read", "write", "migrate"]]] = None,
225
237
  routing_description: str = "",
238
+ conn_max_age: int = 0, # 0 disables persistent connections when using connection pooling
239
+ conn_health_checks: bool = True,
226
240
  **kwargs
227
241
  ) -> "DatabaseConfig":
228
242
  """
@@ -254,6 +268,8 @@ class DatabaseConfig(BaseModel):
254
268
  apps=apps or [],
255
269
  operations=operations or ["read", "write", "migrate"],
256
270
  routing_description=routing_description,
271
+ conn_max_age=conn_max_age,
272
+ conn_health_checks=conn_health_checks,
257
273
  **kwargs
258
274
  )
259
275
 
@@ -25,6 +25,8 @@ def to_django_config(config: "DatabaseConfig") -> Dict[str, Any]: # type: ignor
25
25
  django_config = {
26
26
  "ENGINE": config.engine,
27
27
  "OPTIONS": {**config.options},
28
+ "CONN_MAX_AGE": config.conn_max_age,
29
+ "CONN_HEALTH_CHECKS": config.conn_health_checks,
28
30
  }
29
31
 
30
32
  # Add database-specific options
@@ -28,7 +28,8 @@ class CompositionElements:
28
28
 
29
29
  @staticmethod
30
30
  def icon_text(icon_or_text: Union[str, Any], text: Any = None,
31
- icon_size: str = "xs", separator: str = " ") -> SafeString:
31
+ icon_size: str = "xs", separator: str = " ",
32
+ color: str = None) -> SafeString:
32
33
  """
33
34
  Render icon with text or emoji with text.
34
35
 
@@ -37,28 +38,35 @@ class CompositionElements:
37
38
  text: Optional text to display after icon
38
39
  icon_size: Icon size (xs, sm, base, lg, xl)
39
40
  separator: Separator between icon and text
41
+ color: Optional color (success, warning, danger, info, secondary, primary)
40
42
 
41
43
  Usage:
42
44
  html.icon_text(Icons.EDIT, 5) # Icon with number
43
- html.icon_text("📝", 5) # Emoji with number
44
45
  html.icon_text("Active") # Just text
46
+ html.icon_text(Icons.CHECK, "Yes", color="success") # Icon with color
45
47
  """
48
+ # Color mapping to Tailwind/Unfold classes
49
+ color_classes = {
50
+ 'success': 'text-green-600 dark:text-green-400',
51
+ 'warning': 'text-yellow-600 dark:text-yellow-400',
52
+ 'danger': 'text-red-600 dark:text-red-400',
53
+ 'error': 'text-red-600 dark:text-red-400',
54
+ 'info': 'text-blue-600 dark:text-blue-400',
55
+ 'secondary': 'text-gray-600 dark:text-gray-400',
56
+ 'primary': 'text-indigo-600 dark:text-indigo-400',
57
+ }
58
+
59
+ color_class = color_classes.get(color, '') if color else ''
60
+
46
61
  if text is None:
47
62
  # Just text
63
+ if color_class:
64
+ return format_html('<span class="{}">{}</span>', color_class, escape(str(icon_or_text)))
48
65
  return format_html('<span>{}</span>', escape(str(icon_or_text)))
49
66
 
50
- # Check if it's a Material Icon (from Icons class) or emoji
67
+ # Render icon (Material Icon from Icons class)
51
68
  icon_str = str(icon_or_text)
52
-
53
- # Detect if it's emoji by checking for non-ASCII characters
54
- is_emoji = any(ord(c) > 127 for c in icon_str)
55
-
56
- if is_emoji or icon_str in ['📝', '💬', '🛒', '👤', '📧', '🔔', '⚙️', '🔧', '📊', '🎯']:
57
- # Emoji
58
- icon_html = escape(icon_str)
59
- else:
60
- # Material Icon
61
- icon_html = CompositionElements.icon(icon_str, size=icon_size)
69
+ icon_html = CompositionElements.icon(icon_str, size=icon_size)
62
70
 
63
71
  # DON'T escape SafeString - it's already safe HTML!
64
72
  from django.utils.safestring import SafeString
@@ -67,8 +75,44 @@ class CompositionElements:
67
75
  else:
68
76
  text_html = escape(str(text))
69
77
 
78
+ # Wrap in span with color class if provided
79
+ if color_class:
80
+ return format_html('<span class="{}">{}{}<span>{}</span></span>',
81
+ color_class, icon_html, separator, text_html)
82
+
70
83
  return format_html('{}{}<span>{}</span>', icon_html, separator, text_html)
71
84
 
85
+ @staticmethod
86
+ def colored_text(text: Any, color: str = None) -> SafeString:
87
+ """
88
+ Render colored text.
89
+
90
+ Args:
91
+ text: Text to display
92
+ color: Color (success, warning, danger, info, secondary, primary)
93
+
94
+ Usage:
95
+ html.colored_text("Active", "success")
96
+ html.colored_text("5 minutes ago", "warning")
97
+ """
98
+ # Color mapping to Tailwind/Unfold classes
99
+ color_classes = {
100
+ 'success': 'text-green-600 dark:text-green-400',
101
+ 'warning': 'text-yellow-600 dark:text-yellow-400',
102
+ 'danger': 'text-red-600 dark:text-red-400',
103
+ 'error': 'text-red-600 dark:text-red-400',
104
+ 'info': 'text-blue-600 dark:text-blue-400',
105
+ 'secondary': 'text-gray-600 dark:text-gray-400',
106
+ 'primary': 'text-indigo-600 dark:text-indigo-400',
107
+ }
108
+
109
+ color_class = color_classes.get(color, '') if color else ''
110
+
111
+ if color_class:
112
+ return format_html('<span class="{}">{}</span>', color_class, escape(str(text)))
113
+
114
+ return format_html('<span>{}</span>', escape(str(text)))
115
+
72
116
  @staticmethod
73
117
  def inline(*items, separator: str = " | ",
74
118
  size: str = "small", css_class: str = "") -> SafeString:
@@ -45,6 +45,7 @@ class HtmlBuilder:
45
45
  # === CompositionElements ===
46
46
  inline = staticmethod(CompositionElements.inline)
47
47
  icon_text = staticmethod(CompositionElements.icon_text)
48
+ colored_text = staticmethod(CompositionElements.colored_text)
48
49
  header = staticmethod(CompositionElements.header)
49
50
 
50
51
  # === FormattingElements ===
@@ -143,6 +143,18 @@ class FilesGenerator:
143
143
  description="Retry utilities with p-retry",
144
144
  )
145
145
 
146
+ def generate_validation_events_file(self):
147
+ """Generate validation-events.ts with browser CustomEvent integration."""
148
+
149
+ template = self.jinja_env.get_template('utils/validation-events.ts.jinja')
150
+ content = template.render()
151
+
152
+ return GeneratedFile(
153
+ path="validation-events.ts",
154
+ content=content,
155
+ description="Zod validation error events for browser integration",
156
+ )
157
+
146
158
  def generate_api_instance_file(self):
147
159
  """Generate api-instance.ts with global singleton."""
148
160
 
@@ -98,6 +98,10 @@ class TypeScriptGenerator(BaseGenerator):
98
98
  # Generate retry.ts with p-retry
99
99
  files.append(self.files_gen.generate_retry_file())
100
100
 
101
+ # Generate validation-events.ts (browser CustomEvent for Zod errors)
102
+ if self.generate_zod_schemas:
103
+ files.append(self.files_gen.generate_validation_events_file())
104
+
101
105
  # Generate api-instance.ts singleton (needed for fetchers/hooks)
102
106
  if self.generate_fetchers:
103
107
  files.append(self.files_gen.generate_api_instance_file())
@@ -146,6 +150,10 @@ class TypeScriptGenerator(BaseGenerator):
146
150
  # Generate retry.ts with p-retry
147
151
  files.append(self.files_gen.generate_retry_file())
148
152
 
153
+ # Generate validation-events.ts (browser CustomEvent for Zod errors)
154
+ if self.generate_zod_schemas:
155
+ files.append(self.files_gen.generate_validation_events_file())
156
+
149
157
  # Generate api-instance.ts singleton (needed for fetchers/hooks)
150
158
  if self.generate_fetchers:
151
159
  files.append(self.files_gen.generate_api_instance_file())
@@ -43,6 +43,28 @@ export async function {{ func_name }}(
43
43
 
44
44
  consola.error('Response data:', response);
45
45
 
46
+ // Dispatch browser CustomEvent (only if window is defined)
47
+ if (typeof window !== 'undefined' && error instanceof Error && 'issues' in error) {
48
+ try {
49
+ const event = new CustomEvent('zod-validation-error', {
50
+ detail: {
51
+ operation: '{{ func_name }}',
52
+ path: '{{ operation.path }}',
53
+ method: '{{ operation.http_method }}',
54
+ error: error,
55
+ response: response,
56
+ timestamp: new Date(),
57
+ },
58
+ bubbles: true,
59
+ cancelable: false,
60
+ });
61
+ window.dispatchEvent(event);
62
+ } catch (eventError) {
63
+ // Silently fail - event dispatch should never crash the app
64
+ consola.warn('Failed to dispatch validation error event:', eventError);
65
+ }
66
+ }
67
+
46
68
  // Re-throw the error
47
69
  throw error;
48
70
  }
@@ -55,6 +55,10 @@ export * as Enums from "./enums";
55
55
 
56
56
  // Re-export Zod schemas for runtime validation
57
57
  export * as Schemas from "./_utils/schemas";
58
+
59
+ // Re-export Zod validation events for browser integration
60
+ export type { ValidationErrorDetail, ValidationErrorEvent } from "./validation-events";
61
+ export { dispatchValidationError, onValidationError, formatZodError } from "./validation-events";
58
62
  {% endif %}
59
63
  {% if generate_fetchers %}
60
64
 
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Zod Validation Events - Browser CustomEvent integration
3
+ *
4
+ * Dispatches browser CustomEvents when Zod validation fails, allowing
5
+ * React/frontend apps to listen and handle validation errors globally.
6
+ *
7
+ * @example
8
+ * ```typescript
9
+ * // In your React app
10
+ * window.addEventListener('zod-validation-error', (event) => {
11
+ * const { operation, path, method, error, response } = event.detail;
12
+ * console.error(`Validation failed for ${method} ${path}`, error);
13
+ * // Show toast notification, log to Sentry, etc.
14
+ * });
15
+ * ```
16
+ */
17
+
18
+ import type { ZodError } from 'zod'
19
+
20
+ /**
21
+ * Validation error event detail
22
+ */
23
+ export interface ValidationErrorDetail {
24
+ /** Operation/function name that failed validation */
25
+ operation: string
26
+ /** API endpoint path */
27
+ path: string
28
+ /** HTTP method */
29
+ method: string
30
+ /** Zod validation error */
31
+ error: ZodError
32
+ /** Raw response data that failed validation */
33
+ response: any
34
+ /** Timestamp of the error */
35
+ timestamp: Date
36
+ }
37
+
38
+ /**
39
+ * Custom event type for Zod validation errors
40
+ */
41
+ export type ValidationErrorEvent = CustomEvent<ValidationErrorDetail>
42
+
43
+ /**
44
+ * Dispatch a Zod validation error event.
45
+ *
46
+ * Only dispatches in browser environment (when window is defined).
47
+ * Safe to call in Node.js/SSR - will be a no-op.
48
+ *
49
+ * @param detail - Validation error details
50
+ */
51
+ export function dispatchValidationError(detail: ValidationErrorDetail): void {
52
+ // Check if running in browser
53
+ if (typeof window === 'undefined') {
54
+ return
55
+ }
56
+
57
+ try {
58
+ const event = new CustomEvent<ValidationErrorDetail>('zod-validation-error', {
59
+ detail,
60
+ bubbles: true,
61
+ cancelable: false,
62
+ })
63
+
64
+ window.dispatchEvent(event)
65
+ } catch (error) {
66
+ // Silently fail - validation event dispatch should never crash the app
67
+ console.warn('Failed to dispatch validation error event:', error)
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Add a global listener for Zod validation errors.
73
+ *
74
+ * @param callback - Function to call when validation error occurs
75
+ * @returns Cleanup function to remove the listener
76
+ *
77
+ * @example
78
+ * ```typescript
79
+ * const cleanup = onValidationError(({ operation, error }) => {
80
+ * toast.error(`Validation failed in ${operation}`);
81
+ * logToSentry(error);
82
+ * });
83
+ *
84
+ * // Later, remove listener
85
+ * cleanup();
86
+ * ```
87
+ */
88
+ export function onValidationError(
89
+ callback: (detail: ValidationErrorDetail) => void
90
+ ): () => void {
91
+ if (typeof window === 'undefined') {
92
+ // Return no-op cleanup function for SSR
93
+ return () => {}
94
+ }
95
+
96
+ const handler = (event: Event) => {
97
+ if (event instanceof CustomEvent) {
98
+ callback(event.detail)
99
+ }
100
+ }
101
+
102
+ window.addEventListener('zod-validation-error', handler)
103
+
104
+ // Return cleanup function
105
+ return () => {
106
+ window.removeEventListener('zod-validation-error', handler)
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Format Zod error for logging/display.
112
+ *
113
+ * @param error - Zod validation error
114
+ * @returns Formatted error message
115
+ */
116
+ export function formatZodError(error: ZodError): string {
117
+ const issues = error.issues.map((issue, index) => {
118
+ const path = issue.path.join('.') || 'root'
119
+ const parts = [`${index + 1}. ${path}: ${issue.message}`]
120
+
121
+ if ('expected' in issue && issue.expected) {
122
+ parts.push(` Expected: ${issue.expected}`)
123
+ }
124
+
125
+ if ('received' in issue && issue.received) {
126
+ parts.push(` Received: ${issue.received}`)
127
+ }
128
+
129
+ return parts.join('\n')
130
+ })
131
+
132
+ return issues.join('\n')
133
+ }