Skip to content

Commit

Permalink
fix: If default values were removed from ini file during execution, w…
Browse files Browse the repository at this point in the history
…rite default values again.

test: Added tests and improved coverage
docs: Docs theme tweaks
  • Loading branch information
umanamente committed Jan 16, 2024
1 parent dcd5d93 commit 05d5de2
Show file tree
Hide file tree
Showing 13 changed files with 745 additions and 80 deletions.
4 changes: 1 addition & 3 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,11 @@
:target: https://coveralls.io/r/umanamente/ConfigModel
.. image:: https://img.shields.io/pypi/v/ConfigModel.svg
:alt: PyPI-Server
.. image:: https://img.shields.io/pypi/v/ConfigModel?color=%234dd47d
:target: https://pypi.org/project/ConfigModel/

.. image:: https://readthedocs.org/projects/py-configmodel/badge/?version=latest
:target: https://py-configmodel.readthedocs.io/en/latest/?badge=latest
:alt: Documentation Status

.. image:: https://coveralls.io/repos/github/umanamente/py-configmodel/badge.svg?branch=coverage_test
:target: https://coveralls.io/github/umanamente/py-configmodel?branch=coverage_test
Expand Down
4 changes: 2 additions & 2 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,8 +154,8 @@

# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
# html_theme = "alabaster"
html_theme = "classic"
html_theme = "alabaster"
# html_theme = "classic"

# Theme options are theme-specific and customize the look and feel of a theme
# further. For a list of options available for each theme, see the
Expand Down
10 changes: 1 addition & 9 deletions src/configmodel/ConfigModel.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,15 +226,7 @@ def _get_all_fields_recursive(self) -> List[FieldInstance]:
return []
all_fields = []
for field_name, field in self._fields.items():
if isinstance(field.definition, type) and issubclass(field.definition, ConfigModel):
# todo: is this code reachable (there should be no nested class definitions)
nested_instance = field.definition._get_instance()
if nested_instance is None:
continue
nested_fields = nested_instance._get_all_fields_recursive()
if nested_fields is not None:
all_fields += nested_fields
elif isinstance(field.definition, ConfigModel):
if isinstance(field.definition, ConfigModel):
nested_fields = field.definition._get_all_fields_recursive()
if nested_fields is not None:
all_fields += nested_fields
Expand Down
4 changes: 0 additions & 4 deletions src/configmodel/Fields.py

This file was deleted.

16 changes: 8 additions & 8 deletions src/configmodel/MixinCachedValues.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,9 @@ def _set_not_dirty(self):
Set all cached values to not dirty
"""
self._is_dirty = False
if self._cached_values is None:
return
for cached_value in self._cached_values.values():
cached_value.is_dirty = False
if self._cached_values:
for cached_value in self._cached_values.values():
cached_value.is_dirty = False

def get_cached_value(self, path):
"""
Expand All @@ -47,19 +46,20 @@ def get_cached_value(self, path):
return None
return cached_value.value

def set_cached_value(self, path, value):
def set_cached_value(self, path, value, is_dirty=True):
"""
Set cached value
"""
self._is_dirty = True
if is_dirty:
self._is_dirty = True
if self._cached_values is None:
self._cached_values = {}
full_name = self._path_to_str(path)
if full_name not in self._cached_values:
self._cached_values[full_name] = self.CachedValue(path, value, True)
self._cached_values[full_name] = self.CachedValue(path, value, is_dirty)
else:
self._cached_values[full_name].value = value
self._cached_values[full_name].is_dirty = True
self._cached_values[full_name].is_dirty = is_dirty

def assign_cached_values(self, cached_values):
"""
Expand Down
106 changes: 106 additions & 0 deletions src/configmodel/MixinDelayedWrite.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# -*- coding: utf-8 -*-
import atexit
import threading
import time


class InterruptibleTimer:
"""
Performs a callback after a specified timeout.
The timer can be restarted by calling prolong() method.
"""
def __init__(self, timeout_seconds, callback):
self.callback = callback
self.thread = threading.Thread(target=self._target)
self.event = threading.Event()
self.lock = threading.Lock()
self.end_time = time.time() + timeout_seconds

# register atexit handler to make sure that changes are committed at exit
atexit.register(self._on_exit)

# start timer
self.thread.start()

def _target(self):
while True:
timeout = self.end_time - time.time()
timer_expired = self.event.wait(timeout)
# check if end_time reached in case of timer restart
if self.end_time <= time.time():
self._fire_callback()
break
# check if callback was already fired
with self.lock:
if self.callback is None:
break

def _fire_callback(self):
with self.lock:
if self.callback is not None:
self.callback()
self.callback = None

def restart(self, timeout_seconds):
# Reset the event and add extra time to the timeout
self.end_time = time.time() + timeout_seconds
self.event.clear()

def cancel(self):
with self.lock:
self.callback = None
self.event.set()

def _on_exit(self):
"""
Fire immediately
"""
self.end_time = 0
self.event.set()
self.thread.join()


class MixinDelayedWrite:
"""
Mixin for delayed write
"""
DEFAULT_DELAY_SECONDS = 1.0

def __init__(self, delayed_write_enabled=False, delay_seconds=DEFAULT_DELAY_SECONDS):
self._delayed_write_enabled = delayed_write_enabled
self._delay_seconds = delay_seconds
self._timer = None

def _set_delayed_write(self, delayed_write_enabled, delay_seconds=DEFAULT_DELAY_SECONDS):
"""
Set delayed write
"""
self._delayed_write_enabled = delayed_write_enabled
self._delay_seconds = delay_seconds

def _restart_delayed_timer(self):
"""
Restart delayed timer
"""
if not self._delayed_write_enabled or self._delay_seconds <= 0:
# fire immediately
self._commit_delayed_write()
else:
# restart timer
if self._timer is None:
self._timer = InterruptibleTimer(self._delay_seconds, self._on_timer_expired)
else:
self._timer.restart(self._delay_seconds)

def _on_timer_expired(self):
"""
On timer expired
"""
self._commit_delayed_write()

def _commit_delayed_write(self):
"""
Commit delayed write.
This method should be implemented in derived classes.
"""
raise NotImplementedError()
89 changes: 35 additions & 54 deletions src/configmodel/SerializerIni.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@

from configmodel.Logger import Log
from configmodel.MixinCachedValues import MixinCachedValues
from configmodel.MixinDelayedWrite import MixinDelayedWrite
from configmodel.SerializerBase import SerializerBase


class SerializerIni(SerializerBase, MixinCachedValues):
class SerializerIni(SerializerBase, MixinCachedValues, MixinDelayedWrite):
DEFAULT_SECTION = "Global"

class ParameterLocation:
DEFAULT_SECTION = "Global"

def __init__(self):
self.section = None
Expand All @@ -24,90 +25,67 @@ def __repr__(self):
return self.full_name

def __init__(self, filename):
super().__init__(filename)
super(MixinCachedValues, self).__init__()
SerializerBase.__init__(self, filename)
MixinCachedValues.__init__(self)
MixinDelayedWrite.__init__(self, delayed_write_enabled=False)

@staticmethod
def _get_parameter_location(path):
"""
Get section and parameter name from path
"""
location = SerializerIni.ParameterLocation()
if len(path) == 0:
return location
if len(path) == 1:
# place parameter in default section
location.section = SerializerIni.ParameterLocation.DEFAULT_SECTION
location.section = SerializerIni.DEFAULT_SECTION
location.parameter = path[0]
else:
assert len(path) > 1
# place parameter in section
location.section = path[0]
location.parameter = ".".join(path[1:])
return location

def _write_all_values_to_ini(self):
def _commit_delayed_write(self):
"""
Write cached values to INI file
"""
Log.debug(f"Writing cached values to INI file: {self.filename}")
ini = configparser.ConfigParser()
ini.read(self.filename)
for full_name, cached_value in self._cached_values.items():
if not cached_value.is_dirty:
# write only dirty values
continue
location = self._get_parameter_location(cached_value.path)
if not ini.has_section(location.section):
ini.add_section(location.section)
ini.set(location.section, location.parameter, str(cached_value.value))
do_write = False
# write value if it is dirty
if cached_value.is_dirty:
do_write = True
# also write value if it is not in INI file
if not ini.has_option(location.section, location.parameter):
do_write = True
# write value
if do_write:
ini.set(location.section, location.parameter, str(cached_value.value))
with open(self.filename, "w") as config_file:
ini.write(config_file)
self._set_not_dirty()

def _read_all_values_from_ini(self):
"""
Read all values from INI file to cache
"""
Log.debug(f"Reading all values from INI file: {self.filename}")
ini = configparser.ConfigParser()
ini.read(self.filename)
cached_values = {}
for section in ini.sections():
section_path = []
if section != SerializerIni.ParameterLocation.DEFAULT_SECTION:
section_path = [section]
for parameter in ini[section]:
parameter_path = section_path + [parameter.split(".")]
value = ini[section][parameter]
full_name = self._path_to_str(parameter_path)
cached_values[full_name] = self.CachedValue(parameter_path, value, False)
self.assign_cached_values(cached_values)

def set_value(self, path, value):
location = self._get_parameter_location(path)
Log.debug(f"Setting value of field '{path}' to '{value}', location: {location}")
ini = configparser.ConfigParser()
ini.read(self.filename)
ini.set(location.section, location.parameter, value)
with open(self.filename, "w") as config_file:
ini.write(config_file)
if not path:
raise ValueError("Parameter path is empty. This is likely a bug in ConfigModel. Please report it.")
# set cached value
self.set_cached_value(path, value, is_dirty=True)
# initiate delayed write
self._restart_delayed_timer()

def get_value(self, path):
location = self._get_parameter_location(path)
Log.debug(f"Getting value of field '{path}', location: {location}")
ini = configparser.ConfigParser()
ini.read(self.filename)
try:
return ini.get(location.section, location.parameter)
except configparser.NoSectionError:
Log.debug(f"Section '{location.section}' does not exist, returning None")
return None
except configparser.NoOptionError:
Log.debug(f"Option '{location.parameter}' does not exist, returning None")
return None
except Exception as e:
Log.error(f"Unknown error: {e}")
raise e
if not path:
raise ValueError("Parameter path is empty. This is likely a bug in ConfigModel. Please report it.")
Log.debug(f"Getting value of field '{path}'")
# get cached value
cached_value = self.get_cached_value(path)
return cached_value

def write_default_values_from_model(self, default_values):
"""
Expand All @@ -123,7 +101,7 @@ def write_default_values_from_model(self, default_values):
cached_values = {}
for section in ini.sections():
section_path = []
if section != SerializerIni.ParameterLocation.DEFAULT_SECTION:
if section != SerializerIni.DEFAULT_SECTION:
section_path = [section]
for parameter in ini[section]:
parameter_path = section_path + parameter.split(".")
Expand All @@ -143,6 +121,9 @@ def write_default_values_from_model(self, default_values):
full_name = self._path_to_str(field.path)
if full_name not in cached_values:
cached_values[full_name] = self.CachedValue(field.path, field.value, False)
# assign cached values
self.assign_cached_values(cached_values)
# write INI file
with open(self.filename, "w") as config_file:
ini.write(config_file)

Expand Down

0 comments on commit 05d5de2

Please sign in to comment.