cgse-common 0.17.2__py3-none-any.whl → 0.17.3__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.
egse/ratelimit.py DELETED
@@ -1,275 +0,0 @@
1
- """
2
- Rate limiting utilities for controlling execution frequency of functions and code blocks.
3
-
4
- This module provides decorators, context managers, and utility functions to limit how
5
- often code executes, which is particularly useful for:
6
-
7
- - Reducing noisy output in loops (print statements, logging)
8
- - Throttling expensive operations
9
- - Controlling debug message frequency
10
- - Implementing backpressure in data processing pipelines
11
-
12
- The rate limiting tracks execution counts per unique key and shows suppressed message
13
- counts similar to system loggers (e.g., "... 5 more similar messages suppressed").
14
-
15
- Basic Usage:
16
- # Decorator - limit function execution
17
- @rate_limit(every_n=10)
18
- def debug_function():
19
- print("Debug info")
20
-
21
- # Context manager - limit code block execution
22
- for i in range(100):
23
- with rate_limited(every_n=10, key="progress") as should_execute:
24
- if should_execute:
25
- print(f"Progress: {i}")
26
-
27
- # Convenience functions
28
- for i in range(100):
29
- rate_limited_print(f"Processing item {i}", every_n=10)
30
-
31
- Advanced Usage:
32
- # Custom rate limiter instances for isolation
33
- debug_limiter = RateLimiter()
34
-
35
- @rate_limit(every_n=5, limiter=debug_limiter)
36
- def isolated_debug():
37
- print("This uses a separate counter")
38
-
39
- # Rate-limited logging with suppression tracking
40
- import logging
41
- logger = logging.getLogger(__name__)
42
-
43
- for i in range(50):
44
- rate_limited_log(logger, logging.WARNING,
45
- "High CPU usage", every_n=10)
46
-
47
- Classes:
48
- RateLimiter: Core rate limiting logic with per-key counters
49
-
50
- Functions:
51
- rate_limit: Decorator for rate-limiting function calls
52
- rate_limited: Context manager for rate-limiting code blocks
53
- rate_limited_print: Convenience function for rate-limited printing
54
- rate_limited_log: Convenience function for rate-limited logging
55
-
56
- Author: Generated by Claude
57
- License: MIT
58
- """
59
-
60
- import time
61
- import logging
62
- from functools import wraps
63
- from collections import defaultdict
64
- from contextlib import contextmanager
65
- from typing import Dict, Any, Optional, Callable
66
-
67
-
68
- class RateLimiter:
69
- """A rate limiter that tracks execution counts and provides rate limiting functionality."""
70
-
71
- def __init__(self):
72
- self.counters: Dict[str, int] = defaultdict(int)
73
- self.last_executed: Dict[str, int] = defaultdict(int)
74
- self.suppressed_count: Dict[str, int] = defaultdict(int)
75
-
76
- def should_execute(self, key: str, every_n: int) -> tuple[bool, int]:
77
- """
78
- Check if an operation should be executed based on rate limiting.
79
-
80
- Returns:
81
- tuple: (should_execute: bool, suppressed_count: int)
82
- """
83
- self.counters[key] += 1
84
-
85
- # Execute on 1st call, then every every_n calls after that
86
- if self.counters[key] == 1 or (self.counters[key] - 1) % every_n == 0:
87
- # For the first call, no suppressed count to return
88
- if self.counters[key] == 1:
89
- return True, 0
90
- else:
91
- # For subsequent executions, return the suppressed count and reset it
92
- suppressed = self.suppressed_count[key]
93
- self.suppressed_count[key] = 0
94
- return True, suppressed
95
- else:
96
- self.suppressed_count[key] += 1
97
- return False, 0
98
-
99
- def reset(self, key: Optional[str] = None):
100
- """Reset counters for a specific key or all keys."""
101
- if key:
102
- self.counters[key] = 0
103
- self.last_executed[key] = 0
104
- self.suppressed_count[key] = 0
105
- else:
106
- self.counters.clear()
107
- self.last_executed.clear()
108
- self.suppressed_count.clear()
109
-
110
-
111
- # Global rate limiter instance
112
- _global_limiter = RateLimiter()
113
-
114
-
115
- def rate_limit(
116
- every_n: int = 10, key: Optional[str] = None, show_suppressed: bool = True, limiter: Optional[RateLimiter] = None
117
- ):
118
- """
119
- Decorator that limits function execution to every N calls.
120
-
121
- Args:
122
- every_n: Execute only every N calls
123
- key: Unique identifier for this rate limiter (auto-generated if None)
124
- show_suppressed: Whether to show "... N more suppressed" message
125
- limiter: Custom RateLimiter instance (uses global one if None)
126
- """
127
-
128
- def decorator(func: Callable) -> Callable:
129
- nonlocal key
130
- if key is None:
131
- key = f"{func.__module__}.{func.__qualname__}"
132
-
133
- rate_limiter = limiter or _global_limiter
134
-
135
- @wraps(func)
136
- def wrapper(*args, **kwargs):
137
- should_execute, suppressed = rate_limiter.should_execute(key, every_n)
138
-
139
- if should_execute:
140
- # Show suppressed count BEFORE executing (except for first execution)
141
- if show_suppressed and suppressed > 0:
142
- print(f"... {suppressed} more similar messages suppressed")
143
-
144
- result = func(*args, **kwargs)
145
- return result
146
-
147
- return None # Suppressed execution
148
-
149
- # Add utility methods to the wrapper
150
- wrapper.reset = lambda: rate_limiter.reset(key)
151
- wrapper.get_count = lambda: rate_limiter.counters[key]
152
-
153
- return wrapper
154
-
155
- return decorator
156
-
157
-
158
- @contextmanager
159
- def rate_limited(
160
- every_n: int = 10, key: Optional[str] = None, show_suppressed: bool = True, limiter: Optional[RateLimiter] = None
161
- ):
162
- """
163
- Context manager that limits execution of code block to every N calls.
164
-
165
- Args:
166
- every_n: Execute only every N calls
167
- key: Unique identifier for this rate limiter (required for context manager)
168
- show_suppressed: Whether to show "... N more suppressed" message
169
- limiter: Custom RateLimiter instance (uses global one if None)
170
-
171
- Yields:
172
- bool: True if code should execute, False if suppressed
173
- """
174
- if key is None:
175
- raise ValueError("Context manager requires a unique 'key' parameter")
176
-
177
- rate_limiter = limiter or _global_limiter
178
- should_execute, suppressed = rate_limiter.should_execute(key, every_n)
179
-
180
- if should_execute:
181
- # Show suppressed count BEFORE yielding (except for first execution)
182
- if show_suppressed and suppressed > 0:
183
- print(f"... {suppressed} more similar operations suppressed")
184
-
185
- try:
186
- yield should_execute
187
- finally:
188
- pass # Nothing to do in finally
189
-
190
-
191
- # Convenience functions for common use cases
192
- def rate_limited_print(message: str, every_n: int = 10, key: Optional[str] = None):
193
- """Print a message only every N calls."""
194
- if key is None:
195
- key = f"print_{hash(message) % 10000}"
196
-
197
- with rate_limited(every_n, key=key) as should_execute:
198
- if should_execute:
199
- print(message)
200
-
201
-
202
- def rate_limited_log(
203
- logger: logging.Logger, level: int, message: str, every_n: int = 10, key: Optional[str] = None, *args
204
- ):
205
- """Log a message only every N calls."""
206
- if key is None:
207
- key = f"log_{hash(message) % 10000}"
208
-
209
- with rate_limited(every_n, key=key, show_suppressed=False) as should_execute:
210
- if should_execute:
211
- suppressed = _global_limiter.suppressed_count[key]
212
- if suppressed > 0:
213
- message += f" (... {suppressed} more similar messages suppressed)"
214
- logger.log(level, message, *args)
215
-
216
-
217
- # Example usage and demonstrations
218
- if __name__ == "__main__":
219
- print("=== Decorator Example ===")
220
-
221
- @rate_limit(every_n=3, show_suppressed=True)
222
- def noisy_function(i):
223
- print(f"Processing item {i}")
224
- return f"result_{i}"
225
-
226
- # This will only print every 3rd call
227
- for i in range(10):
228
- noisy_function(i)
229
-
230
- print(f"\nTotal calls to noisy_function: {noisy_function.get_count()}")
231
-
232
- print("\n=== Context Manager Example ===")
233
-
234
- # Using context manager in a loop
235
- for i in range(15):
236
- with rate_limited(every_n=5, key="loop_progress") as should_execute:
237
- if should_execute:
238
- print(f"Progress update: processed {i + 1} items")
239
-
240
- print("\n=== Convenience Functions Example ===")
241
-
242
- # Using convenience functions
243
- for i in range(8):
244
- rate_limited_print(f"Item {i} processed", every_n=3, key="item_processing")
245
-
246
- print("\n=== Logging Example ===")
247
-
248
- # Setup logging
249
- logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
250
- logger = logging.getLogger(__name__)
251
-
252
- # Rate-limited logging
253
- for i in range(12):
254
- rate_limited_log(
255
- logger, logging.WARNING, f"High memory usage detected: {i * 10}%", every_n=4, key="memory_warning"
256
- )
257
-
258
- print("\n=== Multiple Rate Limiters Example ===")
259
-
260
- # Using separate rate limiter instances for different purposes
261
- debug_limiter = RateLimiter()
262
- error_limiter = RateLimiter()
263
-
264
- @rate_limit(every_n=2, key="debug_messages", limiter=debug_limiter)
265
- def debug_message(msg):
266
- print(f"DEBUG: {msg}")
267
-
268
- @rate_limit(every_n=4, key="error_messages", limiter=error_limiter)
269
- def error_message(msg):
270
- print(f"ERROR: {msg}")
271
-
272
- for i in range(10):
273
- debug_message(f"Debug info {i}")
274
- if i % 2 == 0:
275
- error_message(f"Error condition {i}")
egse/socketdevice.py DELETED
@@ -1,379 +0,0 @@
1
- """
2
- This module defines base classes and generic functions to work with sockets.
3
- """
4
-
5
- import asyncio
6
- import select
7
- import socket
8
- import time
9
- from typing import Optional
10
-
11
- from egse.device import AsyncDeviceInterface
12
- from egse.device import AsyncDeviceTransport
13
- from egse.device import DeviceConnectionError
14
- from egse.device import DeviceConnectionInterface
15
- from egse.device import DeviceTimeoutError
16
- from egse.device import DeviceTransport
17
- from egse.log import logger
18
- from egse.system import type_name
19
-
20
-
21
- class SocketDevice(DeviceConnectionInterface, DeviceTransport):
22
- """Base class that implements the socket interface."""
23
-
24
- # We set a default connect timeout of 3.0 sec before connecting and reset
25
- # to None (=blocking) after connecting. The reason for this is that when no
26
- # device is available, e.g. during testing, the timeout will take about
27
- # two minutes which is way too long. It needs to be evaluated if this
28
- # approach is acceptable and not causing problems during production.
29
-
30
- def __init__(
31
- self,
32
- hostname: str,
33
- port: int,
34
- connect_timeout: float = 3.0,
35
- read_timeout: float | None = 1.0,
36
- separator: str = b"\x03",
37
- ):
38
- super().__init__()
39
- self.is_connection_open = False
40
- self.hostname = hostname
41
- self.port = port
42
- self.connect_timeout = connect_timeout
43
- self.read_timeout = read_timeout
44
- self.separator = separator
45
- self.socket = None
46
-
47
- @property
48
- def device_name(self):
49
- """The name of the device that this interface connects to."""
50
- return f"SocketDevice({self.hostname}:{self.port})"
51
-
52
- def connect(self):
53
- """
54
- Connect the device.
55
-
56
- Raises:
57
- ConnectionError: When the connection could not be established. Check the logging
58
- messages for more detail.
59
- TimeoutError: When the connection timed out.
60
- ValueError: When hostname or port number are not provided.
61
- """
62
-
63
- # Sanity checks
64
-
65
- if self.is_connection_open:
66
- logger.warning(f"{self.device_name}: trying to connect to an already connected socket.")
67
- return
68
-
69
- if self.hostname in (None, ""):
70
- raise ValueError(f"{self.device_name}: hostname is not initialized.")
71
-
72
- if self.port in (None, 0):
73
- raise ValueError(f"{self.device_name}: port number is not initialized.")
74
-
75
- # Create a new socket instance
76
-
77
- try:
78
- self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
79
- except socket.error as exc:
80
- raise ConnectionError(f"{self.device_name}: Failed to create socket.") from exc
81
-
82
- try:
83
- logger.debug(f'Connecting a socket to host "{self.hostname}" using port {self.port}')
84
- self.socket.settimeout(self.connect_timeout)
85
- self.socket.connect((self.hostname, self.port))
86
- self.socket.settimeout(None)
87
- except ConnectionRefusedError as exc:
88
- raise ConnectionError(f"{self.device_name}: Connection refused to {self.hostname}:{self.port}.") from exc
89
- except TimeoutError as exc:
90
- raise TimeoutError(f"{self.device_name}: Connection to {self.hostname}:{self.port} timed out.") from exc
91
- except socket.gaierror as exc:
92
- raise ConnectionError(f"{self.device_name}: socket address info error for {self.hostname}") from exc
93
- except socket.herror as exc:
94
- raise ConnectionError(f"{self.device_name}: socket host address error for {self.hostname}") from exc
95
- except socket.timeout as exc:
96
- raise TimeoutError(f"{self.device_name}: socket timeout error for {self.hostname}:{self.port}") from exc
97
- except OSError as exc:
98
- raise ConnectionError(f"{self.device_name}: OSError caught ({exc}).") from exc
99
-
100
- self.is_connection_open = True
101
-
102
- def disconnect(self):
103
- """
104
- Disconnect from the Ethernet connection.
105
-
106
- Raises:
107
- ConnectionError when the socket could not be closed.
108
- """
109
-
110
- try:
111
- if self.is_connection_open:
112
- logger.debug(f"Disconnecting from {self.hostname}")
113
- self.socket.close()
114
- self.is_connection_open = False
115
- except Exception as e_exc:
116
- raise ConnectionError(f"{self.device_name}: Could not close socket to {self.hostname}") from e_exc
117
-
118
- def is_connected(self) -> bool:
119
- """
120
- Check if the device is connected.
121
-
122
- Returns:
123
- True is the device is connected, False otherwise.
124
- """
125
-
126
- return bool(self.is_connection_open)
127
-
128
- def reconnect(self):
129
- """
130
- Reconnect to the device. If the connection is open, this function will first disconnect
131
- and then connect again.
132
- """
133
-
134
- if self.is_connection_open:
135
- self.disconnect()
136
- self.connect()
137
-
138
- def read(self) -> bytes:
139
- """
140
- Read until ETX (b'\x03') or until `self.read_timeout` elapses.
141
- Uses `select` to avoid blocking indefinitely when no data is available.
142
- If `self.read_timeout` was set to None in the constructor, this will block anyway.
143
- """
144
- if not self.socket:
145
- raise DeviceConnectionError(self.device_name, "Not connected")
146
-
147
- buf_size = 1024 * 4
148
- response = bytearray()
149
-
150
- # If read_timeout is None we preserve blocking behaviour; otherwise enforce overall timeout.
151
- if self.read_timeout is None:
152
- end_time = None
153
- else:
154
- end_time = time.monotonic() + self.read_timeout
155
-
156
- try:
157
- while True:
158
- # compute the remaining timeout for select, this is needed because we read in different parts
159
- # until ETX is received, and we want to receive the complete messages including ETX within
160
- # the read timeout.
161
- if end_time is None:
162
- timeout = None
163
- else:
164
- remaining = end_time - time.monotonic()
165
- if remaining <= 0.0:
166
- raise DeviceTimeoutError(self.device_name, "Socket read timed out")
167
- timeout = remaining
168
-
169
- ready, _, _ = select.select([self.socket], [], [], timeout)
170
-
171
- if not ready:
172
- # no socket ready within timeout
173
- raise DeviceTimeoutError(self.device_name, "Socket read timed out")
174
-
175
- try:
176
- data = self.socket.recv(buf_size)
177
- except OSError as exc:
178
- raise DeviceConnectionError(self.device_name, f"Caught {type_name(exc)}: {exc}") from exc
179
-
180
- if not data:
181
- # remote closed connection (EOF)
182
- raise DeviceConnectionError(self.device_name, "Connection closed by peer")
183
-
184
- response.extend(data)
185
-
186
- if self.separator in response:
187
- break
188
-
189
- except DeviceTimeoutError:
190
- raise
191
- except DeviceConnectionError:
192
- raise
193
- except Exception as exc:
194
- # unexpected errors - translate to DeviceConnectionError
195
- raise DeviceConnectionError(self.device_name, "Socket read error") from exc
196
-
197
- return bytes(response)
198
-
199
- def write(self, command: str):
200
- """
201
- Send a command to the device.
202
-
203
- No processing is done on the command string, except for the encoding into a bytes object.
204
-
205
- Args:
206
- command: the command string including terminators.
207
-
208
- Raises:
209
- A DeviceTimeoutError when the send timed out, and a DeviceConnectionError if
210
- there was a socket related error.
211
- """
212
-
213
- try:
214
- self.socket.sendall(command.encode())
215
- except socket.timeout as exc:
216
- raise DeviceTimeoutError(self.device_name, "Socket timeout error") from exc
217
- except socket.error as exc:
218
- # Interpret any socket-related error as an I/O error
219
- raise DeviceConnectionError(self.device_name, "Socket communication error.") from exc
220
-
221
- def trans(self, command: str) -> bytes:
222
- """
223
- Send a command to the device and wait for the response.
224
-
225
- No processing is done on the command string, except for the encoding into a bytes object.
226
-
227
- Args:
228
- command: the command string including terminators.
229
-
230
- Returns:
231
- A bytes object containing the response from the device. No processing is done
232
- on the response.
233
-
234
- Raises:
235
- A DeviceTimeoutError when the send timed out, and a DeviceConnectionError if
236
- there was a socket related error.
237
- """
238
-
239
- try:
240
- # Attempt to send the complete command
241
-
242
- self.socket.sendall(command.encode())
243
-
244
- # wait for, read and return the response (will be at most TBD chars)
245
-
246
- return_string = self.read()
247
-
248
- return return_string
249
-
250
- except socket.timeout as exc:
251
- raise DeviceTimeoutError(self.device_name, "Socket timeout error") from exc
252
- except socket.error as exc:
253
- # Interpret any socket-related error as an I/O error
254
- raise DeviceConnectionError(self.device_name, "Socket communication error.") from exc
255
-
256
-
257
- class AsyncSocketDevice(AsyncDeviceInterface, AsyncDeviceTransport):
258
- """
259
- Async socket-backed device using asyncio streams.
260
-
261
- - async connect() / disconnect()
262
- - async read() reads until ETX (b'\\x03') or timeout
263
- - async write() and async trans()
264
- """
265
-
266
- def __init__(
267
- self,
268
- hostname: str,
269
- port: int,
270
- connect_timeout: float = 3.0,
271
- read_timeout: float | None = 1.0,
272
- separator: str = b"\x03",
273
- ):
274
- super().__init__()
275
- self.hostname = hostname
276
- self.port = port
277
- self.connect_timeout = connect_timeout
278
- self.read_timeout = read_timeout
279
- self.separator = separator
280
- self.reader: Optional[asyncio.StreamReader] = None
281
- self.writer: Optional[asyncio.StreamWriter] = None
282
- self.is_connection_open = False
283
-
284
- @property
285
- def device_name(self) -> str:
286
- # Override this property for a decent name
287
- return f"AsyncSocketDevice({self.hostname}:{self.port})"
288
-
289
- async def connect(self) -> None:
290
- if self.is_connection_open:
291
- logger.debug(f"{self.device_name}: already connected")
292
- return
293
-
294
- if not self.hostname:
295
- raise ValueError(f"{self.device_name}: hostname is not initialized.")
296
- if not self.port:
297
- raise ValueError(f"{self.device_name}: port is not initialized.")
298
-
299
- try:
300
- logger.debug(f"{self.device_name}: connect() called; is_connection_open={self.is_connection_open}")
301
- coro = asyncio.open_connection(self.hostname, self.port)
302
- self.reader, self.writer = await asyncio.wait_for(coro, timeout=self.connect_timeout)
303
- self.is_connection_open = True
304
- logger.debug(f"{self.device_name}: connected -> peer={self.writer.get_extra_info('peername')}")
305
-
306
- except asyncio.TimeoutError as exc:
307
- await self._cleanup()
308
- logger.warning(f"{self.device_name}: connect timed out")
309
- raise DeviceTimeoutError(self.device_name, f"Connection to {self.hostname}:{self.port} timed out.") from exc
310
- except Exception as exc:
311
- await self._cleanup()
312
- logger.warning(f"{self.device_name}: connect failed: {type_name(exc)} – {exc}")
313
- raise DeviceConnectionError(self.device_name, f"Failed to connect to {self.hostname}:{self.port}") from exc
314
-
315
- async def disconnect(self) -> None:
316
- logger.debug(f"{self.device_name}: disconnect() called; writer_exists={bool(self.writer)}")
317
- peer = None
318
- try:
319
- if self.writer and not self.writer.is_closing():
320
- peer = self.writer.get_extra_info("peername")
321
- self.writer.close()
322
- # wait for close, but don't hang forever
323
- try:
324
- await asyncio.wait_for(self.writer.wait_closed(), timeout=1.0)
325
- except asyncio.TimeoutError:
326
- logger.debug(f"{self.device_name}: wait_closed() timed out for peer={peer}")
327
-
328
- finally:
329
- await self._cleanup()
330
- logger.debug(f"{self.device_name}: disconnected ({peer=})")
331
-
332
- def is_connected(self) -> bool:
333
- return bool(self.is_connection_open and self.writer and not self.writer.is_closing())
334
-
335
- async def _cleanup(self) -> None:
336
- self.reader = None
337
- self.writer = None
338
- self.is_connection_open = False
339
-
340
- async def read(self) -> bytes:
341
- if not self.reader:
342
- raise DeviceConnectionError(self.device_name, "Not connected")
343
- try:
344
- # readuntil includes the separator; we keep it for parity with existing code
345
- data = await asyncio.wait_for(self.reader.readuntil(separator=self.separator), timeout=self.read_timeout)
346
- return data
347
- except asyncio.IncompleteReadError as exc:
348
- # EOF before separator
349
- await self._cleanup()
350
- raise DeviceConnectionError(self.device_name, "Connection closed while reading") from exc
351
- except asyncio.TimeoutError as exc:
352
- raise DeviceTimeoutError(self.device_name, "Socket read timed out") from exc
353
- except Exception as exc:
354
- await self._cleanup()
355
- raise DeviceConnectionError(self.device_name, "Socket read error") from exc
356
-
357
- async def write(self, command: str) -> None:
358
- if not self.writer:
359
- raise DeviceConnectionError(self.device_name, "Not connected")
360
- try:
361
- self.writer.write(command.encode())
362
- await asyncio.wait_for(self.writer.drain(), timeout=self.read_timeout)
363
- except asyncio.TimeoutError as exc:
364
- raise DeviceTimeoutError(self.device_name, "Socket write timed out") from exc
365
- except Exception as exc:
366
- await self._cleanup()
367
- raise DeviceConnectionError(self.device_name, "Socket write error") from exc
368
-
369
- async def trans(self, command: str) -> bytes:
370
- if not self.writer or not self.reader:
371
- raise DeviceConnectionError(self.device_name, "Not connected")
372
- try:
373
- await self.write(command)
374
- return await self.read()
375
- except (DeviceTimeoutError, DeviceConnectionError):
376
- raise
377
- except Exception as exc:
378
- await self._cleanup()
379
- raise DeviceConnectionError(self.device_name, "Socket trans error") from exc