"""Helper functions for writing hardware drivers."""
import logging
import re
from collections.abc import Callable
from typing import Any
##############################################################
# extend logging mechanism
SPAM = 5
setattr(logging, "SPAM", 5) # noqa: B010 #TODO: Why is this line necessary at all?
logging.addLevelName(levelName="SPAM", level=5)
[docs]
class Logger(logging.Logger):
"""Extend logger to include a spam level for debugging device communication."""
[docs]
def setLevel(self, level: str | int, globally: bool = False) -> None: # noqa: N802 D102
if isinstance(level, str):
level = level.upper()
try:
level = int(level)
except ValueError:
pass
logging.Logger.setLevel(self, level)
if globally:
for logger in logging.root.manager.loggerDict.values():
if not hasattr(logger, "setLevel"):
continue
logger.setLevel(level)
[docs]
def spam(self, msg: str, *args, **kwargs) -> None:
"""Log a message with severity SPAM, even lower than DEBUG."""
self.log(SPAM, msg, *args, **kwargs)
logging.setLoggerClass(Logger)
format_str = "%(asctime)-15s %(name)s: %(message)s"
logging.basicConfig(format=format_str)
log = logging.getLogger("herosdevices")
SI_PREFIX_EXP = {
"Y": 24,
"Z": 21,
"E": 18,
"P": 15,
"T": 12,
"G": 9,
"M": 6,
"k": 3,
"h": 2,
"base": 0,
"d": -1,
"c": -2,
"m": -3,
"u": -6,
"n": -9,
"p": -12,
"f": -15,
"a": -18,
"z": -21,
"y": -24,
}
[docs]
def limits(lower: float, upper: float) -> Callable[[float], str | bool]:
"""Create a function which checks if a value is within the specified range.
Args:
lower: The lower bound of the valid range.
upper: The upper bound of the valid range.
Returns:
A function that takes a value and returns True if within the range, or a message
indicating it's out of range.
"""
def check(val: float) -> str | bool:
if val < lower or val > upper:
return f"Value {val} is out of range [{lower}, {upper}]"
return True
return check
[docs]
def limits_int(lower: int, upper: int) -> Callable[[int], str | bool]:
"""Create a function to check if a value is within a specified range and is an integer.
Args:
lower: The lower bound of the valid range.
upper: The upper bound of the valid range.
Returns:
A function that takes a value and returns True if within the range and is an integer,
or a message indicating why it's invalid.
"""
def check(val: int) -> str | bool:
if val < lower or val > upper:
return f"Value {val} is out of range [{lower}, {upper}]"
if val % 1 != 0:
return f"Value {val} is not an integer"
return True
return check
[docs]
def explicit(values: list[Any]) -> Callable[[Any], str | bool]:
"""Create a function to check if a value is in a list of allowed values.
Args:
values: A list of allowed values.
Returns:
A function that takes a value and returns True if within the list, or a message
indicating it's not in the list.
"""
def check(val: Any) -> bool | str:
if val not in values:
return f"Value {val} is not in list of allowed values {values}"
return True
return check
[docs]
def merge_dicts(dict1: dict, dict2: dict) -> dict:
"""Recursively merge two dicts of dicts."""
new_dict = dict1.copy()
for k, v in dict2.items():
if k in dict1 and isinstance(dict1[k], dict) and isinstance(v, dict):
new_dict[k] = merge_dicts(new_dict[k], v)
else:
new_dict[k] = v
return new_dict
[docs]
def add_class_descriptor(cls: type, attr_name: str, descriptor) -> None: # noqa: ANN001
"""
Add a descriptor to a class.
This is a simple helper function which uses `setattr` to add an attribute to the class and then also calls
`__set_name__` on the attribute.
Args:
cls: Class to add the descriptor to
attr_name: Name of the attribute the descriptor will be added to
descriptor: The descriptor to be added
"""
setattr(cls, attr_name, descriptor)
getattr(cls, attr_name).__set_name__(cls, attr_name)
[docs]
def mark_driver(
name: str | None = None,
info: str | None = None,
state: str = "unknown",
additional_docs: list | None = None,
requires: list | None = None,
product_page: str | None = None,
) -> Callable:
"""Mark a class as a driver.
This decorator can be used to mark a class as a driver and attach meta data to it, which is then accessed by the
sphinx documentation. All drivers marked with this decorator will be listed on the "Hardware" page. It does not
have any effect beyond documentation
Args:
state: State of the driver, can be "alpha" for very untested code "beta" for tested but under active development
or "stable" for well tested and stable drivers.
name: Name of the represented hardware as it should appear in the doc.
info: Small info line which is shown as a subtitle in the doc.
additional_docs: List of additional ``.rst`` files that are added to the documentation. For example to document
complicated vendor library installation procedures.
requires: List of additional packages that are required to use the driver.
product_page: URL to the vendor product page
"""
if additional_docs is None:
additional_docs = []
if requires is None:
requires = []
def decorator(cls: type) -> type:
cls.__driver_data__ = { # type: ignore
"state": state,
"name": name,
"info": info,
"additional_docs": additional_docs,
"requires": requires,
"product_page": product_page,
}
return cls
return decorator