diff --git a/gramps/gen/test/config_test.py b/gramps/gen/test/config_test.py index b61042be1..28cadeaf5 100644 --- a/gramps/gen/test/config_test.py +++ b/gramps/gen/test/config_test.py @@ -139,6 +139,19 @@ class CompleteCheck(unittest.TestCase): self.assertEqual(self.CM.get("section2.dict"), {'a': "apple", "b": "banana"}) self.assertEqual(self.CM.get("section2.unicode"), "Raötröme") + self.assertRaises(AttributeError, self.CM.save, TEST2_INI, comments=123) + self.assertRaises(AttributeError, self.CM.save, TEST2_INI, comments={"key":"pair"}) + self.assertRaises(AttributeError, self.CM.save, TEST2_INI, comments=[123]) + self.assertRaises(AttributeError, self.CM.save, TEST2_INI, comments=["line1", 123]) + + self.CM.save(TEST2_INI, comments=["test comment1"]) + self.CM.load(TEST2_INI) + self.assertEqual(self.CM.get("section.setting3"), "Another String") + + self.CM.save(TEST2_INI, comments=["test comment1", "test comment2"]) + self.CM.load(TEST2_INI) + self.assertEqual(self.CM.get("section.setting3"), "Another String") + if __name__ == "__main__": unittest.main() diff --git a/gramps/gen/utils/configmanager.py b/gramps/gen/utils/configmanager.py index 79d3c8086..134771c6b 100644 --- a/gramps/gen/utils/configmanager.py +++ b/gramps/gen/utils/configmanager.py @@ -4,6 +4,7 @@ # Copyright (C) 2005-2007 Donald N. Allingham # Copyright (C) 2008-2009 Gary Burton # Copyright (C) 2009 Doug Blank +# Copyright (C) 2023 Christopher Horn # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -24,46 +25,45 @@ This package implements access to Gramps configuration. """ -#--------------------------------------------------------------- +# --------------------------------------------------------------- # -# System imports +# Python modules # -#--------------------------------------------------------------- +# --------------------------------------------------------------- +import configparser +import copy +import errno +import logging import os import sys import time -import configparser -import errno -import copy -import logging +# --------------------------------------------------------------- +# +# Gramps modules +# +# --------------------------------------------------------------- from ..const import GRAMPS_LOCALE as glocale + _ = glocale.translation.gettext + def safe_eval(exp): # restrict eval to empty environment return eval(exp, {}) -##try: -## from ast import literal_eval as safe_eval -## # this leaks memory !! -##except: -## # PYTHON2.5 COMPATIBILITY: no ast present -## # not as safe as literal_eval, but works for python2.5: -## def safe_eval(exp): -## # restrict eval to empty environment -## return eval(exp, {}) -#--------------------------------------------------------------- +# --------------------------------------------------------------- # -# Classes +# ConfigManager # -#--------------------------------------------------------------- +# --------------------------------------------------------------- class ConfigManager: """ Class to construct the singleton CONFIGMAN where all settings are stored. """ + PLUGINS = {} def __init__(self, filename=None, plugins=None): @@ -101,18 +101,20 @@ class ConfigManager: :param plugins: (if given) is a relative path to filename. """ - self._cb_id = 0 # callback id counter - self.config_path, config_filename = \ - os.path.split(os.path.abspath(filename)) - self.filename = filename # fullpath and filename, or None - self.plugins = plugins # relative directory name, or None + self._cb_id = 0 # callback id counter + self.config_path, dummy_config_filename = os.path.split( + os.path.abspath(filename) + ) + self.filename = filename # fullpath and filename, or None + self.plugins = plugins # relative directory name, or None self.callbacks = {} self.default = {} self.data = {} self.reset() - def register_manager(self, name, override="", use_plugins_path=True, - use_config_path=False): + def register_manager( + self, name, override="", use_plugins_path=True, use_config_path=False + ): """ Register a plugin manager. @@ -151,13 +153,15 @@ class ConfigManager: use_plugins_path=False) # will use /tmp/Other.ini """ - if isinstance(override, str): # directory or filename + if isinstance(override, str): # directory or filename if override: path, ininame = os.path.split(os.path.abspath(override)) else: - path, ininame = os.path.split(sys._getframe(1).f_code.co_filename) + path, ininame = os.path.split( + sys._getframe(1).f_code.co_filename + ) if not ininame.endswith(".ini"): - ininame = "%s.ini" % name + ininame = f"{name}.ini" if use_config_path: path = self.config_path elif use_plugins_path: @@ -172,12 +176,17 @@ class ConfigManager: return ConfigManager.PLUGINS[name] def get_manager(self, name): + """ + Return manager for a plugin. + """ if name in ConfigManager.PLUGINS: return ConfigManager.PLUGINS[name] - else: - raise AttributeError("config '%s': does not exist"% name) + raise AttributeError(f"config '{name}': does not exist") def has_manager(self, name): + """ + Check if have manager for a plugin. + """ return name in ConfigManager.PLUGINS def init(self): @@ -212,25 +221,29 @@ class ConfigManager: setting = None elif "." in key: section, setting = key.split(".", 1) - else: # key is not None and doesn't have a "." + else: # key is not None and doesn't have a "." section = key setting = None + # Now, do the reset on the right parts: if section is None: self.data = {} for section in self.default: self.data[section] = {} for setting in self.default[section]: - self.data[section][setting] = \ - copy.deepcopy(self.default[section][setting]) + self.data[section][setting] = copy.deepcopy( + self.default[section][setting] + ) elif setting is None: self.data[section] = {} for setting in self.default[section]: - self.data[section][setting] = \ - copy.deepcopy(self.default[section][setting]) + self.data[section][setting] = copy.deepcopy( + self.default[section][setting] + ) else: - self.data[section][setting] = \ - copy.deepcopy(self.default[section][setting]) + self.data[section][setting] = copy.deepcopy( + self.default[section][setting] + ) # Callbacks are still connected def get_sections(self): @@ -253,80 +266,97 @@ class ConfigManager: filename = self.filename if filename and os.path.exists(filename): parser = configparser.RawConfigParser() - try: # see bugs 5356, 5490, 5591, 5651, 5718, etc. - parser.read(filename, encoding='utf8') + try: # see bugs 5356, 5490, 5591, 5651, 5718, etc. + parser.read(filename, encoding="utf8") except Exception as err: - msg1 = _("WARNING: could not parse file:\n%(file)s\n" - "because %(error)s -- recreating it\n") % { - 'file' : filename, - 'error' : str(err)} - logging.warn(msg1) + msg1 = _( + "WARNING: could not parse file:\n%(file)s\n" + "because %(error)s -- recreating it\n" + ) % {"file": filename, "error": str(err)} + logging.warning(msg1) return - for sec in parser.sections(): - name = sec.lower() - if name not in self.data: - # Add the setting from file - # These might be old settings, or third-party settings - self.data[name] = {} - for opt in parser.options(sec): - raw_value = parser.get(sec, opt).strip() - if raw_value[:2] == "u'": - raw_value = raw_value[1:] - elif raw_value.startswith('['): - raw_value = raw_value.replace(", u'", ", '") - raw_value = raw_value.replace("[u'", "['") - setting = opt.lower() - if oldstyle: - ####################### Upgrade from oldstyle < 3.2 - # Oldstyle didn't mark setting type, but had it - # set in preferences. New style gets it from evaling - # the setting's value - ####################### - # if we know this setting, convert type - key = "%s.%s" % (name, setting) - if self.has_default(key): - vtype = type(self.get_default(key)) - if vtype == bool: - value = raw_value in ["1", "True"] - elif vtype == list: - logging.warning("WARNING: ignoring old key '%s'" % key) - continue # there were no lists in oldstyle - else: - value = vtype(raw_value) - else: - # else, ignore it - logging.warning("WARNING: ignoring old key '%s'" % key) - continue # with next setting - ####################### End upgrade code - else: - try: - value = safe_eval(raw_value) - except: - # most likely exception is SyntaxError but - # others are possible ex: '0L' from Python2 days - value = None - ####################### Now, let's test and set: - if (name in self.default and - setting in self.default[name]): - if isinstance(self.default[name][setting], bool): - #make sure 0 and 1 are False and True - if value == 0: - value = False - elif value == 1: - value = True - if self.check_type(self.default[name][setting], value): - self.data[name][setting] = value - else: - logging.warning("WARNING: ignoring key with wrong type " - "'%s.%s' %s needed instead of %s" % - (name, setting, - type(self.data[name][setting]), - type(value))) - else: - # this could be a third-party setting; add it: - self.data[name][setting] = value - def save(self, filename = None): + if oldstyle: + loader = self._load_oldstyle_section + else: + loader = self._load_section + + for section in parser.sections(): + name = section.lower() + if name not in self.data: + self.data[name] = {} + loader(section, parser) + + def _load_section(self, section, parser): + """ + Load a section of an .ini file into self.data + """ + name = section.lower() + for option in parser.options(section): + raw_value = get_raw_value(parser, section, option) + setting = option.lower() + + try: + value = safe_eval(raw_value) + except: + # most likely exception is SyntaxError but + # others are possible ex: '0L' from Python2 days + value = None + + self._load_setting(name, setting, value) + + def _load_oldstyle_section(self, section, parser): + """ + Load a section of an .ini file into self.data + """ + name = section.lower() + for option in parser.options(section): + raw_value = get_raw_value(parser, section, option) + setting = option.lower() + + ####################### Upgrade from oldstyle < 3.2 + # Oldstyle didn't mark setting type, but had it + # set in preferences. New style gets it from evaling + # the setting's value + ####################### + # if we know this setting, convert type + key = f"{name}.{setting}" + if self.has_default(key): + vtype = type(self.get_default(key)) + if vtype == bool: + value = raw_value in ["1", "True"] + elif vtype == list: + logging.warning("WARNING: ignoring old key '%s'", key) + continue # there were no lists in oldstyle + else: + value = vtype(raw_value) + else: + # else, ignore it + logging.warning("WARNING: ignoring old key '%s'", key) + continue # with next setting + ####################### End upgrade code + self._load_setting(name, setting, value) + + def _load_setting(self, name, setting, value): + if name in self.default and setting in self.default[name]: + if isinstance(self.default[name][setting], bool): + value = bool(value) + if self.check_type(self.default[name][setting], value): + self.data[name][setting] = value + else: + logging.warning( + "WARNING: ignoring key with wrong type " + "'%s.%s' %s needed instead of %s", + name, + setting, + type(self.data[name][setting]), + type(value), + ) + else: + # this could be a third-party setting; add it: + self.data[name][setting] = value + + def save(self, filename=None, comments=None): """ Saves the current section/settings to an .ini file. Optional filename will override the default filename to save to, if given. @@ -343,26 +373,30 @@ class ConfigManager: try: with open(filename, "w", encoding="utf-8") as key_file: key_file.write(";; Gramps key file\n") - key_file.write(";; Automatically created at %s" % - time.strftime("%Y/%m/%d %H:%M:%S") + "\n\n") + key_file.write( + ";; Automatically created at " + f"{time.strftime('%Y/%m/%d %H:%M:%S')}\n\n" + ) + write_comments(key_file, comments) for section in sorted(self.data): - key_file.write("[%s]\n" % section) + key_file.write(f"[{section}]\n") for key in sorted(self.data[section]): value = self.data[section][key] - default = "" # might be a third-party setting - if self.has_default("%s.%s" % (section, key)): - if value == self.get_default("%s.%s" - % (section, key)): + default = "" # might be a third-party setting + if self.has_default(f"{section}.{key}"): + if value == self.get_default( + f"{section}.{key}" + ): default = ";;" if isinstance(value, int): - value = int(value) # TODO why is this needed? - key_file.write("%s%s=%s\n" % (default, key, - repr(value))) + value = int(value) # TODO why is this needed? + key_file.write(f"{default}{key}={repr(value)}\n") key_file.write("\n") # else, no filename given; nothing to save so do nothing quietly except IOError as err: - logging.warn("Failed to open %s because %s", - filename, str(err)) + logging.warning( + "Failed to open %s because %s", filename, str(err) + ) return def get(self, key): @@ -370,27 +404,37 @@ class ConfigManager: Get the setting's value. raise an error if an invalid section.setting. Key is a string in the "section.setting" format. """ - if "." in key: + try: section, setting = key.split(".", 1) - else: - raise AttributeError("Invalid config section.setting name: '%s'" % - key) - if section not in self.data: - raise AttributeError("No such config section name: '%s'" % section) - if setting not in self.data[section]: - raise AttributeError("No such config setting name: '%s.%s'" % - (section, setting)) + except ValueError as error: + raise AttributeError( + f"Invalid config section.setting name: '{key}'" + ) from error + + self._validate_section_and_setting(section, setting) return self.data[section][setting] + def _validate_section_and_setting(self, section, setting): + """ + Validate section and setting present in the loaded data. + """ + if section not in self.data: + raise AttributeError(f"No such config section name: '{section}'") + if setting not in self.data[section]: + raise AttributeError( + f"No such config setting name: '{section}.{setting}'" + ) + def is_set(self, key): """ Does the setting exist? Returns True if does, False otherwise. Key is a string in the "section.setting" format. """ - if "." in key: + try: section, setting = key.split(".", 1) - else: + except ValueError: return False + if section not in self.data: return False if setting not in self.data[section]: @@ -402,10 +446,11 @@ class ConfigManager: Does the setting have a default value? Returns True if it does, False otherwise. Key is a string in the "section.setting" format. """ - if "." in key: + try: section, setting = key.split(".", 1) - else: + except ValueError: return False + if section not in self.default: return False if setting not in self.default[section]: @@ -417,16 +462,19 @@ class ConfigManager: Get the setting's default value. Raises an error if invalid key is give. Key is a string in the "section.setting" format. """ - if "." in key: + try: section, setting = key.split(".", 1) - else: - raise AttributeError("Invalid config section.setting name: '%s'" % - key) + except ValueError as error: + raise AttributeError( + f"Invalid config section.setting name: '{key}'" + ) from error + if section not in self.default: - raise AttributeError("No such config section name: '%s'" % section) + raise AttributeError(f"No such config section name: '{section}'") if setting not in self.default[section]: - raise AttributeError("No such config setting name: '%s.%s'" % - (section, setting)) + raise AttributeError( + f"No such config setting name: '{section}.{setting}'" + ) return self.default[section][setting] def register(self, key, default): @@ -435,11 +483,13 @@ class ConfigManager: Will overwrite any previously set default, and set setting if not one. The default value deterimines the type of the setting. """ - if "." in key: + try: section, setting = key.split(".", 1) - else: - raise AttributeError("Invalid config section.setting name: '%s'" % - key) + except ValueError as error: + raise AttributeError( + f"Invalid config section.setting name: '{key}'" + ) from error + if section not in self.data: self.data[section] = {} if section not in self.default: @@ -458,16 +508,14 @@ class ConfigManager: """ Connect a callback func that gets called when key is changed. """ - if "." in key: + try: section, setting = key.split(".", 1) - else: - raise AttributeError("Invalid config section.setting name: '%s'" % - key) - if section not in self.data: - raise AttributeError("No such config section name: '%s'" % section) - if setting not in self.data[section]: - raise AttributeError("No such config setting name: '%s.%s'" % - (section, setting)) + except ValueError as error: + raise AttributeError( + f"Invalid config section.setting name: '{key}'" + ) from error + + self._validate_section_and_setting(section, setting) self._cb_id += 1 self.callbacks[section][setting].append((self._cb_id, func)) return self._cb_id @@ -477,28 +525,31 @@ class ConfigManager: Removes a callback given its callback ID. The ID is generated and returned when the function is connected to the key (section.setting). """ - for section in self.callbacks: - for setting in self.callbacks[section]: - for (cbid, func) in self.callbacks[section][setting]: + for dummy_section, settings in self.callbacks.items(): + for dummy_setting, callbacks in settings.items(): + for cbid, func in callbacks: if callback_id == cbid: - self.callbacks[section][setting].remove((cbid, func)) + callbacks.remove((cbid, func)) def emit(self, key): """ Emits the signal "key" which will call the callbacks associated with that setting. """ - if "." in key: + try: section, setting = key.split(".", 1) - else: - raise AttributeError("Invalid config section.setting name: '%s'" % - key) + except ValueError as error: + raise AttributeError( + f"Invalid config section.setting name: '{key}'" + ) from error + if section not in self.callbacks: - raise AttributeError("No such config section name: '%s'" % section) + raise AttributeError(f"No such config section name: '{section}'") if setting not in self.callbacks[section]: - raise AttributeError("No such config setting name: '%s.%s'" % - (section, setting)) - for (cbid, func) in self.callbacks[section][setting]: + raise AttributeError( + f"No such config setting name: '{section}.{setting}'" + ) + for dummy_cbid, func in self.callbacks[section][setting]: func(self, 0, str(self.data[section][setting]), None) def set(self, key, value): @@ -507,33 +558,37 @@ class ConfigManager: the data dictionary: via the :meth:`load` method that reads a file, or from this method. """ - if "." in key: + try: section, setting = key.split(".", 1) - else: - raise AttributeError("Invalid config section.setting name: '%s'" % - key) - if section not in self.data: - raise AttributeError("No such config section name: '%s'" % section) - if setting not in self.data[section]: - raise AttributeError("No such config setting name: '%s.%s'" % - (section, setting)) + except ValueError as error: + raise AttributeError( + "Invalid config section.setting name: '{key}'" + ) from error + + self._validate_section_and_setting(section, setting) + # Check value to see if right type: if self.has_default(key): if not self.check_type(self.get_default(key), value): - raise AttributeError("attempting to set '%s' to wrong type " - "'%s'; should be '%s'" % - (key, type(value), - type(self.get_default(key)))) - if (setting in self.data[section] and - self.data[section][setting] == value): + raise AttributeError( + f"attempting to set '{key}' to wrong type " + f"'{type(value)}'; should be " + f"'{type(self.get_default(key))}'" + ) + if ( + setting in self.data[section] + and self.data[section][setting] == value + ): # Do nothing if existed and is the same pass else: # Set the value: self.data[section][setting] = value # Only call callback if the value changed! - if (section in self.callbacks and - setting in self.callbacks[section]): + if ( + section in self.callbacks + and setting in self.callbacks[section] + ): self.emit(key) def check_type(self, value1, value2): @@ -544,11 +599,37 @@ class ConfigManager: type2 = type(value2) if type1 == type2: return True - elif (isinstance(value1, str) and - isinstance(value2, str)): + if isinstance(value1, str) and isinstance(value2, str): return True - elif (type1 in [int, float] and - type2 in [int, float]): + if type1 in [int, float] and type2 in [int, float]: return True - else: - return False + return False + + +def get_raw_value(parser, section, option): + """ + Prepare and return raw value. + """ + raw_value = parser.get(section, option).strip() + if raw_value[:2] == "u'": + raw_value = raw_value[1:] + elif raw_value.startswith("["): + raw_value = raw_value.replace(", u'", ", '") + raw_value = raw_value.replace("[u'", "['") + return raw_value + + +def write_comments(output_file, comments): + """ + Sanity check and write comments out to a .ini file. + """ + if comments: + if not isinstance(comments, list): + raise AttributeError("Comments should be a list") + + output_file.write("\n") + for comment in comments: + if not isinstance(comment, str): + raise AttributeError("Comment should be a string") + clean_comment = comment.strip("; \n") + output_file.write(f";; {clean_comment}\n")