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.

Files changed (53) 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/apps.py +2 -1
  5. django_cfg/apps/integrations/centrifugo/codegen/generators/typescript_thin/templates/rpc-client.ts.j2 +151 -12
  6. django_cfg/apps/integrations/centrifugo/management/commands/generate_centrifugo_clients.py +2 -2
  7. django_cfg/apps/integrations/centrifugo/services/__init__.py +6 -0
  8. django_cfg/apps/integrations/centrifugo/services/client/__init__.py +6 -1
  9. django_cfg/apps/integrations/centrifugo/services/client/direct_client.py +282 -0
  10. django_cfg/apps/integrations/centrifugo/services/publisher.py +371 -0
  11. django_cfg/apps/integrations/centrifugo/services/token_generator.py +122 -0
  12. django_cfg/apps/integrations/centrifugo/urls.py +8 -0
  13. django_cfg/apps/integrations/centrifugo/views/__init__.py +2 -0
  14. django_cfg/apps/integrations/centrifugo/views/testing_api.py +0 -79
  15. django_cfg/apps/integrations/centrifugo/views/token_api.py +101 -0
  16. django_cfg/apps/integrations/centrifugo/views/wrapper.py +257 -0
  17. django_cfg/apps/integrations/grpc/centrifugo/__init__.py +29 -0
  18. django_cfg/apps/integrations/grpc/centrifugo/bridge.py +277 -0
  19. django_cfg/apps/integrations/grpc/centrifugo/config.py +167 -0
  20. django_cfg/apps/integrations/grpc/centrifugo/demo.py +626 -0
  21. django_cfg/apps/integrations/grpc/centrifugo/test_publish.py +229 -0
  22. django_cfg/apps/integrations/grpc/centrifugo/transformers.py +89 -0
  23. django_cfg/apps/integrations/grpc/interceptors/__init__.py +3 -1
  24. django_cfg/apps/integrations/grpc/interceptors/centrifugo.py +541 -0
  25. django_cfg/apps/integrations/grpc/management/commands/compile_proto.py +105 -0
  26. django_cfg/apps/integrations/grpc/management/commands/generate_protos.py +55 -0
  27. django_cfg/apps/integrations/grpc/management/commands/rungrpc.py +311 -7
  28. django_cfg/apps/integrations/grpc/management/proto/__init__.py +3 -0
  29. django_cfg/apps/integrations/grpc/management/proto/compiler.py +194 -0
  30. django_cfg/apps/integrations/grpc/services/discovery.py +7 -1
  31. django_cfg/apps/integrations/grpc/utils/SERVER_LOGGING.md +164 -0
  32. django_cfg/apps/integrations/grpc/utils/streaming_logger.py +206 -5
  33. django_cfg/apps/system/dashboard/serializers/config.py +95 -9
  34. django_cfg/apps/system/dashboard/serializers/statistics.py +9 -4
  35. django_cfg/apps/system/frontend/views.py +87 -6
  36. django_cfg/core/builders/security_builder.py +1 -0
  37. django_cfg/core/generation/integration_generators/api.py +2 -0
  38. django_cfg/modules/django_client/core/generator/typescript/generator.py +26 -0
  39. django_cfg/modules/django_client/core/generator/typescript/hooks_generator.py +7 -1
  40. django_cfg/modules/django_client/core/generator/typescript/models_generator.py +5 -0
  41. django_cfg/modules/django_client/core/generator/typescript/schemas_generator.py +11 -0
  42. django_cfg/modules/django_client/core/generator/typescript/templates/fetchers/fetchers.ts.jinja +1 -0
  43. django_cfg/modules/django_client/core/generator/typescript/templates/fetchers/function.ts.jinja +29 -1
  44. django_cfg/modules/django_client/core/generator/typescript/templates/hooks/hooks.ts.jinja +4 -0
  45. django_cfg/modules/django_client/core/ir/schema.py +15 -1
  46. django_cfg/modules/django_client/core/parser/base.py +12 -0
  47. django_cfg/pyproject.toml +1 -1
  48. django_cfg/static/frontend/admin.zip +0 -0
  49. {django_cfg-1.5.14.dist-info → django_cfg-1.5.20.dist-info}/METADATA +1 -1
  50. {django_cfg-1.5.14.dist-info → django_cfg-1.5.20.dist-info}/RECORD +53 -37
  51. {django_cfg-1.5.14.dist-info → django_cfg-1.5.20.dist-info}/WHEEL +0 -0
  52. {django_cfg-1.5.14.dist-info → django_cfg-1.5.20.dist-info}/entry_points.txt +0 -0
  53. {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 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
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
- # Signal handler will take care of graceful shutdown
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,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}")