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/decorators.py ADDED
@@ -0,0 +1,419 @@
1
+ """
2
+ A collection of useful decorator functions.
3
+ """
4
+ import cProfile
5
+ import functools
6
+ import logging
7
+ import pstats
8
+ import time
9
+ import warnings
10
+ from typing import Callable
11
+ from typing import Optional
12
+
13
+ from egse.settings import Settings
14
+ from egse.system import get_caller_info
15
+
16
+ _LOGGER = logging.getLogger(__name__)
17
+
18
+
19
+ def static_vars(**kwargs):
20
+ """Define static variables in a function."""
21
+ def decorator(func):
22
+ for kw in kwargs:
23
+ setattr(func, kw, kwargs[kw])
24
+ return func
25
+ return decorator
26
+
27
+
28
+ def dynamic_interface(func):
29
+ """Adds a static variable `__dynamic_interface` to a method.
30
+
31
+ The intended use of this function is as a decorator for functions in an interface class.
32
+
33
+ The static variable is currently used by the Proxy class to check if a method
34
+ is meant to be overridden dynamically. The idea behind this is to loosen the contract
35
+ of an abstract base class (ABC) into an interface. For an ABC, the abstract methods
36
+ must be implemented at construction/initialization. This is not possible for the Proxy
37
+ subclasses as they load their commands (i.e. methods) from the control server, and the
38
+ method will be added to the Proxy interface after loading. Nevertheless, we like the
39
+ interface already defined for auto-completion during development or interactive use.
40
+
41
+ When a Proxy subclass that implements an interface with methods decorated by
42
+ the `@dynamic_interface` does overwrite one or more of the decorated methods statically,
43
+ these methods will not be dynamically overwritten when loading the interface from the
44
+ control server. A warning will be logged instead.
45
+ """
46
+ setattr(func, "__dynamic_interface", True)
47
+ return func
48
+
49
+
50
+ def query_command(func):
51
+ """Adds a static variable `__query_command` to a method.
52
+ """
53
+
54
+ setattr(func, "__query_command", True)
55
+ return func
56
+
57
+
58
+ def transaction_command(func):
59
+ """Adds a static variable `__transaction_command` to a method.
60
+ """
61
+
62
+ setattr(func, "__transaction_command", True)
63
+ return func
64
+
65
+
66
+ def read_command(func):
67
+ """Adds a static variable `__read_command` to a method.
68
+ """
69
+
70
+ setattr(func, "__read_command", True)
71
+ return func
72
+
73
+
74
+ def write_command(func):
75
+ """Adds a static variable `__write_command` to a method.
76
+ """
77
+
78
+ setattr(func, "__write_command", True)
79
+ return func
80
+
81
+
82
+ def timer(*, level: int = logging.INFO, precision: int = 4):
83
+ """
84
+ Print the runtime of the decorated function.
85
+
86
+ Args:
87
+ level: the logging level for the time message [default=INFO]
88
+ precision: the number of decimals for the time [default=3 (ms)]
89
+ """
90
+
91
+ def actual_decorator(func):
92
+ @functools.wraps(func)
93
+ def wrapper_timer(*args, **kwargs):
94
+ start_time = time.perf_counter()
95
+ value = func(*args, **kwargs)
96
+ end_time = time.perf_counter()
97
+ run_time = end_time - start_time
98
+ _LOGGER.log(level, f"Finished {func.__name__!r} in {run_time:.{precision}f} secs")
99
+ return value
100
+
101
+ return wrapper_timer
102
+ return actual_decorator
103
+
104
+
105
+ def time_it(count: int = 1000):
106
+ """Print the runtime of the decorated function.
107
+
108
+ This is a simple replacement for the builtin ``timeit`` function. The purpose is to simplify
109
+ calling a function with some parameters.
110
+
111
+ The intended way to call this is as a function:
112
+
113
+ value = function(args)
114
+
115
+ value = time_it(10_000)(function)(args)
116
+
117
+ The `time_it` function can be called as a decorator in which case it will always call the
118
+ function `count` times which is probably not what you want.
119
+
120
+ Args:
121
+ count (int): the number of executions [default=1000].
122
+
123
+ Returns:
124
+ value: the return value of the last function execution.
125
+
126
+ See also:
127
+ the ``Timer`` context manager located in ``egse.system``.
128
+
129
+ Usage:
130
+ @time_it(count=10000)
131
+ def function(args):
132
+ pass
133
+
134
+ time_it(10000)(function)(args)
135
+ """
136
+
137
+ def actual_decorator(func):
138
+ @functools.wraps(func)
139
+ def wrapper_timer(*args, **kwargs):
140
+ value = None
141
+ start_time = time.perf_counter()
142
+ for _ in range(count):
143
+ value = func(*args, **kwargs)
144
+ end_time = time.perf_counter()
145
+ run_time = end_time - start_time
146
+ logging.info(f"Finished {func.__name__!r} in {run_time/count:.4f} secs (total time: {run_time:.2f}s, "
147
+ f"count: {count})")
148
+ return value
149
+
150
+ return wrapper_timer
151
+ return actual_decorator
152
+
153
+
154
+ def debug(func):
155
+ """Print the function signature and return value"""
156
+
157
+ @functools.wraps(func)
158
+ def wrapper_debug(*args, **kwargs):
159
+ if __debug__:
160
+ args_repr = [repr(a) for a in args]
161
+ kwargs_repr = [f"{k}={v!r}" for k, v in kwargs.items()]
162
+ signature = ", ".join(args_repr + kwargs_repr)
163
+ _LOGGER.debug(f"Calling {func.__name__}({signature})")
164
+ value = func(*args, **kwargs)
165
+ _LOGGER.debug(f"{func.__name__!r} returned {value!r}")
166
+ else:
167
+ value = func(*args, **kwargs)
168
+ return value
169
+
170
+ return wrapper_debug
171
+
172
+
173
+ def profile_func(output_file=None, sort_by='cumulative', lines_to_print=None, strip_dirs=False):
174
+ """A time profiler decorator.
175
+
176
+ This code was taken from: https://gist.github.com/ekhoda/2de44cf60d29ce24ad29758ce8635b78
177
+
178
+ Inspired by and modified the profile decorator of Giampaolo Rodola:
179
+ http://code.activestate.com/recipes/577817-profile-decorator/
180
+
181
+ Args:
182
+ output_file: str or None. Default is None
183
+ Path of the output file. If only name of the file is given, it's
184
+ saved in the current directory.
185
+ If it's None, the name of the decorated function is used.
186
+ sort_by: str or SortKey enum or tuple/list of str/SortKey enum
187
+ Sorting criteria for the Stats object.
188
+ For a list of valid string and SortKey refer to:
189
+ https://docs.python.org/3/library/profile.html#pstats.Stats.sort_stats
190
+ lines_to_print: int or None
191
+ Number of lines to print. Default (None) is for all the lines.
192
+ This is useful in reducing the size of the printout, especially
193
+ that sorting by 'cumulative', the time consuming operations
194
+ are printed toward the top of the file.
195
+ strip_dirs: bool
196
+ Whether to remove the leading path info from file names.
197
+ This is also useful in reducing the size of the printout
198
+
199
+ Returns:
200
+ Profile of the decorated function
201
+ """
202
+
203
+ def inner(func):
204
+ @functools.wraps(func)
205
+ def wrapper(*args, **kwargs):
206
+ _output_file = output_file or func.__name__ + '.prof'
207
+ pr = cProfile.Profile()
208
+ pr.enable()
209
+ retval = func(*args, **kwargs)
210
+ pr.disable()
211
+ pr.dump_stats(_output_file)
212
+
213
+ with open(_output_file, 'w') as f:
214
+ ps = pstats.Stats(pr, stream=f)
215
+ if strip_dirs:
216
+ ps.strip_dirs()
217
+ if isinstance(sort_by, (tuple, list)):
218
+ ps.sort_stats(*sort_by)
219
+ else:
220
+ ps.sort_stats(sort_by)
221
+ ps.print_stats(lines_to_print)
222
+ return retval
223
+
224
+ return wrapper
225
+
226
+ return inner
227
+
228
+
229
+ def profile(func):
230
+ """Print the function signature and return value"""
231
+ if not hasattr(profile, "counter"):
232
+ profile.counter = 0
233
+
234
+ @functools.wraps(func)
235
+ def wrapper_profile(*args, **kwargs):
236
+ if Settings.profiling():
237
+ profile.counter += 1
238
+ args_repr = [repr(a) for a in args]
239
+ kwargs_repr = [f"{k}={v!r}" for k, v in kwargs.items()]
240
+ signature = ", ".join(args_repr + kwargs_repr)
241
+ caller = get_caller_info(level=2)
242
+ prefix = f"PROFILE[{profile.counter}]: "
243
+ _LOGGER.info(f"{prefix}Calling {func.__name__}({signature})")
244
+ _LOGGER.info(f"{prefix} from {caller.filename} at {caller.lineno}.")
245
+ value = func(*args, **kwargs)
246
+ _LOGGER.info(f"{prefix}{func.__name__!r} returned {value!r}")
247
+ profile.counter -= 1
248
+ else:
249
+ value = func(*args, **kwargs)
250
+ return value
251
+
252
+ return wrapper_profile
253
+
254
+
255
+ def to_be_implemented(func):
256
+ """Print a warning message that this function/method has to be implemented."""
257
+
258
+ @functools.wraps(func)
259
+ def wrapper_tbi(*args, **kwargs):
260
+ _LOGGER.warning(f"The function/method {func.__name__} is not yet implemented.")
261
+ return func(*args, **kwargs)
262
+
263
+ return wrapper_tbi
264
+
265
+
266
+ # Taken and adapted from https://github.com/QCoDeS/Qcodes
267
+
268
+ def deprecate(reason: Optional[str] = None,
269
+ alternative: Optional[str] = None) -> Callable:
270
+ """
271
+ Deprecate a function or method. This will print a warning with the function name and where
272
+ it is called from. If the optional parameters `reason` and `alternative` are given, that
273
+ information will be printed with the warning.
274
+
275
+ Args:
276
+ reason: provide a short explanation why this function is deprecated. Generates 'because {reason}'
277
+ alternative: provides an alternative function/parameters to be used. Generates 'Use {alternative}
278
+ as an alternative'
279
+ Returns:
280
+ The decorated function.
281
+ """
282
+
283
+ def actual_decorator(func: Callable) -> Callable:
284
+ @functools.wraps(func)
285
+ def decorated_func(*args, **kwargs):
286
+ caller = get_caller_info(2)
287
+ msg = f'The function \"{func.__name__}\" used at {caller.filename}:{caller.lineno} is deprecated'
288
+ if reason is not None:
289
+ msg += f', because {reason}'
290
+ if alternative is not None:
291
+ msg += f'. Use {alternative} as an alternative'
292
+ msg += '.'
293
+ warnings.warn(msg, DeprecationWarning, stacklevel=2)
294
+ return func(*args, **kwargs)
295
+
296
+ decorated_func.__doc__ = (
297
+ f"This function is DEPRECATED, because {reason}, use {alternative} as an alternative.\n"
298
+ )
299
+ return decorated_func
300
+
301
+ return actual_decorator
302
+
303
+
304
+ def singleton(cls):
305
+ """
306
+ Use class as a singleton.
307
+
308
+ from: https://wiki.python.org/moin/PythonDecoratorLibrary#Singleton
309
+ """
310
+
311
+ cls.__new_original__ = cls.__new__
312
+
313
+ @functools.wraps(cls.__new__)
314
+ def singleton_new(cls, *args, **kw):
315
+ it = cls.__dict__.get('__it__')
316
+ if it is not None:
317
+ return it
318
+
319
+ cls.__it__ = it = cls.__new_original__(cls, *args, **kw)
320
+ it.__init_original__(*args, **kw)
321
+ return it
322
+
323
+ cls.__new__ = singleton_new
324
+ cls.__init_original__ = cls.__init__
325
+ cls.__init__ = object.__init__
326
+
327
+ return cls
328
+
329
+
330
+ def borg(cls):
331
+ """
332
+ Use the Borg pattern to make a class with a shared state between its instances and subclasses.
333
+
334
+ from: http://code.activestate.com/recipes/66531-singleton-we-dont-need-no-stinkin-singleton-the-bo/
335
+ """
336
+
337
+ cls._shared_state = {}
338
+ orig_init = cls.__init__
339
+
340
+ def new_init(self, *args, **kwargs):
341
+ self.__dict__ = cls._shared_state
342
+ orig_init(self, *args, **kwargs)
343
+
344
+ cls.__init__ = new_init
345
+
346
+ return cls
347
+
348
+
349
+ class classproperty:
350
+ """Defines a read-only class property.
351
+
352
+ Usage:
353
+
354
+ >>> class Message:
355
+ ... def __init__(self, msg):
356
+ ... self._msg = msg
357
+ ...
358
+ ... @classproperty
359
+ ... def name(cls):
360
+ ... return cls.__name__
361
+
362
+ >>> msg = Message("a simple doctest")
363
+ >>> assert "Message" == msg.name
364
+
365
+ """
366
+ def __init__(self, func):
367
+ self.func = func
368
+
369
+ def __get__(self, instance, owner):
370
+ return self.func(owner)
371
+
372
+ def __set__(self, instance, value):
373
+ raise AttributeError(
374
+ f"Cannot change class property '{self.func.__name__}' for class '{instance.__class__.__name__}'.")
375
+
376
+
377
+ class Nothing:
378
+ """Just to get a nice repr for Nothing. It is kind of a Null object..."""
379
+ def __repr__(self):
380
+ return "<Nothing>"
381
+
382
+
383
+ def spy_on_attr_change(obj: object, obj_name: str = None) -> None:
384
+ """
385
+ Tweak an object to show attributes changing. The changes are reported as WARNING log messages
386
+ in the `egse.spy` logger.
387
+
388
+ Note this is not a decorator, but a function that changes the class of an object.
389
+
390
+ Note that this function is a debugging aid and should not be used in production code!
391
+
392
+ Args:
393
+ obj (object): any object that you want to monitor
394
+ obj_name (str): the variable name of the object that was given in the code, if None than
395
+ the class name will be printed.
396
+
397
+ Examples:
398
+
399
+ >>> class X:
400
+ ... pass
401
+ >>> x = X()
402
+ >>> spy_on_attr_change(x, obj_name="x")
403
+ >>> x.a = 5
404
+
405
+ From: https://nedbatchelder.com/blog/202206/adding_a_dunder_to_an_object.html
406
+ """
407
+ logger = logging.getLogger("egse.spy")
408
+
409
+ class Wrapper(obj.__class__):
410
+
411
+ def __setattr__(self, name, value):
412
+ old = getattr(self, name, Nothing())
413
+ logger.warning(
414
+ f"Spy: in {obj_name or obj.__class__.__name__} -> {name}: {old!r} -> {value!r}")
415
+ return super().__setattr__(name, value)
416
+
417
+ class_name = obj.__class__.__name__
418
+ obj.__class__ = Wrapper
419
+ obj.__class__.__name__ = class_name
egse/device.py ADDED
@@ -0,0 +1,269 @@
1
+ """
2
+ This module defines the generic interfaces to connect devices.
3
+ """
4
+ from enum import IntEnum
5
+ from typing import List
6
+
7
+ from egse.decorators import dynamic_interface
8
+ from egse.exceptions import Error
9
+
10
+
11
+ class DeviceConnectionState(IntEnum):
12
+ """Defines connection states for device connections."""
13
+
14
+ # We do not use zero '0' as the connected state to prevent a state to be set
15
+ # to connected by default without it explicitly being set.
16
+
17
+ DEVICE_CONNECTED = 1
18
+ DEVICE_NOT_CONNECTED = 2
19
+
20
+
21
+ class DeviceError(Error):
22
+ """Generic device error.
23
+
24
+ Args:
25
+ device_name (str): The name of the device
26
+ message (str): a clear and brief description of the problem
27
+ """
28
+
29
+ def __init__(self, device_name: str, message: str):
30
+ self.device_name = device_name
31
+ self.message = message
32
+
33
+ def __str__(self):
34
+ return f"{self.device_name}: {self.message}"
35
+
36
+
37
+ class DeviceControllerError(DeviceError):
38
+ """Any error that is returned by the device controller.
39
+
40
+ When the device controller is connected through an e.g. Ethernet interface, it will usually
41
+ return error codes as part of the response to a command. When such an error is returned,
42
+ raise this `DeviceControllerError` instead of passing the return code (response) to the caller.
43
+
44
+ Args:
45
+ device_name (str): The name of the device
46
+ message (str): a clear and brief description of the problem
47
+ """
48
+ def __init__(self, device_name: str, message: str):
49
+ super().__init__(device_name, message)
50
+
51
+
52
+ class DeviceConnectionError(DeviceError):
53
+ """A generic error for all connection type of problems.
54
+
55
+ Args:
56
+ device_name (str): The name of the device
57
+ message (str): a clear and brief description of the problem
58
+ """
59
+ def __init__(self, device_name: str, message: str):
60
+ super().__init__(device_name, message)
61
+
62
+
63
+ class DeviceTimeoutError(DeviceError):
64
+ """A timeout on a device that we could not handle.
65
+
66
+ Args:
67
+ device_name (str): The name of the device
68
+ message (str): a clear and brief description of the problem
69
+ """
70
+ def __init__(self, device_name: str, message: str):
71
+ super().__init__(device_name, message)
72
+
73
+
74
+ class DeviceInterfaceError(DeviceError):
75
+ """Any error that is returned or raised by the higher level interface to the device.
76
+
77
+ Args:
78
+ device_name (str): The name of the device
79
+ message (str): a clear and brief description of the problem
80
+ """
81
+ def __init__(self, device_name: str, message: str):
82
+ super().__init__(device_name, message)
83
+
84
+
85
+ class DeviceConnectionObserver:
86
+ """
87
+ An observer for the connection state of a device. Add the subclass of this class to
88
+ the class that inherits from DeviceConnectionObservable. The observable will notify an
89
+ update of its state by calling the `update_connection_state()` method.
90
+ """
91
+ def __init__(self):
92
+ self._state = DeviceConnectionState.DEVICE_NOT_CONNECTED
93
+
94
+ def update_connection_state(self, state: DeviceConnectionState):
95
+ """Updates the connection state with the given state."""
96
+ self._state = state
97
+
98
+ @property
99
+ def state(self):
100
+ """Returns the current connection state of the device."""
101
+ return self._state
102
+
103
+
104
+ class DeviceConnectionObservable:
105
+ """
106
+ An observable for the connection state of a device. An observer can be added with the
107
+ `add_observer()` method. Whenever the connection state of the device changes, the subclass
108
+ is responsible for notifying the observers by calling the `notify_observers()` method
109
+ with the correct state.
110
+ """
111
+ def __init__(self):
112
+ self._observers: List[DeviceConnectionObserver] = []
113
+
114
+ def add_observer(self, observer: DeviceConnectionObserver):
115
+ """Add an observer."""
116
+ if observer not in self._observers:
117
+ self._observers.append(observer)
118
+
119
+ def notify_observers(self, state: DeviceConnectionState):
120
+ """Notify the observers of a possible state change."""
121
+ for observer in self._observers:
122
+ observer.update_connection_state(state)
123
+
124
+
125
+ class DeviceConnectionInterface(DeviceConnectionObservable):
126
+ """Generic connection interface for all Device classes and Controllers.
127
+
128
+ This interface shall be implemented in the Controllers that directly connect to the
129
+ hardware, but also in the simulators to guarantee an identical interface as the controllers.
130
+
131
+ This interface will be implemented in the Proxy classes through the
132
+ YAML definitions. Therefore, the YAML files shall define at least
133
+ the following commands: `connect`, `disconnect`, `reconnect`, `is_connected`.
134
+ """
135
+
136
+ def __init__(self):
137
+ super().__init__()
138
+
139
+ def __enter__(self):
140
+ self.connect()
141
+ return self
142
+
143
+ def __exit__(self, exc_type, exc_val, exc_tb):
144
+ self.disconnect()
145
+
146
+ @dynamic_interface
147
+ def connect(self):
148
+ """Connect to the device controller.
149
+
150
+ Raises:
151
+ ConnectionError: when the connection can not be opened.
152
+ """
153
+ raise NotImplementedError
154
+
155
+ @dynamic_interface
156
+ def disconnect(self):
157
+ """Disconnect from the device controller.
158
+
159
+ Raises:
160
+ ConnectionError: when the connection can not be closed.
161
+ """
162
+ raise NotImplementedError
163
+
164
+ @dynamic_interface
165
+ def reconnect(self):
166
+ """Reconnect the device controller.
167
+
168
+ Raises:
169
+ ConnectionError: when the device can not be reconnected for some reason.
170
+ """
171
+ raise NotImplementedError
172
+
173
+ @dynamic_interface
174
+ def is_connected(self) -> bool:
175
+ """Check if the device is connected.
176
+
177
+ Returns:
178
+ True if the device is connected and responds to a command, False otherwise.
179
+ """
180
+ raise NotImplementedError
181
+
182
+
183
+ class DeviceInterface(DeviceConnectionInterface):
184
+ """Generic interface for all device classes."""
185
+
186
+ @dynamic_interface
187
+ def is_simulator(self) -> bool:
188
+ """Ask if the device class is a Simulator instead of the real Controller.
189
+
190
+ This can be useful for testing purposes or when doing actual movement simulations.
191
+
192
+ Returns:
193
+ True if the Device is a Simulator, False if the Device is connected to real hardware.
194
+ """
195
+ raise NotImplementedError
196
+
197
+
198
+ class DeviceTransport:
199
+ """
200
+ Base class for the device transport layer.
201
+ """
202
+
203
+ def write(self, command: str):
204
+ """
205
+ Sends a complete command to the device, handle line termination, and write timeouts.
206
+
207
+ Args:
208
+ command: the command to be sent to the instrument.
209
+ """
210
+
211
+ raise NotImplementedError()
212
+
213
+ def read(self) -> bytes:
214
+ """
215
+ Reads a string back from the instrument.
216
+ """
217
+
218
+ raise NotImplementedError
219
+
220
+ def trans(self, command: str) -> bytes:
221
+ """
222
+ Send a single command to the device controller and block until a response from the
223
+ controller.
224
+
225
+ Args:
226
+ command: is the command to be sent to the instrument
227
+
228
+ Returns:
229
+ Either a string returned by the controller (on success), or an error message (on
230
+ failure).
231
+
232
+ Raises:
233
+ DeviceConnectionError when there was an I/O problem during communication with the
234
+ controller.
235
+
236
+ DeviceTimeoutError when there was a timeout in either sending the command or
237
+ receiving the response.
238
+ """
239
+
240
+ raise NotImplementedError
241
+
242
+ def query(self, command: str) -> bytes:
243
+ """
244
+ Send a query to the device and wait for the response.
245
+
246
+ This `query` method is an alias for the `trans` command. For some commands it might be
247
+ more intuitive to use the `query` instead of the `trans`action. No need to override this
248
+ method as it delegates to `trans`.
249
+
250
+ Args:
251
+ command (str): the query command.
252
+
253
+ Returns:
254
+ The response to the query.
255
+ """
256
+ return self.trans(command)
257
+
258
+
259
+ class DeviceFactoryInterface:
260
+ """
261
+ Base class for creating a device factory class to access devices.
262
+
263
+ This interface defines one interface method that shall be implemented by the Factory:
264
+
265
+ create(device_name: str, *, device_id: str, **_ignored)
266
+ """
267
+
268
+ def create(self, device_name: str, *, device_id: str, **_ignored):
269
+ ...