Merge pull request #1378 from cdhorn/ch-configmgr-comment

This commit is contained in:
Nick Hall 2023-07-24 19:09:37 +01:00
commit 5b540d65bc
2 changed files with 283 additions and 189 deletions

View File

@ -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.dict"), {'a': "apple", "b": "banana"})
self.assertEqual(self.CM.get("section2.unicode"), "Raötröme") 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__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@ -4,6 +4,7 @@
# Copyright (C) 2005-2007 Donald N. Allingham # Copyright (C) 2005-2007 Donald N. Allingham
# Copyright (C) 2008-2009 Gary Burton # Copyright (C) 2008-2009 Gary Burton
# Copyright (C) 2009 Doug Blank <doug.blank@gmail.com> # Copyright (C) 2009 Doug Blank <doug.blank@gmail.com>
# Copyright (C) 2023 Christopher Horn
# #
# This program is free software; you can redistribute it and/or modify # 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 # it under the terms of the GNU General Public License as published by
@ -24,46 +25,45 @@
This package implements access to Gramps configuration. This package implements access to Gramps configuration.
""" """
#--------------------------------------------------------------- # ---------------------------------------------------------------
# #
# System imports # Python modules
# #
#--------------------------------------------------------------- # ---------------------------------------------------------------
import configparser
import copy
import errno
import logging
import os import os
import sys import sys
import time import time
import configparser
import errno
import copy
import logging
# ---------------------------------------------------------------
#
# Gramps modules
#
# ---------------------------------------------------------------
from ..const import GRAMPS_LOCALE as glocale from ..const import GRAMPS_LOCALE as glocale
_ = glocale.translation.gettext _ = glocale.translation.gettext
def safe_eval(exp): def safe_eval(exp):
# restrict eval to empty environment # restrict eval to empty environment
return eval(exp, {}) 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 ConfigManager:
""" """
Class to construct the singleton CONFIGMAN where all Class to construct the singleton CONFIGMAN where all
settings are stored. settings are stored.
""" """
PLUGINS = {} PLUGINS = {}
def __init__(self, filename=None, plugins=None): def __init__(self, filename=None, plugins=None):
@ -101,18 +101,20 @@ class ConfigManager:
:param plugins: (if given) is a relative path to filename. :param plugins: (if given) is a relative path to filename.
""" """
self._cb_id = 0 # callback id counter self._cb_id = 0 # callback id counter
self.config_path, config_filename = \ self.config_path, dummy_config_filename = os.path.split(
os.path.split(os.path.abspath(filename)) os.path.abspath(filename)
self.filename = filename # fullpath and filename, or None )
self.plugins = plugins # relative directory name, or None self.filename = filename # fullpath and filename, or None
self.plugins = plugins # relative directory name, or None
self.callbacks = {} self.callbacks = {}
self.default = {} self.default = {}
self.data = {} self.data = {}
self.reset() self.reset()
def register_manager(self, name, override="", use_plugins_path=True, def register_manager(
use_config_path=False): self, name, override="", use_plugins_path=True, use_config_path=False
):
""" """
Register a plugin manager. Register a plugin manager.
@ -151,13 +153,15 @@ class ConfigManager:
use_plugins_path=False) use_plugins_path=False)
# will use /tmp/Other.ini # will use /tmp/Other.ini
""" """
if isinstance(override, str): # directory or filename if isinstance(override, str): # directory or filename
if override: if override:
path, ininame = os.path.split(os.path.abspath(override)) path, ininame = os.path.split(os.path.abspath(override))
else: 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"): if not ininame.endswith(".ini"):
ininame = "%s.ini" % name ininame = f"{name}.ini"
if use_config_path: if use_config_path:
path = self.config_path path = self.config_path
elif use_plugins_path: elif use_plugins_path:
@ -172,12 +176,17 @@ class ConfigManager:
return ConfigManager.PLUGINS[name] return ConfigManager.PLUGINS[name]
def get_manager(self, name): def get_manager(self, name):
"""
Return manager for a plugin.
"""
if name in ConfigManager.PLUGINS: if name in ConfigManager.PLUGINS:
return ConfigManager.PLUGINS[name] return ConfigManager.PLUGINS[name]
else: raise AttributeError(f"config '{name}': does not exist")
raise AttributeError("config '%s': does not exist"% name)
def has_manager(self, name): def has_manager(self, name):
"""
Check if have manager for a plugin.
"""
return name in ConfigManager.PLUGINS return name in ConfigManager.PLUGINS
def init(self): def init(self):
@ -212,25 +221,29 @@ class ConfigManager:
setting = None setting = None
elif "." in key: elif "." in key:
section, setting = key.split(".", 1) 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 section = key
setting = None setting = None
# Now, do the reset on the right parts: # Now, do the reset on the right parts:
if section is None: if section is None:
self.data = {} self.data = {}
for section in self.default: for section in self.default:
self.data[section] = {} self.data[section] = {}
for setting in self.default[section]: for setting in self.default[section]:
self.data[section][setting] = \ self.data[section][setting] = copy.deepcopy(
copy.deepcopy(self.default[section][setting]) self.default[section][setting]
)
elif setting is None: elif setting is None:
self.data[section] = {} self.data[section] = {}
for setting in self.default[section]: for setting in self.default[section]:
self.data[section][setting] = \ self.data[section][setting] = copy.deepcopy(
copy.deepcopy(self.default[section][setting]) self.default[section][setting]
)
else: else:
self.data[section][setting] = \ self.data[section][setting] = copy.deepcopy(
copy.deepcopy(self.default[section][setting]) self.default[section][setting]
)
# Callbacks are still connected # Callbacks are still connected
def get_sections(self): def get_sections(self):
@ -253,80 +266,97 @@ class ConfigManager:
filename = self.filename filename = self.filename
if filename and os.path.exists(filename): if filename and os.path.exists(filename):
parser = configparser.RawConfigParser() parser = configparser.RawConfigParser()
try: # see bugs 5356, 5490, 5591, 5651, 5718, etc. try: # see bugs 5356, 5490, 5591, 5651, 5718, etc.
parser.read(filename, encoding='utf8') parser.read(filename, encoding="utf8")
except Exception as err: except Exception as err:
msg1 = _("WARNING: could not parse file:\n%(file)s\n" msg1 = _(
"because %(error)s -- recreating it\n") % { "WARNING: could not parse file:\n%(file)s\n"
'file' : filename, "because %(error)s -- recreating it\n"
'error' : str(err)} ) % {"file": filename, "error": str(err)}
logging.warn(msg1) logging.warning(msg1)
return 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 Saves the current section/settings to an .ini file. Optional filename
will override the default filename to save to, if given. will override the default filename to save to, if given.
@ -343,26 +373,30 @@ class ConfigManager:
try: try:
with open(filename, "w", encoding="utf-8") as key_file: with open(filename, "w", encoding="utf-8") as key_file:
key_file.write(";; Gramps key file\n") key_file.write(";; Gramps key file\n")
key_file.write(";; Automatically created at %s" % key_file.write(
time.strftime("%Y/%m/%d %H:%M:%S") + "\n\n") ";; 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): 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]): for key in sorted(self.data[section]):
value = self.data[section][key] value = self.data[section][key]
default = "" # might be a third-party setting default = "" # might be a third-party setting
if self.has_default("%s.%s" % (section, key)): if self.has_default(f"{section}.{key}"):
if value == self.get_default("%s.%s" if value == self.get_default(
% (section, key)): f"{section}.{key}"
):
default = ";;" default = ";;"
if isinstance(value, int): if isinstance(value, int):
value = int(value) # TODO why is this needed? value = int(value) # TODO why is this needed?
key_file.write("%s%s=%s\n" % (default, key, key_file.write(f"{default}{key}={repr(value)}\n")
repr(value)))
key_file.write("\n") key_file.write("\n")
# else, no filename given; nothing to save so do nothing quietly # else, no filename given; nothing to save so do nothing quietly
except IOError as err: except IOError as err:
logging.warn("Failed to open %s because %s", logging.warning(
filename, str(err)) "Failed to open %s because %s", filename, str(err)
)
return return
def get(self, key): def get(self, key):
@ -370,27 +404,37 @@ class ConfigManager:
Get the setting's value. raise an error if an invalid section.setting. Get the setting's value. raise an error if an invalid section.setting.
Key is a string in the "section.setting" format. Key is a string in the "section.setting" format.
""" """
if "." in key: try:
section, setting = key.split(".", 1) section, setting = key.split(".", 1)
else: except ValueError as error:
raise AttributeError("Invalid config section.setting name: '%s'" % raise AttributeError(
key) f"Invalid config section.setting name: '{key}'"
if section not in self.data: ) from error
raise AttributeError("No such config section name: '%s'" % section)
if setting not in self.data[section]: self._validate_section_and_setting(section, setting)
raise AttributeError("No such config setting name: '%s.%s'" %
(section, setting))
return self.data[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): def is_set(self, key):
""" """
Does the setting exist? Returns True if does, False otherwise. Does the setting exist? Returns True if does, False otherwise.
Key is a string in the "section.setting" format. Key is a string in the "section.setting" format.
""" """
if "." in key: try:
section, setting = key.split(".", 1) section, setting = key.split(".", 1)
else: except ValueError:
return False return False
if section not in self.data: if section not in self.data:
return False return False
if setting not in self.data[section]: 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, Does the setting have a default value? Returns True if it does,
False otherwise. Key is a string in the "section.setting" format. False otherwise. Key is a string in the "section.setting" format.
""" """
if "." in key: try:
section, setting = key.split(".", 1) section, setting = key.split(".", 1)
else: except ValueError:
return False return False
if section not in self.default: if section not in self.default:
return False return False
if setting not in self.default[section]: 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 Get the setting's default value. Raises an error if invalid key is
give. Key is a string in the "section.setting" format. give. Key is a string in the "section.setting" format.
""" """
if "." in key: try:
section, setting = key.split(".", 1) section, setting = key.split(".", 1)
else: except ValueError as error:
raise AttributeError("Invalid config section.setting name: '%s'" % raise AttributeError(
key) f"Invalid config section.setting name: '{key}'"
) from error
if section not in self.default: 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]: if setting not in self.default[section]:
raise AttributeError("No such config setting name: '%s.%s'" % raise AttributeError(
(section, setting)) f"No such config setting name: '{section}.{setting}'"
)
return self.default[section][setting] return self.default[section][setting]
def register(self, key, default): def register(self, key, default):
@ -435,11 +483,13 @@ class ConfigManager:
Will overwrite any previously set default, and set setting if not one. Will overwrite any previously set default, and set setting if not one.
The default value deterimines the type of the setting. The default value deterimines the type of the setting.
""" """
if "." in key: try:
section, setting = key.split(".", 1) section, setting = key.split(".", 1)
else: except ValueError as error:
raise AttributeError("Invalid config section.setting name: '%s'" % raise AttributeError(
key) f"Invalid config section.setting name: '{key}'"
) from error
if section not in self.data: if section not in self.data:
self.data[section] = {} self.data[section] = {}
if section not in self.default: if section not in self.default:
@ -458,16 +508,14 @@ class ConfigManager:
""" """
Connect a callback func that gets called when key is changed. Connect a callback func that gets called when key is changed.
""" """
if "." in key: try:
section, setting = key.split(".", 1) section, setting = key.split(".", 1)
else: except ValueError as error:
raise AttributeError("Invalid config section.setting name: '%s'" % raise AttributeError(
key) f"Invalid config section.setting name: '{key}'"
if section not in self.data: ) from error
raise AttributeError("No such config section name: '%s'" % section)
if setting not in self.data[section]: self._validate_section_and_setting(section, setting)
raise AttributeError("No such config setting name: '%s.%s'" %
(section, setting))
self._cb_id += 1 self._cb_id += 1
self.callbacks[section][setting].append((self._cb_id, func)) self.callbacks[section][setting].append((self._cb_id, func))
return self._cb_id return self._cb_id
@ -477,28 +525,31 @@ class ConfigManager:
Removes a callback given its callback ID. The ID is generated and Removes a callback given its callback ID. The ID is generated and
returned when the function is connected to the key (section.setting). returned when the function is connected to the key (section.setting).
""" """
for section in self.callbacks: for dummy_section, settings in self.callbacks.items():
for setting in self.callbacks[section]: for dummy_setting, callbacks in settings.items():
for (cbid, func) in self.callbacks[section][setting]: for cbid, func in callbacks:
if callback_id == cbid: if callback_id == cbid:
self.callbacks[section][setting].remove((cbid, func)) callbacks.remove((cbid, func))
def emit(self, key): def emit(self, key):
""" """
Emits the signal "key" which will call the callbacks associated Emits the signal "key" which will call the callbacks associated
with that setting. with that setting.
""" """
if "." in key: try:
section, setting = key.split(".", 1) section, setting = key.split(".", 1)
else: except ValueError as error:
raise AttributeError("Invalid config section.setting name: '%s'" % raise AttributeError(
key) f"Invalid config section.setting name: '{key}'"
) from error
if section not in self.callbacks: 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]: if setting not in self.callbacks[section]:
raise AttributeError("No such config setting name: '%s.%s'" % raise AttributeError(
(section, setting)) f"No such config setting name: '{section}.{setting}'"
for (cbid, func) in self.callbacks[section][setting]: )
for dummy_cbid, func in self.callbacks[section][setting]:
func(self, 0, str(self.data[section][setting]), None) func(self, 0, str(self.data[section][setting]), None)
def set(self, key, value): def set(self, key, value):
@ -507,33 +558,37 @@ class ConfigManager:
the data dictionary: via the :meth:`load` method that reads a file, the data dictionary: via the :meth:`load` method that reads a file,
or from this method. or from this method.
""" """
if "." in key: try:
section, setting = key.split(".", 1) section, setting = key.split(".", 1)
else: except ValueError as error:
raise AttributeError("Invalid config section.setting name: '%s'" % raise AttributeError(
key) "Invalid config section.setting name: '{key}'"
if section not in self.data: ) from error
raise AttributeError("No such config section name: '%s'" % section)
if setting not in self.data[section]: self._validate_section_and_setting(section, setting)
raise AttributeError("No such config setting name: '%s.%s'" %
(section, setting))
# Check value to see if right type: # Check value to see if right type:
if self.has_default(key): if self.has_default(key):
if not self.check_type(self.get_default(key), value): if not self.check_type(self.get_default(key), value):
raise AttributeError("attempting to set '%s' to wrong type " raise AttributeError(
"'%s'; should be '%s'" % f"attempting to set '{key}' to wrong type "
(key, type(value), f"'{type(value)}'; should be "
type(self.get_default(key)))) f"'{type(self.get_default(key))}'"
if (setting in self.data[section] and )
self.data[section][setting] == value): if (
setting in self.data[section]
and self.data[section][setting] == value
):
# Do nothing if existed and is the same # Do nothing if existed and is the same
pass pass
else: else:
# Set the value: # Set the value:
self.data[section][setting] = value self.data[section][setting] = value
# Only call callback if the value changed! # Only call callback if the value changed!
if (section in self.callbacks and if (
setting in self.callbacks[section]): section in self.callbacks
and setting in self.callbacks[section]
):
self.emit(key) self.emit(key)
def check_type(self, value1, value2): def check_type(self, value1, value2):
@ -544,11 +599,37 @@ class ConfigManager:
type2 = type(value2) type2 = type(value2)
if type1 == type2: if type1 == type2:
return True return True
elif (isinstance(value1, str) and if isinstance(value1, str) and isinstance(value2, str):
isinstance(value2, str)):
return True return True
elif (type1 in [int, float] and if type1 in [int, float] and type2 in [int, float]:
type2 in [int, float]):
return True 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")