Split manager functionality out. Closes #5
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
|
||||
@ -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…
Reference in new issue