Split manager functionality out. Closes #5

master
Tom Wilson 6 years ago
parent 07a817141c
commit f7ac5ef0c3

@ -0,0 +1,2 @@
from .specification import *
from .manager import *

@ -1,229 +1,7 @@
"""
Configuration management module. Enables configuration to be validated against
requirement specifications before being loaded and used.
Compatible with both raw config data structures and TOML files, config data must
start with a root dict containing named "config bundles". These are intended to
refer to different modular parts of the application needing configuration, and the
config data structure must contain at least one.
Each config bundle itself needs to have a dict at the root, and so in practice a minimal
TOML config file would look like::
[myapp]
config_thingy_a = "foooooo!"
important_number = 8237
This would resolve to a config bundle named "myapp" that results in the dict::
{"config_thingy_a": "foooooo!", "important_number": 8237}
Root items that are not dicts are not supported, for instance both the following
TOML files would fail::
[[myapp]]
important_number = 8237
[[myapp]]
another_important_number = 2963
(root object in bundle is a list)
::
root_thingy = 46
(root object in config is a single value)
"""
import re
import toml
from abc import ABC, abstractmethod
from copy import deepcopy
from preserve import preservable, restore
class InvalidConfigError(Exception):
pass
# The Table and Array terms from the TOML convention essentially
# map directly to Dictionaries (Tables), and Lists (Arrays)
class _ValueSpecification(ABC):
def __init__(self, default=None, optional=False, helptext=""):
self.default = default
self.optional = optional
self.helptext = helptext
@abstractmethod
def validate(self, value):
"""
Checks the supplied value to confirm that it complies with this specification.
Raises InvalidConfigError on failure.
"""
pass
@preservable
class BoolSpec(_ValueSpecification):
def __init__(self, default=None, optional=False, helptext=""):
super().__init__(default, optional, helptext)
def validate(self, value):
if not isinstance(value, bool):
raise InvalidConfigError("Config value must be a boolean")
@preservable
class IntSpec(_ValueSpecification):
def __init__(self, default=None, minval=None, maxval=None,
optional=False, helptext=""):
super().__init__(default, optional, helptext)
self.minval = minval
self.maxval = maxval
def validate(self, value):
if not isinstance(value, int):
raise InvalidConfigError("Config value must be an integer")
if self.minval is not None and value < self.minval:
raise InvalidConfigError("Config value must be >= " +
str(self.minval))
if self.maxval is not None and value > self.maxval:
raise InvalidConfigError("Config value must be <= " +
str(self.maxval))
@preservable
class StringSpec(_ValueSpecification):
def __init__(self, default="", minlength=None, maxlength=None,
optional=False, helptext=""):
super().__init__(default, optional, helptext)
self.minlength = minlength
self.maxlength = maxlength
def validate(self, value):
if not isinstance(value, str):
raise InvalidConfigError(F"Config value must be a string and is {value}")
if self.minlength is not None and len(value) < self.minlength:
raise InvalidConfigError("Config string length must be >= " +
str(self.minlength))
if self.maxlength is not None and len(value) > self.maxlength:
raise InvalidConfigError("Config string length must be <= " +
str(self.maxlength))
@preservable
class DictSpec(_ValueSpecification):
def __init__(self, default=None, optional=False, helptext=""):
super().__init__(default, optional, helptext)
self.spec_dict = {}
def add_spec(self, name, newspec):
if not isinstance(newspec, _ValueSpecification):
raise TypeError("Config specification must be an instance of a "
"_ValueSpecification subclass")
if not isinstance(name, str):
raise TypeError("Config specification name must be a string")
self.spec_dict[name] = newspec
return newspec
def validate(self, value_dict):
"""
Checks the supplied value to confirm that it complies with this specification.
Raises InvalidConfigError on failure.
This *can* modify the supplied value dict, inserting defaults for any child
config specifications that are marked as optional.
"""
spec_set = set(self.spec_dict.keys())
value_set = set(value_dict.keys())
for missing_key in spec_set - value_set:
if not self.spec_dict[missing_key].optional:
raise InvalidConfigError("Dict must contain key: " +
missing_key)
else:
value_dict[missing_key] = self.spec_dict[missing_key].default
for extra_key in value_set - spec_set:
raise InvalidConfigError("Dict contains unknown key: " +
extra_key)
for key, value in value_dict.items():
try:
self.spec_dict[key].validate(value)
except InvalidConfigError as e:
e.args = ("Key: " + key,) + e.args
raise
def get_template(self, include_optional=False):
"""
Return a config dict with the minimum structure required for this specification.
Default values will be included, though not all required fields will necessarily have
defaults that successfully validate.
Args:
include_optional: If set true, will include *all* config fields, not just the
required ones
Returns:
Dict containing the structure that should be passed back in (with values) to comply
with this config specification.
"""
template = {}
for key, confspec in self.spec_dict.items():
if confspec.optional and (not include_optional):
continue
if hasattr(confspec, "get_template"):
template[key] = confspec.get_template(include_optional)
else:
template[key] = confspec.default
return template
class _ListSpecMixin():
def validate(self, value_list):
if not isinstance(value_list, list):
raise InvalidConfigError("Config item must be a list")
for index, value in enumerate(value_list):
try:
super().validate(value)
except InvalidConfigError as e:
e.args = ("List index: " + str(index),) + e.args
raise
def get_template(self, include_optional=False):
if hasattr(super(), "get_template"):
return [super().get_template(include_optional)]
else:
return [self.default]
@preservable
class BoolListSpec(_ListSpecMixin, BoolSpec):
pass
@preservable
class IntListSpec(_ListSpecMixin, IntSpec):
pass
@preservable
class StringListSpec(_ListSpecMixin, StringSpec):
pass
@preservable
class DictListSpec(_ListSpecMixin, DictSpec):
pass
@preservable
class ConfigSpecification(DictSpec):
pass
from .specification import ConfigSpecification, InvalidConfigError
class ConfigManager():

@ -0,0 +1,222 @@
"""
Configuration management module. Enables configuration to be validated against
requirement specifications before being loaded and used.
Compatible with both raw config data structures and TOML files, config data must
start with a root dict containing named "config bundles". These are intended to
refer to different modular parts of the application needing configuration, and the
config data structure must contain at least one.
Each config bundle itself needs to have a dict at the root, and so in practice a minimal
TOML config file would look like::
[myapp]
config_thingy_a = "foooooo!"
important_number = 8237
This would resolve to a config bundle named "myapp" that results in the dict::
{"config_thingy_a": "foooooo!", "important_number": 8237}
Root items that are not dicts are not supported, for instance both the following
TOML files would fail::
[[myapp]]
important_number = 8237
[[myapp]]
another_important_number = 2963
(root object in bundle is a list)
::
root_thingy = 46
(root object in config is a single value)
"""
from abc import ABC, abstractmethod
from preserve import preservable, restore
class InvalidConfigError(Exception):
pass
# The Table and Array terms from the TOML convention essentially
# map directly to Dictionaries (Tables), and Lists (Arrays)
class _ValueSpecification(ABC):
def __init__(self, default=None, optional=False, helptext=""):
self.default = default
self.optional = optional
self.helptext = helptext
@abstractmethod
def validate(self, value):
"""
Checks the supplied value to confirm that it complies with this specification.
Raises InvalidConfigError on failure.
"""
pass
@preservable
class BoolSpec(_ValueSpecification):
def __init__(self, default=None, optional=False, helptext=""):
super().__init__(default, optional, helptext)
def validate(self, value):
if not isinstance(value, bool):
raise InvalidConfigError("Config value must be a boolean")
@preservable
class IntSpec(_ValueSpecification):
def __init__(self, default=None, minval=None, maxval=None,
optional=False, helptext=""):
super().__init__(default, optional, helptext)
self.minval = minval
self.maxval = maxval
def validate(self, value):
if not isinstance(value, int):
raise InvalidConfigError("Config value must be an integer")
if self.minval is not None and value < self.minval:
raise InvalidConfigError("Config value must be >= " +
str(self.minval))
if self.maxval is not None and value > self.maxval:
raise InvalidConfigError("Config value must be <= " +
str(self.maxval))
@preservable
class StringSpec(_ValueSpecification):
def __init__(self, default="", minlength=None, maxlength=None,
optional=False, helptext=""):
super().__init__(default, optional, helptext)
self.minlength = minlength
self.maxlength = maxlength
def validate(self, value):
if not isinstance(value, str):
raise InvalidConfigError(F"Config value must be a string and is {value}")
if self.minlength is not None and len(value) < self.minlength:
raise InvalidConfigError("Config string length must be >= " +
str(self.minlength))
if self.maxlength is not None and len(value) > self.maxlength:
raise InvalidConfigError("Config string length must be <= " +
str(self.maxlength))
@preservable
class DictSpec(_ValueSpecification):
def __init__(self, default=None, optional=False, helptext=""):
super().__init__(default, optional, helptext)
self.spec_dict = {}
def add_spec(self, name, newspec):
if not isinstance(newspec, _ValueSpecification):
raise TypeError("Config specification must be an instance of a "
"_ValueSpecification subclass")
if not isinstance(name, str):
raise TypeError("Config specification name must be a string")
self.spec_dict[name] = newspec
return newspec
def validate(self, value_dict):
"""
Checks the supplied value to confirm that it complies with this specification.
Raises InvalidConfigError on failure.
This *can* modify the supplied value dict, inserting defaults for any child
config specifications that are marked as optional.
"""
spec_set = set(self.spec_dict.keys())
value_set = set(value_dict.keys())
for missing_key in spec_set - value_set:
if not self.spec_dict[missing_key].optional:
raise InvalidConfigError("Dict must contain key: " +
missing_key)
else:
value_dict[missing_key] = self.spec_dict[missing_key].default
for extra_key in value_set - spec_set:
raise InvalidConfigError("Dict contains unknown key: " +
extra_key)
for key, value in value_dict.items():
try:
self.spec_dict[key].validate(value)
except InvalidConfigError as e:
e.args = ("Key: " + key,) + e.args
raise
def get_template(self, include_optional=False):
"""
Return a config dict with the minimum structure required for this specification.
Default values will be included, though not all required fields will necessarily have
defaults that successfully validate.
Args:
include_optional: If set true, will include *all* config fields, not just the
required ones
Returns:
Dict containing the structure that should be passed back in (with values) to comply
with this config specification.
"""
template = {}
for key, confspec in self.spec_dict.items():
if confspec.optional and (not include_optional):
continue
if hasattr(confspec, "get_template"):
template[key] = confspec.get_template(include_optional)
else:
template[key] = confspec.default
return template
class _ListSpecMixin():
def validate(self, value_list):
if not isinstance(value_list, list):
raise InvalidConfigError("Config item must be a list")
for index, value in enumerate(value_list):
try:
super().validate(value)
except InvalidConfigError as e:
e.args = ("List index: " + str(index),) + e.args
raise
def get_template(self, include_optional=False):
if hasattr(super(), "get_template"):
return [super().get_template(include_optional)]
else:
return [self.default]
@preservable
class BoolListSpec(_ListSpecMixin, BoolSpec):
pass
@preservable
class IntListSpec(_ListSpecMixin, IntSpec):
pass
@preservable
class StringListSpec(_ListSpecMixin, StringSpec):
pass
@preservable
class DictListSpec(_ListSpecMixin, DictSpec):
pass
@preservable
class ConfigSpecification(DictSpec):
pass

@ -7,9 +7,9 @@ setup(name='config-spec',
author='novirium',
author_email='t.wilson.au@gmail.com',
license='MIT',
packages=[],
py_modules=['configspec'],
packages=['configspec'],
install_requires=[
"toml",
"preserve@git+https://git.distreon.net/novirium/python-preserve.git"
],
extras_require={

@ -0,0 +1,20 @@
import pytest
import configspec
from copy import deepcopy
from test_configspec import example_spec, example_values
def test_man(example_spec, example_values):
confman = configspec.ConfigManager()
confman.add_confspec("test_bundle", example_spec)
confman.load({"test_bundle": example_values})
assert confman.get_config_bundle("test_bundle") == example_values
confman.freeze_value("test_bundle", "int_a")
newvals = deepcopy(example_values)
newvals["int_a"] = 4
confman.load({"test_bundle": newvals})
assert confman.get_config_bundle("test_bundle") == example_values
confman.load_overlay({"test_bundle": {"string_a": "new text"}})
assert confman.get_config_bundle("test_bundle")["string_a"] == "new text"
Loading…
Cancel
Save