diff --git a/README.md b/README.md index e02c5cc3686c97f27483317295951f93816a861f..c886fba47db2cb3afe03a0746459ae8ecb041424 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,7 @@ tox -e debug tests.requests.test_prometheus ## Release notes +- 0.18.8 - Migrate case insensitive dict from station control - 0.18.7 - Add support for various ZeroMQ package receivers - 0.18.6 - Compatability with new black versions - 0.18.5 - Compatability with python 3.10 and higher diff --git a/VERSION b/VERSION index fc67997161f3e5a2d0fa074b69f109dad05db210..0f2596acd87483a7374c252cc9ff57e0a58b92ba 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.18.7 +0.18.8 diff --git a/lofar_station_client/common/__init__.py b/lofar_station_client/common/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..0697086a7033b53b8ea9b0828b4227111248f5f2 --- /dev/null +++ b/lofar_station_client/common/__init__.py @@ -0,0 +1,9 @@ +# Copyright (C) 2024 ASTRON (Netherlands Institute for Radio Astronomy) +# SPDX-License-Identifier: Apache-2.0 + +""" Common classes used in station """ + +from ._case_insensitive_dict import CaseInsensitiveDict, ReversibleKeysView +from ._case_insensitive_string import CaseInsensitiveString + +__all__ = ["CaseInsensitiveDict", "CaseInsensitiveString", "ReversibleKeysView"] diff --git a/lofar_station_client/common/_case_insensitive_dict.py b/lofar_station_client/common/_case_insensitive_dict.py new file mode 100644 index 0000000000000000000000000000000000000000..65f514cb31ac64e93cec7c3dccb7bd65d1fa255e --- /dev/null +++ b/lofar_station_client/common/_case_insensitive_dict.py @@ -0,0 +1,150 @@ +# Copyright (C) 2024 ASTRON (Netherlands Institute for Radio Astronomy) +# SPDX-License-Identifier: Apache-2.0 + +""" Provides a special dictionary with case-insensitive keys """ + +import abc +from collections import UserDict +from typing import List +from typing import Tuple +from typing import Union + +from ._case_insensitive_string import CaseInsensitiveString + + +def _case_insensitive_comprehend_keys(data: dict) -> List[CaseInsensitiveString]: + return [CaseInsensitiveString(key) for key in data] + + +def _case_insensitive_comprehend_items( + data: dict, +) -> List[Tuple[CaseInsensitiveString, any]]: + return [(CaseInsensitiveString(key), value) for key, value in data.items()] + + +class ReversibleIterator: + """Reversible iterator using instance of self method + + See real-python for yield iterator method: + https://realpython.com/python-reverse-list/#the-special-method-__reversed__ + """ + + def __init__(self, data: List, start: int, stop: int, step: int): + self.data = data + self.current = start + self.stop = stop + self.step = step + + def __iter__(self): + return self + + def __next__(self): + if self.current == self.stop: + raise StopIteration + + elem = self.data[self.current] + self.current += self.step + return elem + + def __reversed__(self): + return ReversibleIterator(self.data, self.stop, self.current, -1) + + +class AbstractReversibleView(abc.ABC): + """An abstract reversible view""" + + def __init__(self, data: UserDict): + self.data = data + self.len = len(data) + + def __repr__(self): + return f"{self.__class__.__name__}({self.data})" + + @abc.abstractmethod + def __iter__(self): + pass + + @abc.abstractmethod + def __reversed__(self): + pass + + +class ReversibleItemsView(AbstractReversibleView): + """Reversible view on items""" + + def __iter__(self): + return ReversibleIterator( + _case_insensitive_comprehend_items(self.data.data), 0, self.len, 1 + ) + + def __reversed__(self): + return ReversibleIterator( + _case_insensitive_comprehend_items(self.data.data), self.len - 1, -1, -1 + ) + + +class ReversibleKeysView(AbstractReversibleView): + """Reversible view on keys""" + + def __iter__(self): + return ReversibleIterator( + _case_insensitive_comprehend_keys(self.data.data), 0, self.len, 1 + ) + + def __reversed__(self): + return ReversibleIterator( + _case_insensitive_comprehend_keys(self.data.data), self.len - 1, -1, -1 + ) + + +class ReversibleValuesView(AbstractReversibleView): + """Reversible view on values""" + + def __iter__(self): + return ReversibleIterator(list(self.data.data.values()), 0, self.len, 1) + + def __reversed__(self): + return ReversibleIterator(list(self.data.data.values()), self.len - 1, -1, -1) + + +class CaseInsensitiveDict(UserDict): + """Special dictionary that ignores key casing if string + + While UserDict is the least performant / flexible it ensures __set_item__ and + __get_item__ are used in all code paths reducing LoC severely. + + Background reference: + https://realpython.com/inherit-python-dict/#creating-dictionary-like-classes-in-python + + Alternative (should this stop working at some point): + https://github.com/DeveloperRSquared/case-insensitive-dict/blob/main/case_insensitive_dict/case_insensitive_dict.py + """ + + def __setitem__(self, key, value): + if isinstance(key, str): + key = CaseInsensitiveString(key) + super().__setitem__(key, value) + + def __getitem__(self, key: Union[int, str]): + if isinstance(key, str): + key = CaseInsensitiveString(key) + return super().__getitem__(key) + + def __iter__(self): + return ReversibleIterator( + _case_insensitive_comprehend_keys(self.data), 0, len(self.data), 1 + ) + + def __contains__(self, key): + if isinstance(key, str): + key = CaseInsensitiveString(key) + return super().__contains__(key) + + def keys(self) -> ReversibleKeysView: + return ReversibleKeysView(self) + + def values(self) -> ReversibleValuesView: + return ReversibleValuesView(self) + + def items(self) -> ReversibleItemsView: + return ReversibleItemsView(self) diff --git a/lofar_station_client/common/_case_insensitive_string.py b/lofar_station_client/common/_case_insensitive_string.py new file mode 100644 index 0000000000000000000000000000000000000000..8176962157638cf67f275ff57d361e787f761e48 --- /dev/null +++ b/lofar_station_client/common/_case_insensitive_string.py @@ -0,0 +1,28 @@ +# Copyright (C) 2024 ASTRON (Netherlands Institute for Radio Astronomy) +# SPDX-License-Identifier: Apache-2.0 + +"""Special string that ignores casing in comparison""" + + +class CaseInsensitiveString(str): + """Special string that ignores casing in comparison""" + + def __eq__(self, other): + if isinstance(other, str): + return self.casefold() == other.casefold() + + return self.casefold() == other + + def __hash__(self): + return hash(self.__str__()) + + def __contains__(self, key): + if isinstance(key, str): + return key.casefold() in str(self) + return key in str(self) + + def __str__(self) -> str: + return self.casefold().__str__() + + def __repr__(self) -> str: + return self.casefold().__repr__() diff --git a/lofar_station_client/statistics/statistics_data.py b/lofar_station_client/statistics/statistics_data.py index 16b2ecd4c7a130bff00d4a1b3aa81e0d2ca533b5..40fbedbb9de504818acb4d8ece24515a6f9f8ed0 100644 --- a/lofar_station_client/statistics/statistics_data.py +++ b/lofar_station_client/statistics/statistics_data.py @@ -1,4 +1,4 @@ -# Copyright (C) 2023 ASTRON (Netherlands Institute for Radio Astronomy) +# Copyright (C) 2024 ASTRON (Netherlands Institute for Radio Astronomy) # SPDX-License-Identifier: Apache-2.0 # too-few-public-methods @@ -193,26 +193,26 @@ class StatisticsFileHeader: antennafield_device: str = attribute(optional=True) """ Name of the antennafield device """ - antenna_names: str = attribute(optional=True) + antenna_names: [str] = attribute(optional=True) """ Antenna names """ antenna_type: str = attribute(optional=True) """ The type of antenna in this field (LBA or HBA). """ - antenna_quality: str = attribute(optional=True) + antenna_quality: [str] = attribute(optional=True) """ The quality of each antenna, as a string. """ - antenna_usage_mask: str = attribute(optional=True) + antenna_usage_mask: [bool] = attribute(optional=True) """ Whether each antenna would have been used. """ - antenna_reference_itrf: str = attribute(optional=True) + antenna_reference_itrf: [str] = attribute(optional=True) """ Absolute reference position of each tile, in ITRF (XYZ) """ - fpga_firmware_version: str = attribute(optional=True) - fpga_hardware_version: str = attribute(optional=True) + fpga_firmware_version: [str] = attribute(optional=True) + fpga_hardware_version: [str] = attribute(optional=True) - rcu_pcb_id: int = attribute(optional=True) - rcu_pcb_version: str = attribute(optional=True) + rcu_pcb_id: [int] = attribute(optional=True) + rcu_pcb_version: [str] = attribute(optional=True) def __eq__(self, other): for attr in [ diff --git a/tests/common/__init__.py b/tests/common/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..24a6af40de86106b6a27a36e996db6118a0f87e5 --- /dev/null +++ b/tests/common/__init__.py @@ -0,0 +1,2 @@ +# Copyright (C) 2024 ASTRON (Netherlands Institute for Radio Astronomy) +# SPDX-License-Identifier: Apache-2.0 diff --git a/tests/common/test_case_insensitive_dict.py b/tests/common/test_case_insensitive_dict.py new file mode 100644 index 0000000000000000000000000000000000000000..17c25d1b28dbb28e0c4259ac055210f947ce371d --- /dev/null +++ b/tests/common/test_case_insensitive_dict.py @@ -0,0 +1,164 @@ +# Copyright (C) 2024 ASTRON (Netherlands Institute for Radio Astronomy) +# SPDX-License-Identifier: Apache-2.0 +from enum import Enum + +from lofar_station_client.common import CaseInsensitiveDict +from lofar_station_client.common import CaseInsensitiveString +from lofar_station_client.common import ReversibleKeysView +from tests import base + + +class TestCaseInsensitiveDict(base.TestCase): + def test_set_get_item(self): + """Get and set an item with different casing""" + + t_value = "VALUE" + t_key = "KEY" + t_dict = CaseInsensitiveDict() + + t_dict[t_key] = t_value + + self.assertEqual(t_value, t_dict[t_key.lower()]) + self.assertEqual(t_value, t_dict.get(t_key.lower())) + + def test_set_overwrite(self): + """Overwrite a previous element with different casing""" + + t_value = "VALUE" + t_key = "KEY" + t_dict = CaseInsensitiveDict() + + t_dict[t_key] = t_value + t_dict[t_key.lower()] = t_value.lower() + + self.assertEqual(t_value.lower(), t_dict[t_key]) + self.assertEqual(t_value.lower(), t_dict.get(t_key)) + + class ConstructTestEnum(Enum): + DICT = "dict" + ITER = "iter" + KWARGS = "kwargs" + + def construct_base(self, test_type: ConstructTestEnum): + t_key1 = "KEY1" + t_key2 = "key2" + t_value1 = 123 + t_value2 = 456 + t_mapping = {t_key1: t_value1, t_key2: t_value2} + + t_dict = CaseInsensitiveDict() + if test_type is self.ConstructTestEnum.DICT: + t_dict = CaseInsensitiveDict(t_mapping) + elif test_type is self.ConstructTestEnum.ITER: + t_dict = CaseInsensitiveDict(t_mapping.items()) + elif test_type is self.ConstructTestEnum.KWARGS: + t_dict = CaseInsensitiveDict(KEY1=t_value1, key2=t_value2) + + self.assertEqual(t_value1, t_dict[t_key1.lower()]) + self.assertEqual(t_value2, t_dict.get(t_key2.upper())) + + def test_construct_mapping(self): + self.construct_base(self.ConstructTestEnum.DICT) + + def test_construct_iterable(self): + self.construct_base(self.ConstructTestEnum.ITER) + + def test_construct_kwargs(self): + self.construct_base(self.ConstructTestEnum.KWARGS) + + def test_setdefault(self): + t_key = "KEY" + t_value = "value" + t_dict = CaseInsensitiveDict() + + t_dict.setdefault(t_key, t_value) + + self.assertIn(t_key.lower(), t_dict.keys()) + + for key in t_dict.keys(): + self.assertEqual(t_key, key) + self.assertIsInstance(key, CaseInsensitiveString) + + def test_keys(self): + t_key = "KEY" + t_value = "value" + t_dict = CaseInsensitiveDict() + + t_dict[t_key] = t_value + + self.assertIn(t_key.lower(), t_dict.keys()) + self.assertIsInstance(t_dict.keys(), ReversibleKeysView) + + for key in t_dict.keys(): + self.assertEqual(t_key, key) + self.assertIsInstance(key, CaseInsensitiveString) + + def test_items(self): + t_key = "KEY" + t_value = "VALUE" + t_dict = CaseInsensitiveDict() + + t_dict[t_key] = t_value + + for key, value in t_dict.items(): + self.assertEqual(t_key, key) + self.assertIsInstance(key, CaseInsensitiveString) + self.assertNotEqual(t_value.casefold(), value) + + def test_values(self): + t_key = "KEY" + t_value = "VALUE" + t_dict = CaseInsensitiveDict() + + t_dict[t_key] = t_value + + for value in t_dict.values(): + self.assertEqual(t_value, value) + self.assertIsInstance(value, str) + + def test_in(self): + t_key = "KEY" + t_value = "VALUE" + t_dict = CaseInsensitiveDict() + + t_dict[t_key] = t_value + + self.assertTrue(t_key.lower() in t_dict) + + def test_reverse(self): + t_key1 = "KEY1" + t_key2 = "KEY2" + t_value = "VALUE" + t_dict = CaseInsensitiveDict() + + t_dict[t_key1] = t_value + t_dict[t_key2] = t_value + + forward = [] + for key, _ in t_dict.items(): + forward.append(key) + + backward = [] + for key, _ in reversed(t_dict.items()): + backward.append(key) + + self.assertEqual(forward[0], backward[1]) + self.assertEqual(forward[1], backward[0]) + + backward = [] + for key in reversed(t_dict.keys()): + backward.append(key) + + self.assertEqual(forward[0], backward[1]) + self.assertEqual(forward[1], backward[0]) + + forward = [] + for item in t_dict.values(): + forward.append(item) + + backward = [] + for value in reversed(t_dict.values()): + backward.append(value) + + self.assertEqual(forward[0], backward[1]) + self.assertEqual(forward[1], backward[0]) diff --git a/tests/common/test_case_insensitive_string.py b/tests/common/test_case_insensitive_string.py new file mode 100644 index 0000000000000000000000000000000000000000..3ccbc5c4913375a6a7a8ba8e2cc4448390c3fea0 --- /dev/null +++ b/tests/common/test_case_insensitive_string.py @@ -0,0 +1,22 @@ +# Copyright (C) 2024 ASTRON (Netherlands Institute for Radio Astronomy) +# SPDX-License-Identifier: Apache-2.0 +from lofar_station_client.common import CaseInsensitiveString + +from tests import base + + +class TestCaseInsensitiveString(base.TestCase): + def test_a_in_b(self): + """Get and set an item with different casing""" + + self.assertTrue(CaseInsensitiveString("hba0") in CaseInsensitiveString("HBA0")) + + def test_b_in_a(self): + """Get and set an item with different casing""" + + self.assertTrue(CaseInsensitiveString("HBA0") in CaseInsensitiveString("hba0")) + + def test_a_not_in_b(self): + """Get and set an item with different casing""" + + self.assertFalse(CaseInsensitiveString("hba0") in CaseInsensitiveString("LBA0"))