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/XLLIFY_DIST_VERSION +2 -0
- xllify/__init__.py +101 -0
- xllify/__main__.py +343 -0
- xllify/diagnostics.py +78 -0
- xllify/funcinfo.py +375 -0
- xllify/install.py +251 -0
- xllify/py.typed +1 -0
- xllify/rpc_server.py +639 -0
- xllify/rtd_client.py +576 -0
- xllify-0.8.9.dist-info/METADATA +407 -0
- xllify-0.8.9.dist-info/RECORD +15 -0
- xllify-0.8.9.dist-info/WHEEL +5 -0
- xllify-0.8.9.dist-info/entry_points.txt +5 -0
- xllify-0.8.9.dist-info/licenses/LICENSE +21 -0
- xllify-0.8.9.dist-info/top_level.txt +1 -0
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()
|