UserSettings get support for .ini (config files) in addition to JSON files

This commit is contained in:
PySimpleGUI 2021-10-11 08:39:59 -04:00
parent 3f4f38a849
commit d14f110a80
1 changed files with 293 additions and 48 deletions

View File

@ -1,6 +1,6 @@
#!/usr/bin/python3
version = __version__ = "4.49.0.9 Unreleased"
version = __version__ = "4.49.0.10 Unreleased"
_change_log = """
@ -27,6 +27,20 @@ _change_log = """
Removed print when patch of 8.6.9 ttk treeview code is patched
4.49.0.9
Removed print when bind callback happens. Was there for debugging and forgot to remove.
4.49.0.10
Phase 1 Support of config files for UserSettings APIs - load, read/modify, save are done. The basic version is done.
This first phase supports them only via the object interface
Read access: settings[section][key]
Modify existing section and key: settings[section][key] = new_value
Create a new key in an existing section: settings[section][new_key] = new_value
Create a new section and key: settings[new_section][new_key] = new_value
Get a section as a dictionary-like object: settings[section]
This object can be modified and it will modify the settings file as a result.
Has a get and set method just like a normal setting.
Delete a section: settings.delete_section(section)
Save the INI file: settings.save()
Option to convert bools and None. Normally stored as strings. Will convert them to Python True, False, None automatically (on by default)
"""
__version__ = version.split()[0] # For PEP 396 and PEP 345
@ -178,6 +192,7 @@ import threading
import itertools
import os
import json
import configparser
import queue
try:
@ -19189,7 +19204,7 @@ class UserSettings:
# to access the user settings without diarectly using the UserSettings class
_default_for_function_interface = None # type: UserSettings
def __init__(self, filename=None, path=None, silent_on_error=False, autosave=True):
def __init__(self, filename=None, path=None, silent_on_error=False, autosave=True, use_config_file=False, retain_config_comments=True, convert_bools_and_none=True):
"""
User Settings
@ -19201,6 +19216,10 @@ class UserSettings:
:type silent_on_error: (bool)
:param autosave: If True the settings file is saved after every update
:type autosave: (bool)
:param use_config_file: If True then the file format will be a config.ini rather than json
:type use_config_file: (bool)
:param retain_config_comments: Not yet implemented
:type retain_config_comments: (bool)
"""
self.path = path
@ -19210,9 +19229,131 @@ class UserSettings:
self.default_value = None
self.silent_on_error = silent_on_error
self.autosave = autosave
self.use_config_file = use_config_file
self.retain_config_comments = retain_config_comments
self.convert_bools = convert_bools_and_none
if use_config_file:
self.config = configparser.ConfigParser()
self.config.optionxform = str
# self.config_dict = {}
self.section_class_dict = {} # type: dict[self._SectionDict]
if filename is not None or path is not None:
self.load(filename=filename, path=path)
########################################################################################################
## FIRST is the SectionDict helper class
########################################################################################################
class _SectionDict:
item_count = 0
def __init__(self, section_name, section_dict, config, user_settings_parent): # (str, Dict, configparser.ConfigParser)
self.section_name = section_name
self.section_dict = section_dict # type: Dict
self.new_section = False
self.config = config # type: configparser.ConfigParser
self.user_settings_parent = user_settings_parent # type: UserSettings
UserSettings._SectionDict.item_count += 1
if self.user_settings_parent.convert_bools:
for key, value in self.section_dict.items():
if value == 'True':
value = True
self.section_dict[key] = value
elif value == 'False':
value = False
self.section_dict[key] = value
elif value == 'None':
value = None
self.section_dict[key] = value
# print(f'++++++ making a new SectionDict with name = {section_name}')
def __repr__(self):
"""
Converts the settings dictionary into a string for easy display
:return: the dictionary as a string
:rtype: (str)
"""
# return '{} :\n {}'.format(self.section_name, pprint.pformat(self.section_dict))
return '{} :\n {}\n'.format(self.section_name, self.section_dict)
def get(self, key, default=None):
"""
Returns the value of a specified setting. If the setting is not found in the settings dictionary, then
the user specified default value will be returned. It no default is specified and nothing is found, then
the "default value" is returned. This default can be specified in this call, or previously defined
by calling set_default. If nothing specified now or previously, then None is returned as default.
:param key: Key used to lookup the setting in the settings dictionary
:type key: (Any)
:param default: Value to use should the key not be found in the dictionary
:type default: (Any)
:return: Value of specified settings
:rtype: (Any)
"""
value = self.section_dict.get(key, default)
if self.user_settings_parent.convert_bools:
if value == 'True':
value = True
elif value == 'False':
value = False
return value
def set(self, key, value):
value = str(value) # all values must be strings
if self.new_section:
self.config.add_section(self.section_name)
self.new_section = False
self.config.set(section=self.section_name, option=key, value=value)
self.section_dict[key] = value
if self.user_settings_parent.autosave:
self.user_settings_parent.save()
def delete_section(self):
# print(f'** Section Dict deleting section = {self.section_name}')
self.config.remove_section(section=self.section_name)
def __getitem__(self, item):
# print('*** In SectionDict Get ***')
return self.get(item)
def __setitem__(self, item, value):
"""
Enables setting a setting by using [ ] notation like a dictionary.
Your code will have this kind of design pattern:
settings = sg.UserSettings()
settings[item] = value
:param item: The key for the setting to change. Needs to be a hashable type. Basically anything but a list
:type item: Any
:param value: The value to set the setting to
:type value: Any
"""
# print(f'*** In SectionDict SET *** item = {item} value = {value}')
self.set(item, value)
self.section_dict[item] = value
def __delitem__(self, item):
"""
Delete an individual user setting. This is the same as calling delete_entry. The syntax
for deleting the item using this manner is:
del settings['entry']
:param item: The key for the setting to delete
:type item: Any
"""
# print(f'** In SectionDict delete! section name = {self.section_name} item = {item} ')
self.config.remove_option(section=self.section_name, option=item)
del self.section_dict[item]
if self.user_settings_parent.autosave:
self.user_settings_parent.save()
# self.section_dict.pop(item)
# print(f'id of section_dict = {id(self.section_dict)} id of self = {id(self)} item count = {self.item_count}')
# print(self.section_dict)
########################################################################################################
def __repr__(self):
"""
Converts the settings dictionary into a string for easy display
@ -19220,8 +19361,18 @@ class UserSettings:
:return: the dictionary as a string
:rtype: (str)
"""
if not self.use_config_file:
return pprint.pformat(self.dict)
return str(self.dict) # previouisly returned just a string version of the dictionary
else:
rvalue = '-------------------- Settings ----------------------\n'
for section in self.section_class_dict.keys():
rvalue += str(self.section_class_dict[section])
rvalue += '\n-------------------- Settings End----------------------\n'
return rvalue
# return str(self.dict) # previouisly returned just a string version of the dictionary
def set_default_value(self, default):
"""
@ -19251,7 +19402,10 @@ class UserSettings:
elif self.filename is not None:
filename = self.filename
else:
if not self.use_config_file:
filename = os.path.splitext(os.path.basename(sys.modules["__main__"].__file__))[0] + '.json'
else:
filename = os.path.splitext(os.path.basename(sys.modules["__main__"].__file__))[0] + '.ini'
if path is None:
if self.path is not None:
@ -19309,11 +19463,40 @@ class UserSettings:
:return: The full pathname of the settings file that has both the path and filename combined.
:rtype: (str)
"""
if filename is not None or path is not None or (filename is None and path is None):
if filename is not None or path is not None or (filename is None and path is None and self.full_filename is None):
self.set_location(filename=filename, path=path)
self.read()
return self.full_filename
def merge_comments_from_file(self, full_filename):
print('--- merging comments -----')
merged_lines = []
with open(full_filename, 'r') as f:
new_file_contents = f.readlines()
current_section = ''
for line in new_file_contents:
if len(line) == 0: # skip blank lines
merged_lines.append(line)
continue
if line[0] == '[': # if a new section
current_section = line[:line.index(']')]
merged_lines.append(line)
continue
if len(line.lstrip()):
if line.lstrip()[0] == '#': # if a comment line, save it
merged_lines.append(line)
# Process a line with an = in it
try:
key = line[:line.index('=')]
merged_lines.append(line)
except:
merged_lines.append(line)
print('--- merging complete ----')
print(*merged_lines)
def save(self, filename=None, path=None):
"""
Saves the current settings dictionary. If a filename or path is specified in the call, then it will override any
@ -19332,13 +19515,21 @@ class UserSettings:
if not os.path.exists(self.path):
os.makedirs(self.path)
with open(self.full_filename, 'w') as f:
if not self.use_config_file:
json.dump(self.dict, f)
else:
self.config.write(f)
except Exception as e:
if not self.silent_on_error:
print('*** Error saving settings to file:***\n', self.full_filename, e)
print(_create_error_message())
_error_popup_with_traceback('UserSettings.save error', '*** UserSettings.save() Error saving settings to file:***\n', self.full_filename, e)
# if self.use_config_file and self.retain_config_comments:
# self.merge_comments_from_file(self.full_filename)
return self.full_filename
def load(self, filename=None, path=None):
"""
Specifies the path and filename to use for the settings and reads the contents of the file.
@ -19376,8 +19567,8 @@ class UserSettings:
os.remove(self.full_filename)
except Exception as e:
if not self.silent_on_error:
print('*** User settings delete filename warning ***\n', e)
print(_create_error_message())
_error_popup_with_traceback('UserSettings delete_file warning ***', 'Exception trying to perform os.remove', e)
# print(_create_error_message())
self.dict = {}
def write_new_dictionary(self, settings_dict):
@ -19392,10 +19583,24 @@ class UserSettings:
self.dict = settings_dict
self.save()
# def as_dict(config):
# """
# Converts a ConfigParser object into a dictionary.
#
# The resulting dictionary has sections as keys which point to a dict of the
# sections options as key => value pairs.
# """
# the_dict = {}
# for section in config.sections():
# the_dict[section] = {}
# for key, val in config.items(section):
# the_dict[section][key] = val
# return the_dict
def read(self):
"""
Reads settings file and returns the dictionary.
If you have anything changed in an existing settings dictionary, you will lose your changes.
:return: settings dictionary
:rtype: (dict)
"""
@ -19404,11 +19609,26 @@ class UserSettings:
try:
if os.path.exists(self.full_filename):
with open(self.full_filename, 'r') as f:
if not self.use_config_file: # if using json
self.dict = json.load(f)
else: # if using a config file
self.config.read_file(f)
# Make a dictionary of SectionDict classses. Keys are the config.sections().
self.section_class_dict = {}
for section in self.config.sections():
section_dict = dict(self.config[section])
self.section_class_dict[section] = self._SectionDict(section, section_dict, self.config, self)
self.dict = self.section_class_dict
self.config_sections = self.config.sections()
# self.config_dict = {section_name : dict(self.config[section_name]) for section_name in self.config.sections()}
if self.retain_config_comments:
self.config_file_contents = f.readlines()
except Exception as e:
if not self.silent_on_error:
print('*** Error reading settings from file: ***\n', self.full_filename, e)
print(_create_error_message())
_error_popup_with_traceback('User settings read warning', 'Error reading settings from file', self.full_filename, e)
# print('*** UserSettings.read - Error reading settings from file: ***\n', self.full_filename, e)
# print(_create_error_message())
return self.dict
@ -19426,7 +19646,7 @@ class UserSettings:
return True
return False
def delete_entry(self, key):
def delete_entry(self, key, section=None):
"""
Deletes an individual entry. If no filename has been specified up to this point,
then a default filename will be used.
@ -19438,6 +19658,7 @@ class UserSettings:
if self.full_filename is None:
self.set_location()
self.read()
if not self.use_config_file: # Is using JSON file
if key in self.dict:
del self.dict[key]
if self.autosave:
@ -19446,19 +19667,36 @@ class UserSettings:
if not self.silent_on_error:
print('*** Warning - key ', key, ' not found in settings ***\n')
print(_create_error_message())
else:
if section is not None:
section_dict = self.get(section)
# print(f'** Trying to delete an entry with a config file in use ** id of section_dict = {id(section_dict)}')
# section_dict = self.section_class_dict[section]
del self.get(section)[key]
# del section_dict[key]
# del section_dict[key]
def delete_section(self, section):
if not self.use_config_file:
return
section_dict = self.section_class_dict.get(section, None)
section_dict.delete_section()
del self.section_class_dict[section]
if self.autosave:
self.save()
def set(self, key, value):
"""
Sets an individual setting to the specified value. If no filename has been specified up to this point,
then a default filename will be used.
After value has been modified, the settings file is written to disk.
Note that this call is not value for a config file normally. If it is, then the key is assumed to be the
Section key and the value written will be the default value.
:param key: Setting to be saved. Can be any valid dictionary key type
:type key: (Any)
:param value: Value to save as the setting's value. Can be anything
:type value: (Any)
:param autosave: If True then the value will be saved to the file
:type autosave: (bool)
:return: value that key was set to
:rtype: (Any)
"""
@ -19466,9 +19704,13 @@ class UserSettings:
if self.full_filename is None:
self.set_location()
# if not autosaving, then don't read the file or else will lose changes
if not self.use_config_file:
if self.autosave or self.dict == {}:
self.read()
self.dict[key] = value
else:
self.section_class_dict[key].set(value, self.default_value)
if self.autosave:
self.save()
return value
@ -19494,13 +19736,14 @@ class UserSettings:
self.set_location()
if self.autosave or self.dict == {}:
self.read()
if not self.use_config_file:
value = self.dict.get(key, default)
# Previously was saving creating an entry and saving the dictionary if the
# key was not found. I don't understand why it was originally coded this way.
# Hopefully nothing is going to break removing this code.
# if key not in self.dict:
# self.set(key, value)
# self.save()
else:
value = self.section_class_dict.get(key, None)
if key not in list(self.section_class_dict.keys()):
self.section_class_dict[key] = self._SectionDict(key, {}, self.config, self)
value = self.section_class_dict[key]
value.new_section = True
return value
def get_dict(self):
@ -19532,8 +19775,7 @@ class UserSettings:
:param value: The value to set the setting to
:type value: Any
"""
self.set(item, value)
return self.set(item, value)
def __getitem__(self, item):
"""
@ -19556,6 +19798,9 @@ class UserSettings:
:param item: The key for the setting to delete
:type item: Any
"""
if self.use_config_file:
return self.get(item)
else:
self.delete_entry(key=item)