xllify 0.8.9__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.
xllify/rtd_client.py ADDED
@@ -0,0 +1,576 @@
1
+ """
2
+ RTD Server Protocol Client using ZeroMQ
3
+ Supports sending commands to xllify RTD server via ZeroMQ DEALER socket
4
+ """
5
+
6
+ from typing import Union, List, Callable, Optional, Any
7
+ from dataclasses import dataclass
8
+ from enum import Enum
9
+ import threading
10
+ import logging
11
+ import atexit
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ try:
16
+ import zmq
17
+ except ImportError:
18
+ raise ImportError("This module requires pyzmq.")
19
+
20
+ # Check for optional dependencies once at module load
21
+ try:
22
+ import pandas as pd
23
+
24
+ HAS_PANDAS = True
25
+ except ImportError:
26
+ HAS_PANDAS = False
27
+ pd = None # type: ignore
28
+
29
+ try:
30
+ import numpy as np
31
+
32
+ HAS_NUMPY = True
33
+ except ImportError:
34
+ HAS_NUMPY = False
35
+ np = None # type: ignore
36
+
37
+
38
+ # Type aliases matching C++ implementation
39
+ # C++: using CellValue = std::variant<double, bool, std::wstring>;
40
+ CellValue = Union[float, bool, str, None]
41
+ """A single Excel cell value: float, bool, str, or None (displays as empty cell)"""
42
+
43
+ # C++: using Matrix = std::vector<std::vector<CellValue>>;
44
+ Matrix = List[List[CellValue]]
45
+ """A 2D array/matrix of cell values for Excel ranges (cells can be float, bool, str, or None)"""
46
+
47
+ # Type hint for values that can be sent to Excel
48
+ # Note: We use Any for DataFrame to avoid requiring pandas as a dependency
49
+ ExcelValue = Union[CellValue, Matrix, Any]
50
+ """
51
+ Any value that can be returned to Excel:
52
+ - CellValue: Single cell (float, bool, str)
53
+ - Matrix: 2D array/range
54
+ - pandas.DataFrame: Automatically converted to Matrix with headers (optional dependency)
55
+ """
56
+
57
+
58
+ class RTDCommand(Enum):
59
+ """RTD protocol commands"""
60
+
61
+ UPDATE = "U"
62
+ COMPLETE = "C"
63
+ DIRTY = "D"
64
+ DIRTY_COMPLETE = "DC"
65
+ BULK = "B"
66
+ BULK_COMPLETE = "BC"
67
+ PING = "PING"
68
+ EVICTALL = "EVICTALL"
69
+
70
+
71
+ @dataclass
72
+ class TopicUpdate:
73
+ """Single topic update for bulk operations"""
74
+
75
+ topic: Union[str, int] # Topic name or ID
76
+ value: Union[str, float, int]
77
+
78
+
79
+ def _dataframe_to_matrix(df) -> Matrix:
80
+ """
81
+ Convert pandas DataFrame to Matrix format with column headers.
82
+
83
+ Args:
84
+ df: pandas DataFrame to convert
85
+
86
+ Returns:
87
+ Matrix with first row as column headers, subsequent rows as data
88
+
89
+ Note:
90
+ - NaN/None values are converted to empty strings
91
+ - Numeric types (int, float) are converted to float
92
+ - Boolean values are preserved
93
+ - All other types are converted to strings
94
+ - DataFrame index is ignored
95
+ """
96
+ if not HAS_PANDAS:
97
+ raise ImportError(
98
+ "pandas is required to serialize DataFrames. Install with: pip install pandas"
99
+ )
100
+
101
+ # Start with column headers
102
+ headers: List[CellValue] = [str(col) for col in df.columns]
103
+ result: Matrix = [headers]
104
+
105
+ # Convert each row
106
+ for _, row in df.iterrows():
107
+ row_data: List[CellValue] = []
108
+ for val in row:
109
+ # Handle missing values
110
+ if pd.isna(val):
111
+ row_data.append("")
112
+ # Check bool before numeric (bool is subclass of int)
113
+ elif isinstance(val, (bool, np.bool_)):
114
+ row_data.append(bool(val))
115
+ elif isinstance(val, (np.integer, np.floating)):
116
+ row_data.append(float(val))
117
+ elif isinstance(val, (int, float)):
118
+ row_data.append(float(val))
119
+ else:
120
+ row_data.append(str(val))
121
+ result.append(row_data)
122
+
123
+ return result
124
+
125
+
126
+ def _serialize_value(value: Any) -> str:
127
+ """
128
+ Serialize a value with type prefix
129
+
130
+ Type prefixes: i: (int), d: (double), b: (bool), s: (string), n: (null/none), m: (matrix), e: (error)
131
+
132
+ Args:
133
+ value: Single value, 2D matrix, or pandas DataFrame
134
+
135
+ Returns:
136
+ Type-prefixed string
137
+
138
+ Example:
139
+ 42 -> "i:42"
140
+ 42.5 -> "d:42.5"
141
+ True -> "b:true"
142
+ "hello" -> "s:hello"
143
+ None -> "n:"
144
+ [[1, 1.5, True]] -> "m:i:1\nd:1.5\nb:true"
145
+ pd.DataFrame(...) -> "m:..." (converted to matrix with headers)
146
+ Exception("error") -> "e:error"
147
+ "#ERROR: msg" -> "e:#ERROR: msg"
148
+ """
149
+ # Check if value is an Exception or error string
150
+ if isinstance(value, BaseException):
151
+ return f"e:{str(value)}"
152
+
153
+ # Check for error strings (starting with #ERROR, #VALUE!, #N/A, etc.)
154
+ if isinstance(value, str) and value.startswith("#"):
155
+ return f"e:{value}"
156
+
157
+ if HAS_PANDAS and isinstance(value, pd.DataFrame):
158
+ # Convert DataFrame to matrix format with headers
159
+ value = _dataframe_to_matrix(value)
160
+
161
+ # Check for 2D matrix (list of lists)
162
+ if isinstance(value, list) and len(value) > 0 and isinstance(value[0], list):
163
+ # Format: m:rows,cols\nd:1\nd:2\nd:3\nd:4\ns:bob
164
+ rows = len(value)
165
+ # Calculate maximum columns across all rows to handle irregular matrices
166
+ cols = max(len(row) for row in value) if rows > 0 else 0
167
+
168
+ # Flatten matrix to newline-separated cells (row-major order)
169
+ cells = [f"{rows},{cols}"] # First line is dimensions
170
+ for row in value:
171
+ for cell in row:
172
+ # Handle None as null type
173
+ if cell is None:
174
+ cells.append("n:")
175
+ # Handle NaN as null type (if numpy is available)
176
+ elif HAS_NUMPY and isinstance(cell, (float, np.floating)) and np.isnan(cell):
177
+ cells.append("n:")
178
+ # Check bool before numeric (bool is subclass of int)
179
+ elif isinstance(cell, bool):
180
+ cells.append(f"b:{str(cell).lower()}")
181
+ # Handle numpy bool types
182
+ elif HAS_NUMPY and isinstance(cell, np.bool_):
183
+ cells.append(f"b:{str(bool(cell)).lower()}")
184
+ # Handle integer types (check int before float since we want to preserve type)
185
+ elif isinstance(cell, int):
186
+ cells.append(f"i:{cell}")
187
+ elif HAS_NUMPY and isinstance(cell, np.integer):
188
+ cells.append(f"i:{int(cell)}")
189
+ # Handle float types
190
+ elif isinstance(cell, float):
191
+ cells.append(f"d:{cell}")
192
+ elif HAS_NUMPY and isinstance(cell, np.floating):
193
+ cells.append(f"d:{float(cell)}")
194
+ else:
195
+ # Convert to string
196
+ cells.append(f"s:{str(cell)}")
197
+ # Pad shorter rows with null values to match column count
198
+ for _ in range(cols - len(row)):
199
+ cells.append("n:")
200
+ return "m:" + "\n".join(cells)
201
+
202
+ # Scalar values
203
+ # Handle None as null type
204
+ if value is None:
205
+ return "n:"
206
+
207
+ # Check for NaN - also serialize as null
208
+ if HAS_NUMPY and isinstance(value, (float, np.floating)) and np.isnan(value):
209
+ return "n:"
210
+ elif isinstance(value, float):
211
+ import math
212
+
213
+ if math.isnan(value):
214
+ return "n:"
215
+
216
+ # Check bool before numeric! ouch
217
+ if isinstance(value, bool):
218
+ return f"b:{str(value).lower()}"
219
+
220
+ # Check for numpy types if available
221
+ if HAS_NUMPY:
222
+ if isinstance(value, np.bool_):
223
+ return f"b:{str(bool(value)).lower()}"
224
+ elif isinstance(value, np.integer):
225
+ return f"i:{int(value)}"
226
+ elif isinstance(value, np.floating):
227
+ return f"d:{float(value)}"
228
+
229
+ if isinstance(value, int):
230
+ return f"i:{value}"
231
+ elif isinstance(value, float):
232
+ return f"d:{value}"
233
+ else:
234
+ return f"s:{str(value)}"
235
+
236
+
237
+ class RTDClient:
238
+ """Client for communicating with xllify RTD server via ZeroMQ DEALER socket"""
239
+
240
+ # ZeroMQ TCP endpoint for RTD server ROUTER socket (ipc:// not supported on Windows)
241
+ ZMQ_ROUTER_ENDPOINT = "tcp://127.0.0.1:55555"
242
+ REQUEST_TIMEOUT_MS = 1500 # 1 second timeout for requests
243
+
244
+ def __init__(
245
+ self,
246
+ enable_batching: bool = True,
247
+ batch_size: int = 250,
248
+ batch_timeout_ms: int = 5,
249
+ min_batch_size: int = 50,
250
+ ):
251
+ """
252
+ Initialize RTD client with optional batching support
253
+
254
+ Args:
255
+ enable_batching: Enable automatic batching of complete() calls (default: False for immediate sends)
256
+ batch_size: Maximum number of calls to batch together before auto-flush (default: 50)
257
+ batch_timeout_ms: Maximum time to wait before flushing batch in milliseconds (default: 10)
258
+ min_batch_size: Minimum batch size to enable timer-based flushing (default: 10)
259
+ If queue is smaller, sends immediately instead of waiting for timer
260
+ """
261
+ self.enable_batching = enable_batching
262
+ self.batch_size = batch_size
263
+ self.batch_timeout_ms = batch_timeout_ms
264
+ self.min_batch_size = min_batch_size
265
+
266
+ self._context: Optional[zmq.Context] = None
267
+ self._dealer: Optional[zmq.Socket] = None
268
+ self._socket_lock = threading.Lock()
269
+ self._connected = False
270
+
271
+ # Batch queue for complete() calls
272
+ self._batch_queue: List[tuple[Union[str, int], Union[str, float, int]]] = []
273
+ self._batch_lock = threading.Lock()
274
+ self._batch_timer: Optional[threading.Timer] = None
275
+
276
+ # Register cleanup on exit
277
+ atexit.register(self.close)
278
+
279
+ def _ensure_connected(self) -> bool:
280
+ """Ensure ZeroMQ DEALER socket is connected (lazy initialization)"""
281
+ if self._connected:
282
+ return True
283
+
284
+ with self._socket_lock:
285
+ if self._connected:
286
+ return True
287
+
288
+ try:
289
+ # Create context and DEALER socket
290
+ self._context = zmq.Context()
291
+ self._dealer = self._context.socket(zmq.DEALER)
292
+
293
+ # Set socket options
294
+ self._dealer.setsockopt(zmq.LINGER, 0) # Don't wait on close
295
+ self._dealer.setsockopt(zmq.RCVTIMEO, self.REQUEST_TIMEOUT_MS)
296
+ self._dealer.setsockopt(zmq.SNDTIMEO, self.REQUEST_TIMEOUT_MS)
297
+
298
+ # Set buffer sizes for large messages (32MB buffers - supports ~1M cells)
299
+ buffer_size = 32 * 1024 * 1024 # 32MB
300
+ self._dealer.setsockopt(zmq.SNDBUF, buffer_size)
301
+ self._dealer.setsockopt(zmq.RCVBUF, buffer_size)
302
+
303
+ # Set high water marks (queue depth before blocking)
304
+ hwm = 1000 # Higher for bursts of complete() calls
305
+ self._dealer.setsockopt(zmq.SNDHWM, hwm)
306
+ self._dealer.setsockopt(zmq.RCVHWM, hwm)
307
+
308
+ # Connect to RTD server
309
+ self._dealer.connect(self.ZMQ_ROUTER_ENDPOINT)
310
+ self._connected = True
311
+ return True
312
+
313
+ except Exception as e:
314
+ logger.debug(f"Failed to connect to RTD server: {e}")
315
+ self._connected = False
316
+ return False
317
+
318
+ def _send_command(self, frames: List[str]) -> bool:
319
+ """
320
+ Send a command to the RTD server via ZeroMQ
321
+
322
+ Args:
323
+ frames: List of message frames [command, topic, value, ...]
324
+
325
+ Returns:
326
+ True if command was sent and acknowledged successfully
327
+ """
328
+ if not self._ensure_connected():
329
+ logger.warning("Failed to connect to RTD server")
330
+ return False
331
+
332
+ with self._socket_lock:
333
+ try:
334
+ # Send multipart message
335
+ # Use send() with explicit UTF-8 encoding to preserve tabs and special chars
336
+ for i, frame in enumerate(frames):
337
+ flags = zmq.SNDMORE if i < len(frames) - 1 else 0
338
+ self._dealer.send(frame.encode("utf-8"), flags)
339
+
340
+ # Receive response: [status, message]
341
+ status = self._dealer.recv_string()
342
+ message = self._dealer.recv_string()
343
+
344
+ if status == "OK":
345
+ logger.debug(f"Command successful: {frames[0]} (frames={len(frames)})")
346
+ return True
347
+ else:
348
+ logger.error(f"RTD command '{frames[0]}' failed: {message}")
349
+ return False
350
+
351
+ except zmq.Again:
352
+ # Timeout is normal if RTD server hasn't started yet (no Excel RTD calls yet)
353
+ logger.warning("Timeout waiting for RTD server response (may not be started yet)")
354
+ self._connected = False # Mark as disconnected, will retry next time
355
+ return False
356
+ except Exception as e:
357
+ logger.error(f"ZeroMQ error sending command '{frames[0]}': {e}", exc_info=True)
358
+ self._connected = False
359
+ return False
360
+
361
+ def update(self, topic: Union[str, int], value: Union[str, float, int]) -> bool:
362
+ """
363
+ Update a single RTD topic
364
+
365
+ Args:
366
+ topic: Topic name (string) or ID (int)
367
+ value: New value (string, float, or int)
368
+
369
+ Example:
370
+ client.update("priceData", 42.5)
371
+ client.update(123, "hello world")
372
+ """
373
+ return self._send_command(["U", str(topic), str(value)])
374
+
375
+ def _flush_batch(self):
376
+ """Flush the current batch of complete() calls"""
377
+ with self._batch_lock:
378
+ if not self._batch_queue:
379
+ return
380
+
381
+ # Cancel pending timer
382
+ if self._batch_timer:
383
+ try:
384
+ self._batch_timer.cancel()
385
+ except:
386
+ pass # Timer may have already fired
387
+ self._batch_timer = None
388
+
389
+ # Make a copy of the queue and clear it
390
+ batch_copy = list(self._batch_queue)
391
+ self._batch_queue.clear()
392
+
393
+ # Send bulk complete command outside of lock
394
+ updates = [TopicUpdate(topic, value) for topic, value in batch_copy]
395
+ return self.bulk_update(updates, auto_complete=True)
396
+
397
+ def complete(self, topic: Union[str, int], value: ExcelValue) -> bool:
398
+ """
399
+ Mark topic as complete with final value
400
+ Triggers cache notification to all subscribed clients via PUB socket
401
+ All values are serialized with type prefixes (d:, b:, s:, m:)
402
+
403
+ When batching is enabled, calls are automatically batched together.
404
+ For small batches (< min_batch_size), sends immediately to avoid delays.
405
+ For larger batches, flushes when batch_size is reached or batch_timeout_ms elapses.
406
+
407
+ Args:
408
+ topic: Topic name (string) or ID (int)
409
+ value: Final value (scalar, 2D matrix, or pandas DataFrame)
410
+
411
+ Example:
412
+ client.complete("calculation", 100.0) # Sends as "d:100.0"
413
+ client.complete("status", "DONE") # Sends as "s:DONE"
414
+ client.complete("flag", True) # Sends as "b:true"
415
+ client.complete("matrix", [[1, 2]]) # Sends as "m:d:1\td:2"
416
+ client.complete("data", df) # DataFrame with headers (requires pandas)
417
+ """
418
+ serialized = _serialize_value(value)
419
+
420
+ if not self.enable_batching:
421
+ # Direct send without batching
422
+ return self._send_command(["C", str(topic), serialized])
423
+
424
+ # Add to batch queue (store serialized value)
425
+ should_flush = False
426
+ with self._batch_lock:
427
+ self._batch_queue.append((topic, serialized))
428
+ queue_size = len(self._batch_queue)
429
+
430
+ # Start timer on first item
431
+ if queue_size == 1 and self._batch_timer is None:
432
+ self._batch_timer = threading.Timer(
433
+ self.batch_timeout_ms / 1000.0, self._flush_batch
434
+ )
435
+ self._batch_timer.daemon = True
436
+ self._batch_timer.start()
437
+
438
+ # Flush immediately when batch is full
439
+ if queue_size >= self.batch_size:
440
+ should_flush = True
441
+
442
+ # Flush outside of lock to avoid deadlock
443
+ if should_flush:
444
+ self._flush_batch()
445
+
446
+ return True
447
+
448
+ def dirty(self, topic: Union[str, int]) -> bool:
449
+ """
450
+ Mark topic as dirty (needs refresh) without changing value
451
+
452
+ Args:
453
+ topic: Topic name (string) or ID (int)
454
+
455
+ Example:
456
+ client.dirty("myTopic")
457
+ client.dirty(123)
458
+ """
459
+ return self._send_command(["D", str(topic), ""])
460
+
461
+ def dirty_complete(self, topic: Union[str, int]) -> bool:
462
+ """
463
+ Mark topic as dirty and complete (for in-process cache updates)
464
+
465
+ Note: This is an optimization for in-process updates where the value
466
+ is already in RTDCache. External processes should use complete() instead.
467
+
468
+ Args:
469
+ topic: Topic name (string) or ID (int)
470
+
471
+ Example:
472
+ client.dirty_complete("myTopic")
473
+ """
474
+ return self._send_command(["DC", str(topic), ""])
475
+
476
+ def bulk_update(self, updates: List[TopicUpdate], auto_complete: bool = True) -> bool:
477
+ """
478
+ Update multiple topics in a single bulk operation
479
+ More efficient than individual updates
480
+ Values are automatically serialized with type prefixes
481
+
482
+ Args:
483
+ updates: List of TopicUpdate objects
484
+ auto_complete: If True, marks all topics as complete and triggers cache broadcast (default: True)
485
+
486
+ Example:
487
+ # Mark all as complete (default behavior)
488
+ client.bulk_update([
489
+ TopicUpdate("price", 42.5),
490
+ TopicUpdate("status", "Processing"),
491
+ TopicUpdate(789, 100)
492
+ ])
493
+
494
+ # Just update without completing
495
+ client.bulk_update([
496
+ TopicUpdate("price", 42.5),
497
+ ], auto_complete=False)
498
+ """
499
+ # Build bulk message: ["BC", "count", "topic1", "value1", "topic2", "value2", ...]
500
+ # Or ["B", "count", ...] for bulk update without complete
501
+ cmd = "BC" if auto_complete else "B"
502
+ frames = [cmd, str(len(updates))]
503
+ for update in updates:
504
+ frames.append(str(update.topic))
505
+ # Serialize value if not already serialized (check for type prefix)
506
+ value_str = str(update.value)
507
+ if not (len(value_str) >= 2 and value_str[1] == ":"):
508
+ # Not yet serialized, serialize it
509
+ value_str = _serialize_value(update.value)
510
+ frames.append(value_str)
511
+
512
+ return self._send_command(frames)
513
+
514
+ def flush(self):
515
+ """
516
+ Manually flush any pending batched complete() calls
517
+ Useful when you want to ensure all pending updates are sent immediately
518
+ """
519
+ self._flush_batch()
520
+
521
+ def ping(self) -> bool:
522
+ """
523
+ Send a ping to check if RTD server is alive
524
+
525
+ Returns:
526
+ True if server responded
527
+ """
528
+ return self._send_command(["PING", ""])
529
+
530
+ def evict_all(self) -> bool:
531
+ """
532
+ Evict all topics from the cache
533
+ Broadcasts cache eviction for all registered topics
534
+
535
+ Returns:
536
+ True if command was successful
537
+
538
+ Example:
539
+ client.evict_all()
540
+ """
541
+ return self._send_command(["EVICTALL"])
542
+
543
+ def close(self):
544
+ """Close the ZeroMQ connection and flush any pending batches"""
545
+ # Flush any pending batches before closing
546
+ try:
547
+ self._flush_batch()
548
+ except:
549
+ pass
550
+
551
+ # Cancel any pending timers
552
+ with self._batch_lock:
553
+ if self._batch_timer:
554
+ try:
555
+ self._batch_timer.cancel()
556
+ except:
557
+ pass
558
+ self._batch_timer = None
559
+
560
+ # Close sockets
561
+ with self._socket_lock:
562
+ if self._dealer:
563
+ self._dealer.close()
564
+ self._dealer = None
565
+ if self._context:
566
+ self._context.term()
567
+ self._context = None
568
+ self._connected = False
569
+
570
+ def __enter__(self):
571
+ """Context manager support"""
572
+ return self
573
+
574
+ def __exit__(self, exc_type, exc_val, exc_tb):
575
+ """Context manager cleanup"""
576
+ self.close()