cgse-common 0.17.0__tar.gz → 0.17.2__tar.gz

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.
Files changed (48) hide show
  1. {cgse_common-0.17.0 → cgse_common-0.17.2}/.gitignore +5 -0
  2. {cgse_common-0.17.0 → cgse_common-0.17.2}/PKG-INFO +1 -1
  3. {cgse_common-0.17.0 → cgse_common-0.17.2}/pyproject.toml +17 -6
  4. {cgse_common-0.17.0 → cgse_common-0.17.2}/src/egse/bits.py +14 -17
  5. {cgse_common-0.17.0 → cgse_common-0.17.2}/src/egse/config.py +3 -2
  6. {cgse_common-0.17.0 → cgse_common-0.17.2}/src/egse/decorators.py +6 -3
  7. {cgse_common-0.17.0 → cgse_common-0.17.2}/src/egse/env.py +4 -1
  8. {cgse_common-0.17.0 → cgse_common-0.17.2}/src/egse/exceptions.py +3 -0
  9. {cgse_common-0.17.0 → cgse_common-0.17.2}/src/egse/log.py +7 -8
  10. {cgse_common-0.17.0 → cgse_common-0.17.2}/src/egse/scpi.py +61 -47
  11. {cgse_common-0.17.0 → cgse_common-0.17.2}/src/egse/settings.py +38 -2
  12. {cgse_common-0.17.0 → cgse_common-0.17.2}/src/egse/socketdevice.py +4 -2
  13. {cgse_common-0.17.0 → cgse_common-0.17.2}/src/egse/system.py +63 -24
  14. {cgse_common-0.17.0 → cgse_common-0.17.2}/src/egse/version.py +24 -13
  15. {cgse_common-0.17.0 → cgse_common-0.17.2}/README.md +0 -0
  16. {cgse_common-0.17.0 → cgse_common-0.17.2}/justfile +0 -0
  17. {cgse_common-0.17.0 → cgse_common-0.17.2}/noxfile.py +0 -0
  18. {cgse_common-0.17.0 → cgse_common-0.17.2}/service_registry.db +0 -0
  19. {cgse_common-0.17.0 → cgse_common-0.17.2}/src/cgse_common/__init__.py +0 -0
  20. {cgse_common-0.17.0 → cgse_common-0.17.2}/src/cgse_common/cgse.py +0 -0
  21. {cgse_common-0.17.0 → cgse_common-0.17.2}/src/cgse_common/settings.yaml +0 -0
  22. {cgse_common-0.17.0 → cgse_common-0.17.2}/src/egse/calibration.py +0 -0
  23. {cgse_common-0.17.0 → cgse_common-0.17.2}/src/egse/counter.py +0 -0
  24. {cgse_common-0.17.0 → cgse_common-0.17.2}/src/egse/device.py +0 -0
  25. {cgse_common-0.17.0 → cgse_common-0.17.2}/src/egse/dicts.py +0 -0
  26. {cgse_common-0.17.0 → cgse_common-0.17.2}/src/egse/heartbeat.py +0 -0
  27. {cgse_common-0.17.0 → cgse_common-0.17.2}/src/egse/hk.py +0 -0
  28. {cgse_common-0.17.0 → cgse_common-0.17.2}/src/egse/metrics.py +0 -0
  29. {cgse_common-0.17.0 → cgse_common-0.17.2}/src/egse/observer.py +0 -0
  30. {cgse_common-0.17.0 → cgse_common-0.17.2}/src/egse/obsid.py +0 -0
  31. {cgse_common-0.17.0 → cgse_common-0.17.2}/src/egse/persistence.py +0 -0
  32. {cgse_common-0.17.0 → cgse_common-0.17.2}/src/egse/plugin.py +0 -0
  33. {cgse_common-0.17.0 → cgse_common-0.17.2}/src/egse/plugins/metrics/duckdb.py +0 -0
  34. {cgse_common-0.17.0 → cgse_common-0.17.2}/src/egse/plugins/metrics/influxdb.py +0 -0
  35. {cgse_common-0.17.0 → cgse_common-0.17.2}/src/egse/plugins/metrics/timescaledb.py +0 -0
  36. {cgse_common-0.17.0 → cgse_common-0.17.2}/src/egse/process.py +0 -0
  37. {cgse_common-0.17.0 → cgse_common-0.17.2}/src/egse/py.typed +0 -0
  38. {cgse_common-0.17.0 → cgse_common-0.17.2}/src/egse/randomwalk.py +0 -0
  39. {cgse_common-0.17.0 → cgse_common-0.17.2}/src/egse/ratelimit.py +0 -0
  40. {cgse_common-0.17.0 → cgse_common-0.17.2}/src/egse/reload.py +0 -0
  41. {cgse_common-0.17.0 → cgse_common-0.17.2}/src/egse/resource.py +0 -0
  42. {cgse_common-0.17.0 → cgse_common-0.17.2}/src/egse/response.py +0 -0
  43. {cgse_common-0.17.0 → cgse_common-0.17.2}/src/egse/settings.yaml +0 -0
  44. {cgse_common-0.17.0 → cgse_common-0.17.2}/src/egse/setup.py +0 -0
  45. {cgse_common-0.17.0 → cgse_common-0.17.2}/src/egse/signal.py +0 -0
  46. {cgse_common-0.17.0 → cgse_common-0.17.2}/src/egse/state.py +0 -0
  47. {cgse_common-0.17.0 → cgse_common-0.17.2}/src/egse/task.py +0 -0
  48. {cgse_common-0.17.0 → cgse_common-0.17.2}/src/egse/zmq_ser.py +0 -0
@@ -32,6 +32,11 @@ venv
32
32
 
33
33
  .idea
34
34
 
35
+ # VSCode IDE
36
+
37
+ .vscode
38
+ *.code-workspace
39
+
35
40
  # MKDOCS documentation site
36
41
 
37
42
  /site
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cgse-common
3
- Version: 0.17.0
3
+ Version: 0.17.2
4
4
  Summary: Software framework to support hardware testing
5
5
  Author: IvS KU Leuven
6
6
  Maintainer-email: Rik Huygen <rik.huygen@kuleuven.be>, Sara Regibo <sara.regibo@kuleuven.be>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "cgse-common"
3
- version = "0.17.0"
3
+ version = "0.17.2"
4
4
  description = "Software framework to support hardware testing"
5
5
  authors = [
6
6
  {name = "IvS KU Leuven"}
@@ -64,20 +64,27 @@ log_cli_level = "DEBUG"
64
64
  filterwarnings = [
65
65
  "ignore::DeprecationWarning"
66
66
  ]
67
+ markers = [
68
+ "slow: marks tests as slow (deselect with '-m \"not slow\"')"
69
+ ]
67
70
 
71
+ # Some of these omitted files are only temporary omitted until refactored
68
72
  [tool.coverage.run]
69
73
  omit = [
70
74
  "tests/*",
71
75
  "conftest.py",
76
+ "src/egse/ratelimit.py",
77
+ "src/egse/plugins/metrics/duckdb.py",
78
+ "src/egse/plugins/metrics/timescaledb.py",
72
79
  ]
73
80
 
74
81
  [tool.hatch.build.targets.sdist]
75
82
  exclude = [
76
- "/tests",
77
- "/pytest.ini",
78
- "/.gitignore",
79
- "/*poetry*",
80
- "/*setuptools*",
83
+ "tests",
84
+ "pytest.ini",
85
+ ".gitignore",
86
+ "*poetry*",
87
+ "*setuptools*",
81
88
  ]
82
89
 
83
90
  [tool.hatch.build.targets.wheel]
@@ -87,8 +94,12 @@ packages = ["src/egse", "src/scripts", "src/cgse_common"]
87
94
  line-length = 120
88
95
 
89
96
  [tool.ruff.lint]
97
+ select = ["F", "I"]
90
98
  extend-select = ["E", "W"]
91
99
 
100
+ [tool.ruff.lint.isort]
101
+ force-single-line = true
102
+
92
103
  [build-system]
93
104
  requires = ["hatchling"]
94
105
  build-backend = "hatchling.build"
@@ -5,7 +5,6 @@ This module contains a number of convenience functions to work with bits, bytes
5
5
  from __future__ import annotations
6
6
 
7
7
  import ctypes
8
- from collections.abc import Iterable
9
8
  from typing import Union
10
9
 
11
10
 
@@ -33,7 +32,7 @@ def extract_bits(value: int, start_position: int, num_bits: int) -> int:
33
32
  return extracted_bits
34
33
 
35
34
 
36
- def set_bit(value: int, bit) -> int:
35
+ def set_bit(value: int, bit: int) -> int:
37
36
  """
38
37
  Set bit to 1 for the given value.
39
38
 
@@ -47,7 +46,7 @@ def set_bit(value: int, bit) -> int:
47
46
  return value | (1 << bit)
48
47
 
49
48
 
50
- def set_bits(value: int, bits: tuple) -> int:
49
+ def set_bits(value: int, bits: tuple[int, int]) -> int:
51
50
  """
52
51
  Set the given bits in value to 1.
53
52
 
@@ -63,7 +62,7 @@ def set_bits(value: int, bits: tuple) -> int:
63
62
  return value
64
63
 
65
64
 
66
- def clear_bit(value: int, bit) -> int:
65
+ def clear_bit(value: int, bit: int) -> int:
67
66
  """
68
67
  Set bit to 0 for the given value.
69
68
 
@@ -77,7 +76,7 @@ def clear_bit(value: int, bit) -> int:
77
76
  return value & ~(1 << bit)
78
77
 
79
78
 
80
- def clear_bits(value: int, bits: tuple) -> int:
79
+ def clear_bits(value: int, bits: tuple[int, int]) -> int:
81
80
  """
82
81
  Set the given bits in value to 0.
83
82
 
@@ -93,7 +92,7 @@ def clear_bits(value: int, bits: tuple) -> int:
93
92
  return value
94
93
 
95
94
 
96
- def toggle_bit(value: int, bit) -> int:
95
+ def toggle_bit(value: int, bit: int) -> int:
97
96
  """
98
97
  Toggle the bit in the given value.
99
98
 
@@ -107,7 +106,7 @@ def toggle_bit(value: int, bit) -> int:
107
106
  return value ^ (1 << bit)
108
107
 
109
108
 
110
- def bit_set(value: int, bit) -> bool:
109
+ def bit_set(value: int, bit: int) -> bool:
111
110
  """
112
111
  Return True if the bit is set.
113
112
 
@@ -122,27 +121,25 @@ def bit_set(value: int, bit) -> bool:
122
121
  return value & bit_value == bit_value
123
122
 
124
123
 
125
- def bits_set(value: int, *args: Union[int, Iterable[int]]) -> bool:
124
+ def bits_set(value: int, *args: int) -> bool:
126
125
  """
127
126
  Return True if all the bits are set.
128
127
 
129
128
  Args:
130
129
  value (int): the value to check
131
130
  args: a set of indices of the bits to check, starting from 0 at the LSB.
132
- All the indices can be given as separate arguments, or they can be passed
133
- in as a list.
131
+ All the indices shall be given as separate arguments, i.e. unpack a list
132
+ if needed.
134
133
 
135
134
  Returns:
136
135
  True if all the bits are set (1).
137
136
 
138
137
  Examples:
139
- >>> assert bits_set(0b0101_0000_1011, [0, 1, 3, 8, 10])
140
- >>> assert bits_set(0b0101_0000_1011, [3, 8])
141
- >>> assert not bits_set(0b0101_0000_1011, [1, 2, 3])
138
+ >>> assert bits_set(0b0101_0000_1011, 0, 1, 3, 8, 10)
139
+ >>> assert bits_set(0b0101_0000_1011, 3, 8)
140
+ >>> assert not bits_set(0b0101_0000_1011, *[1, 2, 3])
142
141
  """
143
142
 
144
- if len(args) == 1 and isinstance(args[0], list):
145
- args = args[0]
146
143
  return all([bit_set(value, bit) for bit in args])
147
144
 
148
145
 
@@ -506,7 +503,7 @@ def crc_calc(data: list[bytes | int], start: int, len_: int) -> int:
506
503
  Reference:
507
504
  The description of the CRC calculation for RMAP is given in the ECSS document
508
505
  _Space Engineering: SpaceWire - Remote Memory Access Protocol_, section A.3
509
- on page 80 [ECSSEST5052C].
506
+ on page 80 [ECSS-E-ST-50-52C].
510
507
 
511
508
  """
512
509
  crc: int = 0
@@ -548,7 +545,7 @@ def s16(value: int) -> int:
548
545
  >>> s16(0b1000_0000_0001_0001)
549
546
  -32751
550
547
 
551
- The 'bin()' fuction will return a strange representation of this number:
548
+ The 'bin()' function will return a strange representation of this number:
552
549
 
553
550
  >>> bin(-32751)
554
551
  '-0b111111111101111'
@@ -154,13 +154,14 @@ def find_files(pattern: str, root: Path | str, in_dir: str = None) -> Generator[
154
154
  Returns:
155
155
  Paths of files matching pattern, from root.
156
156
  """
157
- root = Path(root).resolve()
157
+ root = Path(root).expanduser().resolve()
158
158
  if not root.is_dir():
159
159
  root = root.parent
160
160
  if not root.exists():
161
161
  raise ValueError(f"The root argument didn't resolve into a valid directory: {root}.")
162
162
 
163
- exclude_dirs = ("venv", "venv38", ".venv", ".nox", ".git", ".idea", ".DS_Store")
163
+ # FIXME: at some point we might want to make this configurable, through env?
164
+ exclude_dirs = ("venv", "venv38", ".venv", ".nox", ".git", ".idea", ".DS_Store", "__pycache__")
164
165
 
165
166
  for path, folders, files in os.walk(root):
166
167
  folders[:] = list(filter(lambda x: x not in exclude_dirs, folders))
@@ -15,8 +15,8 @@ from typing import Optional
15
15
 
16
16
  import rich
17
17
 
18
- from egse.system import get_caller_info
19
18
  from egse.log import logger
19
+ from egse.system import get_caller_info
20
20
 
21
21
 
22
22
  def static_vars(**kwargs):
@@ -316,7 +316,10 @@ def debug(func):
316
316
 
317
317
 
318
318
  def profile_func(
319
- output_file: str = None, sort_by: str = "cumulative", lines_to_print: int = None, strip_dirs: bool = False
319
+ output_file: str | None = None,
320
+ sort_by: str = "cumulative",
321
+ lines_to_print: int | None = None,
322
+ strip_dirs: bool = False,
320
323
  ) -> Callable:
321
324
  """A time profiler decorator.
322
325
 
@@ -346,7 +349,7 @@ def profile_func(
346
349
  decorator](https://gist.github.com/ekhoda/2de44cf60d29ce24ad29758ce8635b78).
347
350
 
348
351
  Inspired by and modified the profile decorator of Giampaolo Rodola:
349
- [profile decorato](http://code.activestate.com/recipes/577817-profile-decorator/).
352
+ [profile decorator](http://code.activestate.com/recipes/577817-profile-decorator/).
350
353
 
351
354
 
352
355
  """
@@ -72,8 +72,8 @@ import os
72
72
  import warnings
73
73
  from pathlib import Path
74
74
 
75
- from dotenv import load_dotenv as _load_dotenv
76
75
  from dotenv import find_dotenv
76
+ from dotenv import load_dotenv as _load_dotenv
77
77
  from rich.console import Console
78
78
 
79
79
  from egse.decorators import static_vars
@@ -678,6 +678,7 @@ def env_var(**kwargs: str | int | float | bool | None):
678
678
  else:
679
679
  os.environ[k] = v
680
680
 
681
+ setup_env.is_initialized = False
681
682
  setup_env()
682
683
 
683
684
  yield
@@ -689,12 +690,14 @@ def env_var(**kwargs: str | int | float | bool | None):
689
690
  else:
690
691
  os.environ[k] = v
691
692
 
693
+ setup_env.is_initialized = False
692
694
  setup_env()
693
695
 
694
696
 
695
697
  def main(args: list | None = None): # pragma: no cover
696
698
  import argparse
697
699
  import sys
700
+
698
701
  import rich
699
702
 
700
703
  parser = argparse.ArgumentParser()
@@ -61,3 +61,6 @@ class Abort(RuntimeError):
61
61
 
62
62
  class InitialisationError(Error):
63
63
  """Raised when an initialisation failed."""
64
+
65
+
66
+ InitializationError = InitialisationError # Alias with American spelling
@@ -18,6 +18,7 @@ __all__ = [
18
18
  "LOG_DATE_FORMAT_FULL",
19
19
  "LOG_DATE_FORMAT_CLEAN",
20
20
  "logger",
21
+ "logging", # export to guarantee that this module is imported and initialized before users use logging
21
22
  "root_logger",
22
23
  "egse_logger",
23
24
  "get_log_level_from_env",
@@ -84,7 +85,7 @@ class NonEGSEFilter(logging.Filter):
84
85
  return not record.name.startswith("egse")
85
86
 
86
87
 
87
- def get_log_level_from_env(env_var: str = "LOG_LEVEL", default: int = logging.INFO):
88
+ def get_log_level_from_env(env_var: str = "LOG_LEVEL", default: str = "INFO") -> int:
88
89
  """Read the log level from an environment variable."""
89
90
  log_level_str = os.getenv(env_var, default)
90
91
 
@@ -95,18 +96,16 @@ def get_log_level_from_env(env_var: str = "LOG_LEVEL", default: int = logging.IN
95
96
  if 10 <= log_level <= 50:
96
97
  return log_level
97
98
  else:
98
- logging.warning(
99
- f"Log level {log_level} outside standard range (10-50). Using {logging.getLevelName(default)}."
100
- )
101
- return default
99
+ logging.warning(f"Log level {log_level} outside standard range (10-50). Using {default.upper()}.")
100
+ return logging._nameToLevel[default.upper()]
102
101
 
103
102
  except ValueError:
104
103
  log_level_str = log_level_str.upper()
105
104
  try:
106
105
  return getattr(logging, log_level_str)
107
106
  except AttributeError:
108
- logging.error(f"Invalid LOG_LEVEL '{log_level_str}'. Using {logging.getLevelName(default)}.")
109
- return default
107
+ logging.error(f"Invalid LOG_LEVEL '{log_level_str}'. Using {default.upper()}.")
108
+ return logging._nameToLevel[default.upper()]
110
109
 
111
110
 
112
111
  egse_logger = logging.getLogger("egse")
@@ -145,7 +144,7 @@ if __name__ == "__main__":
145
144
  Environment variables:
146
145
  - LOG_LEVEL=debug|info|warning|critical
147
146
  - LOG_FORMAT=full|clean
148
-
147
+
149
148
  Example logging statements
150
149
  - logging level set to INFO
151
150
  - logging format set to full
@@ -9,12 +9,18 @@ from egse.device import AsyncDeviceTransport
9
9
  from egse.device import DeviceConnectionError
10
10
  from egse.device import DeviceError
11
11
  from egse.device import DeviceTimeoutError
12
+ from egse.env import bool_env
12
13
  from egse.log import logger
13
14
 
14
15
  DEFAULT_READ_TIMEOUT = 1.0 # seconds
15
16
  DEFAULT_CONNECT_TIMEOUT = 3.0 # seconds
16
17
  IDENTIFICATION_QUERY = "*IDN?"
17
18
 
19
+ VERBOSE_DEBUG = bool_env("VERBOSE_DEBUG")
20
+
21
+ SEPARATOR = b"\n"
22
+ SEPARATOR_STR = SEPARATOR.decode()
23
+
18
24
 
19
25
  class SCPICommand:
20
26
  """Base class for SCPI commands."""
@@ -57,7 +63,8 @@ class AsyncSCPIInterface(AsyncDeviceInterface, AsyncDeviceTransport):
57
63
  id_validation: String that should appear in the device's identification response
58
64
  """
59
65
  super().__init__()
60
- self.device_name = device_name
66
+
67
+ self._device_name = device_name
61
68
  self.hostname = hostname
62
69
  self.port = port
63
70
  self.settings = settings or {}
@@ -71,12 +78,15 @@ class AsyncSCPIInterface(AsyncDeviceInterface, AsyncDeviceTransport):
71
78
  self._connect_lock = asyncio.Lock()
72
79
  """Prevents multiple, simultaneous connect or disconnect attempts."""
73
80
  self._io_lock = asyncio.Lock()
74
- """Prevents multiple coroutines from attempting to read, write or query from the same stream
75
- at the same time."""
81
+ """Prevents multiple coroutines from attempting to read, write or query from the same stream at the same time."""
76
82
 
77
83
  def is_simulator(self) -> bool:
78
84
  return False
79
85
 
86
+ @property
87
+ def device_name(self) -> str:
88
+ return self._device_name
89
+
80
90
  async def initialize(self, commands: list[tuple[str, bool]] = None, reset_device: bool = False) -> list[str | None]:
81
91
  """Initialize the device with optional reset and command sequence.
82
92
 
@@ -113,7 +123,7 @@ class AsyncSCPIInterface(AsyncDeviceInterface, AsyncDeviceTransport):
113
123
  responses = []
114
124
 
115
125
  if reset_device:
116
- logger.info(f"Resetting the {self.device_name}...")
126
+ logger.info(f"Resetting the {self._device_name}...")
117
127
  await self.write("*RST") # this also resets the user-defined buffer
118
128
 
119
129
  for cmd, expects_response in commands:
@@ -140,47 +150,51 @@ class AsyncSCPIInterface(AsyncDeviceInterface, AsyncDeviceTransport):
140
150
  async with self._connect_lock:
141
151
  # Sanity checks
142
152
  if self._is_connection_open:
143
- logger.warning(f"{self.device_name}: Trying to connect to an already connected device.")
153
+ logger.warning(f"{self._device_name}: Trying to connect to an already connected device.")
144
154
  return
145
155
 
146
156
  if not self.hostname:
147
- raise ValueError(f"{self.device_name}: Hostname is not initialized.")
157
+ raise ValueError(f"{self._device_name}: Hostname is not initialized.")
148
158
 
149
159
  if not self.port:
150
- raise ValueError(f"{self.device_name}: Port number is not initialized.")
160
+ raise ValueError(f"{self._device_name}: Port number is not initialized.")
151
161
 
152
162
  # Attempt to establish a connection
153
163
  try:
154
- logger.debug(f'Connecting to {self.device_name} at "{self.hostname}" using port {self.port}')
164
+ logger.debug(f'Connecting to {self._device_name} at "{self.hostname}" using port {self.port}')
155
165
 
156
166
  connect_task = asyncio.open_connection(self.hostname, self.port)
157
167
  self._reader, self._writer = await asyncio.wait_for(connect_task, timeout=self.connect_timeout)
158
168
 
159
169
  self._is_connection_open = True
160
170
 
161
- logger.debug(f"Successfully connected to {self.device_name}.")
171
+ response = await self.read_string()
172
+ if VERBOSE_DEBUG:
173
+ logger.debug(f"Response after connection: {response}")
174
+
175
+ logger.debug(f"Successfully connected to {self._device_name}.")
162
176
 
163
177
  except asyncio.TimeoutError as exc:
164
178
  raise DeviceTimeoutError(
165
- self.device_name, f"Connection to {self.hostname}:{self.port} timed out"
179
+ self._device_name, f"Connection to {self.hostname}:{self.port} timed out"
166
180
  ) from exc
167
181
  except ConnectionRefusedError as exc:
168
182
  raise DeviceConnectionError(
169
- self.device_name, f"Connection refused to {self.hostname}:{self.port}"
183
+ self._device_name, f"Connection refused to {self.hostname}:{self.port}"
170
184
  ) from exc
171
185
  except socket.gaierror as exc:
172
- raise DeviceConnectionError(self.device_name, f"Address resolution error for {self.hostname}") from exc
186
+ raise DeviceConnectionError(self._device_name, f"Address resolution error for {self.hostname}") from exc
173
187
  except socket.herror as exc:
174
- raise DeviceConnectionError(self.device_name, f"Host address error for {self.hostname}") from exc
188
+ raise DeviceConnectionError(self._device_name, f"Host address error for {self.hostname}") from exc
175
189
  except OSError as exc:
176
- raise DeviceConnectionError(self.device_name, f"OS error: {exc}") from exc
190
+ raise DeviceConnectionError(self._device_name, f"OS error: {exc}") from exc
177
191
 
178
192
  # Validate device identity if requested
179
193
  if self.id_validation:
180
194
  logger.debug("Validating connection..")
181
195
  if not await self.is_connected():
182
196
  await self.disconnect()
183
- raise DeviceConnectionError(self.device_name, "Device connected but failed identity verification")
197
+ raise DeviceConnectionError(self._device_name, "Device connected but failed identity verification")
184
198
 
185
199
  async def disconnect(self) -> None:
186
200
  """Disconnect from the device asynchronously.
@@ -191,14 +205,14 @@ class AsyncSCPIInterface(AsyncDeviceInterface, AsyncDeviceTransport):
191
205
  async with self._connect_lock:
192
206
  try:
193
207
  if self._is_connection_open and self._writer is not None:
194
- logger.debug(f"Disconnecting from {self.device_name} at {self.hostname}")
208
+ logger.debug(f"Disconnecting from {self._device_name} at {self.hostname}")
195
209
  self._writer.close()
196
210
  await self._writer.wait_closed()
197
211
  self._writer = None
198
212
  self._reader = None
199
213
  self._is_connection_open = False
200
214
  except Exception as exc:
201
- raise DeviceConnectionError(self.device_name, f"Could not close connection: {exc}") from exc
215
+ raise DeviceConnectionError(self._device_name, f"Could not close connection: {exc}") from exc
202
216
 
203
217
  async def reconnect(self) -> None:
204
218
  """Reconnect to the device asynchronously."""
@@ -222,7 +236,7 @@ class AsyncSCPIInterface(AsyncDeviceInterface, AsyncDeviceTransport):
222
236
  # Validate the response if validation string is provided
223
237
  if self.id_validation and self.id_validation not in id_response:
224
238
  logger.error(
225
- f"{self.device_name}: Device did not respond correctly to identification query. "
239
+ f"{self._device_name}: Device did not respond correctly to identification query. "
226
240
  f'Expected "{self.id_validation}" in response, got: {id_response}'
227
241
  )
228
242
  await self.disconnect()
@@ -231,7 +245,7 @@ class AsyncSCPIInterface(AsyncDeviceInterface, AsyncDeviceTransport):
231
245
  return True
232
246
 
233
247
  except DeviceError as exc:
234
- logger.error(f"{self.device_name}: Connection test failed: {exc}", exc_info=True)
248
+ logger.error(f"{self._device_name}: Connection test failed: {exc}", exc_info=True)
235
249
  await self.disconnect()
236
250
  return False
237
251
 
@@ -248,20 +262,20 @@ class AsyncSCPIInterface(AsyncDeviceInterface, AsyncDeviceTransport):
248
262
  async with self._io_lock:
249
263
  try:
250
264
  if not self._is_connection_open or self._writer is None:
251
- raise DeviceConnectionError(self.device_name, "Device not connected, use connect() first")
265
+ raise DeviceConnectionError(self._device_name, "Device not connected, use connect() first")
252
266
 
253
- # Ensure command ends with newline
254
- if not command.endswith("\n"):
255
- command += "\n"
267
+ # Ensure command ends with the proper separator or terminator
268
+ if not command.endswith(SEPARATOR_STR):
269
+ command += SEPARATOR_STR
256
270
 
257
271
  logger.info(f"-----> {command}")
258
272
  self._writer.write(command.encode())
259
273
  await self._writer.drain()
260
274
 
261
275
  except asyncio.TimeoutError as exc:
262
- raise DeviceTimeoutError(self.device_name, "Write operation timed out") from exc
276
+ raise DeviceTimeoutError(self._device_name, "Write operation timed out") from exc
263
277
  except (ConnectionError, OSError) as exc:
264
- raise DeviceConnectionError(self.device_name, f"Communication error: {exc}") from exc
278
+ raise DeviceConnectionError(self._device_name, f"Communication error: {exc}") from exc
265
279
 
266
280
  async def read(self) -> bytes:
267
281
  """
@@ -276,7 +290,7 @@ class AsyncSCPIInterface(AsyncDeviceInterface, AsyncDeviceTransport):
276
290
  """
277
291
  async with self._io_lock:
278
292
  if not self._is_connection_open or self._reader is None:
279
- raise DeviceConnectionError(self.device_name, "Device not connected, use connect() first")
293
+ raise DeviceConnectionError(self._device_name, "Device not connected, use connect() first")
280
294
 
281
295
  try:
282
296
  # First, small delay to allow device to prepare response
@@ -285,19 +299,19 @@ class AsyncSCPIInterface(AsyncDeviceInterface, AsyncDeviceTransport):
285
299
  # Try to read until newline (common SCPI terminator)
286
300
  try:
287
301
  response = await asyncio.wait_for(
288
- self._reader.readuntil(separator=b"\n"), timeout=self.read_timeout
302
+ self._reader.readuntil(separator=SEPARATOR), timeout=self.read_timeout
289
303
  )
290
304
  logger.info(f"<----- {response}")
291
305
  return response
292
306
 
293
307
  except asyncio.IncompleteReadError as exc:
294
308
  # Connection closed before receiving full response
295
- logger.warning(f"{self.device_name}: Incomplete read, got {len(exc.partial)} bytes")
296
- return exc.partial if exc.partial else b"\r\n"
309
+ logger.warning(f"{self._device_name}: Incomplete read, got {len(exc.partial)} bytes")
310
+ return exc.partial if exc.partial else SEPARATOR
297
311
 
298
312
  except asyncio.LimitOverrunError:
299
313
  # Response too large for buffer
300
- logger.warning(f"{self.device_name}: Response exceeded buffer limits")
314
+ logger.warning(f"{self._device_name}: Response exceeded buffer limits")
301
315
  # Fall back to reading a large chunk
302
316
  return await asyncio.wait_for(
303
317
  self._reader.read(8192), # Larger buffer for exceptional cases
@@ -305,9 +319,9 @@ class AsyncSCPIInterface(AsyncDeviceInterface, AsyncDeviceTransport):
305
319
  )
306
320
 
307
321
  except asyncio.TimeoutError as exc:
308
- raise DeviceTimeoutError(self.device_name, "Read operation timed out") from exc
322
+ raise DeviceTimeoutError(self._device_name, "Read operation timed out") from exc
309
323
  except Exception as exc:
310
- raise DeviceConnectionError(self.device_name, f"Read error: {exc}") from exc
324
+ raise DeviceConnectionError(self._device_name, f"Read error: {exc}") from exc
311
325
 
312
326
  async def trans(self, command: str) -> bytes:
313
327
  """
@@ -328,35 +342,35 @@ class AsyncSCPIInterface(AsyncDeviceInterface, AsyncDeviceTransport):
328
342
  async with self._io_lock:
329
343
  try:
330
344
  if not self._is_connection_open or self._writer is None:
331
- raise DeviceConnectionError(self.device_name, "Device not connected, use connect() first")
345
+ raise DeviceConnectionError(self._device_name, "Device not connected, use connect() first")
332
346
 
333
- # Ensure command ends with newline
334
- if not command.endswith("\n"):
335
- command += "\n"
347
+ # Ensure command ends with the required terminator
348
+ if not command.endswith(SEPARATOR_STR):
349
+ command += SEPARATOR_STR
336
350
 
337
- logger.info(f"-----> {command}")
351
+ logger.info(f"-----> {command=}")
338
352
  self._writer.write(command.encode())
339
353
  await self._writer.drain()
340
354
 
341
- # First, small delay to allow device to prepare response
355
+ # First, small delay to allow the device to prepare a response
342
356
  await asyncio.sleep(0.01)
343
357
 
344
- # Try to read until newline (common SCPI terminator)
358
+ # Try to read until the required separator (common SCPI terminator)
345
359
  try:
346
360
  response = await asyncio.wait_for(
347
- self._reader.readuntil(separator=b"\n"), timeout=self.read_timeout
361
+ self._reader.readuntil(separator=SEPARATOR), timeout=self.read_timeout
348
362
  )
349
- logger.info(f"<----- {response}")
363
+ logger.info(f"<----- {response=}")
350
364
  return response
351
365
 
352
366
  except asyncio.IncompleteReadError as exc:
353
367
  # Connection closed before receiving full response
354
- logger.warning(f"{self.device_name}: Incomplete read, got {len(exc.partial)} bytes")
355
- return exc.partial if exc.partial else b"\r\n"
368
+ logger.warning(f"{self._device_name}: Incomplete read, got {len(exc.partial)} bytes")
369
+ return exc.partial if exc.partial else SEPARATOR
356
370
 
357
371
  except asyncio.LimitOverrunError:
358
372
  # Response too large for buffer
359
- logger.warning(f"{self.device_name}: Response exceeded buffer limits")
373
+ logger.warning(f"{self._device_name}: Response exceeded buffer limits")
360
374
  # Fall back to reading a large chunk
361
375
  return await asyncio.wait_for(
362
376
  self._reader.read(8192), # Larger buffer for exceptional cases
@@ -364,11 +378,11 @@ class AsyncSCPIInterface(AsyncDeviceInterface, AsyncDeviceTransport):
364
378
  )
365
379
 
366
380
  except asyncio.TimeoutError as exc:
367
- raise DeviceTimeoutError(self.device_name, "Communication timed out") from exc
381
+ raise DeviceTimeoutError(self._device_name, "Communication timed out") from exc
368
382
  except (ConnectionError, OSError) as exc:
369
- raise DeviceConnectionError(self.device_name, f"Communication error: {exc}") from exc
383
+ raise DeviceConnectionError(self._device_name, f"Communication error: {exc}") from exc
370
384
  except Exception as exc:
371
- raise DeviceConnectionError(self.device_name, f"Transaction error: {exc}") from exc
385
+ raise DeviceConnectionError(self._device_name, f"Transaction error: {exc}") from exc
372
386
 
373
387
  async def __aenter__(self):
374
388
  """Async context manager entry."""
@@ -72,13 +72,14 @@ from typing import Any
72
72
 
73
73
  import yaml # This module is provided by the pip package PyYaml - pip install pyyaml
74
74
 
75
+ from egse.env import bool_env
75
76
  from egse.env import get_local_settings_env_name
76
77
  from egse.env import get_local_settings_path
77
78
  from egse.log import logger
78
79
  from egse.system import attrdict
79
80
  from egse.system import recursive_dict_update
80
81
 
81
- _HERE = Path(__file__).resolve().parent
82
+ VERBOSE_DEBUG = bool_env("VERBOSE_DEBUG")
82
83
 
83
84
 
84
85
  class SettingsError(Exception):
@@ -192,6 +193,9 @@ def load_local_settings(force: bool = False) -> attrdict:
192
193
  local_settings = attrdict()
193
194
 
194
195
  local_settings_path = get_local_settings_path()
196
+ if VERBOSE_DEBUG:
197
+ logger.debug(f"{get_local_settings_env_name()=}")
198
+ logger.debug(f"{local_settings_path=}")
195
199
 
196
200
  if local_settings_path:
197
201
  path = Path(local_settings_path).expanduser()
@@ -218,7 +222,8 @@ def read_configuration_file(filename: Path, *, force=False) -> dict:
218
222
  filename = str(filename)
219
223
 
220
224
  if force or not Settings.is_memoized(filename):
221
- logger.debug(f"Parsing YAML configuration file {filename}.")
225
+ if VERBOSE_DEBUG:
226
+ logger.debug(f"Parsing YAML configuration file {filename}.")
222
227
 
223
228
  with open(filename, "r") as stream:
224
229
  try:
@@ -375,6 +380,37 @@ class Settings:
375
380
 
376
381
  return msg.rstrip()
377
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
+
378
414
 
379
415
  def main(args: list | None = None): # pragma: no cover
380
416
  # We provide convenience to inspect the settings by calling this module directly from Python.
@@ -8,6 +8,8 @@ import socket
8
8
  import time
9
9
  from typing import Optional
10
10
 
11
+ from egse.device import AsyncDeviceInterface
12
+ from egse.device import AsyncDeviceTransport
11
13
  from egse.device import DeviceConnectionError
12
14
  from egse.device import DeviceConnectionInterface
13
15
  from egse.device import DeviceTimeoutError
@@ -153,7 +155,7 @@ class SocketDevice(DeviceConnectionInterface, DeviceTransport):
153
155
 
154
156
  try:
155
157
  while True:
156
- # compute remaining timeout for select, this is needed because we read in different parts
158
+ # compute the remaining timeout for select, this is needed because we read in different parts
157
159
  # until ETX is received, and we want to receive the complete messages including ETX within
158
160
  # the read timeout.
159
161
  if end_time is None:
@@ -252,7 +254,7 @@ class SocketDevice(DeviceConnectionInterface, DeviceTransport):
252
254
  raise DeviceConnectionError(self.device_name, "Socket communication error.") from exc
253
255
 
254
256
 
255
- class AsyncSocketDevice(DeviceConnectionInterface, DeviceTransport):
257
+ class AsyncSocketDevice(AsyncDeviceInterface, AsyncDeviceTransport):
256
258
  """
257
259
  Async socket-backed device using asyncio streams.
258
260
 
@@ -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
 
@@ -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()
File without changes
File without changes
File without changes