diff --git a/pyproject.toml b/pyproject.toml index 9eb45cc..bbaee7b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" [project] name = "server_common" # REQUIRED, is the only field that cannot be marked as dynamic. dynamic = ["version"] -description = "A collection of utilities for python based IOC servers used at ISIS" +description = "A collection of helper utilities for python used at ISIS" readme = "README.md" requires-python = ">=3.12" license = {file = "LICENSE"} @@ -39,14 +39,17 @@ classifiers = [ ] dependencies = [ +] + +[project.optional-dependencies] +epics = [ "pcaspy", "genie-python[plot]", "CaChannel", "pysnmp", ] - -[project.optional-dependencies] dev = [ + "server_common[epics]", "ruff>=0.8", "pyright", "pytest", diff --git a/src/server_common/channel_access.py b/src/server_common/channel_access.py index d213df3..dd5db2a 100644 --- a/src/server_common/channel_access.py +++ b/src/server_common/channel_access.py @@ -24,83 +24,13 @@ # Number of threads to serve caputs NUMBER_OF_CAPUT_THREADS = 20 -try: - from genie_python.channel_access_exceptions import ( - ReadAccessException, - UnableToConnectToPVException, - ) # noqa: F401 -except ImportError: - - class UnableToConnectToPVException(IOError): # noqa: N818 - """ - The system is unable to connect to a PV for some reason. - """ - - def __init__(self, pv_name: str, err: Exception) -> None: - super(UnableToConnectToPVException, self).__init__( - "Unable to connect to PV {0}: {1}".format(pv_name, err) - ) - - class ReadAccessException(IOError): # noqa: N818 - """ - PV exists but its value is unavailable to read. - """ - - def __init__(self, pv_name: str) -> None: - super(ReadAccessException, self).__init__( - "Read access denied for PV {}".format(pv_name) - ) - - -try: - # noinspection PyUnresolvedReferences - from genie_python.genie_cachannel_wrapper import EXIST_TIMEOUT, CaChannelWrapper -except ImportError: - print("ERROR: No genie_python on the system can not import CaChannelWrapper!") - -try: - from genie_python.genie_cachannel_wrapper import AlarmCondition as AlarmStatus - from genie_python.genie_cachannel_wrapper import AlarmSeverity -except ImportError: - from enum import IntEnum - - class AlarmSeverity(IntEnum): - """ - Enum for severity of alarm - """ - - No = 0 - Minor = 1 - Major = 2 - Invalid = 3 - - class AlarmStatus(IntEnum): - """ - Enum for status of alarm - """ - - BadSub = 16 - Calc = 12 - Comm = 9 - Cos = 8 - Disable = 18 - High = 4 - HiHi = 3 - HwLimit = 11 - Link = 14 - Lolo = 5 - Low = 6 - No = 0 - Read = 1 - ReadAccess = 20 - Scam = 13 - Simm = 19 - Soft = 15 - State = 7 - Timeout = 10 - UDF = 17 - Write = 2 - WriteAccess = 21 +from genie_python.channel_access_exceptions import ( + ReadAccessException, + UnableToConnectToPVException, +) +from genie_python.genie_cachannel_wrapper import EXIST_TIMEOUT, CaChannelWrapper +from genie_python.genie_cachannel_wrapper import AlarmCondition as AlarmStatus # noqa: F401 +from genie_python.genie_cachannel_wrapper import AlarmSeverity # noqa: F401 def _create_caput_pool() -> ThreadPoolExecutor: diff --git a/src/server_common/helpers.py b/src/server_common/helpers.py index 3762906..fe4b026 100644 --- a/src/server_common/helpers.py +++ b/src/server_common/helpers.py @@ -1,43 +1,7 @@ import json import os -import sys from typing import Dict -from genie_python import genie as g -from genie_python.mysql_abstraction_layer import SQLAbstraction - -from server_common.ioc_data_source import IocDataSource -from server_common.utilities import SEVERITY, print_and_log - - -def register_ioc_start( - ioc_name: str, - pv_database: Dict[str, Dict[str, str]] = None, - prefix: str | None = None, -) -> None: - """ - A helper function to register the start of an ioc. - Args: - ioc_name: name of the ioc to start - pv_database: doctionary of pvs in the iov - prefix: prefix of pvs in this ioc - """ - try: - exepath = sys.argv[0] - if pv_database is None: - pv_database = {} - if prefix is None: - prefix = "none" - - ioc_data_source = IocDataSource(SQLAbstraction("iocdb", "iocdb", "$iocdb")) - ioc_data_source.insert_ioc_start(ioc_name, os.getpid(), exepath, pv_database, prefix) - except Exception as e: - print_and_log( - "Error registering ioc start: {}: {}".format(e.__class__.__name__, e), - SEVERITY.MAJOR, - ) - - def get_macro_values() -> Dict[str, str]: """ Parse macro environment JSON into dict. To make this work use the icpconfigGetMacros program. @@ -50,9 +14,6 @@ def get_macro_values() -> Dict[str, str]: return macros -motor_in_set_mode = g.adv.motor_in_set_mode - - def _get_env_var(name: str) -> str: try: return os.environ[name] diff --git a/src/server_common/ioc_startup.py b/src/server_common/ioc_startup.py new file mode 100644 index 0000000..03db3d7 --- /dev/null +++ b/src/server_common/ioc_startup.py @@ -0,0 +1,35 @@ +import os +import sys +from typing import Dict + +from genie_python.mysql_abstraction_layer import SQLAbstraction +from server_common.ioc_data_source import IocDataSource +from server_common.utilities import SEVERITY, print_and_log + + +def register_ioc_start( + ioc_name: str, + pv_database: Dict[str, Dict[str, str]] = None, + prefix: str | None = None, +) -> None: + """ + A helper function to register the start of an ioc. + Args: + ioc_name: name of the ioc to start + pv_database: doctionary of pvs in the iov + prefix: prefix of pvs in this ioc + """ + try: + exepath = sys.argv[0] + if pv_database is None: + pv_database = {} + if prefix is None: + prefix = "none" + + ioc_data_source = IocDataSource(SQLAbstraction("iocdb", "iocdb", "$iocdb")) + ioc_data_source.insert_ioc_start(ioc_name, os.getpid(), exepath, pv_database, prefix) + except Exception as e: + print_and_log( + "Error registering ioc start: {}: {}".format(e.__class__.__name__, e), + SEVERITY.MAJOR, + ) diff --git a/src/server_common/test_modules/__init__.py b/src/server_common/test_modules/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/server_common/utilities.py b/src/server_common/utilities.py index d31d3ab..835023b 100644 --- a/src/server_common/utilities.py +++ b/src/server_common/utilities.py @@ -24,6 +24,7 @@ import threading import time import zlib +from typing import Any, Iterable from xml.etree import ElementTree from server_common.common_exceptions import MaxAttemptsExceededException @@ -113,6 +114,14 @@ def dehex_and_decompress(value): ) return zlib.decompress(binascii.unhexlify(value)) +def dehex_decompress_and_dejson(value: bytes) -> Any: # noqa: ANN401 + """ + Convert string from zipped hexed json to a python representation + :param value: value to convert + :return: python representation of json + """ + return json.loads(dehex_and_decompress(value)) + def dehex_and_decompress_waveform(value): """Decompresses the inputted waveform, assuming it is a array of integers representing characters (null terminated). @@ -157,7 +166,7 @@ def convert_from_json(value): return json.loads(value) -def parse_boolean(string): +def parse_boolean(string: str) -> bool: """Parses an xml true/false value to boolean Args: @@ -265,19 +274,21 @@ def parse_xml_removing_namespace(file_path): return it.root -def waveform_to_string(data): +def waveform_to_string(data: Iterable[int | str]) -> str: """ Args: data: waveform as null terminated string Returns: waveform as a sting - """ - output = str() + output = "" for i in data: if i == 0: break - output += chr(i) + if isinstance(i, str): + output += i + else: + output += str(chr(i)) return output @@ -294,7 +305,7 @@ def ioc_restart_pending(ioc_pv, channel_access): return channel_access.caget(ioc_pv + ":RESTART", as_string=True) == "Busy" -def retry(max_attempts, interval, exception): +def retry(max_attempts: int, interval: int, exception: BaseException): """ Attempt to perform a function a number of times in specified intervals before failing. @@ -327,7 +338,7 @@ def _wrapper(*args, **kwargs): return _tags_decorator -def remove_from_end(string, text_to_remove): +def remove_from_end(string: str, text_to_remove: str) -> str: """ Remove a String from the end of a string if it exists Args: @@ -342,7 +353,7 @@ def remove_from_end(string, text_to_remove): return string -def lowercase_and_make_unique(in_list): +def lowercase_and_make_unique(in_list: list[str]) -> set[str]: """ Takes a collection of strings, and returns it with all strings lowercased and with duplicates removed. diff --git a/src/server_common/test_modules/test_autosave.py b/tests/test_autosave.py similarity index 100% rename from src/server_common/test_modules/test_autosave.py rename to tests/test_autosave.py diff --git a/src/server_common/test_modules/test_channel_access.py b/tests/test_channel_access.py similarity index 100% rename from src/server_common/test_modules/test_channel_access.py rename to tests/test_channel_access.py diff --git a/src/server_common/test_modules/test_common_utilities.py b/tests/test_common_utilities.py similarity index 100% rename from src/server_common/test_modules/test_common_utilities.py rename to tests/test_common_utilities.py diff --git a/src/server_common/test_modules/test_file_path_manager.py b/tests/test_file_path_manager.py similarity index 97% rename from src/server_common/test_modules/test_file_path_manager.py rename to tests/test_file_path_manager.py index 3d62b17..c245e9f 100644 --- a/src/server_common/test_modules/test_file_path_manager.py +++ b/tests/test_file_path_manager.py @@ -35,7 +35,7 @@ class TestFilePathManagerSequence(unittest.TestCase): def setUp(self): # Find the schema directory - dir = os.path.join(".") + dir = os.path.join("../src/server_common/test_modules") self.config_path = os.path.abspath(CONFIG_PATH) self.script_path = os.path.abspath(SCRIPT_PATH) diff --git a/tests/test_helpers.py b/tests/test_helpers.py new file mode 100644 index 0000000..da7bc4e --- /dev/null +++ b/tests/test_helpers.py @@ -0,0 +1,160 @@ +import pytest + +from server_common.utilities import dehex_and_decompress_waveform, dehex_and_decompress, compress_and_hex, \ + dehex_decompress_and_dejson + + +def test_can_dehex_and_decompress(): + expected = b"test123" + hexed_and_compressed = b"789c2b492d2e31343206000aca0257" + result = dehex_and_decompress(hexed_and_compressed) + assert result == expected + + +def test_can_hex_and_compress(): + to_compress_and_hex = "test123" + expected = b"789c2b492d2e31343206000aca0257" + result = compress_and_hex(to_compress_and_hex) + assert result == expected + + +def test_non_bytes_given_to_dehex_and_decompress_raises_assertionerror(): + with pytest.raises(AssertionError): + dehex_and_decompress("test") + + +def test_non_string_given_to_compress_and_hex_raises_assertionerror(): + with pytest.raises(AssertionError): + compress_and_hex(b"test") + + +def test_non_list_given_to_dehex_and_decompress_waveform_raises_assertionerror(): + with pytest.raises(AssertionError): + dehex_and_decompress_waveform("test") + + +def test_dehex_and_decompress_waveform_with_ok_waveform_returns_expected(): + test = [ + 55, + 56, + 57, + 99, + 56, + 98, + 53, + 54, + 52, + 97, + 99, + 99, + 99, + 57, + 52, + 99, + 52, + 101, + 53, + 53, + 100, + 50, + 53, + 49, + 53, + 48, + 52, + 97, + 99, + 98, + 99, + 57, + 50, + 99, + 50, + 56, + 52, + 56, + 50, + 100, + 48, + 50, + 51, + 49, + 57, + 51, + 102, + 50, + 57, + 51, + 52, + 48, + 53, + 52, + 55, + 49, + 52, + 57, + 53, + 49, + 98, + 99, + 49, + 49, + 56, + 99, + 54, + 49, + 48, + 56, + 54, + 53, + 56, + 48, + 97, + 56, + 100, + 99, + 102, + 99, + 49, + 50, + 49, + 48, + 53, + 53, + 54, + 48, + 48, + 97, + 50, + 54, + 56, + 100, + 57, + 53, + 54, + 50, + 48, + 49, + 101, + 49, + 99, + 53, + 49, + 51, + 54, + 52, + ] + + res = dehex_and_decompress_waveform(test) + + assert res == b'["alice", "flipper", "bob", "str_2", "str_1", "str", "mot", "p5", "p3"]' + + +def test_dehex_decompress_dejson(): + expected = {"key1": "value1", "key2": "value2"} + assert ( + dehex_decompress_and_dejson( + b"789cab56ca4ead3454b252502a4bcc294d3554d251008918c1458c946a01c39b0a9b" + ) + == expected + ) diff --git a/src/server_common/test_modules/test_ioc_data.py b/tests/test_ioc_data.py similarity index 100% rename from src/server_common/test_modules/test_ioc_data.py rename to tests/test_ioc_data.py diff --git a/src/server_common/test_modules/test_ioc_data_source.py b/tests/test_ioc_data_source.py similarity index 100% rename from src/server_common/test_modules/test_ioc_data_source.py rename to tests/test_ioc_data_source.py diff --git a/src/server_common/test_modules/test_log_writer.py b/tests/test_log_writer.py similarity index 100% rename from src/server_common/test_modules/test_log_writer.py rename to tests/test_log_writer.py diff --git a/src/server_common/test_modules/test_observable.py b/tests/test_observable.py similarity index 100% rename from src/server_common/test_modules/test_observable.py rename to tests/test_observable.py diff --git a/tests/test_utilities.py b/tests/test_utilities.py new file mode 100644 index 0000000..e47edfc --- /dev/null +++ b/tests/test_utilities.py @@ -0,0 +1,85 @@ +import pytest +from server_common.utilities import waveform_to_string + + +def check_waveform(input_value, expected_value): + assert expected_value in waveform_to_string(input_value) + + +def create_waveform_from_list(input_list) -> str: + if len(input_list) != 0 and isinstance(input_list[0], str): + return "".join(input_list) + return "".join([chr(i) for i in input_list]) + + +def test_GIVEN_short_list_of_strings_WHEN_waveform_converted_to_string_THEN_result_contains_a_string_of_strings(): + # Arrange + test_waveform = ["hello", "world"] + + expected_value = create_waveform_from_list(test_waveform) + + # Act + + # Assert + check_waveform(test_waveform, expected_value) + + +def test_GIVEN_long_list_of_strings_WHEN_waveform_converted_to_string_THEN_result_contains_a_string_of_strings(): + # Arrange + test_waveform = ["this", "is", "a", "long", "list", "of", "strings!"] + + expected_value = create_waveform_from_list(test_waveform) + + # Act + + # Assert + check_waveform(test_waveform, expected_value) + + +def test_GIVEN_short_list_of_numbers_WHEN_waveform_converted_to_string_THEN_result_contains_string_of_unicode_chars_for_numbers(): + # Arrange + test_waveform = [1, 2, 3, 4] + + expected_value = create_waveform_from_list(test_waveform) + + # Act + + # Assert + check_waveform(test_waveform, expected_value) + + +def test_GIVEN_list_of_numbers_containing_0_WHEN_waveform_converted_to_string_THEN_result_terminates_at_character_before_0(): + # Arrange + test_waveform = [1, 2, 3, 4, 0, 5, 6, 7, 8, 9] + + expected_value = create_waveform_from_list([1, 2, 3, 4]) + + # Act + + # Assert + check_waveform(test_waveform, expected_value) + + +def test_GIVEN_long_list_of_numbers_WHEN_waveform_converted_to_string_THEN_result_contains_string_of_unicode_chars_for_numbers(): + # Arrange + max_unichr = 128 + length = 1000 + test_waveform = [max(i % max_unichr, 1) for i in range(1, length)] + + expected_value = create_waveform_from_list(test_waveform) + + # Act + + # Assert + check_waveform(test_waveform, expected_value) + + +def test_GIVEN_negative_integer_in_waveform_WHEN_waveform_converted_to_string_THEN_result_raises_value_error(): + # Arrange + test_waveform = [-1] + + # Act + + # Assert + with pytest.raises(ValueError): + waveform_to_string(test_waveform)