django-cfg 1.5.14__py3-none-any.whl → 1.5.29__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of django-cfg might be problematic. Click here for more details.

Files changed (118) hide show
  1. django_cfg/__init__.py +1 -1
  2. django_cfg/apps/business/accounts/serializers/profile.py +42 -0
  3. django_cfg/apps/business/support/serializers.py +3 -2
  4. django_cfg/apps/integrations/centrifugo/__init__.py +2 -0
  5. django_cfg/apps/integrations/centrifugo/apps.py +2 -1
  6. django_cfg/apps/integrations/centrifugo/codegen/generators/typescript_thin/templates/rpc-client.ts.j2 +151 -12
  7. django_cfg/apps/integrations/centrifugo/management/commands/generate_centrifugo_clients.py +2 -2
  8. django_cfg/apps/integrations/centrifugo/services/__init__.py +6 -0
  9. django_cfg/apps/integrations/centrifugo/services/client/__init__.py +6 -1
  10. django_cfg/apps/integrations/centrifugo/services/client/client.py +1 -1
  11. django_cfg/apps/integrations/centrifugo/services/client/direct_client.py +282 -0
  12. django_cfg/apps/integrations/centrifugo/services/logging.py +47 -0
  13. django_cfg/apps/integrations/centrifugo/services/publisher.py +371 -0
  14. django_cfg/apps/integrations/centrifugo/services/token_generator.py +122 -0
  15. django_cfg/apps/integrations/centrifugo/urls.py +8 -0
  16. django_cfg/apps/integrations/centrifugo/views/__init__.py +2 -0
  17. django_cfg/apps/integrations/centrifugo/views/admin_api.py +29 -32
  18. django_cfg/apps/integrations/centrifugo/views/testing_api.py +31 -116
  19. django_cfg/apps/integrations/centrifugo/views/token_api.py +101 -0
  20. django_cfg/apps/integrations/centrifugo/views/wrapper.py +259 -0
  21. django_cfg/apps/integrations/grpc/auth/api_key_auth.py +11 -10
  22. django_cfg/apps/integrations/grpc/management/commands/compile_proto.py +105 -0
  23. django_cfg/apps/integrations/grpc/management/commands/generate_protos.py +56 -1
  24. django_cfg/apps/integrations/grpc/management/commands/rungrpc.py +315 -26
  25. django_cfg/apps/integrations/grpc/management/proto/__init__.py +3 -0
  26. django_cfg/apps/integrations/grpc/management/proto/compiler.py +194 -0
  27. django_cfg/apps/integrations/grpc/managers/grpc_request_log.py +84 -0
  28. django_cfg/apps/integrations/grpc/managers/grpc_server_status.py +126 -3
  29. django_cfg/apps/integrations/grpc/models/grpc_api_key.py +7 -1
  30. django_cfg/apps/integrations/grpc/models/grpc_server_status.py +22 -3
  31. django_cfg/apps/integrations/grpc/services/__init__.py +102 -17
  32. django_cfg/apps/integrations/grpc/services/centrifugo/__init__.py +29 -0
  33. django_cfg/apps/integrations/grpc/services/centrifugo/bridge.py +469 -0
  34. django_cfg/apps/integrations/grpc/services/centrifugo/config.py +167 -0
  35. django_cfg/apps/integrations/grpc/services/centrifugo/demo.py +626 -0
  36. django_cfg/apps/integrations/grpc/services/centrifugo/test_publish.py +229 -0
  37. django_cfg/apps/integrations/grpc/services/centrifugo/transformers.py +89 -0
  38. django_cfg/apps/integrations/grpc/services/client/__init__.py +26 -0
  39. django_cfg/apps/integrations/grpc/services/commands/IMPLEMENTATION.md +456 -0
  40. django_cfg/apps/integrations/grpc/services/commands/README.md +252 -0
  41. django_cfg/apps/integrations/grpc/services/commands/__init__.py +93 -0
  42. django_cfg/apps/integrations/grpc/services/commands/base.py +243 -0
  43. django_cfg/apps/integrations/grpc/services/commands/examples/__init__.py +22 -0
  44. django_cfg/apps/integrations/grpc/services/commands/examples/base_client.py +228 -0
  45. django_cfg/apps/integrations/grpc/services/commands/examples/client.py +272 -0
  46. django_cfg/apps/integrations/grpc/services/commands/examples/config.py +177 -0
  47. django_cfg/apps/integrations/grpc/services/commands/examples/start.py +125 -0
  48. django_cfg/apps/integrations/grpc/services/commands/examples/stop.py +101 -0
  49. django_cfg/apps/integrations/grpc/services/commands/registry.py +170 -0
  50. django_cfg/apps/integrations/grpc/services/discovery/__init__.py +39 -0
  51. django_cfg/apps/integrations/grpc/services/{discovery.py → discovery/discovery.py} +67 -54
  52. django_cfg/apps/integrations/grpc/services/{service_registry.py → discovery/registry.py} +215 -5
  53. django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/__init__.py +3 -1
  54. django_cfg/apps/integrations/grpc/services/interceptors/centrifugo.py +541 -0
  55. django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/metrics.py +3 -3
  56. django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/request_logger.py +10 -13
  57. django_cfg/apps/integrations/grpc/services/management/__init__.py +37 -0
  58. django_cfg/apps/integrations/grpc/services/monitoring/__init__.py +38 -0
  59. django_cfg/apps/integrations/grpc/services/{monitoring_service.py → monitoring/monitoring.py} +2 -2
  60. django_cfg/apps/integrations/grpc/services/{testing_service.py → monitoring/testing.py} +5 -5
  61. django_cfg/apps/integrations/grpc/services/rendering/__init__.py +27 -0
  62. django_cfg/apps/integrations/grpc/services/{chart_generator.py → rendering/charts.py} +1 -1
  63. django_cfg/apps/integrations/grpc/services/routing/__init__.py +59 -0
  64. django_cfg/apps/integrations/grpc/services/routing/config.py +76 -0
  65. django_cfg/apps/integrations/grpc/services/routing/router.py +430 -0
  66. django_cfg/apps/integrations/grpc/services/streaming/__init__.py +117 -0
  67. django_cfg/apps/integrations/grpc/services/streaming/config.py +451 -0
  68. django_cfg/apps/integrations/grpc/services/streaming/service.py +651 -0
  69. django_cfg/apps/integrations/grpc/services/streaming/types.py +367 -0
  70. django_cfg/apps/integrations/grpc/utils/SERVER_LOGGING.md +164 -0
  71. django_cfg/apps/integrations/grpc/utils/__init__.py +58 -1
  72. django_cfg/apps/integrations/grpc/utils/converters.py +565 -0
  73. django_cfg/apps/integrations/grpc/utils/handlers.py +242 -0
  74. django_cfg/apps/integrations/grpc/utils/proto_gen.py +1 -1
  75. django_cfg/apps/integrations/grpc/utils/streaming_logger.py +261 -13
  76. django_cfg/apps/integrations/grpc/views/charts.py +1 -1
  77. django_cfg/apps/integrations/grpc/views/config.py +1 -1
  78. django_cfg/apps/system/dashboard/serializers/config.py +95 -9
  79. django_cfg/apps/system/dashboard/serializers/statistics.py +9 -4
  80. django_cfg/apps/system/frontend/views.py +87 -6
  81. django_cfg/core/base/config_model.py +11 -0
  82. django_cfg/core/builders/middleware_builder.py +5 -0
  83. django_cfg/core/builders/security_builder.py +1 -0
  84. django_cfg/core/generation/integration_generators/api.py +2 -0
  85. django_cfg/management/commands/pool_status.py +153 -0
  86. django_cfg/middleware/pool_cleanup.py +261 -0
  87. django_cfg/models/api/grpc/config.py +2 -2
  88. django_cfg/models/infrastructure/database/config.py +16 -0
  89. django_cfg/models/infrastructure/database/converters.py +2 -0
  90. django_cfg/modules/django_admin/utils/html/composition.py +57 -13
  91. django_cfg/modules/django_admin/utils/html_builder.py +1 -0
  92. django_cfg/modules/django_client/core/generator/typescript/generator.py +26 -0
  93. django_cfg/modules/django_client/core/generator/typescript/hooks_generator.py +7 -1
  94. django_cfg/modules/django_client/core/generator/typescript/models_generator.py +5 -0
  95. django_cfg/modules/django_client/core/generator/typescript/schemas_generator.py +11 -0
  96. django_cfg/modules/django_client/core/generator/typescript/templates/fetchers/fetchers.ts.jinja +1 -0
  97. django_cfg/modules/django_client/core/generator/typescript/templates/fetchers/function.ts.jinja +29 -1
  98. django_cfg/modules/django_client/core/generator/typescript/templates/hooks/hooks.ts.jinja +4 -0
  99. django_cfg/modules/django_client/core/groups/manager.py +25 -18
  100. django_cfg/modules/django_client/core/ir/schema.py +15 -1
  101. django_cfg/modules/django_client/core/parser/base.py +12 -0
  102. django_cfg/modules/django_client/management/commands/generate_client.py +9 -5
  103. django_cfg/modules/django_logging/django_logger.py +58 -19
  104. django_cfg/pyproject.toml +3 -3
  105. django_cfg/static/frontend/admin.zip +0 -0
  106. django_cfg/templates/admin/index.html +0 -39
  107. django_cfg/utils/pool_monitor.py +320 -0
  108. django_cfg/utils/smart_defaults.py +233 -7
  109. {django_cfg-1.5.14.dist-info → django_cfg-1.5.29.dist-info}/METADATA +75 -5
  110. {django_cfg-1.5.14.dist-info → django_cfg-1.5.29.dist-info}/RECORD +118 -74
  111. /django_cfg/apps/integrations/grpc/services/{grpc_client.py → client/client.py} +0 -0
  112. /django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/errors.py +0 -0
  113. /django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/logging.py +0 -0
  114. /django_cfg/apps/integrations/grpc/services/{config_helper.py → management/config_helper.py} +0 -0
  115. /django_cfg/apps/integrations/grpc/services/{proto_files_manager.py → management/proto_manager.py} +0 -0
  116. {django_cfg-1.5.14.dist-info → django_cfg-1.5.29.dist-info}/WHEEL +0 -0
  117. {django_cfg-1.5.14.dist-info → django_cfg-1.5.29.dist-info}/entry_points.txt +0 -0
  118. {django_cfg-1.5.14.dist-info → django_cfg-1.5.29.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,651 @@
1
+ """
2
+ Universal bidirectional streaming service for gRPC.
3
+
4
+ This module provides a generic, type-safe implementation of bidirectional gRPC streaming.
5
+ It extracts the common pattern used across signals and trading_bots services.
6
+
7
+ **Key Features**:
8
+ - Generic over TMessage (input) and TCommand (output) types
9
+ - Type-safe callbacks via Protocol types
10
+ - Pydantic v2 configuration with validation
11
+ - Automatic ping/keepalive handling
12
+ - Proper concurrent input/output processing
13
+ - Critical `await asyncio.sleep(0)` for event loop yielding
14
+ - Connection lifecycle management
15
+
16
+ **Usage Example**:
17
+ ```python
18
+ from .types import MessageProcessor, ClientIdExtractor, PingMessageCreator
19
+ from .config import BidirectionalStreamingConfig, ConfigPresets
20
+
21
+ # Define your callbacks
22
+ async def process_messages(
23
+ client_id: str,
24
+ message: SignalCommand,
25
+ output_queue: asyncio.Queue[SignalMessage]
26
+ ) -> None:
27
+ # Your business logic
28
+ response = await handle_signal(message)
29
+ await output_queue.put(response)
30
+
31
+ def extract_client_id(message: SignalCommand) -> str:
32
+ return message.client_id
33
+
34
+ def create_ping() -> SignalMessage:
35
+ return SignalMessage(is_ping=True)
36
+
37
+ # Create service instance
38
+ service = BidirectionalStreamingService(
39
+ config=ConfigPresets.PRODUCTION,
40
+ message_processor=process_messages,
41
+ client_id_extractor=extract_client_id,
42
+ ping_message_creator=create_ping,
43
+ )
44
+
45
+ # Use in gRPC servicer
46
+ async def BidirectionalStream(self, request_iterator, context):
47
+ async for response in service.handle_stream(request_iterator, context):
48
+ yield response
49
+ ```
50
+
51
+ Created: 2025-11-07
52
+ Status: %%PRODUCTION%%
53
+ Phase: Phase 1 - Universal Components
54
+ """
55
+
56
+ from typing import Generic, Optional, AsyncIterator, Dict
57
+ import asyncio
58
+ import logging
59
+ import time
60
+
61
+ import grpc
62
+
63
+ from .types import (
64
+ TMessage,
65
+ TCommand,
66
+ MessageProcessor,
67
+ ClientIdExtractor,
68
+ PingMessageCreator,
69
+ ConnectionCallback,
70
+ ErrorHandler,
71
+ )
72
+ from .config import BidirectionalStreamingConfig, StreamingMode, PingStrategy
73
+
74
+ # Import setup_streaming_logger for auto-created logger
75
+ from django_cfg.apps.integrations.grpc.utils.streaming_logger import setup_streaming_logger
76
+
77
+
78
+ # Module-level logger (fallback only)
79
+ logger = logging.getLogger(__name__)
80
+
81
+
82
+ # ============================================================================
83
+ # Main Service Class
84
+ # ============================================================================
85
+
86
+ class BidirectionalStreamingService(Generic[TMessage, TCommand]):
87
+ """
88
+ Universal bidirectional streaming service with type-safe callbacks.
89
+
90
+ This service handles the complex concurrent streaming pattern used in
91
+ signals and trading_bots services, making it reusable across projects.
92
+
93
+ **Type Parameters**:
94
+ TMessage: Type of incoming messages from client
95
+ TCommand: Type of outgoing commands to client
96
+
97
+ **Architecture**:
98
+ ```
99
+ ┌─────────────────────────────────────────────────────────────┐
100
+ │ BidirectionalStreamingService │
101
+ │ │
102
+ │ ┌─────────────────┐ ┌──────────────────┐ │
103
+ │ │ Input Task │───────>│ output_queue │ │
104
+ │ │ (processes │ │ (asyncio.Queue) │ │
105
+ │ │ messages) │ └──────────────────┘ │
106
+ │ └─────────────────┘ │ │
107
+ │ │ │ │
108
+ │ │ await asyncio.sleep(0) │ │
109
+ │ │ (CRITICAL!) │ │
110
+ │ │ ▼ │
111
+ │ │ ┌──────────────────┐ │
112
+ │ │ │ Output Loop │ │
113
+ │ │ │ (yields to │ │
114
+ │ └──────────────────│ client) │ │
115
+ │ └──────────────────┘ │
116
+ └─────────────────────────────────────────────────────────────┘
117
+ ```
118
+
119
+ **Concurrency Model**:
120
+ - Input task runs concurrently, processing incoming messages
121
+ - Output loop yields commands from queue back to client
122
+ - `await asyncio.sleep(0)` ensures output loop can yield promptly
123
+ - Ping messages sent on timeout to keep connection alive
124
+
125
+ **Parameters**:
126
+ config: Pydantic configuration model
127
+ message_processor: Callback to process each incoming message
128
+ client_id_extractor: Callback to extract client ID from message
129
+ ping_message_creator: Callback to create ping messages
130
+ on_connect: Optional callback when client connects
131
+ on_disconnect: Optional callback when client disconnects
132
+ on_error: Optional callback on errors
133
+ """
134
+
135
+ def __init__(
136
+ self,
137
+ config: BidirectionalStreamingConfig,
138
+ message_processor: MessageProcessor[TMessage, TCommand],
139
+ client_id_extractor: ClientIdExtractor[TMessage],
140
+ ping_message_creator: PingMessageCreator[TCommand],
141
+ on_connect: Optional[ConnectionCallback] = None,
142
+ on_disconnect: Optional[ConnectionCallback] = None,
143
+ on_error: Optional[ErrorHandler] = None,
144
+ logger: Optional[logging.Logger] = None,
145
+ ):
146
+ """
147
+ Initialize bidirectional streaming service.
148
+
149
+ Args:
150
+ config: Pydantic configuration (frozen, validated)
151
+ message_processor: Process incoming messages
152
+ client_id_extractor: Extract client ID from messages
153
+ ping_message_creator: Create ping messages
154
+ on_connect: Optional connection callback
155
+ on_disconnect: Optional disconnection callback
156
+ on_error: Optional error callback
157
+ logger: Optional logger instance (auto-created if None)
158
+ """
159
+ self.config = config
160
+ self.message_processor = message_processor
161
+ self.client_id_extractor = client_id_extractor
162
+ self.ping_message_creator = ping_message_creator
163
+ self.on_connect = on_connect
164
+ self.on_disconnect = on_disconnect
165
+ self.on_error = on_error
166
+
167
+ # Auto-create logger if not provided
168
+ if logger is None:
169
+ logger_name = self.config.logger_name or "grpc_streaming"
170
+ self.logger = setup_streaming_logger(
171
+ name=logger_name,
172
+ level=logging.DEBUG,
173
+ console_level=logging.INFO
174
+ )
175
+ else:
176
+ self.logger = logger
177
+
178
+ # Active connections tracking
179
+ self._active_connections: Dict[str, asyncio.Queue[TCommand]] = {}
180
+
181
+ if self.config.enable_logging:
182
+ self.logger.info(
183
+ f"BidirectionalStreamingService initialized: "
184
+ f"mode={self.config.streaming_mode.value}, "
185
+ f"ping={self.config.ping_strategy.value}, "
186
+ f"interval={self.config.ping_interval}s"
187
+ )
188
+
189
+ # ------------------------------------------------------------------------
190
+ # Main Stream Handler
191
+ # ------------------------------------------------------------------------
192
+
193
+ async def handle_stream(
194
+ self,
195
+ request_iterator: AsyncIterator[TMessage],
196
+ context: grpc.aio.ServicerContext,
197
+ ) -> AsyncIterator[TCommand]:
198
+ """
199
+ Handle bidirectional gRPC stream.
200
+
201
+ This is the main entry point called by gRPC servicer methods.
202
+
203
+ **Flow**:
204
+ 1. Create output queue for this connection
205
+ 2. Start input task to process messages concurrently
206
+ 3. Yield commands from output queue (with ping on timeout)
207
+ 4. Handle cancellation and cleanup
208
+
209
+ Args:
210
+ request_iterator: Incoming message stream from client
211
+ context: gRPC service context
212
+
213
+ Yields:
214
+ Commands to send back to client
215
+
216
+ Raises:
217
+ asyncio.CancelledError: On client disconnect
218
+ grpc.RpcError: On gRPC errors
219
+ """
220
+ client_id: Optional[str] = None
221
+ output_queue: Optional[asyncio.Queue[TCommand]] = None
222
+ input_task: Optional[asyncio.Task] = None
223
+
224
+ try:
225
+ # Create output queue for this connection
226
+ output_queue = asyncio.Queue(maxsize=self.config.max_queue_size)
227
+
228
+ # Start background task to process incoming messages
229
+ # This runs concurrently with output streaming below
230
+ input_task = asyncio.create_task(
231
+ self._process_input_stream(
232
+ request_iterator,
233
+ output_queue,
234
+ context,
235
+ )
236
+ )
237
+
238
+ # Main output loop: yield commands from queue
239
+ async for command in self._output_loop(output_queue, context):
240
+ yield command
241
+
242
+ # Output loop finished, wait for input task
243
+ if self.config.enable_logging:
244
+ self.logger.info("Output loop finished, waiting for input task...")
245
+
246
+ try:
247
+ await input_task
248
+ if self.config.enable_logging:
249
+ self.logger.info("Input task completed successfully")
250
+ except Exception as e:
251
+ if self.config.enable_logging:
252
+ self.logger.error(f"Input task error: {e}", exc_info=True)
253
+ if self.on_error and client_id:
254
+ await self.on_error(client_id, e)
255
+
256
+ except asyncio.CancelledError:
257
+ if self.config.enable_logging:
258
+ self.logger.info(f"Client {client_id} stream cancelled")
259
+
260
+ # Cancel input task if still running
261
+ if input_task and not input_task.done():
262
+ input_task.cancel()
263
+ try:
264
+ await input_task
265
+ except asyncio.CancelledError:
266
+ pass
267
+
268
+ raise
269
+
270
+ except Exception as e:
271
+ if self.config.enable_logging:
272
+ self.logger.error(f"Client {client_id} stream error: {e}", exc_info=True)
273
+
274
+ if self.on_error and client_id:
275
+ await self.on_error(client_id, e)
276
+
277
+ await context.abort(grpc.StatusCode.INTERNAL, f"Server error: {e}")
278
+
279
+ finally:
280
+ # Cleanup connection
281
+ if client_id and client_id in self._active_connections:
282
+ del self._active_connections[client_id]
283
+ if self.config.enable_logging:
284
+ self.logger.info(f"Client {client_id} disconnected")
285
+
286
+ if self.on_disconnect:
287
+ await self.on_disconnect(client_id)
288
+
289
+ # ------------------------------------------------------------------------
290
+ # Output Loop
291
+ # ------------------------------------------------------------------------
292
+
293
+ async def _output_loop(
294
+ self,
295
+ output_queue: asyncio.Queue[TCommand],
296
+ context: grpc.aio.ServicerContext,
297
+ ) -> AsyncIterator[TCommand]:
298
+ """
299
+ Main output loop that yields commands to client.
300
+
301
+ **Logic**:
302
+ - Wait for commands in queue with timeout
303
+ - If timeout and ping enabled -> send ping
304
+ - Yield commands to client
305
+ - Stop when context cancelled or sentinel received
306
+
307
+ Args:
308
+ output_queue: Queue containing commands to send
309
+ context: gRPC service context
310
+
311
+ Yields:
312
+ Commands to send to client
313
+ """
314
+ ping_sequence = 0
315
+ last_message_time = time.time()
316
+ consecutive_errors = 0
317
+
318
+ try:
319
+ while not context.cancelled():
320
+ try:
321
+ # Determine timeout based on ping strategy
322
+ timeout = self._get_output_timeout(last_message_time)
323
+
324
+ # Wait for command with timeout
325
+ command = await asyncio.wait_for(
326
+ output_queue.get(),
327
+ timeout=timeout,
328
+ )
329
+
330
+ # Check for shutdown sentinel (None)
331
+ if command is None:
332
+ if self.config.enable_logging:
333
+ self.logger.info("Received shutdown sentinel")
334
+ break
335
+
336
+ # Yield command to client
337
+ yield command
338
+ last_message_time = time.time()
339
+ consecutive_errors = 0 # Reset error counter
340
+
341
+ if self.config.enable_logging:
342
+ self.logger.debug("Sent command to client")
343
+
344
+ except asyncio.TimeoutError:
345
+ # Timeout - send ping if enabled
346
+ if self.config.is_ping_enabled():
347
+ ping_sequence += 1
348
+ ping_command = self.ping_message_creator()
349
+ yield ping_command
350
+ last_message_time = time.time()
351
+
352
+ if self.config.enable_logging:
353
+ self.logger.debug(f"Sent PING #{ping_sequence}")
354
+
355
+ except Exception as e:
356
+ consecutive_errors += 1
357
+ if self.config.enable_logging:
358
+ self.logger.error(f"Output loop error: {e}", exc_info=True)
359
+
360
+ # Check if max consecutive errors exceeded
361
+ if (
362
+ self.config.max_consecutive_errors > 0
363
+ and consecutive_errors >= self.config.max_consecutive_errors
364
+ ):
365
+ if self.config.enable_logging:
366
+ self.logger.error(
367
+ f"Max consecutive errors ({self.config.max_consecutive_errors}) exceeded"
368
+ )
369
+ break
370
+
371
+ except asyncio.CancelledError:
372
+ if self.config.enable_logging:
373
+ self.logger.info("Output loop cancelled")
374
+ raise
375
+
376
+ def _get_output_timeout(self, last_message_time: float) -> Optional[float]:
377
+ """
378
+ Calculate output queue timeout based on ping strategy.
379
+
380
+ Args:
381
+ last_message_time: Timestamp of last sent message
382
+
383
+ Returns:
384
+ Timeout in seconds, or None for no timeout
385
+ """
386
+ if self.config.ping_strategy == PingStrategy.DISABLED:
387
+ # No timeout when ping disabled (wait indefinitely)
388
+ return None
389
+
390
+ elif self.config.ping_strategy == PingStrategy.INTERVAL:
391
+ # Fixed interval timeout
392
+ return self.config.ping_interval
393
+
394
+ elif self.config.ping_strategy == PingStrategy.ON_IDLE:
395
+ # Timeout based on time since last message
396
+ elapsed = time.time() - last_message_time
397
+ remaining = self.config.ping_interval - elapsed
398
+ return max(remaining, 0.1) # At least 0.1s
399
+
400
+ return self.config.ping_interval # Fallback
401
+
402
+ # ------------------------------------------------------------------------
403
+ # Input Processing
404
+ # ------------------------------------------------------------------------
405
+
406
+ async def _process_input_stream(
407
+ self,
408
+ request_iterator: AsyncIterator[TMessage],
409
+ output_queue: asyncio.Queue[TCommand],
410
+ context: grpc.aio.ServicerContext,
411
+ ) -> None:
412
+ """
413
+ Process incoming messages from client.
414
+
415
+ **Flow**:
416
+ 1. Iterate over incoming messages
417
+ 2. Extract client ID from first message
418
+ 3. Call on_connect callback
419
+ 4. Process each message via message_processor
420
+ 5. **CRITICAL**: `await asyncio.sleep(0)` to yield event loop
421
+
422
+ Args:
423
+ request_iterator: Stream of incoming messages
424
+ output_queue: Queue for outgoing commands
425
+ context: gRPC service context
426
+
427
+ Raises:
428
+ Exception: Any processing errors
429
+ """
430
+ client_id: Optional[str] = None
431
+ is_first_message = True
432
+
433
+ try:
434
+ # Choose iteration mode based on config
435
+ if self.config.streaming_mode == StreamingMode.ASYNC_FOR:
436
+ await self._process_async_for(
437
+ request_iterator,
438
+ output_queue,
439
+ context,
440
+ )
441
+ else: # StreamingMode.ANEXT
442
+ await self._process_anext(
443
+ request_iterator,
444
+ output_queue,
445
+ context,
446
+ )
447
+
448
+ except asyncio.CancelledError:
449
+ if self.config.enable_logging:
450
+ self.logger.info(f"Input stream cancelled for client {client_id}")
451
+ raise
452
+
453
+ except Exception as e:
454
+ if self.config.enable_logging:
455
+ self.logger.error(f"Input stream error for client {client_id}: {e}", exc_info=True)
456
+ raise
457
+
458
+ async def _process_async_for(
459
+ self,
460
+ request_iterator: AsyncIterator[TMessage],
461
+ output_queue: asyncio.Queue[TCommand],
462
+ context: grpc.aio.ServicerContext,
463
+ ) -> None:
464
+ """Process input stream using async for iteration."""
465
+ client_id: Optional[str] = None
466
+ is_first_message = True
467
+
468
+ async for message in request_iterator:
469
+ # Extract client ID from first message
470
+ if is_first_message:
471
+ client_id = self.client_id_extractor(message)
472
+ self._active_connections[client_id] = output_queue
473
+ is_first_message = False
474
+
475
+ if self.config.enable_logging:
476
+ self.logger.info(f"Client {client_id} connected")
477
+
478
+ if self.on_connect:
479
+ await self.on_connect(client_id)
480
+
481
+ # Process message
482
+ await self.message_processor(client_id, message, output_queue)
483
+
484
+ # ⚠️ CRITICAL: Yield to event loop!
485
+ # Without this, the next message read blocks output loop from yielding.
486
+ # This is the key pattern that makes bidirectional streaming work correctly.
487
+ if self.config.should_yield_event_loop():
488
+ await asyncio.sleep(0)
489
+
490
+ async def _process_anext(
491
+ self,
492
+ request_iterator: AsyncIterator[TMessage],
493
+ output_queue: asyncio.Queue[TCommand],
494
+ context: grpc.aio.ServicerContext,
495
+ ) -> None:
496
+ """Process input stream using anext() calls."""
497
+ client_id: Optional[str] = None
498
+ is_first_message = True
499
+
500
+ while not context.cancelled():
501
+ try:
502
+ # Get next message with optional timeout
503
+ if self.config.connection_timeout:
504
+ message = await asyncio.wait_for(
505
+ anext(request_iterator),
506
+ timeout=self.config.connection_timeout,
507
+ )
508
+ else:
509
+ message = await anext(request_iterator)
510
+
511
+ # Extract client ID from first message
512
+ if is_first_message:
513
+ client_id = self.client_id_extractor(message)
514
+ self._active_connections[client_id] = output_queue
515
+ is_first_message = False
516
+
517
+ if self.config.enable_logging:
518
+ self.logger.info(f"Client {client_id} connected")
519
+
520
+ if self.on_connect:
521
+ await self.on_connect(client_id)
522
+
523
+ # Process message
524
+ await self.message_processor(client_id, message, output_queue)
525
+
526
+ # ⚠️ CRITICAL: Yield to event loop!
527
+ if self.config.should_yield_event_loop():
528
+ await asyncio.sleep(0)
529
+
530
+ except StopAsyncIteration:
531
+ # Stream ended normally
532
+ if self.config.enable_logging:
533
+ self.logger.info(f"Client {client_id} stream ended")
534
+ break
535
+
536
+ except asyncio.TimeoutError:
537
+ if self.config.enable_logging:
538
+ self.logger.warning(f"Client {client_id} connection timeout")
539
+ break
540
+
541
+ # ------------------------------------------------------------------------
542
+ # Connection Management
543
+ # ------------------------------------------------------------------------
544
+
545
+ def get_active_connections(self) -> Dict[str, asyncio.Queue[TCommand]]:
546
+ """
547
+ Get all active connections.
548
+
549
+ Returns:
550
+ Dict mapping client_id to output_queue
551
+ """
552
+ return self._active_connections.copy()
553
+
554
+ def is_client_connected(self, client_id: str) -> bool:
555
+ """
556
+ Check if client is currently connected.
557
+
558
+ Args:
559
+ client_id: Client identifier
560
+
561
+ Returns:
562
+ True if client has active connection
563
+ """
564
+ return client_id in self._active_connections
565
+
566
+ async def send_to_client(
567
+ self,
568
+ client_id: str,
569
+ command: TCommand,
570
+ timeout: Optional[float] = None,
571
+ ) -> bool:
572
+ """
573
+ Send command to specific client.
574
+
575
+ Args:
576
+ client_id: Target client identifier
577
+ command: Command to send
578
+ timeout: Optional timeout for enqueue (uses config.queue_timeout if None)
579
+
580
+ Returns:
581
+ True if sent successfully, False if client not connected or timeout
582
+
583
+ Raises:
584
+ asyncio.TimeoutError: If enqueue times out and no default handler
585
+ """
586
+ if client_id not in self._active_connections:
587
+ if self.config.enable_logging:
588
+ self.logger.warning(f"Client {client_id} not connected")
589
+ return False
590
+
591
+ queue = self._active_connections[client_id]
592
+ timeout = timeout or self.config.queue_timeout
593
+
594
+ try:
595
+ await asyncio.wait_for(queue.put(command), timeout=timeout)
596
+ return True
597
+ except asyncio.TimeoutError:
598
+ if self.config.enable_logging:
599
+ self.logger.warning(f"Timeout sending to client {client_id}")
600
+ return False
601
+ except Exception as e:
602
+ if self.config.enable_logging:
603
+ self.logger.error(f"Error sending to client {client_id}: {e}")
604
+ return False
605
+
606
+ async def broadcast_to_all(
607
+ self,
608
+ command: TCommand,
609
+ exclude: Optional[list[str]] = None,
610
+ ) -> int:
611
+ """
612
+ Broadcast command to all connected clients.
613
+
614
+ Args:
615
+ command: Command to broadcast
616
+ exclude: Optional list of client IDs to exclude
617
+
618
+ Returns:
619
+ Number of clients successfully sent to
620
+ """
621
+ exclude = exclude or []
622
+ sent_count = 0
623
+
624
+ for client_id in list(self._active_connections.keys()):
625
+ if client_id not in exclude:
626
+ if await self.send_to_client(client_id, command):
627
+ sent_count += 1
628
+
629
+ return sent_count
630
+
631
+ async def disconnect_client(self, client_id: str) -> None:
632
+ """
633
+ Gracefully disconnect a client.
634
+
635
+ Sends shutdown sentinel (None) to trigger clean disconnection.
636
+
637
+ Args:
638
+ client_id: Client to disconnect
639
+ """
640
+ if client_id in self._active_connections:
641
+ queue = self._active_connections[client_id]
642
+ await queue.put(None) # Sentinel for shutdown
643
+
644
+
645
+ # ============================================================================
646
+ # Exports
647
+ # ============================================================================
648
+
649
+ __all__ = [
650
+ 'BidirectionalStreamingService',
651
+ ]