slight rework cfg module, moving file validation and logic into setters. slight improvement on logging and tests

This commit is contained in:
Mathew Guest 2020-07-18 23:02:44 -06:00
parent 64ee90066d
commit 4e083d6a3d
14 changed files with 352 additions and 173 deletions

@ -23,14 +23,19 @@ _log_fmt = '%(levelname)-7s:%(message)s'
_logger_name = 'app_skellington'
_bootstrap_logger = logging.getLogger(_logger_name)
# Logging is manually switched on via environment variable:
if os.environ.get('APP_SKELLINGTON_DEBUG', None):
# NOTE(MG) This is done twice: once when app_skellington
# module is imported via _bootstrap.py and again if logging
# configuration is reloaded. This catches APPSKELLINGTON_DEBUG
# environment variable the first time, as app_skellington module
# is imported. See cfg.py
if os.environ.get('APPSKELLINGTON_DEBUG', None):
_bootstrap_logger.setLevel(logging.DEBUG) # Don't filter any log levels
fmt = logging.Formatter(_log_fmt)
handler = logging.StreamHandler()
handler.setFormatter(fmt)
_bootstrap_logger.addHandler(handler)
_bootstrap_logger.debug('debug log enabled: APP_SKELLINGTON_DEBUG set in environment variables')
_bootstrap_logger.debug('log - APPSKELLINGTON_DEBUG set in environment: Enabling verbose logging.')
# Logging is by default off, excepting CRITICAL
else:

@ -48,20 +48,19 @@ class ApplicationContainer:
def __init__(
self,
configspec_filepath=None,
config_filepath=None,
configini_filepath=None,
*args, **kwargs
):
# Instantiate root application context (container for globals)
if configspec_filepath is None:
configspec_filepath = self._get_configspec_filepath()
# if configspec_filepath is None:
# configspec_filepath = self._get_configspec_filepath()
self.appname = kwargs.get('appname') or DEFAULT_APP_NAME
self.appauthor = kwargs.get('appauthor') or DEFAULT_APP_AUTHOR
self._dependencies = {}
config = cfg.Config(configspec_filepath)
config.load_config_from_file(config_filepath)
config = cfg.Config(configspec_filepath, configini_filepath)
logger = log.LoggingLayer(self.appname, self.appauthor)

@ -1,3 +1,9 @@
# The cfg module provides Config, the app_skellington service for interfacing
# config.ini files, environment variables (*todo), and
# application-wide configuration. The underlying mechanism is built on top of
# ConfigObj module and it's recommended to use config.spec files to define
# your available configuration of the relevant application.
from . import _util
from ._bootstrap import _bootstrap_logger
@ -18,15 +24,99 @@ class Config:
'allow_options_beyond_spec': True,
}
def __init__(self, configspec_filepath=None, capabilities=None):
self.config_obj = None # Reference to configobj.ConfigObj()
self._config_filepaths = []
def __init__(
self,
configspec_filepath=None,
configini_filepath=None,
capabilities=None
):
self._config_obj = None # must be type configobj.ConfigObj()
self._configini_data = None
self._configini_filepath = None
self._configspec_data = None
self._configspec_filepath = None
# self.configspec_filepath = configspec_filepath
self._has_accessed_internal_configobj = True
self._has_changed_internally = True
self._capability_enforce_strict_spec_validation = False
# Register the files and parse provided config.spec and config.ini
self.configspec_filepath = configspec_filepath
self.configini_filepath = configini_filepath
# NOTE(MG) Setters above trigger load_config()
@property
def config_obj(self):
self._has_accessed_internal_configobj = True
return self._config_obj
# config_filepath:
###########################################################################
@property
def configini_filepath(self):
"""
Config.ini filepath of site-specified configuration settings. File
stored on user machine. Reloads config when set.
"""
return self._configini_filepath
@configini_filepath.setter
def configini_filepath(self, value):
self._configini_filepath = value
self._has_changed_internally = True
self.load_config()
# configspec_filepath:
###########################################################################
@property
def configspec_filepath(self):
"""
Config.spec filepath of site-specified configuration settings. File
stored in module directory. Reloads config when set.
"""
return self._configspec_filepath
@configspec_filepath.setter
def configspec_filepath(self, filepath):
# Check if exists as file (at least seems to):
# if not _util.does_file_exist(filepath):
# _bootstrap_logger.error(
# 'failed to set config.spec: file not found '
# '(%s)', filepath)
# raise Exception
if filepath is None:
_bootstrap_logger.debug(
'cfg - clearing configspec'
)
self._configspec_filepath = None
self._configspec_data = None
self._has_changed_internally = True
self.load_config()
return
try:
with open(filepath) as fp:
data = fp.read()
self._configspec_filepath = filepath
self._configspec_data = data
self._has_changed_internally = True
_bootstrap_logger.debug(
'cfg - set configspec and read contents: %s',
filepath
)
self.load_config()
return
except OSError as ex:
_bootstrap_logger.critical(
'cfg - failed to find config.spec: file not found (%s)',
filepath
)
raise OSError('failed to read provided config.spec file')
self.load_config()
def __contains__(self, key):
try:
has_item = key in self.config_obj
has_item = key in self._config_obj
return has_item
except KeyError as ex:
pass
@ -46,9 +136,13 @@ class Config:
Returns the value of the configuration item identified by <key>.
"""
try:
return self.config_obj[key].dict()
v = self._config_obj[key]
if isinstance(v, str):
return v
else:
# return self._config_obj[key].dict()
return self._config_obj[key]
except KeyError as ex:
# raise ConfigurationItemNotFoundError()
raise
def __setitem__(self, key, value):
@ -56,135 +150,103 @@ class Config:
Assigns the value of the configuration item
identified by <key> as <value>.
"""
self[key] = value
self._config_obj[key] = value
@property
def config_filepath(self, idx=0):
"""
Returns the config filepath (optionally specified by index
when using multiple config files).
"""
assert idx>=0, 'invalid idx argument: index must be greater than 0'
if len(self._config_filepaths) > 0:
try:
return self._config_filepaths[idx]
except ValueError as ex:
return
def load_config(
self, configspec_filepath=None, configini_filepath=None
):
# Set new arguments if were passed in:
if configspec_filepath is not None:
self.configspec_filepath = configspec_filepath
if configini_filepath is not None:
self.configini_filepath = configini_filepath
@config_filepath.setter
def config_filepath(self, value, idx=0):
"""
Assigns <value> as the config filepath (optionally specified by index
when using multiple config files).
"""
assert idx>=0, 'invalid idx argument: index must be greater than 0'
self._config_filepaths[idx] = value
@property
def configspec_filepath(self):
return self._configspec_filepath
@configspec_filepath.setter
def configspec_filepath(self, filepath):
if _util.does_file_exist(filepath):
self._configspec_filepath = filepath
else:
_bootstrap_logger.error(
'failed to set config.spec: file not found '
'(%s)', filepath)
def load_config_from_file(self, config_filepath):
"""
Loads configuration settings from file, overwritting all configuration.
"""
# Record all config.ini files passed in
if config_filepath not in self._config_filepaths:
self._config_filepaths.append(config_filepath)
# Check for config.spec
# Load and validate configuration if possible:
self._load_config_files()
if self.configspec_filepath:
_bootstrap_logger.info('using config.spec: %s', self.configspec_filepath)
else:
_bootstrap_logger.info('config.spec not defined')
_bootstrap_logger.info('using config file: %s', config_filepath)
rc = self._validate_config_against_spec()
if not rc:
if self._capability_enforce_strict_spec_validation:
raise RuntimeError('Failed to validate config.ini against spec.')
return False
return True
# Pre-check for config.ini existence
if _util.does_file_exist(config_filepath):
_bootstrap_logger.info('existing config file found')
else:
_bootstrap_logger.info('no config file found: using defaults')
def _load_config_files(self):
config_spec = self.configspec_filepath
config_ini = self.configini_filepath
# interpolation='template' changes config file variable replacement to
# use the form $var instead of %(var)s, which is useful to enable
# literal %(text)s values in the config.
try:
configspec_filepath = self.configspec_filepath
if configspec_filepath:
self.config_obj = configobj.ConfigObj(
config_filepath,
configspec=configspec_filepath,
interpolation='template'
)
else:
self.config_obj = configobj.ConfigObj(
config_filepath,
# configspec=configspec_filepath,
interpolation='template'
)
self._config_obj = configobj.ConfigObj(
infile=config_ini,
# options
configspec=config_spec,
# encoding
interpolation='template'
# raise_errors
)
_bootstrap_logger.debug(
'cfg - Parsed configuration. config.spec = %s, config.ini = %s',
config_spec, config_ini
)
return True
except configobj.ParseError as ex:
msg = 'failed to load config: error in config.spec configuration: {}'.format(config_filepath)
msg = 'cfg - Failed to load config: error in config.spec configuration: {}'.format(config_filepath)
_bootstrap_logger.error(msg)
_util.eprint(msg)
return False
except OSError as ex:
msg = 'failed to load config: config.spec file not found'
msg = 'cfg - Failed to load config: config.spec file not found.'
_bootstrap_logger.error(msg)
_util.eprint(msg)
return False
except Exception as ex:
print(ex)
def _validate_config_against_spec(self):
config_spec = self.configspec_filepath
config_ini = self.configini_filepath
# Hack the configobj module to alter the interpolation for validate.py:
configobj.DEFAULT_INTERPOLATION = 'template'
self.config_obj.filename = config_filepath
if self.configspec_filepath:
# Validate config.ini against config.spec
try:
_bootstrap_logger.info('validating config file against spec')
val = validate.Validator()
test_results = self.config_obj.validate(
val, copy=True, preserve_errors=True
# Validate config.ini against config.spec
try:
_bootstrap_logger.info('cfg - Validating config file against spec')
val = validate.Validator()
assert isinstance(self._config_obj, configobj.ConfigObj), 'expecting configobj.ConfigObj, received %s' % type(self._config_obj)
# NOTE(MG) copy below instructs configobj to use defaults from spec file
test_results = self._config_obj.validate(
val, copy=True, preserve_errors=True
)
if test_results is True:
_bootstrap_logger.info(
'cfg- Successfully validated configuration against spec. input = %s, validation spec = %s',
config_ini, config_spec
)
if test_results is True:
_bootstrap_logger.info(
'application configuration file passed validation. input = %s, validation spec = %s',
config_filepath, configspec_filepath
)
return True
else:
_bootstrap_logger.critical('config file failed validation')
elif test_results is False:
_bootstrap_logger.debug(
'cfg - Potentially discovered invalid config.spec'
)
for (section_list, key, rslt) in configobj.flatten_errors(self.config_obj, test_results):
_bootstrap_logger.critical('config error info: %s %s %s', section_list, key, rslt)
if key is not None:
_bootstrap_logger.critical('config failed validation: [%s].%s appears invalid. msg = %s', '.'.join(section_list), key, rslt)
else:
_bootstrap_logger.critical("config failed validation: missing section, name = '%s'. msg = %s", '.'.join(section_list), rslt)
return False
except ValueError as ex:
_bootstrap_logger.error('failed validating configspec')
else:
self._validate_parse_errors(test_results)
return False
except ValueError as ex:
_bootstrap_logger.error('cfg - Failed while validating config against spec. ')
return False
# Create the config file if it doesn't exist
# if not _util.does_file_exist(config_filepath):
if True:
_bootstrap_logger.info('writing new config file: %s', config_filepath)
dirname = os.path.dirname(config_filepath)
_util.ensure_dir_exists(dirname)
self.config_obj.write()
_bootstrap_logger.info('done loading config file')
return True
def _validate_parse_errors(self, test_results):
_bootstrap_logger.critical('cfg - Config file failed validation.')
for (section_list, key, rslt) in configobj.flatten_errors(self._config_obj, test_results):
_bootstrap_logger.critical('cfg - Config error info: %s %s %s', section_list, key, rslt)
if key is not None:
_bootstrap_logger.critical('cfg - Config failed validation: [%s].%s appears invalid. msg = %s', '.'.join(section_list), key, rslt)
else:
_bootstrap_logger.critical("cfg - Config failed validation: missing section, name = '%s'. msg = %s", '.'.join(section_list), rslt)
def print_config(self):
"""
@ -192,16 +254,13 @@ class Config:
"""
print('config:')
self.config_obj.walk(print)
for section in self.config_obj.sections:
self._config_obj.walk(print)
for section in self._config_obj.sections:
print(section)
for key in self.config_obj[section]:
print(' ', self.config_obj[section][key])
for key in self._config_obj[section]:
print(' ', self._config_obj[section][key])
class EnvironmentVariables:
def __init__(self):
raise NotImplementedError
class ConfigurationItemNotFoundError(Exception):
pass

@ -153,7 +153,8 @@ class CommandTree:
key,
help=helptext,
nargs='?',
default=param.default)
default=param.default
)
else:
helptext = 'required'
self.root_parser.add_argument(
@ -219,20 +220,20 @@ class CommandTree:
def run_command(self, args=None):
args, unk, success = self.parse(args)
if not success:
_bootstrap_logger.info('SystemExit: Perhaps user invoked --help')
_bootstrap_logger.info('cli - SystemExit: Perhaps user invoked --help')
return
if args is False and unk is False:
_bootstrap_logger.error('failed parsing args')
return False
_bootstrap_logger.info('received args from shell: %s', args)
_bootstrap_logger.info('cli - received args from shell: %s', args)
args = vars(args)
cmd = self._lookup_command(args)
if cmd is None:
print('cmd is None')
_bootstrap_logger.error('failed to find command')
_bootstrap_logger.error('cli - failed to find command')
return False
return self._invoke_command(cmd, args)
@ -265,10 +266,10 @@ class CommandTree:
input('<broken>')
val = args.get(argparse_param)
_bootstrap_logger.debug('argparse command is \'{}\' = {}'.format(argparse_param, val))
_bootstrap_logger.debug('cli - argparse command is \'{}\' = {}'.format(argparse_param, val))
lookup = submenu.entries.get(val)
_bootstrap_logger.debug('lookup, entries[{}] = {}'.format(val, lookup))
_bootstrap_logger.debug('cli - lookup, entries[{}] = {}'.format(val, lookup))
# print(submenu.entries)
# pop value
@ -295,8 +296,8 @@ class CommandTree:
if param.name in args:
func_args.append(args[param.name])
_bootstrap_logger.info('function: %s', func)
_bootstrap_logger.info('function args: %s', func_args)
_bootstrap_logger.info('cli - function: %s', func)
_bootstrap_logger.info('cli - function args: %s', func_args)
return command_to_be_invoked(*func_args)
def _get_subparser(self):
@ -394,12 +395,14 @@ class SubMenu:
key,
help=helptext,
nargs='?',
default=param.default)
default=param.default
)
else:
helptext = 'required'
child_node.add_argument(
key,
help=helptext)
help=helptext
)
# # Wrapper function that instantiates an object and runs a method
# # on-demand. The object is created, injected with necessary
@ -419,7 +422,7 @@ class SubMenu:
registered_name = '{}.{}'.format(
self.submenu_path,
cmd_name)
_bootstrap_logger.info('registered command: %s', registered_name)
_bootstrap_logger.info('cli - registered command: %s', registered_name)
self.entries[cmd_name] = cmd
def create_submenu(
@ -471,7 +474,7 @@ class SubMenu:
submenu.submenu_path = '{}.{}'.format(self.submenu_path, cmd_entry_name)
submenu_name = submenu.submenu_path
_bootstrap_logger.info('registered submenu: %s', submenu_name)
_bootstrap_logger.info('cli - registered submenu: %s', submenu_name)
self.entries[cmd_entry_name] = submenu
return submenu

@ -70,16 +70,16 @@ class LoggingLayer:
noise for typical operation.
"""
if config_dict is None:
_bootstrap_logger.debug('No application logging configuration provided. Using default')
_bootstrap_logger.debug('log - No application logging configuration provided. Using default')
config_dict = DEFAULT_LOG_SETTINGS
self.transform_config(config_dict)
try:
# TODO(MG) switch to pretty-print, as it'd be more human readable
_bootstrap_logger.debug('Log configuration: %s', config_dict)
_bootstrap_logger.debug('log - Log configuration: %s', config_dict)
logging.config.dictConfig(config_dict)
_bootstrap_logger.debug('Configured all logging')
_bootstrap_logger.debug('log - Configured all logging')
except Exception as ex:
print('unable to configure logging:', ex, type(ex))
@ -127,7 +127,13 @@ class LoggingLayer:
config_dict['handlers']['file']['filename'] = log_filepath
def _add_own_logconfig(self, config_dict):
if os.environ.get('APP_SKELLINGTON_DEBUG', None):
# NOTE(MG) This is done twice: once when app_skellington
# module is imported again if logging configuration is
# reloaded. This catches APPSKELLINGTON_DEBUG environment
# variable the second time, when it's being reloaded as a
# logging configuration is read from config file.
# See _bootstrap.py
if os.environ.get('APPSKELLINGTON_DEBUG', None):
if 'app_skellington' not in config_dict['loggers']:
config_dict['loggers']['app_skellington'] = {
'level': 'debug', 'propagate': 'false'

18
tests/README.md Normal file

@ -0,0 +1,18 @@
Software tests for app_skellington
==================================
Contained is the early stages of unit testing for app_skellington framework.
Usage
-----
Run all tests (cwd is testing directory):
pytest .
Run test by keyword:
pytest -k "<test keyword>" .
Run test by directory:
pytest <dirname>

0
tests/cfg/__init__.py Normal file

@ -0,0 +1,5 @@
root_option = root_option_val
[app]
sub_option = sub_option_val

@ -0,0 +1,5 @@
root_option = string(max=255, default='def_string')
[app]
sub_option = string(max=255, default='def_sub')

@ -0,0 +1,5 @@
root_option = root_option_val
[app]
sub_option = sub_option_val

@ -0,0 +1,6 @@
root_option = string(max=255, default='def_string')
int_option = integer(min=0, max=100)
[app]
sub_option = string(max=255, default='def_sub')

@ -0,0 +1,2 @@
root_option = invalid(max=255, default='def_string')

97
tests/cfg/test_cfg.py Normal file

@ -0,0 +1,97 @@
from app_skellington.cfg import Config
from app_skellington import _util
import pytest
@pytest.fixture
def sample_configspec_filepath():
return _util.get_asset(__name__, 'sample_config.spec')
@pytest.fixture
def sample_configini_filepath():
return _util.get_asset(__name__, 'sample_config.ini')
@pytest.fixture
def sample_full_configspec_filepath():
return _util.get_asset(__name__, 'sample_config_full.spec')
@pytest.fixture
def sample_full_configini_filepath():
return _util.get_asset(__name__, 'sample_config_full.ini')
@pytest.fixture
def sample_invalid_configspec_filepath():
return _util.get_asset(__name__, 'sample_config_invalid.spec')
class TestConfig_e2e:
def test_allows_reading_ini_and_no_spec(
self, sample_configini_filepath
):
cfg = Config(
configini_filepath=sample_configini_filepath
)
assert cfg['root_option'] == 'root_option_val', 'expecting default from config.spec (didnt get)'
assert cfg['app']['sub_option'] == 'sub_option_val', 'expecting default for sub option'
def test_allows_reading_spec_and_no_ini(
self, sample_configspec_filepath
):
cfg = Config(
configspec_filepath=sample_configspec_filepath
)
assert cfg['root_option'] == 'def_string', 'expecting default from config.spec (didnt get)'
# NOTE(MG) Changed the functionality to not do it this way.
# def test_constructor_fails_with_invalid_spec(
# self, sample_invalid_configspec_filepath
# ):
# with pytest.raises(Exception):
# cfg = Config(
# configspec_filepath=sample_invalid_configspec_filepath
# )
def test_allows_options_beyond_spec(
self, sample_configspec_filepath
):
cfg = Config(
configspec_filepath=sample_configspec_filepath
)
cfg['foo'] = 'test my value'
assert cfg['foo'] == 'test my value'
cfg['app']['bar'] = 'another value'
assert cfg['app']['bar'] == 'another value'
# def test_can_read_config_file_mutiple_times(self):
# pass
def test_can_override_config_file_manually(
self, sample_configini_filepath
):
cfg = Config(
configini_filepath=sample_configini_filepath
)
cfg['root_option'] = 'newval'
assert cfg['root_option'] == 'newval'
cfg['app']['sub_option'] = 'another_new_val'
assert cfg['app']['sub_option'] == 'another_new_val', 'expecting default for sub option'
def test_can_set_option_without_config(self):
cfg = Config()
cfg['foo'] = 'test my value'
assert cfg['foo'] == 'test my value'
cfg['app'] = {}
cfg['app']['bar'] = 'another value'
assert cfg['app']['bar'] == 'another value'
def test_uses_spec_as_defaults(
self, sample_configspec_filepath
):
cfg = Config(
configspec_filepath=sample_configspec_filepath
)
assert cfg['root_option'] == 'def_string', 'expecting default from config.spec (didnt get)'
assert cfg['app']['sub_option'] == 'def_sub', 'expecting default for sub option'

@ -1,31 +0,0 @@
from app_skellington.cfg import Config
class TestConfig_e2e:
def test_allows_reading_with_no_spec(self):
x = Config()
assert True == False
def test_allows_reading_with_sample_spec(self):
x = Config()
assert True == False
def test_constructor_fails_with_invalid_spec(self):
x = Config()
assert True == False
def test_allows_options_beyond_spec(self):
x = Config()
assert True == False
def test_can_read_config_correctly_from_file(self):
pass
def test_can_read_config_file_mutiple_times(self):
pass
def test_can_override_config_file_manually(self):
pass
def test_can_set_option_without_config(self):
pass