cgse-common 0.17.1__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.
- {cgse_common-0.17.1.dist-info → cgse_common-0.17.3.dist-info}/METADATA +1 -1
- {cgse_common-0.17.1.dist-info → cgse_common-0.17.3.dist-info}/RECORD +14 -18
- {cgse_common-0.17.1.dist-info → cgse_common-0.17.3.dist-info}/WHEEL +1 -1
- egse/bits.py +14 -17
- egse/decorators.py +6 -3
- egse/device.py +0 -70
- egse/env.py +2 -1
- egse/exceptions.py +3 -0
- egse/log.py +7 -8
- egse/scpi.py +47 -112
- egse/settings.py +31 -0
- egse/system.py +63 -24
- egse/version.py +24 -13
- egse/plugins/metrics/duckdb.py +0 -442
- egse/plugins/metrics/timescaledb.py +0 -596
- egse/ratelimit.py +0 -275
- egse/socketdevice.py +0 -379
- {cgse_common-0.17.1.dist-info → cgse_common-0.17.3.dist-info}/entry_points.txt +0 -0
egse/scpi.py
CHANGED
|
@@ -4,20 +4,17 @@ from typing import Any
|
|
|
4
4
|
from typing import Dict
|
|
5
5
|
from typing import Optional
|
|
6
6
|
|
|
7
|
-
from egse.device import AsyncDeviceInterface
|
|
8
7
|
from egse.device import AsyncDeviceTransport
|
|
9
8
|
from egse.device import DeviceConnectionError
|
|
10
9
|
from egse.device import DeviceError
|
|
11
10
|
from egse.device import DeviceTimeoutError
|
|
12
11
|
from egse.log import logger
|
|
13
12
|
|
|
13
|
+
# Constants that can be overridden by specific device implementations
|
|
14
14
|
DEFAULT_READ_TIMEOUT = 1.0 # seconds
|
|
15
15
|
DEFAULT_CONNECT_TIMEOUT = 3.0 # seconds
|
|
16
16
|
IDENTIFICATION_QUERY = "*IDN?"
|
|
17
17
|
|
|
18
|
-
SEPARATOR = b"\n"
|
|
19
|
-
SEPARATOR_STR = SEPARATOR.decode()
|
|
20
|
-
|
|
21
18
|
|
|
22
19
|
class SCPICommand:
|
|
23
20
|
"""Base class for SCPI commands."""
|
|
@@ -35,7 +32,7 @@ class SCPICommand:
|
|
|
35
32
|
raise NotImplementedError("Subclasses must implement get_cmd_string().")
|
|
36
33
|
|
|
37
34
|
|
|
38
|
-
class AsyncSCPIInterface(
|
|
35
|
+
class AsyncSCPIInterface(AsyncDeviceTransport):
|
|
39
36
|
"""Generic asynchronous interface for devices that use SCPI commands over Ethernet."""
|
|
40
37
|
|
|
41
38
|
def __init__(
|
|
@@ -59,9 +56,7 @@ class AsyncSCPIInterface(AsyncDeviceInterface, AsyncDeviceTransport):
|
|
|
59
56
|
read_timeout: Timeout for read operations in seconds
|
|
60
57
|
id_validation: String that should appear in the device's identification response
|
|
61
58
|
"""
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
self._device_name = device_name
|
|
59
|
+
self.device_name = device_name
|
|
65
60
|
self.hostname = hostname
|
|
66
61
|
self.port = port
|
|
67
62
|
self.settings = settings or {}
|
|
@@ -78,65 +73,6 @@ class AsyncSCPIInterface(AsyncDeviceInterface, AsyncDeviceTransport):
|
|
|
78
73
|
"""Prevents multiple coroutines from attempting to read, write or query from the same stream
|
|
79
74
|
at the same time."""
|
|
80
75
|
|
|
81
|
-
def is_simulator(self) -> bool:
|
|
82
|
-
return False
|
|
83
|
-
|
|
84
|
-
@property
|
|
85
|
-
def device_name(self) -> str:
|
|
86
|
-
return self._device_name
|
|
87
|
-
|
|
88
|
-
async def initialize(self, commands: list[tuple[str, bool]] = None, reset_device: bool = False) -> list[str | None]:
|
|
89
|
-
"""Initialize the device with optional reset and command sequence.
|
|
90
|
-
|
|
91
|
-
Performs device initialization by optionally resetting the device and then
|
|
92
|
-
executing a sequence of commands. Each command can optionally expect a
|
|
93
|
-
response that will be logged for debugging purposes.
|
|
94
|
-
|
|
95
|
-
Args:
|
|
96
|
-
commands: List of tuples containing (command_string, expects_response).
|
|
97
|
-
Each tuple specifies a command to send and whether to wait for and
|
|
98
|
-
log the response. Defaults to None (no commands executed).
|
|
99
|
-
reset_device: Whether to send a reset command (*RST) before executing
|
|
100
|
-
the command sequence. Defaults to False.
|
|
101
|
-
|
|
102
|
-
Returns:
|
|
103
|
-
Response for each of the commands, or None when no response was expected.
|
|
104
|
-
|
|
105
|
-
Raises:
|
|
106
|
-
Any exceptions raised by the underlying write() or trans() methods,
|
|
107
|
-
typically communication errors or device timeouts.
|
|
108
|
-
|
|
109
|
-
Example:
|
|
110
|
-
await device.initialize(
|
|
111
|
-
[
|
|
112
|
-
("*IDN?", True), # Query device ID, expect response
|
|
113
|
-
("SYST:ERR?", True), # Check for errors, expect response
|
|
114
|
-
("OUTP ON", False), # Enable output, no response expected
|
|
115
|
-
],
|
|
116
|
-
reset_device=True
|
|
117
|
-
)
|
|
118
|
-
"""
|
|
119
|
-
|
|
120
|
-
commands = commands or []
|
|
121
|
-
responses = []
|
|
122
|
-
|
|
123
|
-
if reset_device:
|
|
124
|
-
logger.info(f"Resetting the {self._device_name}...")
|
|
125
|
-
await self.write("*RST") # this also resets the user-defined buffer
|
|
126
|
-
|
|
127
|
-
for cmd, expects_response in commands:
|
|
128
|
-
if expects_response:
|
|
129
|
-
logger.debug(f"Sending {cmd}...")
|
|
130
|
-
response = (await self.trans(cmd)).decode().strip()
|
|
131
|
-
responses.append(response)
|
|
132
|
-
logger.debug(f"{response = }")
|
|
133
|
-
else:
|
|
134
|
-
logger.debug(f"Sending {cmd}...")
|
|
135
|
-
await self.write(cmd)
|
|
136
|
-
responses.append(None)
|
|
137
|
-
|
|
138
|
-
return responses
|
|
139
|
-
|
|
140
76
|
async def connect(self) -> None:
|
|
141
77
|
"""Connect to the device asynchronously.
|
|
142
78
|
|
|
@@ -148,47 +84,47 @@ class AsyncSCPIInterface(AsyncDeviceInterface, AsyncDeviceTransport):
|
|
|
148
84
|
async with self._connect_lock:
|
|
149
85
|
# Sanity checks
|
|
150
86
|
if self._is_connection_open:
|
|
151
|
-
logger.warning(f"{self.
|
|
87
|
+
logger.warning(f"{self.device_name}: Trying to connect to an already connected device.")
|
|
152
88
|
return
|
|
153
89
|
|
|
154
90
|
if not self.hostname:
|
|
155
|
-
raise ValueError(f"{self.
|
|
91
|
+
raise ValueError(f"{self.device_name}: Hostname is not initialized.")
|
|
156
92
|
|
|
157
93
|
if not self.port:
|
|
158
|
-
raise ValueError(f"{self.
|
|
94
|
+
raise ValueError(f"{self.device_name}: Port number is not initialized.")
|
|
159
95
|
|
|
160
96
|
# Attempt to establish a connection
|
|
161
97
|
try:
|
|
162
|
-
logger.debug(f'Connecting to {self.
|
|
98
|
+
logger.debug(f'Connecting to {self.device_name} at "{self.hostname}" using port {self.port}')
|
|
163
99
|
|
|
164
100
|
connect_task = asyncio.open_connection(self.hostname, self.port)
|
|
165
101
|
self._reader, self._writer = await asyncio.wait_for(connect_task, timeout=self.connect_timeout)
|
|
166
102
|
|
|
167
103
|
self._is_connection_open = True
|
|
168
104
|
|
|
169
|
-
logger.debug(f"Successfully connected to {self.
|
|
105
|
+
logger.debug(f"Successfully connected to {self.device_name}.")
|
|
170
106
|
|
|
171
107
|
except asyncio.TimeoutError as exc:
|
|
172
108
|
raise DeviceTimeoutError(
|
|
173
|
-
self.
|
|
109
|
+
self.device_name, f"Connection to {self.hostname}:{self.port} timed out"
|
|
174
110
|
) from exc
|
|
175
111
|
except ConnectionRefusedError as exc:
|
|
176
112
|
raise DeviceConnectionError(
|
|
177
|
-
self.
|
|
113
|
+
self.device_name, f"Connection refused to {self.hostname}:{self.port}"
|
|
178
114
|
) from exc
|
|
179
115
|
except socket.gaierror as exc:
|
|
180
|
-
raise DeviceConnectionError(self.
|
|
116
|
+
raise DeviceConnectionError(self.device_name, f"Address resolution error for {self.hostname}") from exc
|
|
181
117
|
except socket.herror as exc:
|
|
182
|
-
raise DeviceConnectionError(self.
|
|
118
|
+
raise DeviceConnectionError(self.device_name, f"Host address error for {self.hostname}") from exc
|
|
183
119
|
except OSError as exc:
|
|
184
|
-
raise DeviceConnectionError(self.
|
|
120
|
+
raise DeviceConnectionError(self.device_name, f"OS error: {exc}") from exc
|
|
185
121
|
|
|
186
122
|
# Validate device identity if requested
|
|
187
123
|
if self.id_validation:
|
|
188
124
|
logger.debug("Validating connection..")
|
|
189
125
|
if not await self.is_connected():
|
|
190
126
|
await self.disconnect()
|
|
191
|
-
raise DeviceConnectionError(self.
|
|
127
|
+
raise DeviceConnectionError(self.device_name, "Device connected but failed identity verification")
|
|
192
128
|
|
|
193
129
|
async def disconnect(self) -> None:
|
|
194
130
|
"""Disconnect from the device asynchronously.
|
|
@@ -199,20 +135,22 @@ class AsyncSCPIInterface(AsyncDeviceInterface, AsyncDeviceTransport):
|
|
|
199
135
|
async with self._connect_lock:
|
|
200
136
|
try:
|
|
201
137
|
if self._is_connection_open and self._writer is not None:
|
|
202
|
-
logger.debug(f"Disconnecting from {self.
|
|
138
|
+
logger.debug(f"Disconnecting from {self.device_name} at {self.hostname}")
|
|
203
139
|
self._writer.close()
|
|
204
140
|
await self._writer.wait_closed()
|
|
205
141
|
self._writer = None
|
|
206
142
|
self._reader = None
|
|
207
143
|
self._is_connection_open = False
|
|
208
144
|
except Exception as exc:
|
|
209
|
-
raise DeviceConnectionError(self.
|
|
145
|
+
raise DeviceConnectionError(self.device_name, f"Could not close connection: {exc}") from exc
|
|
210
146
|
|
|
211
147
|
async def reconnect(self) -> None:
|
|
212
148
|
"""Reconnect to the device asynchronously."""
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
149
|
+
async with self._connect_lock:
|
|
150
|
+
if self._is_connection_open:
|
|
151
|
+
await self.disconnect()
|
|
152
|
+
await asyncio.sleep(0.1)
|
|
153
|
+
await self.connect()
|
|
216
154
|
|
|
217
155
|
async def is_connected(self) -> bool:
|
|
218
156
|
"""Check if the device is connected and responds correctly to identification.
|
|
@@ -230,7 +168,7 @@ class AsyncSCPIInterface(AsyncDeviceInterface, AsyncDeviceTransport):
|
|
|
230
168
|
# Validate the response if validation string is provided
|
|
231
169
|
if self.id_validation and self.id_validation not in id_response:
|
|
232
170
|
logger.error(
|
|
233
|
-
f"{self.
|
|
171
|
+
f"{self.device_name}: Device did not respond correctly to identification query. "
|
|
234
172
|
f'Expected "{self.id_validation}" in response, got: {id_response}'
|
|
235
173
|
)
|
|
236
174
|
await self.disconnect()
|
|
@@ -239,7 +177,8 @@ class AsyncSCPIInterface(AsyncDeviceInterface, AsyncDeviceTransport):
|
|
|
239
177
|
return True
|
|
240
178
|
|
|
241
179
|
except DeviceError as exc:
|
|
242
|
-
logger.
|
|
180
|
+
logger.exception(exc)
|
|
181
|
+
logger.error(f"{self.device_name}: Connection test failed")
|
|
243
182
|
await self.disconnect()
|
|
244
183
|
return False
|
|
245
184
|
|
|
@@ -256,20 +195,19 @@ class AsyncSCPIInterface(AsyncDeviceInterface, AsyncDeviceTransport):
|
|
|
256
195
|
async with self._io_lock:
|
|
257
196
|
try:
|
|
258
197
|
if not self._is_connection_open or self._writer is None:
|
|
259
|
-
raise DeviceConnectionError(self.
|
|
198
|
+
raise DeviceConnectionError(self.device_name, "Device not connected, use connect() first")
|
|
260
199
|
|
|
261
|
-
# Ensure command ends with
|
|
262
|
-
if not command.endswith(
|
|
263
|
-
command +=
|
|
200
|
+
# Ensure command ends with newline
|
|
201
|
+
if not command.endswith("\n"):
|
|
202
|
+
command += "\n"
|
|
264
203
|
|
|
265
|
-
logger.info(f"-----> {command}")
|
|
266
204
|
self._writer.write(command.encode())
|
|
267
205
|
await self._writer.drain()
|
|
268
206
|
|
|
269
207
|
except asyncio.TimeoutError as exc:
|
|
270
|
-
raise DeviceTimeoutError(self.
|
|
208
|
+
raise DeviceTimeoutError(self.device_name, "Write operation timed out") from exc
|
|
271
209
|
except (ConnectionError, OSError) as exc:
|
|
272
|
-
raise DeviceConnectionError(self.
|
|
210
|
+
raise DeviceConnectionError(self.device_name, f"Communication error: {exc}") from exc
|
|
273
211
|
|
|
274
212
|
async def read(self) -> bytes:
|
|
275
213
|
"""
|
|
@@ -284,7 +222,7 @@ class AsyncSCPIInterface(AsyncDeviceInterface, AsyncDeviceTransport):
|
|
|
284
222
|
"""
|
|
285
223
|
async with self._io_lock:
|
|
286
224
|
if not self._is_connection_open or self._reader is None:
|
|
287
|
-
raise DeviceConnectionError(self.
|
|
225
|
+
raise DeviceConnectionError(self.device_name, "Device not connected, use connect() first")
|
|
288
226
|
|
|
289
227
|
try:
|
|
290
228
|
# First, small delay to allow device to prepare response
|
|
@@ -293,19 +231,18 @@ class AsyncSCPIInterface(AsyncDeviceInterface, AsyncDeviceTransport):
|
|
|
293
231
|
# Try to read until newline (common SCPI terminator)
|
|
294
232
|
try:
|
|
295
233
|
response = await asyncio.wait_for(
|
|
296
|
-
self._reader.readuntil(separator=
|
|
234
|
+
self._reader.readuntil(separator=b"\n"), timeout=self.read_timeout
|
|
297
235
|
)
|
|
298
|
-
logger.info(f"<----- {response}")
|
|
299
236
|
return response
|
|
300
237
|
|
|
301
238
|
except asyncio.IncompleteReadError as exc:
|
|
302
239
|
# Connection closed before receiving full response
|
|
303
|
-
logger.warning(f"{self.
|
|
304
|
-
return exc.partial if exc.partial else
|
|
240
|
+
logger.warning(f"{self.device_name}: Incomplete read, got {len(exc.partial)} bytes")
|
|
241
|
+
return exc.partial if exc.partial else b"\r\n"
|
|
305
242
|
|
|
306
243
|
except asyncio.LimitOverrunError:
|
|
307
244
|
# Response too large for buffer
|
|
308
|
-
logger.warning(f"{self.
|
|
245
|
+
logger.warning(f"{self.device_name}: Response exceeded buffer limits")
|
|
309
246
|
# Fall back to reading a large chunk
|
|
310
247
|
return await asyncio.wait_for(
|
|
311
248
|
self._reader.read(8192), # Larger buffer for exceptional cases
|
|
@@ -313,9 +250,9 @@ class AsyncSCPIInterface(AsyncDeviceInterface, AsyncDeviceTransport):
|
|
|
313
250
|
)
|
|
314
251
|
|
|
315
252
|
except asyncio.TimeoutError as exc:
|
|
316
|
-
raise DeviceTimeoutError(self.
|
|
253
|
+
raise DeviceTimeoutError(self.device_name, "Read operation timed out") from exc
|
|
317
254
|
except Exception as exc:
|
|
318
|
-
raise DeviceConnectionError(self.
|
|
255
|
+
raise DeviceConnectionError(self.device_name, f"Read error: {exc}") from exc
|
|
319
256
|
|
|
320
257
|
async def trans(self, command: str) -> bytes:
|
|
321
258
|
"""
|
|
@@ -336,13 +273,12 @@ class AsyncSCPIInterface(AsyncDeviceInterface, AsyncDeviceTransport):
|
|
|
336
273
|
async with self._io_lock:
|
|
337
274
|
try:
|
|
338
275
|
if not self._is_connection_open or self._writer is None:
|
|
339
|
-
raise DeviceConnectionError(self.
|
|
276
|
+
raise DeviceConnectionError(self.device_name, "Device not connected, use connect() first")
|
|
340
277
|
|
|
341
278
|
# Ensure command ends with newline
|
|
342
|
-
if not command.endswith(
|
|
343
|
-
command +=
|
|
279
|
+
if not command.endswith("\n"):
|
|
280
|
+
command += "\n"
|
|
344
281
|
|
|
345
|
-
logger.info(f"-----> {command}")
|
|
346
282
|
self._writer.write(command.encode())
|
|
347
283
|
await self._writer.drain()
|
|
348
284
|
|
|
@@ -352,19 +288,18 @@ class AsyncSCPIInterface(AsyncDeviceInterface, AsyncDeviceTransport):
|
|
|
352
288
|
# Try to read until newline (common SCPI terminator)
|
|
353
289
|
try:
|
|
354
290
|
response = await asyncio.wait_for(
|
|
355
|
-
self._reader.readuntil(separator=
|
|
291
|
+
self._reader.readuntil(separator=b"\n"), timeout=self.read_timeout
|
|
356
292
|
)
|
|
357
|
-
logger.info(f"<----- {response}")
|
|
358
293
|
return response
|
|
359
294
|
|
|
360
295
|
except asyncio.IncompleteReadError as exc:
|
|
361
296
|
# Connection closed before receiving full response
|
|
362
|
-
logger.warning(f"{self.
|
|
363
|
-
return exc.partial if exc.partial else
|
|
297
|
+
logger.warning(f"{self.device_name}: Incomplete read, got {len(exc.partial)} bytes")
|
|
298
|
+
return exc.partial if exc.partial else b"\r\n"
|
|
364
299
|
|
|
365
300
|
except asyncio.LimitOverrunError:
|
|
366
301
|
# Response too large for buffer
|
|
367
|
-
logger.warning(f"{self.
|
|
302
|
+
logger.warning(f"{self.device_name}: Response exceeded buffer limits")
|
|
368
303
|
# Fall back to reading a large chunk
|
|
369
304
|
return await asyncio.wait_for(
|
|
370
305
|
self._reader.read(8192), # Larger buffer for exceptional cases
|
|
@@ -372,11 +307,11 @@ class AsyncSCPIInterface(AsyncDeviceInterface, AsyncDeviceTransport):
|
|
|
372
307
|
)
|
|
373
308
|
|
|
374
309
|
except asyncio.TimeoutError as exc:
|
|
375
|
-
raise DeviceTimeoutError(self.
|
|
310
|
+
raise DeviceTimeoutError(self.device_name, "Communication timed out") from exc
|
|
376
311
|
except (ConnectionError, OSError) as exc:
|
|
377
|
-
raise DeviceConnectionError(self.
|
|
312
|
+
raise DeviceConnectionError(self.device_name, f"Communication error: {exc}") from exc
|
|
378
313
|
except Exception as exc:
|
|
379
|
-
raise DeviceConnectionError(self.
|
|
314
|
+
raise DeviceConnectionError(self.device_name, f"Transaction error: {exc}") from exc
|
|
380
315
|
|
|
381
316
|
async def __aenter__(self):
|
|
382
317
|
"""Async context manager entry."""
|
egse/settings.py
CHANGED
|
@@ -380,6 +380,37 @@ class Settings:
|
|
|
380
380
|
|
|
381
381
|
return msg.rstrip()
|
|
382
382
|
|
|
383
|
+
@classmethod
|
|
384
|
+
def from_string(cls, yaml_string: str, group: str | None = None) -> attrdict:
|
|
385
|
+
"""
|
|
386
|
+
Creates a Settings object from a YAML string.
|
|
387
|
+
|
|
388
|
+
Args:
|
|
389
|
+
yaml_string (str): the YAML configuration as a string
|
|
390
|
+
|
|
391
|
+
Returns:
|
|
392
|
+
An attribute dictionary with all the settings from the given string.
|
|
393
|
+
"""
|
|
394
|
+
try:
|
|
395
|
+
yaml_document = yaml.load(yaml_string, Loader=SAFE_LOADER)
|
|
396
|
+
settings = attrdict({name: value for name, value in yaml_document.items()}, label="Settings")
|
|
397
|
+
except yaml.YAMLError as exc:
|
|
398
|
+
logger.error(exc)
|
|
399
|
+
raise SettingsError("Error loading YAML document from string") from exc
|
|
400
|
+
|
|
401
|
+
if not settings:
|
|
402
|
+
logger.warning(
|
|
403
|
+
"The Settings YAML string is empty. No local settings were loaded, an empty dictionary is returned."
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
if group:
|
|
407
|
+
if group in settings:
|
|
408
|
+
settings = attrdict({name: value for name, value in settings[group].items()}, label=group)
|
|
409
|
+
else:
|
|
410
|
+
raise SettingsError(f"Group name '{group}' is not defined in the provided YAML string.")
|
|
411
|
+
|
|
412
|
+
return settings
|
|
413
|
+
|
|
383
414
|
|
|
384
415
|
def main(args: list | None = None): # pragma: no cover
|
|
385
416
|
# We provide convenience to inspect the settings by calling this module directly from Python.
|
egse/system.py
CHANGED
|
@@ -10,8 +10,6 @@ The module has external dependencies to:
|
|
|
10
10
|
|
|
11
11
|
"""
|
|
12
12
|
|
|
13
|
-
from __future__ import annotations
|
|
14
|
-
|
|
15
13
|
import asyncio
|
|
16
14
|
import builtins
|
|
17
15
|
import collections
|
|
@@ -30,6 +28,7 @@ import os
|
|
|
30
28
|
import platform # For getting the operating system name
|
|
31
29
|
import re
|
|
32
30
|
import shutil
|
|
31
|
+
import signal
|
|
33
32
|
import socket
|
|
34
33
|
import subprocess # For executing a shell command
|
|
35
34
|
import sys
|
|
@@ -60,7 +59,6 @@ from rich.text import Text
|
|
|
60
59
|
from rich.tree import Tree
|
|
61
60
|
from typer.core import TyperCommand
|
|
62
61
|
|
|
63
|
-
import signal
|
|
64
62
|
from egse.log import logger
|
|
65
63
|
|
|
66
64
|
EPOCH_1958_1970 = 378691200
|
|
@@ -107,7 +105,7 @@ class Periodic:
|
|
|
107
105
|
interval: float,
|
|
108
106
|
*,
|
|
109
107
|
name: str | None = None,
|
|
110
|
-
callback: Callable = None,
|
|
108
|
+
callback: Callable | None = None,
|
|
111
109
|
repeat: int | None = None,
|
|
112
110
|
skip: bool = True,
|
|
113
111
|
pause: bool = False,
|
|
@@ -202,7 +200,7 @@ class Periodic:
|
|
|
202
200
|
"""Triggers the Timer's action: either call its callback, or logs a message."""
|
|
203
201
|
|
|
204
202
|
if self._callback is None:
|
|
205
|
-
self._logger.warning(f"Periodic
|
|
203
|
+
self._logger.warning(f"Periodic - No callback provided for interval timer {self.name}.")
|
|
206
204
|
return
|
|
207
205
|
|
|
208
206
|
try:
|
|
@@ -211,7 +209,7 @@ class Periodic:
|
|
|
211
209
|
self._logger.debug("Caught CancelledError on callback function in Periodic.")
|
|
212
210
|
raise
|
|
213
211
|
except Exception as exc:
|
|
214
|
-
self._logger.error(f"{
|
|
212
|
+
self._logger.error(f"{type_name(exc)} caught: {exc}")
|
|
215
213
|
|
|
216
214
|
@property
|
|
217
215
|
def interval(self):
|
|
@@ -370,8 +368,8 @@ def ignore_m_warning(modules=None):
|
|
|
370
368
|
modules = [modules]
|
|
371
369
|
|
|
372
370
|
try:
|
|
373
|
-
import warnings
|
|
374
371
|
import re
|
|
372
|
+
import warnings
|
|
375
373
|
|
|
376
374
|
msg = "'{module}' found in sys.modules after import of package"
|
|
377
375
|
for module in modules:
|
|
@@ -390,7 +388,10 @@ def now(utc: bool = True):
|
|
|
390
388
|
|
|
391
389
|
|
|
392
390
|
def format_datetime(
|
|
393
|
-
dt:
|
|
391
|
+
dt: str | datetime.datetime | datetime.date | None = None,
|
|
392
|
+
fmt: str | None = None,
|
|
393
|
+
width: int = 6,
|
|
394
|
+
precision: int = 3,
|
|
394
395
|
) -> str:
|
|
395
396
|
"""Format a datetime as YYYY-mm-ddTHH:MM:SS.μs+0000.
|
|
396
397
|
|
|
@@ -452,6 +453,10 @@ def format_datetime(
|
|
|
452
453
|
if fmt:
|
|
453
454
|
timestamp = dt.strftime(fmt)
|
|
454
455
|
else:
|
|
456
|
+
# If dt is a date (not datetime), convert to datetime at midnight
|
|
457
|
+
if isinstance(dt, datetime.date) and not isinstance(dt, datetime.datetime):
|
|
458
|
+
dt = datetime.datetime.combine(dt, datetime.time.min)
|
|
459
|
+
|
|
455
460
|
width = min(width, precision)
|
|
456
461
|
timestamp = (
|
|
457
462
|
f"{dt.strftime('%Y-%m-%dT%H:%M')}:"
|
|
@@ -727,18 +732,29 @@ def get_host_ip() -> Optional[str]:
|
|
|
727
732
|
return None
|
|
728
733
|
|
|
729
734
|
|
|
730
|
-
def get_current_location():
|
|
735
|
+
def get_current_location() -> tuple[str, int, str]:
|
|
731
736
|
"""
|
|
732
737
|
Returns the location where this function is called, i.e. the filename, line number, and function name.
|
|
738
|
+
|
|
739
|
+
If the location cannot be determined, ("", 0, "") is returned.
|
|
733
740
|
"""
|
|
734
|
-
frame = inspect.currentframe()
|
|
741
|
+
frame = inspect.currentframe()
|
|
742
|
+
logger.debug(f"{frame = }")
|
|
743
|
+
if frame is None:
|
|
744
|
+
return "", 0, ""
|
|
735
745
|
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
746
|
+
previous_frame = frame.f_back
|
|
747
|
+
logger.debug(f"{previous_frame = }")
|
|
748
|
+
if previous_frame is None:
|
|
749
|
+
return "", 0, ""
|
|
750
|
+
|
|
751
|
+
filename = inspect.getframeinfo(previous_frame).filename
|
|
752
|
+
line_number = inspect.getframeinfo(previous_frame).lineno
|
|
753
|
+
function_name = inspect.getframeinfo(previous_frame).function
|
|
739
754
|
|
|
740
755
|
# Clean up to prevent reference cycles
|
|
741
756
|
del frame
|
|
757
|
+
del previous_frame
|
|
742
758
|
|
|
743
759
|
return filename, line_number, function_name
|
|
744
760
|
|
|
@@ -1364,7 +1380,7 @@ def chdir(dirname=None):
|
|
|
1364
1380
|
|
|
1365
1381
|
|
|
1366
1382
|
@contextlib.contextmanager
|
|
1367
|
-
def env_var(**kwargs:
|
|
1383
|
+
def env_var(**kwargs: str):
|
|
1368
1384
|
"""
|
|
1369
1385
|
Context manager to run some code that need alternate settings for environment variables.
|
|
1370
1386
|
|
|
@@ -1540,6 +1556,7 @@ def read_last_lines(filename: str | Path, num_lines: int) -> List[str]:
|
|
|
1540
1556
|
sanity_check(num_lines >= 0, "the number of lines to read shall be a positive number or zero.")
|
|
1541
1557
|
|
|
1542
1558
|
if not filename.exists():
|
|
1559
|
+
logger.warning(f"File does not exist: {filename}")
|
|
1543
1560
|
return []
|
|
1544
1561
|
|
|
1545
1562
|
# Declaring variable to implement exponential search
|
|
@@ -2321,22 +2338,44 @@ def caffeinate(pid: int = None):
|
|
|
2321
2338
|
subprocess.Popen([shutil.which("caffeinate"), "-i", "-w", str(pid)])
|
|
2322
2339
|
|
|
2323
2340
|
|
|
2324
|
-
def redirect_output_to_log(output_fn: str, append: bool = False) -> TextIO:
|
|
2341
|
+
def redirect_output_to_log(output_fn: str, append: bool = False, overwrite=True) -> TextIO:
|
|
2325
2342
|
"""
|
|
2326
|
-
Open file in the log folder where process output will be redirected.
|
|
2327
|
-
|
|
2343
|
+
Open the file in the log folder where the current process output will be redirected.
|
|
2328
2344
|
When no location can be determined, the user's home directory will be used.
|
|
2329
2345
|
|
|
2330
|
-
The file
|
|
2346
|
+
The file will be opened in text mode.
|
|
2347
|
+
|
|
2348
|
+
Args:
|
|
2349
|
+
output_fn: the name of the output file
|
|
2350
|
+
append: True to append to the file, False to overwrite
|
|
2351
|
+
overwrite: when False and the file exists, an exception is raised
|
|
2352
|
+
|
|
2353
|
+
Returns:
|
|
2354
|
+
The file stream (TextIO) where output can be redirected to.
|
|
2355
|
+
|
|
2356
|
+
Raises:
|
|
2357
|
+
FileExistsError: when the output file exists, append is False and overwrite is False.
|
|
2331
2358
|
"""
|
|
2332
2359
|
|
|
2333
|
-
|
|
2334
|
-
|
|
2360
|
+
if Path(output_fn).is_absolute():
|
|
2361
|
+
output_path = Path(output_fn)
|
|
2362
|
+
else:
|
|
2363
|
+
try:
|
|
2364
|
+
from egse.env import get_log_file_location
|
|
2365
|
+
|
|
2366
|
+
location = get_log_file_location()
|
|
2367
|
+
output_path = Path(location, output_fn).expanduser()
|
|
2368
|
+
except ValueError:
|
|
2369
|
+
output_path = Path.home() / output_fn
|
|
2335
2370
|
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2371
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
2372
|
+
|
|
2373
|
+
if output_path.exists() and not append:
|
|
2374
|
+
if not overwrite:
|
|
2375
|
+
raise FileExistsError(
|
|
2376
|
+
f"Output file {output_path!s} already exists and will be overwritten. "
|
|
2377
|
+
f"Use overwrite=True to allow overwriting."
|
|
2378
|
+
)
|
|
2340
2379
|
|
|
2341
2380
|
out = open(output_path, "a" if append else "w")
|
|
2342
2381
|
|
egse/version.py
CHANGED
|
@@ -31,7 +31,7 @@ __all__ = [
|
|
|
31
31
|
]
|
|
32
32
|
|
|
33
33
|
|
|
34
|
-
def get_version_from_settings_file_raw(group_name: str, location: Path | str = None) -> str:
|
|
34
|
+
def get_version_from_settings_file_raw(group_name: str, location: Path | str | None = None) -> str:
|
|
35
35
|
"""
|
|
36
36
|
Reads the VERSION field from the `settings.yaml` file in raw mode, meaning the file
|
|
37
37
|
is not read using the PyYAML module, but using the `readline()` function of the file
|
|
@@ -66,7 +66,7 @@ def get_version_from_settings_file_raw(group_name: str, location: Path | str = N
|
|
|
66
66
|
return version
|
|
67
67
|
|
|
68
68
|
|
|
69
|
-
def get_version_from_settings(group_name: str, location: Path = None):
|
|
69
|
+
def get_version_from_settings(group_name: str, location: Path | None = None, yaml_string: str | None = None) -> str:
|
|
70
70
|
"""
|
|
71
71
|
Reads the VERSION field from the `settings.yaml` file. This function first tries to load the proper Settings
|
|
72
72
|
and Group and if that fails uses the raw method.
|
|
@@ -74,6 +74,7 @@ def get_version_from_settings(group_name: str, location: Path = None):
|
|
|
74
74
|
Args:
|
|
75
75
|
group_name: major group name that contains the VERSION field, i.e. Common-EGSE or PLATO_TEST_SCRIPTS.
|
|
76
76
|
location: the location of the `settings.yaml` file or None in which case the location of this file is used.
|
|
77
|
+
yaml_string: optional YAML string to read the settings from instead of a file.
|
|
77
78
|
|
|
78
79
|
Raises:
|
|
79
80
|
A RuntimeError when the group_name is incorrect and unknown or the VERSION field is not found.
|
|
@@ -81,10 +82,14 @@ def get_version_from_settings(group_name: str, location: Path = None):
|
|
|
81
82
|
Returns:
|
|
82
83
|
The version from the `settings.yaml` file as a string.
|
|
83
84
|
"""
|
|
84
|
-
from egse.settings import Settings
|
|
85
|
+
from egse.settings import Settings
|
|
86
|
+
from egse.settings import SettingsError
|
|
85
87
|
|
|
86
88
|
try:
|
|
87
|
-
|
|
89
|
+
if location is None and yaml_string is not None:
|
|
90
|
+
settings = Settings.from_string(yaml_string, group=group_name)
|
|
91
|
+
else:
|
|
92
|
+
settings = Settings.load(group_name, location=location)
|
|
88
93
|
version = settings.VERSION
|
|
89
94
|
except (ModuleNotFoundError, SettingsError):
|
|
90
95
|
version = get_version_from_settings_file_raw(group_name, location=location)
|
|
@@ -92,7 +97,7 @@ def get_version_from_settings(group_name: str, location: Path = None):
|
|
|
92
97
|
return version
|
|
93
98
|
|
|
94
99
|
|
|
95
|
-
def get_version_from_git(location: str = None):
|
|
100
|
+
def get_version_from_git(location: str | Path | None = None) -> str:
|
|
96
101
|
"""
|
|
97
102
|
Returns the Git version number for the repository at the given location.
|
|
98
103
|
|
|
@@ -121,12 +126,12 @@ def get_version_from_git(location: str = None):
|
|
|
121
126
|
proc = subprocess.run(
|
|
122
127
|
["git", "describe", "--tags", "--long", "--always"], stderr=subprocess.PIPE, stdout=subprocess.PIPE
|
|
123
128
|
)
|
|
124
|
-
if proc.stderr:
|
|
125
|
-
version = None
|
|
126
129
|
if proc.stdout:
|
|
127
130
|
version = proc.stdout.strip().decode("ascii")
|
|
131
|
+
else:
|
|
132
|
+
version = "0.0.0"
|
|
128
133
|
except subprocess.CalledProcessError:
|
|
129
|
-
version =
|
|
134
|
+
version = "0.0.0"
|
|
130
135
|
|
|
131
136
|
return version
|
|
132
137
|
|
|
@@ -144,12 +149,13 @@ def get_version_installed(package_name: str) -> str:
|
|
|
144
149
|
from egse.system import chdir
|
|
145
150
|
|
|
146
151
|
with chdir(Path(__file__).parent):
|
|
147
|
-
from importlib.metadata import
|
|
152
|
+
from importlib.metadata import PackageNotFoundError
|
|
153
|
+
from importlib.metadata import version as get_version
|
|
148
154
|
|
|
149
155
|
try:
|
|
150
|
-
version =
|
|
156
|
+
version = get_version(package_name)
|
|
151
157
|
except PackageNotFoundError:
|
|
152
|
-
version =
|
|
158
|
+
version = "0.0.0"
|
|
153
159
|
|
|
154
160
|
return version
|
|
155
161
|
|
|
@@ -162,11 +168,12 @@ VERSION = get_version_installed("cgse-common")
|
|
|
162
168
|
# The __PYPI_VERSION__ is the version number that will be used for the installation.
|
|
163
169
|
# The version will appear in PyPI and also as the `installed version`.
|
|
164
170
|
|
|
165
|
-
__PYPI_VERSION__ = VERSION.split("+")[0]
|
|
171
|
+
__PYPI_VERSION__ = VERSION.split("+")[0] if VERSION else "0.0.0"
|
|
166
172
|
|
|
167
173
|
|
|
168
|
-
|
|
174
|
+
def main():
|
|
169
175
|
import rich
|
|
176
|
+
|
|
170
177
|
from egse.plugin import entry_points
|
|
171
178
|
|
|
172
179
|
if VERSION:
|
|
@@ -178,3 +185,7 @@ if __name__ == "__main__":
|
|
|
178
185
|
for ep in entry_points("cgse.version"):
|
|
179
186
|
if installed_version := get_version_installed(ep.name):
|
|
180
187
|
rich.print(f"Installed version for {ep.name}= [bold default]{installed_version}[/]")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
if __name__ == "__main__":
|
|
191
|
+
main()
|