django-cfg 1.5.14__py3-none-any.whl → 1.5.29__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 (118) hide show
  1. django_cfg/__init__.py +1 -1
  2. django_cfg/apps/business/accounts/serializers/profile.py +42 -0
  3. django_cfg/apps/business/support/serializers.py +3 -2
  4. django_cfg/apps/integrations/centrifugo/__init__.py +2 -0
  5. django_cfg/apps/integrations/centrifugo/apps.py +2 -1
  6. django_cfg/apps/integrations/centrifugo/codegen/generators/typescript_thin/templates/rpc-client.ts.j2 +151 -12
  7. django_cfg/apps/integrations/centrifugo/management/commands/generate_centrifugo_clients.py +2 -2
  8. django_cfg/apps/integrations/centrifugo/services/__init__.py +6 -0
  9. django_cfg/apps/integrations/centrifugo/services/client/__init__.py +6 -1
  10. django_cfg/apps/integrations/centrifugo/services/client/client.py +1 -1
  11. django_cfg/apps/integrations/centrifugo/services/client/direct_client.py +282 -0
  12. django_cfg/apps/integrations/centrifugo/services/logging.py +47 -0
  13. django_cfg/apps/integrations/centrifugo/services/publisher.py +371 -0
  14. django_cfg/apps/integrations/centrifugo/services/token_generator.py +122 -0
  15. django_cfg/apps/integrations/centrifugo/urls.py +8 -0
  16. django_cfg/apps/integrations/centrifugo/views/__init__.py +2 -0
  17. django_cfg/apps/integrations/centrifugo/views/admin_api.py +29 -32
  18. django_cfg/apps/integrations/centrifugo/views/testing_api.py +31 -116
  19. django_cfg/apps/integrations/centrifugo/views/token_api.py +101 -0
  20. django_cfg/apps/integrations/centrifugo/views/wrapper.py +259 -0
  21. django_cfg/apps/integrations/grpc/auth/api_key_auth.py +11 -10
  22. django_cfg/apps/integrations/grpc/management/commands/compile_proto.py +105 -0
  23. django_cfg/apps/integrations/grpc/management/commands/generate_protos.py +56 -1
  24. django_cfg/apps/integrations/grpc/management/commands/rungrpc.py +315 -26
  25. django_cfg/apps/integrations/grpc/management/proto/__init__.py +3 -0
  26. django_cfg/apps/integrations/grpc/management/proto/compiler.py +194 -0
  27. django_cfg/apps/integrations/grpc/managers/grpc_request_log.py +84 -0
  28. django_cfg/apps/integrations/grpc/managers/grpc_server_status.py +126 -3
  29. django_cfg/apps/integrations/grpc/models/grpc_api_key.py +7 -1
  30. django_cfg/apps/integrations/grpc/models/grpc_server_status.py +22 -3
  31. django_cfg/apps/integrations/grpc/services/__init__.py +102 -17
  32. django_cfg/apps/integrations/grpc/services/centrifugo/__init__.py +29 -0
  33. django_cfg/apps/integrations/grpc/services/centrifugo/bridge.py +469 -0
  34. django_cfg/apps/integrations/grpc/services/centrifugo/config.py +167 -0
  35. django_cfg/apps/integrations/grpc/services/centrifugo/demo.py +626 -0
  36. django_cfg/apps/integrations/grpc/services/centrifugo/test_publish.py +229 -0
  37. django_cfg/apps/integrations/grpc/services/centrifugo/transformers.py +89 -0
  38. django_cfg/apps/integrations/grpc/services/client/__init__.py +26 -0
  39. django_cfg/apps/integrations/grpc/services/commands/IMPLEMENTATION.md +456 -0
  40. django_cfg/apps/integrations/grpc/services/commands/README.md +252 -0
  41. django_cfg/apps/integrations/grpc/services/commands/__init__.py +93 -0
  42. django_cfg/apps/integrations/grpc/services/commands/base.py +243 -0
  43. django_cfg/apps/integrations/grpc/services/commands/examples/__init__.py +22 -0
  44. django_cfg/apps/integrations/grpc/services/commands/examples/base_client.py +228 -0
  45. django_cfg/apps/integrations/grpc/services/commands/examples/client.py +272 -0
  46. django_cfg/apps/integrations/grpc/services/commands/examples/config.py +177 -0
  47. django_cfg/apps/integrations/grpc/services/commands/examples/start.py +125 -0
  48. django_cfg/apps/integrations/grpc/services/commands/examples/stop.py +101 -0
  49. django_cfg/apps/integrations/grpc/services/commands/registry.py +170 -0
  50. django_cfg/apps/integrations/grpc/services/discovery/__init__.py +39 -0
  51. django_cfg/apps/integrations/grpc/services/{discovery.py → discovery/discovery.py} +67 -54
  52. django_cfg/apps/integrations/grpc/services/{service_registry.py → discovery/registry.py} +215 -5
  53. django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/__init__.py +3 -1
  54. django_cfg/apps/integrations/grpc/services/interceptors/centrifugo.py +541 -0
  55. django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/metrics.py +3 -3
  56. django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/request_logger.py +10 -13
  57. django_cfg/apps/integrations/grpc/services/management/__init__.py +37 -0
  58. django_cfg/apps/integrations/grpc/services/monitoring/__init__.py +38 -0
  59. django_cfg/apps/integrations/grpc/services/{monitoring_service.py → monitoring/monitoring.py} +2 -2
  60. django_cfg/apps/integrations/grpc/services/{testing_service.py → monitoring/testing.py} +5 -5
  61. django_cfg/apps/integrations/grpc/services/rendering/__init__.py +27 -0
  62. django_cfg/apps/integrations/grpc/services/{chart_generator.py → rendering/charts.py} +1 -1
  63. django_cfg/apps/integrations/grpc/services/routing/__init__.py +59 -0
  64. django_cfg/apps/integrations/grpc/services/routing/config.py +76 -0
  65. django_cfg/apps/integrations/grpc/services/routing/router.py +430 -0
  66. django_cfg/apps/integrations/grpc/services/streaming/__init__.py +117 -0
  67. django_cfg/apps/integrations/grpc/services/streaming/config.py +451 -0
  68. django_cfg/apps/integrations/grpc/services/streaming/service.py +651 -0
  69. django_cfg/apps/integrations/grpc/services/streaming/types.py +367 -0
  70. django_cfg/apps/integrations/grpc/utils/SERVER_LOGGING.md +164 -0
  71. django_cfg/apps/integrations/grpc/utils/__init__.py +58 -1
  72. django_cfg/apps/integrations/grpc/utils/converters.py +565 -0
  73. django_cfg/apps/integrations/grpc/utils/handlers.py +242 -0
  74. django_cfg/apps/integrations/grpc/utils/proto_gen.py +1 -1
  75. django_cfg/apps/integrations/grpc/utils/streaming_logger.py +261 -13
  76. django_cfg/apps/integrations/grpc/views/charts.py +1 -1
  77. django_cfg/apps/integrations/grpc/views/config.py +1 -1
  78. django_cfg/apps/system/dashboard/serializers/config.py +95 -9
  79. django_cfg/apps/system/dashboard/serializers/statistics.py +9 -4
  80. django_cfg/apps/system/frontend/views.py +87 -6
  81. django_cfg/core/base/config_model.py +11 -0
  82. django_cfg/core/builders/middleware_builder.py +5 -0
  83. django_cfg/core/builders/security_builder.py +1 -0
  84. django_cfg/core/generation/integration_generators/api.py +2 -0
  85. django_cfg/management/commands/pool_status.py +153 -0
  86. django_cfg/middleware/pool_cleanup.py +261 -0
  87. django_cfg/models/api/grpc/config.py +2 -2
  88. django_cfg/models/infrastructure/database/config.py +16 -0
  89. django_cfg/models/infrastructure/database/converters.py +2 -0
  90. django_cfg/modules/django_admin/utils/html/composition.py +57 -13
  91. django_cfg/modules/django_admin/utils/html_builder.py +1 -0
  92. django_cfg/modules/django_client/core/generator/typescript/generator.py +26 -0
  93. django_cfg/modules/django_client/core/generator/typescript/hooks_generator.py +7 -1
  94. django_cfg/modules/django_client/core/generator/typescript/models_generator.py +5 -0
  95. django_cfg/modules/django_client/core/generator/typescript/schemas_generator.py +11 -0
  96. django_cfg/modules/django_client/core/generator/typescript/templates/fetchers/fetchers.ts.jinja +1 -0
  97. django_cfg/modules/django_client/core/generator/typescript/templates/fetchers/function.ts.jinja +29 -1
  98. django_cfg/modules/django_client/core/generator/typescript/templates/hooks/hooks.ts.jinja +4 -0
  99. django_cfg/modules/django_client/core/groups/manager.py +25 -18
  100. django_cfg/modules/django_client/core/ir/schema.py +15 -1
  101. django_cfg/modules/django_client/core/parser/base.py +12 -0
  102. django_cfg/modules/django_client/management/commands/generate_client.py +9 -5
  103. django_cfg/modules/django_logging/django_logger.py +58 -19
  104. django_cfg/pyproject.toml +3 -3
  105. django_cfg/static/frontend/admin.zip +0 -0
  106. django_cfg/templates/admin/index.html +0 -39
  107. django_cfg/utils/pool_monitor.py +320 -0
  108. django_cfg/utils/smart_defaults.py +233 -7
  109. {django_cfg-1.5.14.dist-info → django_cfg-1.5.29.dist-info}/METADATA +75 -5
  110. {django_cfg-1.5.14.dist-info → django_cfg-1.5.29.dist-info}/RECORD +118 -74
  111. /django_cfg/apps/integrations/grpc/services/{grpc_client.py → client/client.py} +0 -0
  112. /django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/errors.py +0 -0
  113. /django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/logging.py +0 -0
  114. /django_cfg/apps/integrations/grpc/services/{config_helper.py → management/config_helper.py} +0 -0
  115. /django_cfg/apps/integrations/grpc/services/{proto_files_manager.py → management/proto_manager.py} +0 -0
  116. {django_cfg-1.5.14.dist-info → django_cfg-1.5.29.dist-info}/WHEEL +0 -0
  117. {django_cfg-1.5.14.dist-info → django_cfg-1.5.29.dist-info}/entry_points.txt +0 -0
  118. {django_cfg-1.5.14.dist-info → django_cfg-1.5.29.dist-info}/licenses/LICENSE +0 -0
@@ -1,21 +1,53 @@
1
1
  """
2
- Django management command to run async gRPC server.
2
+ Django management command to run async gRPC server with auto-reload support.
3
3
 
4
4
  Usage:
5
+ # Development mode (with auto-reload)
5
6
  python manage.py rungrpc
7
+
8
+ # Production mode (no auto-reload)
9
+ python manage.py rungrpc --noreload
10
+
11
+ # Custom host and port
6
12
  python manage.py rungrpc --host 0.0.0.0 --port 50051
13
+
14
+ # With Centrifugo test event on startup
15
+ python manage.py rungrpc --test
16
+
17
+ # Disable specific features
18
+ python manage.py rungrpc --no-reflection --no-health-check
19
+
20
+ Auto-reload behavior:
21
+ - Enabled by default in development mode (ENV_MODE != "production")
22
+ - Disabled by default in production mode (ENV_MODE == "production")
23
+ - Use --noreload to explicitly disable auto-reload
24
+ - Server will restart automatically when Python files change
25
+
26
+ Test mode:
27
+ - Use --test to send a test Centrifugo event on server startup
28
+ - Useful for verifying Centrifugo integration is working
29
+ - Event published to: grpc#rungrpc#startup#test
7
30
  """
8
31
 
9
32
  from __future__ import annotations
10
33
 
11
34
  import asyncio
35
+ import logging
12
36
  import signal
13
37
  import sys
38
+ import threading
14
39
 
15
40
  from django.conf import settings
16
41
  from django.core.management.base import BaseCommand
42
+ from django.utils import autoreload
17
43
 
44
+ from django_cfg.core.config import get_current_config
18
45
  from django_cfg.modules.django_logging import get_logger
46
+ from django_cfg.apps.integrations.grpc.utils.streaming_logger import (
47
+ setup_streaming_logger,
48
+ log_server_start,
49
+ log_server_shutdown,
50
+ )
19
51
 
20
52
  # Check dependencies before importing grpc
21
53
  from django_cfg.apps.integrations.grpc._cfg import check_grpc_dependencies
@@ -33,16 +65,23 @@ import grpc.aio
33
65
 
34
66
  class Command(BaseCommand):
35
67
  """
36
- Run async gRPC server with auto-discovered services.
68
+ Run async gRPC server with auto-discovered services and hot-reload.
37
69
 
38
70
  Features:
39
71
  - Async server with grpc.aio
40
72
  - Auto-discovers and registers services
73
+ - Hot-reload in development mode (watches for file changes)
41
74
  - Configurable host, port
42
75
  - Health check support
43
76
  - Reflection support
44
77
  - Graceful shutdown
45
78
  - Signal handling
79
+
80
+ Hot-reload:
81
+ - Automatically enabled in development mode (ENV_MODE != "production")
82
+ - Automatically disabled in production mode (ENV_MODE == "production")
83
+ - Use --noreload to explicitly disable in development
84
+ - Works like Django's runserver - restarts on code changes
46
85
  """
47
86
 
48
87
  # Web execution metadata
@@ -50,14 +89,17 @@ class Command(BaseCommand):
50
89
  requires_input = False
51
90
  is_destructive = False
52
91
 
53
- help = "Run async gRPC server"
92
+ help = "Run async gRPC server with optional hot-reload support"
54
93
 
55
94
  def __init__(self, *args, **kwargs):
56
95
  """Initialize with self.logger and async server reference."""
57
96
  super().__init__(*args, **kwargs)
58
97
  self.logger = get_logger('rungrpc')
98
+ self.streaming_logger = None # Will be initialized when server starts
59
99
  self.server = None
60
100
  self.shutdown_event = None
101
+ self.server_status = None
102
+ self.server_config = None # Store config for re-registration
61
103
 
62
104
  def add_arguments(self, parser):
63
105
  """Add command arguments."""
@@ -88,22 +130,114 @@ class Command(BaseCommand):
88
130
  action="store_true",
89
131
  help="Enable asyncio debug mode",
90
132
  )
133
+ parser.add_argument(
134
+ "--noreload",
135
+ action="store_false",
136
+ dest="use_reloader",
137
+ help="Disable auto-reloader (default: enabled in dev mode)",
138
+ )
139
+ parser.add_argument(
140
+ "--test",
141
+ action="store_true",
142
+ help="Send test Centrifugo event on startup (for testing integration)",
143
+ )
91
144
 
92
145
  def handle(self, *args, **options):
93
- """Run async gRPC server."""
146
+ """Run async gRPC server with optional auto-reload."""
147
+ config = get_current_config()
148
+
149
+ # Determine if we should use auto-reload
150
+ # Changed default to False for stability with bidirectional streaming
151
+ use_reloader = options.get("use_reloader", False)
152
+
153
+ # Check if we're in production mode (disable reloader in production)
154
+ if config and hasattr(config, 'is_production'):
155
+ is_production = config.is_production # property, not method
156
+ else:
157
+ # Fallback to settings
158
+ env_mode = getattr(settings, "ENV_MODE", "development").lower()
159
+ is_production = env_mode == "production"
160
+
161
+ # Disable reloader in production by default
162
+ if is_production and options.get("use_reloader") is None:
163
+ use_reloader = False
164
+ self.stdout.write(
165
+ self.style.WARNING(
166
+ "Production mode - auto-reloader disabled"
167
+ )
168
+ )
169
+
94
170
  # Enable asyncio debug if requested
95
171
  if options.get("asyncio_debug"):
96
172
  asyncio.get_event_loop().set_debug(True)
97
173
  self.logger.info("Asyncio debug mode enabled")
98
174
 
99
- # Run async main
100
- asyncio.run(self._async_main(*args, **options))
175
+ # Run with or without reloader
176
+ if use_reloader:
177
+ from datetime import datetime
178
+ current_time = datetime.now().strftime('%H:%M:%S')
179
+
180
+ self.stdout.write(
181
+ self.style.SUCCESS(
182
+ f"🔄 Auto-reloader enabled - watching for file changes [{current_time}]"
183
+ )
184
+ )
185
+ self.stdout.write(
186
+ self.style.WARNING(
187
+ "⚠️ Note: Active streaming connections will be dropped on reload. Use --noreload for stable bots."
188
+ )
189
+ )
190
+
191
+ # Setup autoreload to watch project directory
192
+ from django.utils.autoreload import autoreload_started
193
+
194
+ def watch_project_files(sender, **kwargs):
195
+ """Automatically watch Django project directory for changes."""
196
+ base_dir = getattr(settings, 'BASE_DIR', None)
197
+ if base_dir:
198
+ sender.watch_dir(str(base_dir), '*.py')
199
+ self.logger.debug(f"Watching project directory: {base_dir}")
200
+
201
+ autoreload_started.connect(watch_project_files)
202
+
203
+ # Use autoreload to restart on code changes
204
+ autoreload.run_with_reloader(
205
+ lambda: asyncio.run(self._async_main(*args, **options))
206
+ )
207
+ else:
208
+ # Run directly without reloader
209
+ asyncio.run(self._async_main(*args, **options))
101
210
 
102
211
  async def _async_main(self, *args, **options):
103
212
  """Main async server loop."""
213
+ # Setup streaming logger for detailed gRPC logging
214
+ self.streaming_logger = setup_streaming_logger(
215
+ name='grpc_rungrpc',
216
+ level=logging.DEBUG,
217
+ console_level=logging.INFO
218
+ )
219
+
220
+ config = get_current_config()
221
+ use_reloader = options.get("use_reloader", True)
222
+
223
+ # Determine production mode
224
+ if config and hasattr(config, 'is_production'):
225
+ is_production = config.is_production # property, not method
226
+ else:
227
+ env_mode = getattr(settings, "ENV_MODE", "development").lower()
228
+ is_production = env_mode == "production"
229
+
230
+ # Log startup using reusable function
231
+ start_time = log_server_start(
232
+ self.streaming_logger,
233
+ server_type="gRPC Server",
234
+ mode="Production" if is_production else "Development",
235
+ hotreload_enabled=use_reloader
236
+ )
237
+
104
238
  # Import models here to avoid AppRegistryNotReady
105
239
  from django_cfg.apps.integrations.grpc.models import GRPCServerStatus
106
- from django_cfg.apps.integrations.grpc.services.config_helper import (
240
+ from django_cfg.apps.integrations.grpc.services.management.config_helper import (
107
241
  get_grpc_server_config,
108
242
  )
109
243
 
@@ -182,14 +316,23 @@ class Command(BaseCommand):
182
316
  import os
183
317
  from django_cfg.apps.integrations.grpc.services import ServiceDiscovery
184
318
 
319
+ # Store config for re-registration
320
+ self.server_config = {
321
+ 'host': host,
322
+ 'port': port,
323
+ 'pid': os.getpid(),
324
+ 'max_workers': 0,
325
+ 'enable_reflection': enable_reflection,
326
+ 'enable_health_check': enable_health_check,
327
+ }
328
+
185
329
  # Get registered services metadata (run in thread to avoid blocking)
186
330
  discovery = ServiceDiscovery()
187
331
  services_metadata = await asyncio.to_thread(
188
332
  discovery.get_registered_services
189
333
  )
190
334
 
191
- server_status = await asyncio.to_thread(
192
- GRPCServerStatus.objects.start_server,
335
+ server_status = await GRPCServerStatus.objects.astart_server(
193
336
  host=host,
194
337
  port=port,
195
338
  pid=os.getpid(),
@@ -200,11 +343,13 @@ class Command(BaseCommand):
200
343
 
201
344
  # Store registered services in database
202
345
  server_status.registered_services = services_metadata
203
- await asyncio.to_thread(
204
- server_status.save,
346
+ await server_status.asave(
205
347
  update_fields=["registered_services"]
206
348
  )
207
349
 
350
+ # Store in instance for heartbeat
351
+ self.server_status = server_status
352
+
208
353
  except Exception as e:
209
354
  self.logger.warning(f"Could not start server status tracking: {e}")
210
355
 
@@ -214,10 +359,18 @@ class Command(BaseCommand):
214
359
  # Mark server as running
215
360
  if server_status:
216
361
  try:
217
- await asyncio.to_thread(server_status.mark_running)
362
+ await server_status.amark_running()
218
363
  except Exception as e:
219
364
  self.logger.warning(f"Could not mark server as running: {e}")
220
365
 
366
+ # Start heartbeat background task
367
+ heartbeat_task = None
368
+ if server_status:
369
+ heartbeat_task = asyncio.create_task(
370
+ self._heartbeat_loop(interval=30)
371
+ )
372
+ self.logger.info("Started heartbeat background task (30s interval)")
373
+
221
374
  # Display gRPC-specific startup info
222
375
  try:
223
376
  from django_cfg.core.integration.display import GRPCDisplayManager
@@ -250,13 +403,75 @@ class Command(BaseCommand):
250
403
 
251
404
  # Keep server running
252
405
  self.stdout.write(self.style.SUCCESS("\n✅ Async gRPC server is running..."))
406
+
407
+ # Show reloader status
408
+ if use_reloader and not is_production:
409
+ self.stdout.write(
410
+ self.style.SUCCESS(
411
+ "🔄 Auto-reloader active - server will restart on code changes"
412
+ )
413
+ )
414
+
253
415
  self.stdout.write("Press CTRL+C to stop\n")
254
416
 
417
+ # Log server ready
418
+ self.streaming_logger.info("✅ Server ready and accepting connections")
419
+ if use_reloader:
420
+ self.streaming_logger.info("🔄 Watching for file changes...")
421
+
422
+ # Send test Centrifugo event if --test flag is set
423
+ if options.get("test"):
424
+ self.streaming_logger.info("🧪 Sending test Centrifugo event...")
425
+ try:
426
+ from django_cfg.apps.integrations.grpc.services.centrifugo.demo import send_demo_event
427
+
428
+ test_result = await send_demo_event(
429
+ channel="grpc#rungrpc#startup#test",
430
+ metadata={
431
+ "source": "rungrpc",
432
+ "action": "startup_test",
433
+ "mode": "Development" if not is_production else "Production",
434
+ "host": host,
435
+ "port": port,
436
+ }
437
+ )
438
+
439
+ if test_result:
440
+ self.streaming_logger.info("✅ Test Centrifugo event sent successfully")
441
+ self.stdout.write(self.style.SUCCESS("🧪 Test event published to Centrifugo"))
442
+ else:
443
+ self.streaming_logger.warning("⚠️ Test Centrifugo event failed")
444
+ self.stdout.write(self.style.WARNING("⚠️ Test event failed (check Centrifugo config)"))
445
+
446
+ except Exception as e:
447
+ self.streaming_logger.error(f"❌ Failed to send test event: {e}")
448
+ self.stdout.write(
449
+ self.style.ERROR(f"❌ Test event error: {e}")
450
+ )
451
+
452
+ shutdown_reason = "Unknown"
255
453
  try:
256
454
  await self.server.wait_for_termination()
455
+ shutdown_reason = "Normal termination"
257
456
  except KeyboardInterrupt:
258
- # Signal handler will take care of graceful shutdown
457
+ shutdown_reason = "Keyboard interrupt"
259
458
  pass
459
+ finally:
460
+ # Log shutdown using reusable function
461
+ log_server_shutdown(
462
+ self.streaming_logger,
463
+ start_time,
464
+ server_type="gRPC Server",
465
+ reason=shutdown_reason
466
+ )
467
+
468
+ # Cancel heartbeat task
469
+ if heartbeat_task and not heartbeat_task.done():
470
+ heartbeat_task.cancel()
471
+ try:
472
+ await heartbeat_task
473
+ except asyncio.CancelledError:
474
+ pass
260
475
 
261
476
  def _build_grpc_options(self, config: dict) -> list:
262
477
  """
@@ -462,6 +677,78 @@ class Command(BaseCommand):
462
677
  )
463
678
  return 0
464
679
 
680
+ async def _heartbeat_loop(self, interval: int = 30):
681
+ """
682
+ Periodically update server heartbeat with auto-recovery.
683
+
684
+ If server record is deleted from database, automatically re-registers
685
+ the server to maintain monitoring continuity.
686
+
687
+ Args:
688
+ interval: Heartbeat interval in seconds (default: 30)
689
+ """
690
+ from django_cfg.apps.integrations.grpc.models import GRPCServerStatus
691
+
692
+ try:
693
+ while True:
694
+ await asyncio.sleep(interval)
695
+
696
+ if not self.server_status or not self.server_config:
697
+ self.logger.warning("No server status or config available")
698
+ continue
699
+
700
+ try:
701
+ # Check if record still exists (Django 5.2: Native async ORM)
702
+ record_exists = await GRPCServerStatus.objects.filter(
703
+ id=self.server_status.id
704
+ ).aexists()
705
+
706
+ if not record_exists:
707
+ # Record was deleted - re-register server
708
+ self.logger.warning(
709
+ "Server record was deleted from database, "
710
+ "re-registering..."
711
+ )
712
+
713
+ # Get services metadata for re-registration
714
+ from django_cfg.apps.integrations.grpc.services import ServiceDiscovery
715
+ discovery = ServiceDiscovery()
716
+ services_metadata = await asyncio.to_thread(
717
+ discovery.get_registered_services
718
+ )
719
+
720
+ # Re-register server (Django 5.2: Native async ORM)
721
+ new_server_status = await GRPCServerStatus.objects.astart_server(
722
+ **self.server_config
723
+ )
724
+
725
+ # Store registered services
726
+ new_server_status.registered_services = services_metadata
727
+ await new_server_status.asave(
728
+ update_fields=["registered_services"]
729
+ )
730
+
731
+ # Mark as running (Django 5.2: Native async ORM)
732
+ await new_server_status.amark_running()
733
+
734
+ # Update reference
735
+ self.server_status = new_server_status
736
+
737
+ self.logger.warning(
738
+ f"✅ Successfully re-registered server (ID: {new_server_status.id})"
739
+ )
740
+ else:
741
+ # Record exists - just update heartbeat (Django 5.2: Native async ORM)
742
+ await self.server_status.amark_running()
743
+ self.logger.debug(f"Heartbeat updated (interval: {interval}s)")
744
+
745
+ except Exception as e:
746
+ self.logger.warning(f"Failed to update heartbeat: {e}")
747
+
748
+ except asyncio.CancelledError:
749
+ self.logger.info("Heartbeat task cancelled")
750
+ raise
751
+
465
752
  def _setup_signal_handlers_async(self, server, server_status=None):
466
753
  """
467
754
  Setup signal handlers for graceful async server shutdown.
@@ -469,7 +756,17 @@ class Command(BaseCommand):
469
756
  Args:
470
757
  server: Async gRPC server instance
471
758
  server_status: GRPCServerStatus instance (optional)
759
+
760
+ Note:
761
+ Signal handlers can only be set in the main thread.
762
+ When running with autoreload, we're in a separate thread,
763
+ so we skip signal handler setup.
472
764
  """
765
+ # Check if we're in the main thread
766
+ if threading.current_thread() is not threading.main_thread():
767
+ # In autoreload mode, Django handles signals - we don't need to set them up
768
+ return
769
+
473
770
  # Flag to prevent multiple shutdown attempts
474
771
  shutdown_initiated = {'value': False}
475
772
 
@@ -481,17 +778,10 @@ class Command(BaseCommand):
481
778
 
482
779
  self.stdout.write("\n🛑 Shutting down gracefully...")
483
780
 
484
- # Mark server as stopping
781
+ # Mark server as stopping (sync context - signal handlers are sync)
485
782
  if server_status:
486
783
  try:
487
- import django
488
- if django.VERSION >= (3, 0):
489
- from asgiref.sync import sync_to_async
490
- # Run in sync context
491
- try:
492
- server_status.mark_stopping()
493
- except:
494
- pass
784
+ server_status.mark_stopping()
495
785
  except Exception as e:
496
786
  self.logger.warning(f"Could not mark server as stopping: {e}")
497
787
 
@@ -503,12 +793,11 @@ class Command(BaseCommand):
503
793
  except Exception as e:
504
794
  self.logger.error(f"Error stopping server: {e}")
505
795
 
506
- # Mark server as stopped (async-safe)
796
+ # Mark server as stopped (async-safe, Django 5.2: Native async ORM)
507
797
  if server_status:
508
798
  try:
509
- from asgiref.sync import sync_to_async
510
- # Wrap sync DB operation in sync_to_async
511
- asyncio.create_task(sync_to_async(server_status.mark_stopped)())
799
+ # Use native async method
800
+ asyncio.create_task(server_status.amark_stopped())
512
801
  except Exception as e:
513
802
  self.logger.warning(f"Could not mark server as stopped: {e}")
514
803
 
@@ -0,0 +1,3 @@
1
+ """
2
+ Proto utilities for django-cfg gRPC integration.
3
+ """
@@ -0,0 +1,194 @@
1
+ """
2
+ Proto compiler utilities.
3
+ Shared functionality for compiling .proto files to Python.
4
+ """
5
+
6
+ import logging
7
+ import re
8
+ import subprocess
9
+ import sys
10
+ from pathlib import Path
11
+ from typing import List, Optional
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class ProtoCompiler:
17
+ """Compiles .proto files to Python using grpc_tools.protoc."""
18
+
19
+ def __init__(
20
+ self,
21
+ output_dir: Optional[Path] = None,
22
+ proto_import_path: Optional[Path] = None,
23
+ fix_imports: bool = True,
24
+ verbose: bool = True,
25
+ ):
26
+ """
27
+ Initialize proto compiler.
28
+
29
+ Args:
30
+ output_dir: Output directory for generated files (default: same as proto file)
31
+ proto_import_path: Additional proto import path (passed to protoc -I flag)
32
+ fix_imports: Fix imports in generated _grpc.py files (default: True)
33
+ verbose: Print compilation progress (default: True)
34
+ """
35
+ self.output_dir = output_dir
36
+ self.proto_import_path = proto_import_path
37
+ self.fix_imports = fix_imports
38
+ self.verbose = verbose
39
+
40
+ def compile_file(self, proto_file: Path) -> bool:
41
+ """
42
+ Compile a single .proto file.
43
+
44
+ Args:
45
+ proto_file: Path to .proto file
46
+
47
+ Returns:
48
+ True if compilation succeeded, False otherwise
49
+ """
50
+ if self.verbose:
51
+ logger.info(f"📦 Compiling: {proto_file}")
52
+
53
+ # Determine output directory
54
+ output_dir = self.output_dir or proto_file.parent
55
+
56
+ # Determine proto import path
57
+ proto_import_path = self.proto_import_path or proto_file.parent
58
+
59
+ # Ensure output directory exists
60
+ output_dir.mkdir(parents=True, exist_ok=True)
61
+
62
+ # Build protoc command
63
+ cmd = [
64
+ sys.executable,
65
+ "-m",
66
+ "grpc_tools.protoc",
67
+ f"-I{proto_import_path}",
68
+ f"--python_out={output_dir}",
69
+ f"--grpc_python_out={output_dir}",
70
+ str(proto_file),
71
+ ]
72
+
73
+ # Run protoc
74
+ try:
75
+ result = subprocess.run(
76
+ cmd,
77
+ capture_output=True,
78
+ text=True,
79
+ check=True,
80
+ )
81
+
82
+ if result.stdout and self.verbose:
83
+ logger.info(f" {result.stdout}")
84
+
85
+ if self.verbose:
86
+ logger.info(f" ✅ Compiled successfully")
87
+
88
+ # Fix imports if requested
89
+ if self.fix_imports:
90
+ self._fix_imports(proto_file, output_dir)
91
+
92
+ return True
93
+
94
+ except subprocess.CalledProcessError as e:
95
+ logger.error(f" ❌ Compilation failed")
96
+ logger.error(f" Error: {e.stderr}")
97
+ return False
98
+
99
+ def compile_directory(
100
+ self,
101
+ proto_path: Path,
102
+ recursive: bool = False,
103
+ ) -> tuple[int, int]:
104
+ """
105
+ Compile all .proto files in a directory.
106
+
107
+ Args:
108
+ proto_path: Directory containing .proto files
109
+ recursive: Recursively compile all .proto files
110
+
111
+ Returns:
112
+ Tuple of (success_count, failure_count)
113
+ """
114
+ # Collect proto files
115
+ proto_files = self._collect_proto_files(proto_path, recursive)
116
+
117
+ if not proto_files:
118
+ logger.warning(f"No .proto files found in: {proto_path}")
119
+ return 0, 0
120
+
121
+ if self.verbose:
122
+ logger.info(f"🔧 Compiling {len(proto_files)} proto file(s)...")
123
+
124
+ # Compile each proto file
125
+ success_count = 0
126
+ failure_count = 0
127
+
128
+ for proto_file in proto_files:
129
+ if self.compile_file(proto_file):
130
+ success_count += 1
131
+ else:
132
+ failure_count += 1
133
+
134
+ return success_count, failure_count
135
+
136
+ def _collect_proto_files(self, path: Path, recursive: bool) -> List[Path]:
137
+ """Collect all .proto files from path."""
138
+ if path.is_file():
139
+ if path.suffix == ".proto":
140
+ return [path]
141
+ else:
142
+ raise ValueError(f"File is not a .proto file: {path}")
143
+
144
+ # Directory
145
+ if recursive:
146
+ return list(path.rglob("*.proto"))
147
+ else:
148
+ return list(path.glob("*.proto"))
149
+
150
+ def _fix_imports(self, proto_file: Path, output_dir: Path):
151
+ """
152
+ Fix imports in generated _grpc.py files.
153
+
154
+ Changes: import foo_pb2 as foo__pb2
155
+ To: from . import foo_pb2 as foo__pb2
156
+ """
157
+ # Find generated _grpc.py file
158
+ grpc_file = output_dir / f"{proto_file.stem}_pb2_grpc.py"
159
+
160
+ if not grpc_file.exists():
161
+ if self.verbose:
162
+ logger.warning(f" ⚠️ Skipping import fix: {grpc_file.name} not found")
163
+ return
164
+
165
+ if self.verbose:
166
+ logger.info(f" 🔧 Fixing imports in {grpc_file.name}...")
167
+
168
+ # Read file
169
+ content = grpc_file.read_text()
170
+
171
+ # Pattern to match: import xxx_pb2 as yyy
172
+ # But NOT: from xxx import ...
173
+ pattern = r"^import (\w+_pb2) as (\w+)$"
174
+
175
+ # Replace with: from . import xxx_pb2 as yyy
176
+ def replace_func(match):
177
+ module = match.group(1)
178
+ alias = match.group(2)
179
+ return f"from . import {module} as {alias}"
180
+
181
+ # Apply replacement
182
+ new_content = re.sub(pattern, replace_func, content, flags=re.MULTILINE)
183
+
184
+ # Count changes
185
+ changes = content.count("\nimport ") - new_content.count("\nimport ")
186
+
187
+ if changes > 0:
188
+ # Write back
189
+ grpc_file.write_text(new_content)
190
+ if self.verbose:
191
+ logger.info(f" ✅ Fixed {changes} import(s) in {grpc_file.name}")
192
+ else:
193
+ if self.verbose:
194
+ logger.info(f" ℹ️ No imports to fix in {grpc_file.name}")