From b5953afec314e29c38630b6045b59827a483d396 Mon Sep 17 00:00:00 2001 From: stedif <stefano.difrischia@inaf.it> Date: Mon, 24 Oct 2022 17:13:47 +0200 Subject: [PATCH] L2SS-1030: implement dump ConfigDB --- .../common/configuration.py | 281 ++++++++++++++++++ .../devices/configuration_device.py | 13 +- .../devices/test_device_configuration.py | 8 + 3 files changed, 300 insertions(+), 2 deletions(-) create mode 100644 tangostationcontrol/tangostationcontrol/common/configuration.py diff --git a/tangostationcontrol/tangostationcontrol/common/configuration.py b/tangostationcontrol/tangostationcontrol/common/configuration.py new file mode 100644 index 000000000..bc7a6e07e --- /dev/null +++ b/tangostationcontrol/tangostationcontrol/common/configuration.py @@ -0,0 +1,281 @@ +from collections import defaultdict +import six +try: + from collections.abc import MutableMapping +except ImportError: + from collections import MutableMapping +try: + from collections.abc import Mapping +except ImportError: + from collections import Mapping +from itertools import islice + +def get_servers_with_filters(dbproxy, server="*", clss="*", device="*", + properties=True, attribute_properties=True, + aliases=True, dservers=False, + subdevices=False, uppercase_devices=False, + timeout=10): + """ + A performant way to get servers and devices in bulk from the DB + by direct SQL statements and joins, instead of e.g. using one + query to get the properties of each device. + + TODO: are there any length restrictions on the query results? In + that case, use limit and offset to get page by page. + """ + + server = server.replace("*", "%") # mysql wildcards + clss = clss.replace("*", "%") + device = device.replace("*", "%") + + # Collect all info about each device in this dict + devices = CaselessDictionary() + + # Queries can sometimes take more than de default 3 s, so it's + # good to increase the timeout a bit. + # TODO: maybe instead use automatic retry and increase timeout + # each time? + dbproxy.set_timeout_millis(timeout * 1000) + + if properties: + # Get all relevant device properties + query = ( + "SELECT device, property_device.name, property_device.value" + " FROM property_device" + " INNER JOIN device ON property_device.device = device.name" + " WHERE server LIKE '%s' AND class LIKE '%s' AND device LIKE '%s'") + if not dservers: + query += " AND class != 'DServer'" + if not subdevices: + query += " AND property_device.name != '__SubDevices'" + query += " ORDER BY property_device.count ASC" + _, result = dbproxy.command_inout("DbMySqlSelect", + query % (server, clss, device)) + for d, p, v in nwise(result, 3): + dev = devices.setdefault(d, {}) + # Make sure to use caseless dicts, since in principle property names + # should be caseles too. + props = dev.setdefault("properties", CaselessDictionary()) + value = props.setdefault(p, []) + value.append(v) + + if attribute_properties: + # Get all relevant attribute properties + query = ( + "SELECT device, attribute, property_attribute_device.name," + " property_attribute_device.value" + " FROM property_attribute_device" + " INNER JOIN device ON property_attribute_device.device =" + " device.name" + " WHERE server LIKE '%s' AND class LIKE '%s' AND device LIKE '%s'") + if not dservers: + query += " AND class != 'DServer'" + query += " ORDER BY property_attribute_device.count ASC" + _, result = dbproxy.command_inout("DbMySqlSelect", query % (server, clss, device)) + for d, a, p, v in nwise(result, 4): + dev = devices.setdefault(d, {}) + props = dev.setdefault("attribute_properties", CaselessDictionary()) + attr = props.setdefault(a, CaselessDictionary()) + value = attr.setdefault(p, []) + value.append(v) + + # dump relevant servers + query = ( + "SELECT server, class, name, alias FROM device" + " WHERE server LIKE '%s' AND class LIKE '%s' AND name LIKE '%s'") + + if not dservers: + query += " AND class != 'DServer'" + _, result = dbproxy.command_inout("DbMySqlSelect", query % (server, clss, device)) + + # combine all the information we have + servers = SetterDict() + for s, c, d, a in nwise(result, 4): + try: + srv, inst = s.split("/") + except ValueError: + # Malformed server name? It can happen! + continue + device = devices.get(d, {}) + if a and aliases: + device["alias"] = a + devname = maybe_upper(d, uppercase_devices) + servers[srv][inst][c][devname] = device + + return servers + +def nwise(it, n): + # [s_0, s_1, ...] => [(s_0, ..., s_(n-1)), (s_n, ... s_(2n-1)), ...] + return list(zip(*[islice(it, i, None, n) for i in range(n)])) + +def maybe_upper(s, upper=False): + if upper: + return s.upper() + return s + +class CaselessString(object): + """ + A mixin to make a string subclass case-insensitive in dict lookups. + """ + + def __hash__(self): + return hash(self.lower()) + + def __eq__(self, other): + return self.lower() == other.lower() + + def __cmp__(self, other): + return self.lower().__cmp__(other.lower()) + + @classmethod + def make_caseless(cls, string): + if isinstance(string, six.text_type): + return CaselessUnicode(string) + return CaselessStr(string) + +class CaselessStr(CaselessString, str): + pass + + +class CaselessUnicode(CaselessString, six.text_type): + pass + +class CaselessDictionary(MutableMapping): + """ + A dictionary-like object which ignores but preserves the case of strings. + + Example:: + + >>> cdict = CaselessDictionary() + + Access is case-insensitive:: + + >>> cdict['a'] = 1 + >>> cdict['A'] + 1 + + As is writing:: + + >>> cdict['key'] = 123 + >>> cdict['KeY'] = 456 + >>> cdict['key'] + 456 + + And deletion:: + + >>> del cdict['A'] + >>> 'a' in cdict + False + >>> 'A' in cdict + False + + However, the case of keys is preserved (the case of overridden keys will be + the first one which was set):: + + >>> cdict['aBcDeF'] = 1 + >>> sorted(list(cdict)) + ['aBcDeF', 'key'] + """ + + def __init__(self, *args, **kwargs): + self.__dict__["_dict"] = {} + temp_dict = dict(*args, **kwargs) + for key, value in list(temp_dict.items()): + if isinstance(key, six.string_types): + key = CaselessString.make_caseless(key) + + if isinstance(value, dict): + self._dict[key] = CaselessDictionary(value) + else: + self._dict[key] = value + + def __getitem__(self, key): + return self._dict[CaselessString.make_caseless(key)] + + def __setitem__(self, key, value): + self._dict[CaselessString.make_caseless(key)] = value + + def __delitem__(self, key): + del self._dict[CaselessString.make_caseless(key)] + + def __contains__(self, key): + return CaselessString.make_caseless(key) in self._dict + + def __iter__(self): + return iter(self._dict) + + def __len__(self): + return len(self._dict) + + def keys(self): + # convert back to ordinary strings + return [str(k) for k in self._dict] + + def items(self): + return list(zip(list(self.keys()), list(self.values()))) + +class SetterDict(CaselessDictionary, defaultdict): + """ + A recursive defaultdict with extra bells & whistles + + It enables you to set keys at any depth without first creating the + intermediate levels, e.g. + + d = SetterDict() + d["a"]["b"]["c"] = 1 + + It also allows access using normal getattr syntax, interchangeably: + + d.a["b"].c == d.a.b.c == d["a"]["b"]["c"] + + Note: only allows string keys for now. + + Keys are caseless, meaning that the key "MyKey" is the same as + "mykey", "MYKEY", etc. The case + + Note: this thing is dangerous! Accessing a non-existing key will + result in creating it, which means that confusing behavior is + likely. Please use it carefully and convert to an ordinary dict + (using to_dict()) when you're done creating it. + """ + + def __init__(self, value={}, factory=None): + factory = factory or SetterDict + self.__dict__["_factory"] = factory + CaselessDictionary.__init__(self) + defaultdict.__init__(self, factory) + for k, v in list(value.items()): + self[k] = v + + def __getitem__(self, key): + try: + return CaselessDictionary.__getitem__(self, key) + except KeyError: + return self.__missing__(key) + + def __setitem__(self, key, value): + if isinstance(value, SetterDict): + CaselessDictionary.__setitem__(self, key, value) + elif isinstance(value, Mapping): + CaselessDictionary.__setitem__(self, key, self._factory(value)) + else: + CaselessDictionary.__setitem__(self, key, value) + + def __getattr__(self, name): + return self.__getitem__(name) + + def __setattr__(self, key, value): + return self.__setitem__(key, value) + + def to_dict(self): + """ + Returns a ordinary dict version of itself + """ + result = {} + for key, value in list(self.items()): + if isinstance(value, SetterDict): + result[key] = value.to_dict() + else: + result[key] = value + return result + diff --git a/tangostationcontrol/tangostationcontrol/devices/configuration_device.py b/tangostationcontrol/tangostationcontrol/devices/configuration_device.py index bc8222cd3..7f5092354 100644 --- a/tangostationcontrol/tangostationcontrol/devices/configuration_device.py +++ b/tangostationcontrol/tangostationcontrol/devices/configuration_device.py @@ -13,14 +13,16 @@ Handles and exposes the station configuration """ # PyTango imports -from tango import AttrWriteType +from tango import AttrWriteType, Database, DeviceProxy from tango.server import attribute # Additional import +from tangostationcontrol.common.configuration import SetterDict, get_servers_with_filters from tangostationcontrol.common.entrypoint import entry from tangostationcontrol.devices.lofar_device import lofar_device from tangostationcontrol.common.lofar_logging import device_logging_to_python +import json import logging logger = logging.getLogger() @@ -46,11 +48,18 @@ class Configuration(lofar_device): N.B. it does not update, it loads a full new configuration. """ + # TODO(Stefano): implement load configuration self.proxy.tangodb_properties_RW = tangodb_properties def _dump_configdb(self): """ Returns the TangoDB station configuration as a JSON string """ - return 'Configuration' + dbproxy = DeviceProxy(Database().dev_name()) + # Create a special dictionary to prevent Tango case errors + data = SetterDict() + servers = get_servers_with_filters(dbproxy) + data.servers.update(servers) + return json.dumps(data.to_dict(), ensure_ascii=False, indent=4, sort_keys=True) + # ---------- # Run server diff --git a/tangostationcontrol/tangostationcontrol/integration_test/default/devices/test_device_configuration.py b/tangostationcontrol/tangostationcontrol/integration_test/default/devices/test_device_configuration.py index 4a34cad03..71731810a 100644 --- a/tangostationcontrol/tangostationcontrol/integration_test/default/devices/test_device_configuration.py +++ b/tangostationcontrol/tangostationcontrol/integration_test/default/devices/test_device_configuration.py @@ -9,8 +9,16 @@ from .base import AbstractTestBases +import json class TestDeviceConfiguration(AbstractTestBases.TestDeviceBase): def setUp(self): super().setUp("STAT/Configuration/1") + + def test_read_tangodb_properties(self): + """ Test whether the station control configuration is correctly retrieved as a JSON string """ + tangodb_properties = self.proxy.tangodb_properties_RW + dbdata = json.loads(tangodb_properties) + self.assertTrue(type(dbdata), dict) + self.assertGreater(len(dbdata['servers']), 0) -- GitLab