django-cfg 1.5.14__py3-none-any.whl → 1.5.20__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.
- django_cfg/__init__.py +1 -1
- django_cfg/apps/business/accounts/serializers/profile.py +42 -0
- django_cfg/apps/business/support/serializers.py +3 -2
- django_cfg/apps/integrations/centrifugo/apps.py +2 -1
- django_cfg/apps/integrations/centrifugo/codegen/generators/typescript_thin/templates/rpc-client.ts.j2 +151 -12
- django_cfg/apps/integrations/centrifugo/management/commands/generate_centrifugo_clients.py +2 -2
- django_cfg/apps/integrations/centrifugo/services/__init__.py +6 -0
- django_cfg/apps/integrations/centrifugo/services/client/__init__.py +6 -1
- django_cfg/apps/integrations/centrifugo/services/client/direct_client.py +282 -0
- django_cfg/apps/integrations/centrifugo/services/publisher.py +371 -0
- django_cfg/apps/integrations/centrifugo/services/token_generator.py +122 -0
- django_cfg/apps/integrations/centrifugo/urls.py +8 -0
- django_cfg/apps/integrations/centrifugo/views/__init__.py +2 -0
- django_cfg/apps/integrations/centrifugo/views/testing_api.py +0 -79
- django_cfg/apps/integrations/centrifugo/views/token_api.py +101 -0
- django_cfg/apps/integrations/centrifugo/views/wrapper.py +257 -0
- django_cfg/apps/integrations/grpc/centrifugo/__init__.py +29 -0
- django_cfg/apps/integrations/grpc/centrifugo/bridge.py +277 -0
- django_cfg/apps/integrations/grpc/centrifugo/config.py +167 -0
- django_cfg/apps/integrations/grpc/centrifugo/demo.py +626 -0
- django_cfg/apps/integrations/grpc/centrifugo/test_publish.py +229 -0
- django_cfg/apps/integrations/grpc/centrifugo/transformers.py +89 -0
- django_cfg/apps/integrations/grpc/interceptors/__init__.py +3 -1
- django_cfg/apps/integrations/grpc/interceptors/centrifugo.py +541 -0
- django_cfg/apps/integrations/grpc/management/commands/compile_proto.py +105 -0
- django_cfg/apps/integrations/grpc/management/commands/generate_protos.py +55 -0
- django_cfg/apps/integrations/grpc/management/commands/rungrpc.py +311 -7
- django_cfg/apps/integrations/grpc/management/proto/__init__.py +3 -0
- django_cfg/apps/integrations/grpc/management/proto/compiler.py +194 -0
- django_cfg/apps/integrations/grpc/services/discovery.py +7 -1
- django_cfg/apps/integrations/grpc/utils/SERVER_LOGGING.md +164 -0
- django_cfg/apps/integrations/grpc/utils/streaming_logger.py +206 -5
- django_cfg/apps/system/dashboard/serializers/config.py +95 -9
- django_cfg/apps/system/dashboard/serializers/statistics.py +9 -4
- django_cfg/apps/system/frontend/views.py +87 -6
- django_cfg/core/builders/security_builder.py +1 -0
- django_cfg/core/generation/integration_generators/api.py +2 -0
- django_cfg/modules/django_client/core/generator/typescript/generator.py +26 -0
- django_cfg/modules/django_client/core/generator/typescript/hooks_generator.py +7 -1
- django_cfg/modules/django_client/core/generator/typescript/models_generator.py +5 -0
- django_cfg/modules/django_client/core/generator/typescript/schemas_generator.py +11 -0
- django_cfg/modules/django_client/core/generator/typescript/templates/fetchers/fetchers.ts.jinja +1 -0
- django_cfg/modules/django_client/core/generator/typescript/templates/fetchers/function.ts.jinja +29 -1
- django_cfg/modules/django_client/core/generator/typescript/templates/hooks/hooks.ts.jinja +4 -0
- django_cfg/modules/django_client/core/ir/schema.py +15 -1
- django_cfg/modules/django_client/core/parser/base.py +12 -0
- django_cfg/pyproject.toml +1 -1
- django_cfg/static/frontend/admin.zip +0 -0
- {django_cfg-1.5.14.dist-info → django_cfg-1.5.20.dist-info}/METADATA +1 -1
- {django_cfg-1.5.14.dist-info → django_cfg-1.5.20.dist-info}/RECORD +53 -37
- {django_cfg-1.5.14.dist-info → django_cfg-1.5.20.dist-info}/WHEEL +0 -0
- {django_cfg-1.5.14.dist-info → django_cfg-1.5.20.dist-info}/entry_points.txt +0 -0
- {django_cfg-1.5.14.dist-info → django_cfg-1.5.20.dist-info}/licenses/LICENSE +0 -0
|
@@ -36,6 +36,17 @@ class Command(AdminCommand):
|
|
|
36
36
|
default=None,
|
|
37
37
|
help="Custom output directory (overrides config)",
|
|
38
38
|
)
|
|
39
|
+
parser.add_argument(
|
|
40
|
+
"--compile",
|
|
41
|
+
action="store_true",
|
|
42
|
+
help="Automatically compile generated .proto files to Python",
|
|
43
|
+
)
|
|
44
|
+
parser.add_argument(
|
|
45
|
+
"--no-fix-imports",
|
|
46
|
+
action="store_false",
|
|
47
|
+
dest="fix_imports",
|
|
48
|
+
help="Disable import fixing when compiling (only with --compile)",
|
|
49
|
+
)
|
|
39
50
|
|
|
40
51
|
def handle(self, *args, **options):
|
|
41
52
|
from django_cfg.apps.integrations.grpc.utils.proto_gen import generate_proto_for_app
|
|
@@ -123,8 +134,52 @@ class Command(AdminCommand):
|
|
|
123
134
|
f"📂 Output directory: {output_location}"
|
|
124
135
|
)
|
|
125
136
|
)
|
|
137
|
+
|
|
138
|
+
# Compile proto files if requested
|
|
139
|
+
if options["compile"]:
|
|
140
|
+
self.stdout.write("\n" + "=" * 70)
|
|
141
|
+
self.stdout.write(self.style.SUCCESS("🔧 Compiling generated proto files..."))
|
|
142
|
+
self._compile_protos(output_location, options.get("fix_imports", True))
|
|
126
143
|
else:
|
|
127
144
|
self.stdout.write(
|
|
128
145
|
self.style.WARNING("⚠️ No proto files generated")
|
|
129
146
|
)
|
|
130
147
|
self.stdout.write("=" * 70)
|
|
148
|
+
|
|
149
|
+
def _compile_protos(self, output_dir, fix_imports: bool):
|
|
150
|
+
"""Compile all .proto files in output directory."""
|
|
151
|
+
from pathlib import Path
|
|
152
|
+
from django_cfg.apps.integrations.grpc.management.proto.compiler import ProtoCompiler
|
|
153
|
+
|
|
154
|
+
output_path = Path(output_dir)
|
|
155
|
+
if not output_path.exists():
|
|
156
|
+
self.stdout.write(self.style.ERROR(f" ❌ Output directory not found: {output_dir}"))
|
|
157
|
+
return
|
|
158
|
+
|
|
159
|
+
# Create compiler
|
|
160
|
+
compiler = ProtoCompiler(
|
|
161
|
+
output_dir=output_path / "generated", # Compile to generated/ subdirectory
|
|
162
|
+
proto_import_path=output_path,
|
|
163
|
+
fix_imports=fix_imports,
|
|
164
|
+
verbose=True,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
# Compile all proto files
|
|
168
|
+
success_count, failure_count = compiler.compile_directory(
|
|
169
|
+
output_path,
|
|
170
|
+
recursive=False,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
if failure_count > 0:
|
|
174
|
+
self.stdout.write(
|
|
175
|
+
self.style.ERROR(
|
|
176
|
+
f" ❌ Failed to compile {failure_count} proto file(s) "
|
|
177
|
+
f"({success_count} succeeded)"
|
|
178
|
+
)
|
|
179
|
+
)
|
|
180
|
+
else:
|
|
181
|
+
self.stdout.write(
|
|
182
|
+
self.style.SUCCESS(
|
|
183
|
+
f" ✅ Compiled {success_count} proto file(s) successfully"
|
|
184
|
+
)
|
|
185
|
+
)
|
|
@@ -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,19 +130,111 @@ 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
|
|
100
|
-
|
|
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
240
|
from django_cfg.apps.integrations.grpc.services.config_helper import (
|
|
@@ -182,6 +316,16 @@ 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(
|
|
@@ -205,6 +349,9 @@ class Command(BaseCommand):
|
|
|
205
349
|
update_fields=["registered_services"]
|
|
206
350
|
)
|
|
207
351
|
|
|
352
|
+
# Store in instance for heartbeat
|
|
353
|
+
self.server_status = server_status
|
|
354
|
+
|
|
208
355
|
except Exception as e:
|
|
209
356
|
self.logger.warning(f"Could not start server status tracking: {e}")
|
|
210
357
|
|
|
@@ -218,6 +365,14 @@ class Command(BaseCommand):
|
|
|
218
365
|
except Exception as e:
|
|
219
366
|
self.logger.warning(f"Could not mark server as running: {e}")
|
|
220
367
|
|
|
368
|
+
# Start heartbeat background task
|
|
369
|
+
heartbeat_task = None
|
|
370
|
+
if server_status:
|
|
371
|
+
heartbeat_task = asyncio.create_task(
|
|
372
|
+
self._heartbeat_loop(interval=30)
|
|
373
|
+
)
|
|
374
|
+
self.logger.info("Started heartbeat background task (30s interval)")
|
|
375
|
+
|
|
221
376
|
# Display gRPC-specific startup info
|
|
222
377
|
try:
|
|
223
378
|
from django_cfg.core.integration.display import GRPCDisplayManager
|
|
@@ -250,13 +405,75 @@ class Command(BaseCommand):
|
|
|
250
405
|
|
|
251
406
|
# Keep server running
|
|
252
407
|
self.stdout.write(self.style.SUCCESS("\n✅ Async gRPC server is running..."))
|
|
408
|
+
|
|
409
|
+
# Show reloader status
|
|
410
|
+
if use_reloader and not is_production:
|
|
411
|
+
self.stdout.write(
|
|
412
|
+
self.style.SUCCESS(
|
|
413
|
+
"🔄 Auto-reloader active - server will restart on code changes"
|
|
414
|
+
)
|
|
415
|
+
)
|
|
416
|
+
|
|
253
417
|
self.stdout.write("Press CTRL+C to stop\n")
|
|
254
418
|
|
|
419
|
+
# Log server ready
|
|
420
|
+
self.streaming_logger.info("✅ Server ready and accepting connections")
|
|
421
|
+
if use_reloader:
|
|
422
|
+
self.streaming_logger.info("🔄 Watching for file changes...")
|
|
423
|
+
|
|
424
|
+
# Send test Centrifugo event if --test flag is set
|
|
425
|
+
if options.get("test"):
|
|
426
|
+
self.streaming_logger.info("🧪 Sending test Centrifugo event...")
|
|
427
|
+
try:
|
|
428
|
+
from django_cfg.apps.integrations.grpc.centrifugo.demo import send_demo_event
|
|
429
|
+
|
|
430
|
+
test_result = await send_demo_event(
|
|
431
|
+
channel="grpc#rungrpc#startup#test",
|
|
432
|
+
metadata={
|
|
433
|
+
"source": "rungrpc",
|
|
434
|
+
"action": "startup_test",
|
|
435
|
+
"mode": "Development" if not is_production else "Production",
|
|
436
|
+
"host": host,
|
|
437
|
+
"port": port,
|
|
438
|
+
}
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
if test_result:
|
|
442
|
+
self.streaming_logger.info("✅ Test Centrifugo event sent successfully")
|
|
443
|
+
self.stdout.write(self.style.SUCCESS("🧪 Test event published to Centrifugo"))
|
|
444
|
+
else:
|
|
445
|
+
self.streaming_logger.warning("⚠️ Test Centrifugo event failed")
|
|
446
|
+
self.stdout.write(self.style.WARNING("⚠️ Test event failed (check Centrifugo config)"))
|
|
447
|
+
|
|
448
|
+
except Exception as e:
|
|
449
|
+
self.streaming_logger.error(f"❌ Failed to send test event: {e}")
|
|
450
|
+
self.stdout.write(
|
|
451
|
+
self.style.ERROR(f"❌ Test event error: {e}")
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
shutdown_reason = "Unknown"
|
|
255
455
|
try:
|
|
256
456
|
await self.server.wait_for_termination()
|
|
457
|
+
shutdown_reason = "Normal termination"
|
|
257
458
|
except KeyboardInterrupt:
|
|
258
|
-
|
|
459
|
+
shutdown_reason = "Keyboard interrupt"
|
|
259
460
|
pass
|
|
461
|
+
finally:
|
|
462
|
+
# Log shutdown using reusable function
|
|
463
|
+
log_server_shutdown(
|
|
464
|
+
self.streaming_logger,
|
|
465
|
+
start_time,
|
|
466
|
+
server_type="gRPC Server",
|
|
467
|
+
reason=shutdown_reason
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
# Cancel heartbeat task
|
|
471
|
+
if heartbeat_task and not heartbeat_task.done():
|
|
472
|
+
heartbeat_task.cancel()
|
|
473
|
+
try:
|
|
474
|
+
await heartbeat_task
|
|
475
|
+
except asyncio.CancelledError:
|
|
476
|
+
pass
|
|
260
477
|
|
|
261
478
|
def _build_grpc_options(self, config: dict) -> list:
|
|
262
479
|
"""
|
|
@@ -462,6 +679,83 @@ class Command(BaseCommand):
|
|
|
462
679
|
)
|
|
463
680
|
return 0
|
|
464
681
|
|
|
682
|
+
async def _heartbeat_loop(self, interval: int = 30):
|
|
683
|
+
"""
|
|
684
|
+
Periodically update server heartbeat with auto-recovery.
|
|
685
|
+
|
|
686
|
+
If server record is deleted from database, automatically re-registers
|
|
687
|
+
the server to maintain monitoring continuity.
|
|
688
|
+
|
|
689
|
+
Args:
|
|
690
|
+
interval: Heartbeat interval in seconds (default: 30)
|
|
691
|
+
"""
|
|
692
|
+
from django_cfg.apps.integrations.grpc.models import GRPCServerStatus
|
|
693
|
+
from asgiref.sync import sync_to_async
|
|
694
|
+
|
|
695
|
+
try:
|
|
696
|
+
while True:
|
|
697
|
+
await asyncio.sleep(interval)
|
|
698
|
+
|
|
699
|
+
if not self.server_status or not self.server_config:
|
|
700
|
+
self.logger.warning("No server status or config available")
|
|
701
|
+
continue
|
|
702
|
+
|
|
703
|
+
try:
|
|
704
|
+
# Check if record still exists
|
|
705
|
+
record_exists = await sync_to_async(
|
|
706
|
+
GRPCServerStatus.objects.filter(
|
|
707
|
+
id=self.server_status.id
|
|
708
|
+
).exists
|
|
709
|
+
)()
|
|
710
|
+
|
|
711
|
+
if not record_exists:
|
|
712
|
+
# Record was deleted - re-register server
|
|
713
|
+
self.logger.warning(
|
|
714
|
+
"Server record was deleted from database, "
|
|
715
|
+
"re-registering..."
|
|
716
|
+
)
|
|
717
|
+
|
|
718
|
+
# Get services metadata for re-registration
|
|
719
|
+
from django_cfg.apps.integrations.grpc.services import ServiceDiscovery
|
|
720
|
+
discovery = ServiceDiscovery()
|
|
721
|
+
services_metadata = await asyncio.to_thread(
|
|
722
|
+
discovery.get_registered_services
|
|
723
|
+
)
|
|
724
|
+
|
|
725
|
+
# Re-register server
|
|
726
|
+
new_server_status = await asyncio.to_thread(
|
|
727
|
+
GRPCServerStatus.objects.start_server,
|
|
728
|
+
**self.server_config
|
|
729
|
+
)
|
|
730
|
+
|
|
731
|
+
# Store registered services
|
|
732
|
+
new_server_status.registered_services = services_metadata
|
|
733
|
+
await asyncio.to_thread(
|
|
734
|
+
new_server_status.save,
|
|
735
|
+
update_fields=["registered_services"]
|
|
736
|
+
)
|
|
737
|
+
|
|
738
|
+
# Mark as running
|
|
739
|
+
await asyncio.to_thread(new_server_status.mark_running)
|
|
740
|
+
|
|
741
|
+
# Update reference
|
|
742
|
+
self.server_status = new_server_status
|
|
743
|
+
|
|
744
|
+
self.logger.warning(
|
|
745
|
+
f"✅ Successfully re-registered server (ID: {new_server_status.id})"
|
|
746
|
+
)
|
|
747
|
+
else:
|
|
748
|
+
# Record exists - just update heartbeat
|
|
749
|
+
await asyncio.to_thread(self.server_status.mark_running)
|
|
750
|
+
self.logger.debug(f"Heartbeat updated (interval: {interval}s)")
|
|
751
|
+
|
|
752
|
+
except Exception as e:
|
|
753
|
+
self.logger.warning(f"Failed to update heartbeat: {e}")
|
|
754
|
+
|
|
755
|
+
except asyncio.CancelledError:
|
|
756
|
+
self.logger.info("Heartbeat task cancelled")
|
|
757
|
+
raise
|
|
758
|
+
|
|
465
759
|
def _setup_signal_handlers_async(self, server, server_status=None):
|
|
466
760
|
"""
|
|
467
761
|
Setup signal handlers for graceful async server shutdown.
|
|
@@ -469,7 +763,17 @@ class Command(BaseCommand):
|
|
|
469
763
|
Args:
|
|
470
764
|
server: Async gRPC server instance
|
|
471
765
|
server_status: GRPCServerStatus instance (optional)
|
|
766
|
+
|
|
767
|
+
Note:
|
|
768
|
+
Signal handlers can only be set in the main thread.
|
|
769
|
+
When running with autoreload, we're in a separate thread,
|
|
770
|
+
so we skip signal handler setup.
|
|
472
771
|
"""
|
|
772
|
+
# Check if we're in the main thread
|
|
773
|
+
if threading.current_thread() is not threading.main_thread():
|
|
774
|
+
# In autoreload mode, Django handles signals - we don't need to set them up
|
|
775
|
+
return
|
|
776
|
+
|
|
473
777
|
# Flag to prevent multiple shutdown attempts
|
|
474
778
|
shutdown_initiated = {'value': False}
|
|
475
779
|
|
|
@@ -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}")
|