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/system.py ADDED
@@ -0,0 +1,1499 @@
1
+ """
2
+ The system module defines convenience functions that provide information on system specific
3
+ functionality like, file system interactions, timing, operating system interactions, etc.
4
+
5
+ The module has external dependencies to:
6
+
7
+ * __distro__: for determining the Linux distribution
8
+ * __psutil__: for system statistics
9
+
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import builtins
14
+ import collections
15
+ import contextlib
16
+ import datetime
17
+ import functools
18
+ import importlib
19
+ import inspect
20
+ import itertools
21
+ import logging
22
+ import operator
23
+ import os
24
+ import platform # For getting the operating system name
25
+ import re
26
+ import signal
27
+ import socket
28
+ import subprocess # For executing a shell command
29
+ import sys
30
+ import time
31
+ from collections import namedtuple
32
+ from pathlib import Path
33
+ from types import FunctionType
34
+ from types import ModuleType
35
+ from typing import Any
36
+ from typing import Callable
37
+ from typing import Iterable
38
+ from typing import List
39
+ from typing import NamedTuple
40
+ from typing import Optional
41
+ from typing import Tuple
42
+ from typing import Union
43
+
44
+ import distro # For determining the Linux distribution
45
+ import psutil
46
+ from rich.text import Text
47
+ from rich.tree import Tree
48
+
49
+ EPOCH_1958_1970 = 378691200
50
+ TIME_FORMAT = "%Y-%m-%dT%H:%M:%S.%f%z"
51
+
52
+ logger = logging.getLogger(__name__)
53
+
54
+ from contextlib import contextmanager
55
+ import logging
56
+
57
+
58
+ # Code below is copied from https://gist.github.com/simon-weber/7853144
59
+
60
+
61
+ @contextmanager
62
+ def all_logging_disabled(highest_level=logging.CRITICAL, flag=True):
63
+ """
64
+ A context manager that will prevent any logging messages triggered during the body from being processed.
65
+
66
+ Args:
67
+ highest_level: the maximum logging level in use.
68
+ This would only need to be changed if a custom level greater than CRITICAL is defined.
69
+ flag: True to disable all logging [default=True]
70
+ """
71
+ # two kind-of hacks here:
72
+ # * can't get the highest logging level in effect => delegate to the user
73
+ # * can't get the current module-level override => use an undocumented
74
+ # (but non-private!) interface
75
+
76
+ previous_level = logging.root.manager.disable
77
+
78
+ if flag:
79
+ logging.disable(highest_level)
80
+
81
+ try:
82
+ yield
83
+ finally:
84
+ logging.disable(previous_level)
85
+
86
+
87
+ def get_active_loggers() -> dict:
88
+ return {
89
+ name: logging.getLevelName(logging.getLogger(name).level) for name in sorted(logging.Logger.manager.loggerDict)
90
+ }
91
+
92
+
93
+ # The code below was taken from https://stackoverflow.com/a/69639238/4609203
94
+
95
+
96
+ def ignore_m_warning(modules=None):
97
+ """
98
+ Ignore RuntimeWarning by `runpy` that occurs when executing a module with `python -m package.module`,
99
+ while that module is also imported.
100
+
101
+ The original warning mssage is:
102
+
103
+ '<package.module>' found in sys.modules after import of package '<package'>,
104
+ but prior to execution of '<package.module>'
105
+ """
106
+ if not isinstance(modules, (list, tuple)):
107
+ modules = [modules]
108
+
109
+ try:
110
+ import warnings
111
+ import re
112
+
113
+ msg = "'{module}' found in sys.modules after import of package"
114
+ for module in modules:
115
+ module_msg = re.escape(msg.format(module=module))
116
+ warnings.filterwarnings("ignore", message=module_msg, category=RuntimeWarning, module="runpy") # ignore -m
117
+ except (ImportError, KeyError, AttributeError, Exception):
118
+ pass
119
+
120
+
121
+ def format_datetime(dt: Union[str, datetime.datetime] = None, fmt: str = None, width: int = 6, precision: int = 3):
122
+ """Format a datetime as YYYY-mm-ddTHH:MM:SS.μs+0000.
123
+
124
+ If the given argument is not timezone aware, the last part, i.e. `+0000` will not be there.
125
+
126
+ If no argument is given, the timestamp is generated as
127
+ `datetime.datetime.now(tz=datetime.timezone.utc)`.
128
+
129
+ The `dt` argument can also be a string with the following values: today, yesterday, tomorrow,
130
+ and 'day before yesterday'. The format will then be '%Y%m%d' unless specified.
131
+
132
+ Optionally, a format string can be passed in to customize the formatting of the timestamp.
133
+ This format string will be used with the `strftime()` method and should obey those conventions.
134
+
135
+ Example:
136
+ >>> format_datetime(datetime.datetime(2020, 6, 13, 14, 45, 45, 696138))
137
+ '2020-06-13T14:45:45.696'
138
+ >>> format_datetime(datetime.datetime(2020, 6, 13, 14, 45, 45, 696138), precision=6)
139
+ '2020-06-13T14:45:45.696138'
140
+ >>> format_datetime(datetime.datetime(2020, 6, 13, 14, 45, 59, 999501), precision=3)
141
+ '2020-06-13T14:45:59.999'
142
+ >>> format_datetime(datetime.datetime(2020, 6, 13, 14, 45, 59, 999501), precision=6)
143
+ '2020-06-13T14:45:59.999501'
144
+ >>> _ = format_datetime()
145
+ ...
146
+ >>> format_datetime("yesterday")
147
+ '20220214'
148
+ >>> format_datetime("yesterday", fmt="%d/%m/%Y")
149
+ '14/02/2022'
150
+
151
+ Args:
152
+ dt (datetime): a datetime object or an agreed string like yesterday, tomorrow, ...
153
+ fmt (str): a format string that is accepted by `strftime()`
154
+ width (int): the width to use for formatting the microseconds
155
+ precision (int): the precision for the microseconds
156
+ Returns:
157
+ a string representation of the current time in UTC, e.g. `2020-04-29T12:30:04.862+0000`.
158
+
159
+ Raises:
160
+ A ValueError will be raised when the given dt argument string is not understood.
161
+ """
162
+ dt = dt or datetime.datetime.now(tz=datetime.timezone.utc)
163
+ if isinstance(dt, str):
164
+ fmt = fmt or "%Y%m%d"
165
+ if dt.lower() == "yesterday":
166
+ dt = datetime.date.today() - datetime.timedelta(days=1)
167
+ elif dt.lower() == "today":
168
+ dt = datetime.date.today()
169
+ elif dt.lower() == "day before yesterday":
170
+ dt = datetime.date.today() - datetime.timedelta(days=2)
171
+ elif dt.lower() == "tomorrow":
172
+ dt = datetime.date.today() + datetime.timedelta(days=1)
173
+ else:
174
+ raise ValueError(f"Unknown date passed as an argument: {dt}")
175
+
176
+ if fmt:
177
+ timestamp = dt.strftime(fmt)
178
+ else:
179
+ width = min(width, precision)
180
+ timestamp = (
181
+ f"{dt.strftime('%Y-%m-%dT%H:%M')}:"
182
+ f"{dt.second:02d}.{dt.microsecond//10**(6-precision):0{width}d}{dt.strftime('%z')}"
183
+ )
184
+
185
+ return timestamp
186
+
187
+
188
+ SECONDS_IN_A_DAY = 24 * 60 * 60
189
+ SECONDS_IN_AN_HOUR = 60 * 60
190
+ SECONDS_IN_A_MINUTE = 60
191
+
192
+
193
+ def humanize_seconds(seconds: float, include_micro_seconds: bool = True):
194
+ """
195
+ The number of seconds is represented as "[#D]d [#H]h[#M]m[#S]s.MS" where:
196
+
197
+ * `#D` is the number of days if days > 0
198
+ * `#H` is the number of hours if hours > 0
199
+ * `#M` is the number of minutes if minutes > 0 or hours > 0
200
+ * `#S` is the number of seconds
201
+ * `MS` is the number of microseconds
202
+
203
+ Examples:
204
+ >>> humanize_seconds(20)
205
+ '20s.000'
206
+ >>> humanize_seconds(10*24*60*60)
207
+ '10d 00s.000'
208
+ >>> humanize_seconds(10*86400 + 3*3600 + 42.023)
209
+ '10d 03h00m42s.023'
210
+
211
+ Returns:
212
+ a string representation for the number of seconds.
213
+ """
214
+ micro_seconds = round((seconds - int(seconds)) * 1000)
215
+ rest = int(seconds)
216
+
217
+ days = rest // SECONDS_IN_A_DAY
218
+ rest -= SECONDS_IN_A_DAY * days
219
+
220
+ hours = rest // SECONDS_IN_AN_HOUR
221
+ rest -= SECONDS_IN_AN_HOUR * hours
222
+
223
+ minutes = rest // SECONDS_IN_A_MINUTE
224
+ rest -= SECONDS_IN_A_MINUTE * minutes
225
+
226
+ seconds = rest
227
+
228
+ result = ""
229
+ if days:
230
+ result += f"{days}d "
231
+
232
+ if hours:
233
+ result += f"{hours:02d}h"
234
+
235
+ if minutes or hours:
236
+ result += f"{minutes:02d}m"
237
+
238
+ result += f"{seconds:02d}s"
239
+ if include_micro_seconds:
240
+ result += f".{micro_seconds:03d}"
241
+
242
+ return result
243
+
244
+
245
+ def str_to_datetime(datetime_string: str):
246
+ """Convert the given string to a datetime object.
247
+
248
+ Args:
249
+ - datatime_string: String representing a datetime, in the format %Y-%m-%dT%H:%M:%S.%f%z.
250
+
251
+ Returns: Datetime object.
252
+ """
253
+
254
+ return datetime.datetime.strptime(datetime_string.strip("\r"), TIME_FORMAT)
255
+
256
+
257
+ def duration(dt_start: str | datetime.datetime, dt_end: str | datetime.datetime) -> datetime.timedelta:
258
+ """
259
+ Returns a `timedelta` object with the duration, i.e. time difference between dt_start and dt_end.
260
+
261
+ Notes:
262
+ If you need the number of seconds of your measurement, use the `total_seconds()` method of
263
+ the timedelta object.
264
+
265
+ Even if you —by accident— switch the start and end time arguments, the duration will
266
+ be calculated as expected.
267
+
268
+ Args:
269
+ dt_start: start time of the measurement
270
+ dt_end: end time of the measurement
271
+
272
+ Returns:
273
+ The time difference (duration) between dt_start and dt_end.
274
+ """
275
+ if isinstance(dt_start, str):
276
+ dt_start = str_to_datetime(dt_start)
277
+ if isinstance(dt_end, str):
278
+ dt_end = str_to_datetime(dt_end)
279
+
280
+ return dt_end - dt_start if dt_end > dt_start else dt_start - dt_end
281
+
282
+
283
+ def time_since_epoch_1958(datetime_string: str):
284
+ """Calculate the time since epoch 1958 for the given string representation of a datetime.
285
+
286
+ Args:
287
+ - datetime_string: String representing a datetime, in the format %Y-%m-%dT%H:%M:%S.%f%z.
288
+
289
+ Returns: Time since the 1958 epoch [s].
290
+ """
291
+
292
+ time_since_epoch_1970 = str_to_datetime(datetime_string).timestamp() # Since Jan 1st, 1970, midnight
293
+
294
+ return time_since_epoch_1970 + EPOCH_1958_1970
295
+
296
+
297
+ class Timer(object):
298
+ """
299
+ Context manager to benchmark some lines of code.
300
+
301
+ When the context exits, the elapsed time is sent to the default logger (level=INFO).
302
+
303
+ Elapsed time can be logged with the `log_elapsed()` method and requested in fractional seconds
304
+ by calling the class instance. When the contexts goes out of scope, the elapsed time will not
305
+ increase anymore.
306
+
307
+ Log messages are sent to the logger (including egse_logger for egse.system) and the logging
308
+ level can be passed in as an optional argument. Default logging level is INFO.
309
+
310
+ Examples:
311
+ >>> with Timer("Some calculation") as timer:
312
+ ... # do some calculations
313
+ ... timer.log_elapsed()
314
+ ... # do some more calculations
315
+ ... print(f"Elapsed seconds: {timer()}") # doctest: +ELLIPSIS
316
+ Elapsed seconds: ...
317
+
318
+ Args:
319
+ name (str): a name for the Timer, will be printed in the logging message
320
+ precision (int): the precision for the presentation of the elapsed time
321
+ (number of digits behind the comma ;)
322
+ log_level (int): the log level to report the timing [default=INFO]
323
+
324
+ Returns:
325
+ a context manager class that records the elapsed time.
326
+ """
327
+
328
+ def __init__(self, name="Timer", precision=3, log_level=logging.INFO):
329
+ self.name = name
330
+ self.precision = precision
331
+ self.log_level = log_level
332
+
333
+ def __enter__(self):
334
+ # start is a value containing the start time in fractional seconds
335
+ # end is a function which returns the time in fractional seconds
336
+ self.start = time.perf_counter()
337
+ self.end = time.perf_counter
338
+ return self
339
+
340
+ def __exit__(self, ty, val, tb):
341
+ # The context goes out of scope here and we fix the elapsed time
342
+ self._total_elapsed = time.perf_counter()
343
+
344
+ # Overwrite self.end() so that it always returns the fixed end time
345
+ self.end = self._end
346
+
347
+ logger.log(self.log_level, f"{self.name}: {self.end() - self.start:0.{self.precision}f} seconds")
348
+ return False
349
+
350
+ def __call__(self):
351
+ return self.end() - self.start
352
+
353
+ def log_elapsed(self):
354
+ """Sends the elapsed time info to the default logger."""
355
+ logger.log(self.log_level, f"{self.name}: {self.end() - self.start:0.{self.precision}f} seconds elapsed")
356
+
357
+ def get_elapsed(self) -> float:
358
+ """Returns the elapsed time for this timer as a float in seconds."""
359
+ return self.end() - self.start
360
+
361
+ def _end(self):
362
+ return self._total_elapsed
363
+
364
+
365
+ def ping(host, timeout: float = 3.0):
366
+ """
367
+ Sends a ping request to the given host.
368
+
369
+ Remember that a host may not respond to a ping (ICMP) request even if the host name is valid.
370
+
371
+ Args:
372
+ host (str): hostname or IP address (as a string)
373
+ timeout (float): timeout in seconds
374
+
375
+ Returns:
376
+ True when host responds to a ping request.
377
+
378
+ Reference:
379
+ https://stackoverflow.com/a/32684938
380
+ """
381
+
382
+ # Option for the number of packets as a function of
383
+ param = "-n" if platform.system().lower() == "windows" else "-c"
384
+
385
+ # Building the command. Ex: "ping -c 1 google.com"
386
+ command = ["ping", param, "1", host]
387
+
388
+ try:
389
+ return subprocess.call(command, stdout=subprocess.DEVNULL, timeout=timeout) == 0
390
+ except subprocess.TimeoutExpired:
391
+ logging.info(f"Ping to {host} timed out in {timeout} seconds.")
392
+ return False
393
+
394
+
395
+ def get_host_ip() -> Optional[str]:
396
+ """Returns the IP address."""
397
+
398
+ host_ip = None
399
+
400
+ # The following code needs internet access
401
+
402
+ try:
403
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
404
+ # sock.connect(("8.8.8.8", 80))
405
+ sock.connect(("10.255.255.255", 1))
406
+ host_ip = sock.getsockname()[0]
407
+ sock.close()
408
+ except Exception as exc:
409
+ logger.warning(f"Exception caught: {exc}")
410
+
411
+ if host_ip:
412
+ return host_ip
413
+
414
+ # This may still return 127.0.0.1 when hostname is defined in /etc/hosts
415
+ try:
416
+ host_name = socket.gethostname()
417
+ host_ip = socket.gethostbyname(host_name)
418
+ return host_ip
419
+ except (Exception,):
420
+ return None
421
+
422
+
423
+ CallerInfo = namedtuple("CallerInfo", "filename function lineno")
424
+
425
+
426
+ def get_caller_info(level=1) -> CallerInfo:
427
+ """
428
+ Returns the filename, function name and lineno of the caller.
429
+
430
+ The level indicates how many levels to go back in the stack.
431
+ When level is 0 information about this function will be returned. That is usually not
432
+ what you want so the default level is 1 which returns information about the function
433
+ where the call to `get_caller_info` was made.
434
+
435
+ There is no check
436
+ Args:
437
+ level (int): the number of levels to go back in the stack
438
+
439
+ Returns:
440
+ a namedtuple: CallerInfo['filename', 'function', 'lineno'].
441
+ """
442
+ frame = inspect.currentframe()
443
+ for _ in range(level):
444
+ if frame.f_back is None:
445
+ break
446
+ frame = frame.f_back
447
+ frame_info = inspect.getframeinfo(frame)
448
+
449
+ return CallerInfo(frame_info.filename, frame_info.function, frame_info.lineno)
450
+
451
+
452
+ def get_referenced_var_name(obj: Any) -> List[str]:
453
+ """
454
+ Returns a list of variable names that reference the given object.
455
+ The names can be both in the local and global namespace of the object.
456
+
457
+ Args:
458
+ obj (Any): object for which the variable names are returned
459
+
460
+ Returns:
461
+ a list of variable names.
462
+ """
463
+ frame = inspect.currentframe().f_back
464
+ f_locals = frame.f_locals
465
+ f_globals = frame.f_globals
466
+ if "self" in f_locals:
467
+ f_locals = frame.f_back.f_locals
468
+ name_set = [k for k, v in {**f_locals, **f_globals}.items() if v is obj]
469
+ return name_set or []
470
+
471
+
472
+ class AttributeDict(dict):
473
+ """
474
+ This class is and acts like a dictionary but has the additional functionality
475
+ that all keys in the dictionary are also accessible as instance attributes.
476
+
477
+ >>> ad = AttributeDict({'a': 1, 'b': 2, 'c': 3})
478
+
479
+ >>> assert ad.a == ad['a']
480
+ >>> assert ad.b == ad['b']
481
+ >>> assert ad.c == ad['c']
482
+
483
+ Similarly, adding or defining attributes will make them also keys in the dict.
484
+
485
+ >>> ad.d = 4 # creates a new attribute
486
+ >>> print(ad['d']) # prints 4
487
+ 4
488
+ """
489
+
490
+ def __init__(self, *args, label: str = None, **kwargs):
491
+ super().__init__(*args, **kwargs)
492
+ self.__dict__["_label"] = label
493
+
494
+ __setattr__ = dict.__setitem__
495
+ __delattr__ = dict.__delitem__
496
+
497
+ def __getattr__(self, key):
498
+ try:
499
+ return self[key]
500
+ except KeyError:
501
+ raise AttributeError(key)
502
+
503
+ def __rich__(self) -> Tree:
504
+ label = self.__dict__["_label"] or "AttributeDict"
505
+ tree = Tree(label, guide_style="dim")
506
+ walk_dict_tree(self, tree, text_style="dark grey")
507
+ return tree
508
+
509
+ def __repr__(self):
510
+ # We only want the first 10 key:value pairs
511
+
512
+ count = 10
513
+ sub_msg = ", ".join(f"{k!r}:{v!r}" for k, v in itertools.islice(self.items(), 0, count))
514
+
515
+ # if we left out key:value pairs, print a ', ...' to indicate incompleteness
516
+
517
+ return self.__class__.__name__ + f"({{{sub_msg}{', ...' if len(self) > count else ''}}})"
518
+
519
+
520
+ def walk_dict_tree(dictionary: dict, tree: Tree, text_style: str = "green"):
521
+ for k, v in dictionary.items():
522
+ if isinstance(v, dict):
523
+ branch = tree.add(f"[purple]{k}", style="", guide_style="dim")
524
+ walk_dict_tree(v, branch, text_style=text_style)
525
+ else:
526
+ text = Text.assemble((str(k), "medium_purple1"), ": ", (str(v), text_style))
527
+ tree.add(text)
528
+
529
+
530
+ def recursive_dict_update(this: dict, other: dict):
531
+ """
532
+ Recursively update a dictionary `this` with the content of another dictionary `other`.
533
+
534
+ Any key in `this` dictionary will be recursively updated with the value of the same key in the
535
+ `other` dictionary.
536
+
537
+ Please note that the update will be in-place, i.e. the `this` dictionaory will be
538
+ changed/updated.
539
+
540
+ >>> global_settings = {"A": "GA", "B": "GB", "C": "GC"}
541
+ >>> local_settings = {"B": "LB", "D": "LD"}
542
+ >>> {**global_settings, **local_settings}
543
+ {'A': 'GA', 'B': 'LB', 'C': 'GC', 'D': 'LD'}
544
+
545
+ >>> global_settings = {"A": "GA", "B": "GB", "C": "GC", "R": {"X": "GX", "Y": "GY"}}
546
+ >>> local_settings = {"B": "LB", "D": "LD", "R": {"Y": "LY"}}
547
+ >>> recursive_dict_update(global_settings, local_settings)
548
+ {'A': 'GA', 'B': 'LB', 'C': 'GC', 'R': {'X': 'GX', 'Y': 'LY'}, 'D': 'LD'}
549
+
550
+ >>> global_settings = {"A": {"B": {"C": {"D": 42}}}}
551
+ >>> local_settings = {"A": {"B": {"C": 13, "D": 73}}}
552
+ >>> recursive_dict_update(global_settings, local_settings)
553
+ {'A': {'B': {'C': 13, 'D': 73}}}
554
+
555
+ Args:
556
+ this (dict): The origin dictionary
557
+ other (dict): Changes that shall be applied to `this`
558
+
559
+ Returns:
560
+ A new dictionary with the recursive updates.
561
+ """
562
+
563
+ if not isinstance(this, dict) or not isinstance(other, dict):
564
+ raise ValueError("Expected arguments of type dict.")
565
+
566
+ for key, value in other.items():
567
+ if isinstance(value, dict) and isinstance(this.get(key), dict):
568
+ this[key] = recursive_dict_update(this[key], other[key])
569
+ else:
570
+ this[key] = other[key]
571
+
572
+ return this
573
+
574
+
575
+ def flatten_dict(source_dict: dict):
576
+ """
577
+ Flatten the given dictionary concatenating the keys with a colon ':'.
578
+
579
+ >>> d = {"A": 1, "B": {"E": {"F": 2}}, "C": {"D": 3}}
580
+ >>> flatten_dict(d)
581
+ {'A': 1, 'B:E:F': 2, 'C:D': 3}
582
+
583
+ >>> d = {"A": 'a', "B": {"C": {"D": 'd', "E": 'e'}, "F": 'f'}}
584
+ >>> flatten_dict(d)
585
+ {'A': 'a', 'B:C:D': 'd', 'B:C:E': 'e', 'B:F': 'f'}
586
+
587
+ Args:
588
+ source_dict: the original dictionary that will be flattened
589
+
590
+ Returns:
591
+ A new flattened dictionary.
592
+ """
593
+
594
+ def expand(key, value):
595
+ if isinstance(value, dict):
596
+ return [(key + ":" + k, v) for k, v in flatten_dict(value).items()]
597
+ else:
598
+ return [(key, value)]
599
+
600
+ items = [item for k, v in source_dict.items() for item in expand(k, v)]
601
+
602
+ return dict(items)
603
+
604
+
605
+ def get_system_stats():
606
+ """
607
+ Gather system information about the CPUs and memory usage and return a dictionary with the
608
+ following information:
609
+
610
+ * cpu_load: load average over a period of 1, 5,and 15 minutes given in in percentage
611
+ (i.e. related to the number of CPU cores that are installed on your system) [percentage]
612
+ * cpu_count: physical and logical CPU count, i.e. the number of CPU cores (incl. hyper-threads)
613
+ * total_ram: total physical ram available [bytes]
614
+ * avail_ram: the memory that can be given instantly to processes without the system going
615
+ into swap. This is calculated by summing different memory values depending on the platform
616
+ [bytes]
617
+ * boot_time: the system boot time expressed in seconds since the epoch [s]
618
+ * since: boot time of the system, aka Up time [str]
619
+
620
+ Returns:
621
+ a dictionary with CPU and memory statistics.
622
+ """
623
+ statistics = {}
624
+
625
+ # Get Physical and Logical CPU Count
626
+
627
+ physical_and_logical_cpu_count = psutil.cpu_count()
628
+ statistics["cpu_count"] = physical_and_logical_cpu_count
629
+
630
+ # Load average
631
+ # This is the average system load calculated over a given period of time of 1, 5 and 15 minutes.
632
+ #
633
+ # The numbers returned by psutil.getloadavg() only make sense if
634
+ # related to the number of CPU cores installed on the system.
635
+ #
636
+ # Here we are converting the load average into percentage.
637
+ # The higher the percentage the higher the load.
638
+
639
+ cpu_load = [x / physical_and_logical_cpu_count * 100 for x in psutil.getloadavg()]
640
+ statistics["cpu_load"] = cpu_load
641
+
642
+ # Memory usage
643
+
644
+ vmem = psutil.virtual_memory()
645
+
646
+ statistics["total_ram"] = vmem.total
647
+ statistics["avail_ram"] = vmem.available
648
+
649
+ # boot_time = seconds since the epoch timezone
650
+ # the Unix epoch is 00:00:00 UTC on 1 January 1970.
651
+
652
+ boot_time = psutil.boot_time()
653
+ statistics["boot_time"] = boot_time
654
+ statistics["since"] = datetime.datetime.fromtimestamp(boot_time, tz=datetime.timezone.utc).strftime(
655
+ "%Y-%m-%d %H:%M:%S"
656
+ )
657
+
658
+ return statistics
659
+
660
+
661
+ def get_system_name() -> str:
662
+ """Returns the name of the system in lower case.
663
+
664
+ Returns:
665
+ name: 'linux', 'darwin', 'windows', ...
666
+ """
667
+ return platform.system().lower()
668
+
669
+
670
+ def get_os_name() -> str:
671
+ """Returns the name of the OS in lower case.
672
+
673
+ If no name could be determined, 'unknown' is returned.
674
+
675
+ Returns:
676
+ os: 'macos', 'centos'
677
+ """
678
+ sys_name = get_system_name()
679
+ if sys_name == "darwin":
680
+ return "macos"
681
+ if sys_name == "linux":
682
+ return distro.id().lower()
683
+ if sys_name == "windows":
684
+ return "windows"
685
+ return "unknown"
686
+
687
+
688
+ def get_os_version() -> str:
689
+ """Return the version of the OS.
690
+
691
+ If no version could be determined, 'unknown' is returned.
692
+
693
+ Returns:
694
+ version: as '10.15' or '8.0' or 'unknown'
695
+ """
696
+
697
+ # Don't use `distro.version()` to get the macOS version. That function will return the version
698
+ # of the Darwin kernel.
699
+
700
+ os_name = get_os_name()
701
+ sys_name = get_system_name()
702
+ if os_name == "unknown":
703
+ return "unknown"
704
+ if os_name == "macos":
705
+ version, _, _ = platform.mac_ver()
706
+ return ".".join(version.split(".")[:2])
707
+ if sys_name == "linux":
708
+ return distro.version()
709
+
710
+ # FIXME: add other OS here for their version number
711
+
712
+ return "unknown"
713
+
714
+
715
+ def wait_until(condition, *args, interval=0.1, timeout=1, verbose=False, **kwargs) -> int:
716
+ """
717
+ Sleep until the given condition is fulfilled. The arguments are passed into the condition
718
+ callable which is called in a while loop until the condition is met or the timeout is reached.
719
+
720
+ Note that the condition can be a function, method or callable class object.
721
+ An example of the latter is:
722
+
723
+ class SleepUntilCount:
724
+ def __init__(self, end):
725
+ self._end = end
726
+ self._count = 0
727
+
728
+ def __call__(self, *args, **kwargs):
729
+ self._count += 1
730
+ if self._count >= self._end:
731
+ return True
732
+ else:
733
+ return False
734
+
735
+
736
+ Args:
737
+ condition: a callable that returns True when the condition is met, False otherwise
738
+ interval: the sleep interval between condition checks [s, default=0.1]
739
+ timeout: the period after which the function returns, even when the condition is
740
+ not met [s, default=1]
741
+ verbose: log debugging messages if True
742
+ *args: any arguments that will be passed into the condition function
743
+
744
+ Returns:
745
+ True when function timed out, False otherwise
746
+ """
747
+
748
+ if inspect.isfunction(condition) or inspect.ismethod(condition):
749
+ func_name = condition.__name__
750
+ else:
751
+ func_name = condition.__class__.__name__
752
+
753
+ caller = get_caller_info(level=2)
754
+
755
+ start = time.time()
756
+
757
+ while not condition(*args, **kwargs):
758
+ if time.time() - start > timeout:
759
+ logger.warning(
760
+ f"Timeout after {timeout} sec, from {caller.filename} at {caller.lineno},"
761
+ f" {func_name}{args} not met."
762
+ )
763
+ return True
764
+ time.sleep(interval)
765
+
766
+ if verbose:
767
+ logger.debug(f"wait_until finished successfully, {func_name}{args}{kwargs} is met.")
768
+
769
+ return False
770
+
771
+
772
+ def waiting_for(condition, *args, interval=0.1, timeout=1, verbose=False, **kwargs):
773
+ """
774
+ Sleep until the given condition is fulfilled. The arguments are passed into the condition
775
+ callable which is called in a while loop until the condition is met or the timeout is reached.
776
+
777
+ Note that the condition can be a function, method or callable class object.
778
+ An example of the latter is:
779
+
780
+ class SleepUntilCount:
781
+ def __init__(self, end):
782
+ self._end = end
783
+ self._count = 0
784
+
785
+ def __call__(self, *args, **kwargs):
786
+ self._count += 1
787
+ if self._count >= self._end:
788
+ return True
789
+ else:
790
+ return False
791
+
792
+
793
+ Args:
794
+ condition: a callable that returns True when the condition is met, False otherwise
795
+ interval: the sleep interval between condition checks [s, default=0.1]
796
+ timeout: the period after which the function returns, even when the condition is
797
+ not met [s, default=1]
798
+ verbose: log debugging messages if True
799
+ *args: any arguments that will be passed into the condition function
800
+
801
+ Raises:
802
+ A TimeoutError when the condition was not fulfilled within the timeout period.
803
+ """
804
+
805
+ if inspect.isfunction(condition) or inspect.ismethod(condition):
806
+ func_name = condition.__name__
807
+ else:
808
+ func_name = condition.__class__.__name__
809
+
810
+ caller = get_caller_info(level=2)
811
+
812
+ start = time.time()
813
+
814
+ while not condition(*args, **kwargs):
815
+ if time.time() - start > timeout:
816
+ raise TimeoutError(
817
+ f"Timeout after {timeout} sec, from {caller.filename} at {caller.lineno},"
818
+ f" {func_name}{args} not met."
819
+ )
820
+ time.sleep(interval)
821
+
822
+ duration = time.time() - start
823
+
824
+ if verbose:
825
+ logger.debug(f"waiting_for finished successfully after {duration:.3f}s, {func_name}{args}{kwargs} is met.")
826
+
827
+ return duration
828
+
829
+
830
+ def has_internet(host="8.8.8.8", port=53, timeout=3):
831
+ """Returns True if we have internet connection.
832
+
833
+ Host: 8.8.8.8 (google-public-dns-a.google.com)
834
+ OpenPort: 53/tcp
835
+ Service: domain (DNS/TCP)
836
+
837
+ .. Note::
838
+
839
+ This might give the following error codes:
840
+
841
+ * [Errno 51] Network is unreachable
842
+ * [Errno 61] Connection refused (because the port is blocked?)
843
+ * timed out
844
+
845
+ Source: https://stackoverflow.com/a/33117579
846
+ """
847
+ try:
848
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
849
+ s.settimeout(timeout)
850
+ s.connect((host, port))
851
+ return True
852
+ except socket.error as ex:
853
+ logging.info(f"No Internet: Unable to open socket to {host}:{port} [{ex}]")
854
+ return False
855
+ finally:
856
+ if s is not None:
857
+ s.close()
858
+
859
+
860
+ def do_every(period: float, func: callable, *args) -> None:
861
+ """
862
+
863
+ This method executes a function periodically, taking into account
864
+ that the function that is executed will take time also and using a
865
+ simple `sleep()` will cause a drift. This method will not drift.
866
+
867
+ You can use this function in combination with the threading module to execute the
868
+ function in the background, but be careful as the function might not be thread safe.
869
+
870
+ ```
871
+ timer_thread = threading.Thread(target=do_every, args=(10, func))
872
+ timer_thread.daemon = True
873
+ timer_thread.start()
874
+ ```
875
+
876
+ Args:
877
+ period: a time interval between successive executions [seconds]
878
+ func: the function to be executed
879
+ *args: optional arguments to be passed to the function
880
+ """
881
+
882
+ # Code from SO:https://stackoverflow.com/a/28034554/4609203
883
+ # The max in the yield line serves to protect sleep from negative numbers in case the
884
+ # function being called takes longer than the period specified. In that case it would
885
+ # execute immediately and make up the lost time in the timing of the next execution.
886
+
887
+ def g_tick():
888
+ next_time = time.time()
889
+ while True:
890
+ next_time += period
891
+ yield max(next_time - time.time(), 0)
892
+
893
+ g = g_tick()
894
+ while True:
895
+ time.sleep(next(g))
896
+ func(*args)
897
+
898
+
899
+ @contextlib.contextmanager
900
+ def chdir(dirname=None):
901
+ """
902
+ Context manager to temporarily change directory.
903
+
904
+ Args:
905
+ dirname (str or Path): temporary folder name to switch to within the context
906
+
907
+ Examples:
908
+
909
+ >>> with chdir('/tmp'):
910
+ ... # do stuff in this writable tmp folder
911
+ ... pass
912
+
913
+ """
914
+ current_dir = os.getcwd()
915
+ try:
916
+ if dirname is not None:
917
+ os.chdir(dirname)
918
+ yield
919
+ finally:
920
+ os.chdir(current_dir)
921
+
922
+
923
+ @contextlib.contextmanager
924
+ def env_var(**kwargs):
925
+ """
926
+ Context manager to run some code that need alternate settings for environment variables.
927
+
928
+ Args:
929
+ **kwargs: dictionary with environment variables that are needed
930
+
931
+ Examples:
932
+
933
+ >>> with env_var(PLATO_DATA_STORAGE_LOCATION="/Users/rik/data"):
934
+ ... # do stuff that needs these alternate setting
935
+ ... pass
936
+
937
+ """
938
+ saved_env = {}
939
+ try:
940
+ for k, v in kwargs.items():
941
+ saved_env[k] = os.environ.get(k)
942
+ os.environ[k] = v
943
+ yield
944
+ finally:
945
+ for k, v in saved_env.items():
946
+ if v is not None:
947
+ os.environ[k] = v
948
+ else:
949
+ del os.environ[k]
950
+
951
+
952
+ def filter_by_attr(elements: Iterable, **attrs) -> List:
953
+ """
954
+ A helper that returns the elements from the iterable that meet all the traits passed in `attrs`.
955
+
956
+ The attributes are compared to their value with the `operator.eq` function. However,
957
+ when the given value for an attribute is a tuple, the first element in the tuple is
958
+ considered a comparison function and the second value the actual value. The attribute
959
+ is then compared to the value using this function.
960
+
961
+ ```
962
+ result = filter_by_attr(setups, camera__model="EM", site_id=(is_in, ("CSL", "INTA")))
963
+ ```
964
+ The function `is_in` is defined as follows:
965
+ ```
966
+ def is_in(a, b):
967
+ return a in b
968
+ ```
969
+ but you can of course also use a lambda function: `lambda a, b: a in b`.
970
+
971
+ One function is treated special, it is the built-in function `hasattr`. Using this function,
972
+ the value can be `True` or `False`. Use this to return all elements in the iterable
973
+ that have the attribute, or not. The following example returns all Setups where the
974
+ `gse.ogse.fwc_factor` is not defined:
975
+ ```
976
+ result = filter_by_attr(setups, camera__model="EM", gse__ogse__fwc_factor=(hasattr, False)))
977
+ ```
978
+
979
+ When multiple attributes are specified, they are checked using logical AND, not logical OR.
980
+ Meaning they have to meet every attribute passed in and not one of them.
981
+
982
+ To have a nested attribute search (i.e. search by `gse.hexapod.ID`) then
983
+ pass in `gse__hexapod__ID` as the keyword argument.
984
+
985
+ If nothing is found that matches the attributes passed, then an empty list is returned.
986
+
987
+ When an attribute is not part of the iterated object, that attribute is silently ignored.
988
+
989
+ Args:
990
+ elements: An iterable to search through.
991
+ attrs: Keyword arguments that denote attributes to search with.
992
+ """
993
+
994
+ # This code is based on and originates from the get(iterable, **attr) function in the
995
+ # discord/utils.py package (https://github.com/Rapptz/discord.py). After my own version,
996
+ # Ruud van der Ham, improved the code drastically to the version it is now.
997
+
998
+ def check(attr_, func, value_, el):
999
+ try:
1000
+ a = operator.attrgetter(attr_)(el)
1001
+ return value_ if func is hasattr else func(a, value_)
1002
+ except AttributeError:
1003
+ return not value_ if func is hasattr else False
1004
+
1005
+ attr_func_values = []
1006
+ for attr, value in attrs.items():
1007
+ if not (isinstance(value, (tuple, list)) and len(value) == 2 and callable(value[0])):
1008
+ value = (operator.eq, value)
1009
+ attr_func_values.append((attr.replace("__", "."), *value))
1010
+
1011
+ return [el for el in elements if all(check(attr, func, value, el) for attr, func, value in attr_func_values)]
1012
+
1013
+
1014
+ def replace_environment_variable(input_string: str):
1015
+ """Returns the `input_string` with all occurrences of ENV['var'].
1016
+
1017
+ >>> replace_environment_variable("ENV['HOME']/data/CSL")
1018
+ '/Users/rik/data/CSL'
1019
+
1020
+ Args:
1021
+ input_string (str): the string to replace
1022
+ Returns:
1023
+ The input string with the ENV['var'] replaced, or None when the environment variable
1024
+ doesn't exists.
1025
+ """
1026
+
1027
+ match = re.search(r"(.*)ENV\[['\"](\w+)['\"]\](.*)", input_string)
1028
+ if not match:
1029
+ return input_string
1030
+ pre_match = match.group(1)
1031
+ var = match.group(2)
1032
+ post_match = match.group(3)
1033
+
1034
+ result = os.getenv(var, None)
1035
+
1036
+ return pre_match + result + post_match if result else None
1037
+
1038
+
1039
+ def read_last_line(filename: str | Path, max_line_length=5000):
1040
+ """Returns the last line of a (text) file.
1041
+
1042
+ The argument `max_line_length` should be at least the length of the last line in the file,
1043
+ because this value is used to backtrack from the end of the file as an optimization.
1044
+
1045
+ Args:
1046
+ filename (Path | str): the filename as a string or Path
1047
+ max_line_length (int): the maximum length of the lines in the file
1048
+ Returns:
1049
+ The last line in the file (whitespace stripped from the right). An empty string is returned
1050
+ when the file is empty, `None` is returned when the file doesn't exist.
1051
+ """
1052
+ filename = Path(filename)
1053
+
1054
+ if not filename.exists():
1055
+ return None
1056
+
1057
+ with filename.open("rb") as file:
1058
+ file.seek(0, 2) # 2 is relative to end of file
1059
+ size = file.tell()
1060
+ if size:
1061
+ file.seek(max(0, size - max_line_length))
1062
+ return file.readlines()[-1].decode("utf-8").rstrip("\n")
1063
+ else:
1064
+ return ""
1065
+
1066
+
1067
+ def read_last_lines(filename: str | Path, num_lines: int):
1068
+ """Return the last lines of a text file.
1069
+
1070
+ Args:
1071
+ - filename: Filename.
1072
+ - num_lines: Number of lines at the back of the file that should be read and returned.
1073
+
1074
+ Returns: Last lines of a text file.
1075
+ """
1076
+
1077
+ # See: https://www.geeksforgeeks.org/python-reading-last-n-lines-of-a-file/
1078
+ # (Method 3: Through exponential search)
1079
+
1080
+ filename = Path(filename)
1081
+
1082
+ assert num_lines > 1
1083
+
1084
+ if not filename.exists():
1085
+ return None
1086
+
1087
+ assert num_lines >= 0
1088
+
1089
+ # Declaring variable to implement exponential search
1090
+
1091
+ pos = num_lines + 1
1092
+
1093
+ # List to store last N lines
1094
+
1095
+ lines = []
1096
+
1097
+ with open(filename) as f:
1098
+ while len(lines) <= num_lines:
1099
+ try:
1100
+ f.seek(-pos, 2)
1101
+
1102
+ except IOError:
1103
+ f.seek(0)
1104
+ break
1105
+
1106
+ finally:
1107
+ lines = list(f)
1108
+
1109
+ # Increasing value of variable exponentially
1110
+
1111
+ pos *= 2
1112
+
1113
+ return lines[-num_lines:]
1114
+
1115
+
1116
+ def is_namespace(module) -> bool:
1117
+ if hasattr(module, '__path__') and getattr(module, '__file__', None) is None:
1118
+ return True
1119
+ else:
1120
+ return False
1121
+
1122
+
1123
+ def get_package_location(module) -> List[Path]:
1124
+
1125
+ if isinstance(module, FunctionType):
1126
+ module_name = module.__module__
1127
+ elif isinstance(module, ModuleType):
1128
+ module_name = module.__name__
1129
+ elif isinstance(module, str):
1130
+ module_name = module
1131
+ else:
1132
+ return []
1133
+
1134
+ try:
1135
+ module = importlib.import_module(module)
1136
+ except TypeError as exc:
1137
+ return []
1138
+
1139
+ if is_namespace(module):
1140
+ return [
1141
+ Path(location)
1142
+ for location in module.__path__
1143
+ ]
1144
+ else:
1145
+ location = get_module_location(module)
1146
+ return [] if location is None else [location]
1147
+
1148
+
1149
+ def get_module_location(arg) -> Optional[Path]:
1150
+ """
1151
+ Returns the location of the module as a Path object.
1152
+
1153
+ The function can be given a string, which should then be a module name, or a function or module.
1154
+ For the latter two, the module name will be determined.
1155
+
1156
+ >>> get_module_location('egse')
1157
+ >>> get_module_location(egse.system)
1158
+ >>> get_module_location()
1159
+
1160
+ Args:
1161
+ arg: can be one of the following: function, module, string
1162
+
1163
+ Returns:
1164
+ The location of the module as a Path object or None when the location can not be determined or
1165
+ an invalid argument was provided.
1166
+ """
1167
+ if isinstance(arg, FunctionType):
1168
+ # print(f"func: {arg = }, {arg.__module__ = }")
1169
+ module_name = arg.__module__
1170
+ elif isinstance(arg, ModuleType):
1171
+ # print(f"mod: {arg = }, {arg.__file__ = }")
1172
+ module_name = arg.__name__
1173
+ elif isinstance(arg, str):
1174
+ # print(f"str: {arg = }")
1175
+ module_name = arg
1176
+ else:
1177
+ return None
1178
+
1179
+ # print(f"{module_name = }")
1180
+
1181
+ try:
1182
+ module = importlib.import_module(module_name)
1183
+ except TypeError as exc:
1184
+ return None
1185
+
1186
+ if is_namespace(module):
1187
+ # print(f"{module = }")
1188
+ return None
1189
+
1190
+ location = Path(module.__file__)
1191
+
1192
+ if location.is_dir():
1193
+ return location.resolve()
1194
+ elif location.is_file():
1195
+ return location.parent.resolve()
1196
+ else:
1197
+ # print(f"Unknown {location = }")
1198
+ return None
1199
+
1200
+
1201
+
1202
+ def get_full_classname(obj: object) -> str:
1203
+ """Returns the fully qualified class name for this object."""
1204
+
1205
+ # Take into account that obj might be a class or a builtin or even a
1206
+ # literal like an int or a float or a complex number
1207
+
1208
+ if type(obj) is type or obj.__class__.__module__ == str.__module__:
1209
+ try:
1210
+ module = obj.__module__
1211
+ name = obj.__qualname__
1212
+ except (TypeError, AttributeError):
1213
+ module = type(obj).__module__
1214
+ name = obj.__class__.__qualname__
1215
+ else:
1216
+ module = obj.__class__.__module__
1217
+ name = obj.__class__.__qualname__
1218
+
1219
+ return module + "." + name
1220
+
1221
+
1222
+ def find_class(class_name: str):
1223
+ """Find and returns a class based on the fully qualified name.
1224
+
1225
+ A class name can be preceded with the string `class//`. This is used in YAML
1226
+ files where the class is then instantiated on load.
1227
+
1228
+ Args:
1229
+ class_name (str): a fully qualified name for the class
1230
+ Returns:
1231
+ The class object corresponding to the fully qualified class name.
1232
+ Raises:
1233
+ AttributeError: when the class is not found in the module.
1234
+ ValueError: when the class_name can not be parsed.
1235
+ ModuleNotFoundError: if the module could not be found.
1236
+ """
1237
+ if class_name.startswith("class//"):
1238
+ class_name = class_name[7:]
1239
+
1240
+ module_name, class_name = class_name.rsplit(".", 1)
1241
+ module = importlib.import_module(module_name)
1242
+ return getattr(module, class_name)
1243
+
1244
+
1245
+ def type_name(var):
1246
+ """Returns the name of the type of var."""
1247
+ return type(var).__name__
1248
+
1249
+
1250
+ def check_argument_type(obj: object, name: str, target_class: Union[type, Tuple[type]], allow_none: bool = False):
1251
+ """Check that the given object is of a specific (sub)type of the given target_class.
1252
+
1253
+ The target_class can be a tuple of types.
1254
+
1255
+ Raises:
1256
+ TypeError when not of the required type or None when not allowed.
1257
+ """
1258
+ if obj is None and allow_none:
1259
+ return
1260
+ if obj is None:
1261
+ raise TypeError(f"The argument '{name}' cannot be None.")
1262
+ if not isinstance(obj, target_class):
1263
+ raise TypeError(f"The argument '{name}' must be of type {target_class}, but is {type(obj)}")
1264
+
1265
+
1266
+ def check_str_for_slash(arg: str):
1267
+ """Check if there is a slash in the given string, and raise a ValueError if so."""
1268
+
1269
+ if "/" in arg:
1270
+ ValueError(f"The given argument can not contain slashes, {arg=}.")
1271
+
1272
+
1273
+ def check_is_a_string(var, allow_none=False):
1274
+ """Calls is_a_string and raises a type error if the check fails."""
1275
+ if var is None and allow_none:
1276
+ return
1277
+ if var is None and not allow_none:
1278
+ raise TypeError("The given variable cannot be None.")
1279
+ if not isinstance(var, str):
1280
+ raise TypeError(f"var must be a string, however {type(var)=}")
1281
+
1282
+
1283
+ def sanity_check(flag: bool, msg: str):
1284
+ """
1285
+ This is a replacement for the 'assert' statement. Use this in production code
1286
+ such that your checks are not removed during optimisations.
1287
+ """
1288
+ if not flag:
1289
+ raise AssertionError(msg)
1290
+
1291
+
1292
+ class NotSpecified:
1293
+ """Class for NOT_SPECIFIED constant.
1294
+ Is used so that a parameter can have a default value other than None.
1295
+
1296
+ Evaluate to False when converted to boolean.
1297
+ """
1298
+
1299
+ def __nonzero__(self):
1300
+ """Always returns False. Called when to converting to bool in Python 2."""
1301
+ return False
1302
+
1303
+ def __bool__(self):
1304
+ """Always returns False. Called when to converting to bool in Python 3."""
1305
+ return False
1306
+
1307
+
1308
+ NOT_SPECIFIED = NotSpecified()
1309
+
1310
+ # Do not try to catch SIGKILL (9) that will just terminate your script without any warning
1311
+
1312
+ SIGNAL_NAME = {
1313
+ 1: "SIGHUP",
1314
+ 2: "SIGINT",
1315
+ 3: "SIGQUIT",
1316
+ 6: "SIGABRT",
1317
+ 15: "SIGTERM",
1318
+ 30: "SIGUSR1",
1319
+ 31: "SIGUSR2",
1320
+ }
1321
+
1322
+
1323
+ class SignalCatcher:
1324
+ """
1325
+ This class registers handler to signals. When a signal is caught, the handler is
1326
+ executed and a flag for termination or user action is set to True. Check for this
1327
+ flag in your application loop.
1328
+
1329
+ Termination signals: 1 HUP, 2 INT, 3 QUIT, 6 ABORT, 15 TERM
1330
+ User signals: 30 USR1, 31 USR2
1331
+ """
1332
+
1333
+ def __init__(self):
1334
+ self.term_signal_received = False
1335
+ self.user_signal_received = False
1336
+ self.term_signals = [1, 2, 3, 6, 15]
1337
+ self.user_signals = [30, 31]
1338
+ for signal_number in self.term_signals:
1339
+ signal.signal(signal_number, self.handler)
1340
+ for signal_number in self.user_signals:
1341
+ signal.signal(signal_number, self.handler)
1342
+
1343
+ self._signal_number = None
1344
+ self._signal_name = None
1345
+
1346
+ @property
1347
+ def signal_number(self):
1348
+ return self._signal_number
1349
+
1350
+ @property
1351
+ def signal_name(self):
1352
+ return self._signal_name
1353
+
1354
+ def handler(self, signal_number, frame):
1355
+ """Handle the known signals by setting the appropriate flag."""
1356
+ logger.warning(f"Received signal {SIGNAL_NAME[signal_number]} [{signal_number}].")
1357
+ if signal_number in self.term_signals:
1358
+ self.term_signal_received = True
1359
+ if signal_number in self.user_signals:
1360
+ self.user_signal_received = True
1361
+ self._signal_number = signal_number
1362
+ self._signal_name = SIGNAL_NAME[signal_number]
1363
+
1364
+ def clear(self, term: bool = False):
1365
+ """
1366
+ Call this method to clear the user signal after handling.
1367
+ Termination signals are not cleared by default since the application is supposed to terminate.
1368
+ Pass in a `term=True` to also clear the TERM signals, e.g. when you want to ignore some
1369
+ TERM signals.
1370
+ """
1371
+ self.user_signal_received = False
1372
+ if term:
1373
+ self.term_signal_received = False
1374
+ self._signal_number = None
1375
+ self._signal_name = None
1376
+
1377
+
1378
+ def is_in(a, b):
1379
+ """Returns result of `a in b`."""
1380
+ return a in b
1381
+
1382
+
1383
+ def is_not_in(a, b):
1384
+ """Returns result of `a not in b`."""
1385
+ return a not in b
1386
+
1387
+
1388
+ def is_in_ipython():
1389
+ """Returns True if the code is running in IPython."""
1390
+ return hasattr(builtins, "__IPYTHON__")
1391
+
1392
+
1393
+ _function_timing = {}
1394
+
1395
+
1396
+ def execution_time(func):
1397
+ """
1398
+ A decorator to save the execution time of the function. Use this decorator
1399
+ if you want —by default and always— have an idea of the average execution time
1400
+ of the given function.
1401
+
1402
+ Use this in conjunction with the `get_average_execution_time()` function to
1403
+ retrieve the average execution time for the given function.
1404
+ """
1405
+
1406
+ @functools.wraps(func)
1407
+ def wrapper(*args, **kwargs):
1408
+ return save_average_execution_time(func, *args, **kwargs)
1409
+
1410
+ return wrapper
1411
+
1412
+
1413
+ def save_average_execution_time(func: Callable, *args, **kwargs):
1414
+ """
1415
+ Executes the function 'func' with the given arguments and saves the execution time. All positional
1416
+ arguments (in args) and keyword arguments (in kwargs) are passed into the function. The execution
1417
+ time is saved in a deque of maximum 100 elements. When more times are added, the oldest times are
1418
+ discarded. This function is used in conjunction with the `get_average_execution_time()` function.
1419
+ """
1420
+
1421
+ with Timer(log_level=logging.NOTSET) as timer:
1422
+ response = func(*args, **kwargs)
1423
+
1424
+ if func not in _function_timing:
1425
+ _function_timing[func] = collections.deque(maxlen=100)
1426
+
1427
+ _function_timing[func].append(timer.get_elapsed())
1428
+
1429
+ return response
1430
+
1431
+
1432
+ def get_average_execution_time(func: Callable) -> float:
1433
+ """
1434
+ Returns the average execution time of the given function. The function 'func' shall be previously executed using
1435
+ the `save_average_execution_time()` function which remembers the last 100 execution times of the function.
1436
+ You can also decorate your function with `@execution_time` to permanently monitor it.
1437
+ The average time is a moving average over the last 100 times. If the function was never called before, 0.0 is
1438
+ returned.
1439
+
1440
+ This function can be used when setting a frequency to execute a certain function. When the average execution time
1441
+ of the function is longer than the execution interval, the frequency shall be decreased or the process will get
1442
+ stalled.
1443
+ """
1444
+
1445
+ # If the function was previously wrapped with the `@execution_time` wrapper, we need to get
1446
+ # to the original function object because that's the one that is saved.
1447
+
1448
+ with contextlib.suppress(AttributeError):
1449
+ func = func.__wrapped__
1450
+
1451
+ try:
1452
+ d = _function_timing[func]
1453
+ return sum(d) / len(d)
1454
+ except KeyError:
1455
+ return 0.0
1456
+
1457
+
1458
+ def get_average_execution_times() -> dict:
1459
+ """
1460
+ Returns a dictionary with "function name": average execution time, for all function that have been
1461
+ monitored in this process.
1462
+ """
1463
+ return {func.__name__: get_average_execution_time(func) for func in _function_timing}
1464
+
1465
+
1466
+ def clear_average_execution_times():
1467
+ """Clear out all function timing for this process."""
1468
+ _function_timing.clear()
1469
+
1470
+
1471
+ def get_system_architecture() -> str:
1472
+ """
1473
+ Returns the machine type. This is a string describing the processor architecture,
1474
+ like 'i386' or 'arm64', but the exact string is not defined. An empty string can be
1475
+ returned when the type cannot be determined.
1476
+ """
1477
+ return platform.machine()
1478
+
1479
+
1480
+ def time_in_ms() -> int:
1481
+ """
1482
+ Returns the current time in milliseconds since the Epoch.
1483
+
1484
+ Note: if you are looking for a high performance timer, you should really be using perf_counter()
1485
+ instead of this function.
1486
+ """
1487
+ return int(round(time.time() * 1000))
1488
+
1489
+
1490
+ ignore_m_warning("egse.system")
1491
+
1492
+ if __name__ == "__main__":
1493
+ print(f"Host IP: {get_host_ip()}")
1494
+ print(f"System name: {get_system_name()}")
1495
+ print(f"OS name: {get_os_name()}")
1496
+ print(f"OS version: {get_os_version()}")
1497
+ print(f"Architecture: {get_system_architecture()}")
1498
+ print(f"Python version: {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}")
1499
+ print("Running in IPython") if is_in_ipython() else None