cgse-common 2024.1.1__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/obsid.py ADDED
@@ -0,0 +1,161 @@
1
+ """
2
+ An ObservationIdentifier or OBSID is a unique identifier for an observation or test.
3
+
4
+ Each observation or test needs a unique identification that can be used as a key in a
5
+ database or in a filename for test data etc.
6
+
7
+ """
8
+ from pathlib import Path
9
+ from typing import Union
10
+
11
+ from egse.config import find_dir
12
+ from egse.settings import Settings
13
+
14
+ LAB_SETUP_TEST = 0
15
+ TEST_LAB_SETUP = 1
16
+ TEST_LAB = 3
17
+
18
+ SITE = Settings.load("SITE")
19
+
20
+
21
+ class ObservationIdentifier:
22
+ """A unique identifier for each observation or test."""
23
+
24
+ def __init__(self, lab_id: str = None, setup_id: int = None, test_id: int = None):
25
+ """
26
+ Args:
27
+ lab_id: the identifier for the site or lab that performs the test
28
+ setup_id: the identifier for the setup that is used for the test
29
+ test_id: the test identifier or test number
30
+ """
31
+ if lab_id is None or setup_id is None or test_id is None:
32
+ raise ValueError("arguments can not be None or an empty string")
33
+
34
+ self._lab_id = lab_id
35
+ self._setup_id = setup_id
36
+ self._test_id = test_id
37
+
38
+ # Construct the OBSID as it will be used in serialisation etc.
39
+
40
+ self._obsid = f"{lab_id}_{setup_id:05d}_{test_id:05d}"
41
+
42
+ @staticmethod
43
+ def create_from_string(obsid: str, order: int = LAB_SETUP_TEST):
44
+ if order == LAB_SETUP_TEST:
45
+ lab_id, setup_id, test_id = obsid.split("_")
46
+ elif order == TEST_LAB_SETUP:
47
+ test_id, lab_id, setup_id = obsid.split("_")
48
+ else:
49
+ raise ValueError(
50
+ f"The order argument can only be {LAB_SETUP_TEST=} or {TEST_LAB_SETUP=}")
51
+
52
+ return ObservationIdentifier(lab_id, int(setup_id), int(test_id))
53
+
54
+ @property
55
+ def lab_id(self):
56
+ return self._lab_id
57
+
58
+ @property
59
+ def setup_id(self):
60
+ return self._setup_id
61
+
62
+ @property
63
+ def test_id(self):
64
+ return self._test_id
65
+
66
+ def __eq__(self, other):
67
+ if not isinstance(other, ObservationIdentifier):
68
+ return NotImplemented
69
+ return self._obsid == other._obsid
70
+
71
+ def __hash__(self):
72
+ return hash(self._obsid)
73
+
74
+ def __str__(self):
75
+ return self._obsid
76
+
77
+ def create_id(self, *, order: int = LAB_SETUP_TEST, camera_name: str = None) -> str:
78
+ """
79
+ Creates a string representation of the observation identifier.
80
+
81
+ Args:
82
+ order: the order in which the parts are concatenated
83
+ camera_name: if a camera name is given, it will be appended in lower case
84
+
85
+ Returns:
86
+ A string representation of the obsid with or without camera name attached.
87
+ """
88
+ camera = f"{f'_{camera_name.lower()}' if camera_name else ''}"
89
+
90
+ if order == TEST_LAB_SETUP:
91
+ return f"{self._test_id:05d}_{self._lab_id}_{self._setup_id:05d}{camera}"
92
+ if order == TEST_LAB:
93
+ return f"{self._test_id:05d}_{self._lab_id}{camera}"
94
+ if order == LAB_SETUP_TEST:
95
+ return f"{self._lab_id}_{self._setup_id:05d}_{self._test_id:05d}{camera}"
96
+
97
+
98
+ def obsid_from_storage(obsid: Union[ObservationIdentifier, str, int], data_dir: str,
99
+ site_id: str = None, camera_name: str = None) -> str:
100
+ """
101
+ Return the name of the folder for the given obsid in the 'obs' sub-folder of data_dir.
102
+
103
+ For the oldest observations, the obsid used in the directory structure and filenames was of the format
104
+ TEST_LAB_SETUP. All files in this folder also have the obsid in that format in their name. At some point, we
105
+ decided to change this to TEST_LAB, but we still need to be able to re-process the old data (with the setup ID in
106
+ the names of the directories and files).
107
+
108
+ For newer observations (>= 2023.6.0+CGSE), the camera name is appended to the folder name and also included
109
+ in the filenames in that folder.
110
+
111
+ Args:
112
+ obsid: Observation identifier. This can be an ObservationIdentifier object, a string in format TEST_LAB or
113
+ TEST_LAB_SETUP, or an integer representing the test ID. In this last case, the site id is taken from the
114
+ Settings.
115
+ data_dir: root folder in which the observations are stored. This folder shall have a sub-folder 'obs'.
116
+ site_id: a site id like 'CSL1' or 'IAS', when `None`, the `SITE.ID` from the Settings will be used
117
+ camera_name: if not None, append the camera name to the result
118
+
119
+ Returns:
120
+ The name of the folder for the given obsid in the 'obs' sub-folder of data_dir.
121
+ """
122
+
123
+ obs_dir = f"{data_dir}/obs/"
124
+ site_id = site_id or SITE.ID
125
+ camera = f"_{camera_name.lower()}" if camera_name else ""
126
+
127
+ if isinstance(obsid, ObservationIdentifier):
128
+ test, site = obsid.test_id, obsid.lab_id
129
+ elif isinstance(obsid, str): # TEST_LAB or TEST_LAB_SETUP
130
+ test, site = obsid.split("_")[:2]
131
+ else:
132
+ test, site = obsid, site_id
133
+
134
+ test = int(test)
135
+
136
+ # Remember the camera name can be an empty string, so this will match both
137
+ # '00313_CSL' and '00313_CSL_achel'.
138
+
139
+ result_without_setup = Path(f"{obs_dir}/{test:05d}_{site}{camera}")
140
+
141
+ if result_without_setup.exists():
142
+ return result_without_setup.stem
143
+
144
+ # If a camera name was provided, but we try to find an old observation where the
145
+ # camera name was not appended to the folder name yet, the following will match
146
+ # that folder name.
147
+
148
+ result_without_camera = Path(f"{obs_dir}/{test:05d}_{site}")
149
+
150
+ if result_without_camera.exists():
151
+ return result_without_camera.stem
152
+
153
+ # When we come here, we can still match old observations that included the setup id in their
154
+ # folder name.
155
+
156
+ pattern = f"{test:05d}_{site}_*{camera}"
157
+
158
+ if (match := find_dir(pattern=pattern, root=obs_dir)) is None:
159
+ raise ValueError(f"Could not find a folder matching '{pattern}' in '{obs_dir}'")
160
+
161
+ return match.stem
egse/persistence.py ADDED
@@ -0,0 +1,58 @@
1
+ from abc import ABC
2
+ from abc import abstractmethod
3
+
4
+
5
+ class PersistenceLayer(ABC):
6
+ """The Persistence Layer implements the CRUD paradigm for storing data."""
7
+
8
+ extension = "no_ext"
9
+ """The file extension to use for this persistence type."""
10
+
11
+ @abstractmethod
12
+ def open(self, mode=None):
13
+ """Opens the resource."""
14
+ raise NotImplementedError("Persistence layers must implement the open method")
15
+
16
+ @abstractmethod
17
+ def close(self):
18
+ """Closes the resource."""
19
+ raise NotImplementedError("Persistence layers must implement the close method")
20
+
21
+ @abstractmethod
22
+ def exists(self):
23
+ """Does the resource exists."""
24
+ raise NotImplementedError("Persistence layers must implement the exists method")
25
+
26
+ @abstractmethod
27
+ def create(self, data):
28
+ """Creates an entry in the persistence store."""
29
+ raise NotImplementedError("Persistence layers must implement a create method")
30
+
31
+ @abstractmethod
32
+ def read(self, select=None):
33
+ """Returns a list of all entries in the persistence store.
34
+
35
+ The list can be filtered based on a selection from the `select` argument which
36
+ should be a Callable object.
37
+
38
+ Args:
39
+ select (Callable): a filter function to narrow down the list of all entries.
40
+ Returns:
41
+ A list or generator for all entries in the persistence store.
42
+ """
43
+ raise NotImplementedError("Persistence layers must implement a read method")
44
+
45
+ @abstractmethod
46
+ def update(self, idx, data):
47
+ """Updates the entry for index `idx` in the persistence store."""
48
+ raise NotImplementedError("Persistence layers must implement an update method")
49
+
50
+ @abstractmethod
51
+ def delete(self, idx):
52
+ """Deletes the entry for index `idx` from the persistence store."""
53
+ raise NotImplementedError("Persistence layers must implement a delete method")
54
+
55
+ @abstractmethod
56
+ def get_filepath(self):
57
+ """If this persistence class is file based, return its file path, otherwise return None."""
58
+ raise NotImplementedError("Persistence layers must implement a get_filepath method")
egse/plugin.py ADDED
@@ -0,0 +1,97 @@
1
+ import logging
2
+ import os
3
+ import sys
4
+ import textwrap
5
+ import traceback
6
+
7
+ import click
8
+ import rich
9
+
10
+ LOGGER = logging.getLogger(__name__)
11
+
12
+
13
+ def entry_points(name: str):
14
+ import importlib.metadata
15
+
16
+ try:
17
+ x = importlib.metadata.entry_points()[name]
18
+ return {ep for ep in x} # use of set here to remove duplicates
19
+ except KeyError:
20
+ return []
21
+
22
+
23
+ def load_plugins(entry_point: str) -> dict:
24
+
25
+ eps = {}
26
+ for ep in entry_points(entry_point):
27
+ try:
28
+ eps[ep.name] = ep.load()
29
+ except Exception as exc:
30
+ eps[ep.name] = None
31
+ LOGGER.error(f"Couldn't load entry point: {exc}")
32
+
33
+ return eps
34
+
35
+
36
+ # The following code was adapted from the inspiring package click-plugins
37
+ # at https://github.com/click-contrib/click-plugins/
38
+
39
+ def handle_click_plugins(plugins):
40
+ def decorator(group):
41
+ if not isinstance(group, click.Group):
42
+ raise TypeError("Plugins can only be attached to an instance of click.Group()")
43
+
44
+ for entry_point in plugins or ():
45
+ try:
46
+ group.add_command(entry_point.load())
47
+ except Exception:
48
+ # Catch this so a busted plugin doesn't take down the CLI.
49
+ # Handled by registering a dummy command that does nothing
50
+ # other than explain the error.
51
+ group.add_command(BrokenCommand(entry_point.name))
52
+
53
+ return group
54
+
55
+ return decorator
56
+
57
+
58
+ class BrokenCommand(click.Command):
59
+ """
60
+ Rather than completely crash the CLI when a broken plugin is loaded, this
61
+ class provides a modified help message informing the user that the plugin is
62
+ broken, and they should contact the owner. If the user executes the plugin
63
+ or specifies `--help` a traceback is reported showing the exception the
64
+ plugin loader encountered.
65
+ """
66
+
67
+ def __init__(self, name):
68
+ """
69
+ Define the special help messages after instantiating a `click.Command()`.
70
+ """
71
+
72
+ click.Command.__init__(self, name)
73
+
74
+ util_name = os.path.basename(sys.argv and sys.argv[0] or __file__)
75
+ icon = u'\u2020'
76
+
77
+ self.help = textwrap.dedent(
78
+ f"""\
79
+ Warning: entry point could not be loaded. Contact its author for help.
80
+
81
+ {traceback.format_exc()}
82
+ """
83
+ )
84
+
85
+ self.short_help = f"{icon} Warning: could not load plugin. See `{util_name} {self.name} --help`."
86
+
87
+ def invoke(self, ctx):
88
+ """
89
+ Print the traceback instead of doing nothing.
90
+ """
91
+
92
+ rich.print()
93
+ rich.print(self.help)
94
+ ctx.exit(1)
95
+
96
+ def parse_args(self, ctx, args):
97
+ return args