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.
- 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/__init__.py +2 -0
- 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/client.py +1 -1
- django_cfg/apps/integrations/centrifugo/services/client/direct_client.py +282 -0
- django_cfg/apps/integrations/centrifugo/services/logging.py +47 -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/admin_api.py +29 -32
- django_cfg/apps/integrations/centrifugo/views/testing_api.py +31 -116
- django_cfg/apps/integrations/centrifugo/views/token_api.py +101 -0
- django_cfg/apps/integrations/centrifugo/views/wrapper.py +259 -0
- django_cfg/apps/integrations/grpc/auth/api_key_auth.py +11 -10
- django_cfg/apps/integrations/grpc/management/commands/compile_proto.py +105 -0
- django_cfg/apps/integrations/grpc/management/commands/generate_protos.py +56 -1
- django_cfg/apps/integrations/grpc/management/commands/rungrpc.py +315 -26
- 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/managers/grpc_request_log.py +84 -0
- django_cfg/apps/integrations/grpc/managers/grpc_server_status.py +126 -3
- django_cfg/apps/integrations/grpc/models/grpc_api_key.py +7 -1
- django_cfg/apps/integrations/grpc/models/grpc_server_status.py +22 -3
- django_cfg/apps/integrations/grpc/services/__init__.py +102 -17
- django_cfg/apps/integrations/grpc/services/centrifugo/__init__.py +29 -0
- django_cfg/apps/integrations/grpc/services/centrifugo/bridge.py +469 -0
- django_cfg/apps/integrations/grpc/services/centrifugo/config.py +167 -0
- django_cfg/apps/integrations/grpc/services/centrifugo/demo.py +626 -0
- django_cfg/apps/integrations/grpc/services/centrifugo/test_publish.py +229 -0
- django_cfg/apps/integrations/grpc/services/centrifugo/transformers.py +89 -0
- django_cfg/apps/integrations/grpc/services/client/__init__.py +26 -0
- django_cfg/apps/integrations/grpc/services/commands/IMPLEMENTATION.md +456 -0
- django_cfg/apps/integrations/grpc/services/commands/README.md +252 -0
- django_cfg/apps/integrations/grpc/services/commands/__init__.py +93 -0
- django_cfg/apps/integrations/grpc/services/commands/base.py +243 -0
- django_cfg/apps/integrations/grpc/services/commands/examples/__init__.py +22 -0
- django_cfg/apps/integrations/grpc/services/commands/examples/base_client.py +228 -0
- django_cfg/apps/integrations/grpc/services/commands/examples/client.py +272 -0
- django_cfg/apps/integrations/grpc/services/commands/examples/config.py +177 -0
- django_cfg/apps/integrations/grpc/services/commands/examples/start.py +125 -0
- django_cfg/apps/integrations/grpc/services/commands/examples/stop.py +101 -0
- django_cfg/apps/integrations/grpc/services/commands/registry.py +170 -0
- django_cfg/apps/integrations/grpc/services/discovery/__init__.py +39 -0
- django_cfg/apps/integrations/grpc/services/{discovery.py → discovery/discovery.py} +67 -54
- django_cfg/apps/integrations/grpc/services/{service_registry.py → discovery/registry.py} +215 -5
- django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/__init__.py +3 -1
- django_cfg/apps/integrations/grpc/services/interceptors/centrifugo.py +541 -0
- django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/metrics.py +3 -3
- django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/request_logger.py +10 -13
- django_cfg/apps/integrations/grpc/services/management/__init__.py +37 -0
- django_cfg/apps/integrations/grpc/services/monitoring/__init__.py +38 -0
- django_cfg/apps/integrations/grpc/services/{monitoring_service.py → monitoring/monitoring.py} +2 -2
- django_cfg/apps/integrations/grpc/services/{testing_service.py → monitoring/testing.py} +5 -5
- django_cfg/apps/integrations/grpc/services/rendering/__init__.py +27 -0
- django_cfg/apps/integrations/grpc/services/{chart_generator.py → rendering/charts.py} +1 -1
- django_cfg/apps/integrations/grpc/services/routing/__init__.py +59 -0
- django_cfg/apps/integrations/grpc/services/routing/config.py +76 -0
- django_cfg/apps/integrations/grpc/services/routing/router.py +430 -0
- django_cfg/apps/integrations/grpc/services/streaming/__init__.py +117 -0
- django_cfg/apps/integrations/grpc/services/streaming/config.py +451 -0
- django_cfg/apps/integrations/grpc/services/streaming/service.py +651 -0
- django_cfg/apps/integrations/grpc/services/streaming/types.py +367 -0
- django_cfg/apps/integrations/grpc/utils/SERVER_LOGGING.md +164 -0
- django_cfg/apps/integrations/grpc/utils/__init__.py +58 -1
- django_cfg/apps/integrations/grpc/utils/converters.py +565 -0
- django_cfg/apps/integrations/grpc/utils/handlers.py +242 -0
- django_cfg/apps/integrations/grpc/utils/proto_gen.py +1 -1
- django_cfg/apps/integrations/grpc/utils/streaming_logger.py +261 -13
- django_cfg/apps/integrations/grpc/views/charts.py +1 -1
- django_cfg/apps/integrations/grpc/views/config.py +1 -1
- 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/base/config_model.py +11 -0
- django_cfg/core/builders/middleware_builder.py +5 -0
- django_cfg/core/builders/security_builder.py +1 -0
- django_cfg/core/generation/integration_generators/api.py +2 -0
- django_cfg/management/commands/pool_status.py +153 -0
- django_cfg/middleware/pool_cleanup.py +261 -0
- django_cfg/models/api/grpc/config.py +2 -2
- django_cfg/models/infrastructure/database/config.py +16 -0
- django_cfg/models/infrastructure/database/converters.py +2 -0
- django_cfg/modules/django_admin/utils/html/composition.py +57 -13
- django_cfg/modules/django_admin/utils/html_builder.py +1 -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/groups/manager.py +25 -18
- django_cfg/modules/django_client/core/ir/schema.py +15 -1
- django_cfg/modules/django_client/core/parser/base.py +12 -0
- django_cfg/modules/django_client/management/commands/generate_client.py +9 -5
- django_cfg/modules/django_logging/django_logger.py +58 -19
- django_cfg/pyproject.toml +3 -3
- django_cfg/static/frontend/admin.zip +0 -0
- django_cfg/templates/admin/index.html +0 -39
- django_cfg/utils/pool_monitor.py +320 -0
- django_cfg/utils/smart_defaults.py +233 -7
- {django_cfg-1.5.14.dist-info → django_cfg-1.5.29.dist-info}/METADATA +75 -5
- {django_cfg-1.5.14.dist-info → django_cfg-1.5.29.dist-info}/RECORD +118 -74
- /django_cfg/apps/integrations/grpc/services/{grpc_client.py → client/client.py} +0 -0
- /django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/errors.py +0 -0
- /django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/logging.py +0 -0
- /django_cfg/apps/integrations/grpc/services/{config_helper.py → management/config_helper.py} +0 -0
- /django_cfg/apps/integrations/grpc/services/{proto_files_manager.py → management/proto_manager.py} +0 -0
- {django_cfg-1.5.14.dist-info → django_cfg-1.5.29.dist-info}/WHEEL +0 -0
- {django_cfg-1.5.14.dist-info → django_cfg-1.5.29.dist-info}/entry_points.txt +0 -0
- {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
|
|
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
|
-
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
510
|
-
|
|
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,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}")
|