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.
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(AsyncDeviceInterface, AsyncDeviceTransport):
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
- super().__init__()
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._device_name}: Trying to connect to an already connected device.")
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._device_name}: Hostname is not initialized.")
91
+ raise ValueError(f"{self.device_name}: Hostname is not initialized.")
156
92
 
157
93
  if not self.port:
158
- raise ValueError(f"{self._device_name}: Port number is not initialized.")
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._device_name} at "{self.hostname}" using port {self.port}')
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._device_name}.")
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._device_name, f"Connection to {self.hostname}:{self.port} timed out"
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._device_name, f"Connection refused to {self.hostname}:{self.port}"
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._device_name, f"Address resolution error for {self.hostname}") from exc
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._device_name, f"Host address error for {self.hostname}") from exc
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._device_name, f"OS error: {exc}") from exc
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._device_name, "Device connected but failed identity verification")
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._device_name} at {self.hostname}")
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._device_name, f"Could not close connection: {exc}") from exc
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
- await self.disconnect()
214
- await asyncio.sleep(0.1)
215
- await self.connect()
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._device_name}: Device did not respond correctly to identification query. "
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.error(f"{self._device_name}: Connection test failed: {exc}", exc_info=True)
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._device_name, "Device not connected, use connect() first")
198
+ raise DeviceConnectionError(self.device_name, "Device not connected, use connect() first")
260
199
 
261
- # Ensure command ends with the proper separator or terminator
262
- if not command.endswith(SEPARATOR_STR):
263
- command += SEPARATOR_STR
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._device_name, "Write operation timed out") from exc
208
+ raise DeviceTimeoutError(self.device_name, "Write operation timed out") from exc
271
209
  except (ConnectionError, OSError) as exc:
272
- raise DeviceConnectionError(self._device_name, f"Communication error: {exc}") from exc
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._device_name, "Device not connected, use connect() first")
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=SEPARATOR), timeout=self.read_timeout
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._device_name}: Incomplete read, got {len(exc.partial)} bytes")
304
- return exc.partial if exc.partial else SEPARATOR
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._device_name}: Response exceeded buffer limits")
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._device_name, "Read operation timed out") from exc
253
+ raise DeviceTimeoutError(self.device_name, "Read operation timed out") from exc
317
254
  except Exception as exc:
318
- raise DeviceConnectionError(self._device_name, f"Read error: {exc}") from exc
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._device_name, "Device not connected, use connect() first")
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(SEPARATOR_STR):
343
- command += SEPARATOR_STR
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=SEPARATOR), timeout=self.read_timeout
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._device_name}: Incomplete read, got {len(exc.partial)} bytes")
363
- return exc.partial if exc.partial else SEPARATOR
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._device_name}: Response exceeded buffer limits")
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._device_name, "Communication timed out") from exc
310
+ raise DeviceTimeoutError(self.device_name, "Communication timed out") from exc
376
311
  except (ConnectionError, OSError) as exc:
377
- raise DeviceConnectionError(self._device_name, f"Communication error: {exc}") from exc
312
+ raise DeviceConnectionError(self.device_name, f"Communication error: {exc}") from exc
378
313
  except Exception as exc:
379
- raise DeviceConnectionError(self._device_name, f"Transaction error: {exc}") from exc
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 No callback provided for interval timer {self.name}.")
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"{type(exc).__name__} caught: {exc}")
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: Union[str, datetime.datetime] = None, fmt: str = None, width: int = 6, precision: int = 3
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().f_back
741
+ frame = inspect.currentframe()
742
+ logger.debug(f"{frame = }")
743
+ if frame is None:
744
+ return "", 0, ""
735
745
 
736
- filename = inspect.getframeinfo(frame).filename
737
- line_number = inspect.getframeinfo(frame).lineno
738
- function_name = inspect.getframeinfo(frame).function
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: dict[str, str]):
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 is opened in text mode at the given location and the stream (file descriptor) will be returned.
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
- try:
2334
- from egse.env import get_log_file_location
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
- location = get_log_file_location()
2337
- output_path = Path(location, output_fn).expanduser()
2338
- except ValueError:
2339
- output_path = Path.home() / output_fn
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, SettingsError
85
+ from egse.settings import Settings
86
+ from egse.settings import SettingsError
85
87
 
86
88
  try:
87
- settings = Settings.load(group_name, location=location)
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 = None
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 version, PackageNotFoundError
152
+ from importlib.metadata import PackageNotFoundError
153
+ from importlib.metadata import version as get_version
148
154
 
149
155
  try:
150
- version = version(package_name)
156
+ version = get_version(package_name)
151
157
  except PackageNotFoundError:
152
- version = None
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
- if __name__ == "__main__":
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()