Compare commits

..

15 Commits
v0.3 ... master

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2019 Thomas Wilson
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

@ -36,7 +36,7 @@ For development setup, see the [the Development guide](./DEVELOPMENT.md)
## Specifications and validations ## Specifications and validations
Specifications can be defined in 3 basic Python primary types (``BoolSpec``, ``IntSpec`` and ``StringSpec``), along with dictionaryies to organise them (``DictSpec``). The ``ConfigSpecification`` is just a special type of ``DictSpec`` used to indicate the root of the config. Specifications can be defined in 3 basic Python primary types (``BoolSpec``, ``IntSpec`` and ``StringSpec``), along with dictionaries to organise them (``DictSpec``). The ``ConfigSpecification`` is just a special type of ``DictSpec`` used to indicate the root of the config.
All of the specification types also have a list variant (``BoolListSpec``, ``IntListSpec``, ``StringListSpec``, ``DictListSpec``). All of the specification types also have a list variant (``BoolListSpec``, ``IntListSpec``, ``StringListSpec``, ``DictListSpec``).

@ -1,26 +1,59 @@
import re import re
import toml
from copy import deepcopy from copy import deepcopy
import toml
from .specification import ConfigSpecification, InvalidConfigError from .specification import ConfigSpecification, InvalidConfigError
class ConfigManager(): class ConfigManager():
def __init__(self): def __init__(self):
self.root_config = {} self.config_source = {} # source values
self.root_config = {} # validated config
self.confspecs = {} self.confspecs = {}
self.frozen_config = {} self.frozen_config = {} # validated config to reapply each load
self._saved_state = {}
def save_fallback(self):
"""
Save the current state of the ConfigManager for future restoration with ``fallback()``.
Includes the loaded config source, the validated config, any added config specifications,
and any frozen values. Returns a state dict that may be passed back to `fallback()` to
restore a particular state.
"""
new_saved_state = {}
new_saved_state["config_source"] = deepcopy(self.config_source)
new_saved_state["root_config"] = deepcopy(self.root_config)
new_saved_state["confspecs"] = deepcopy(self.confspecs)
new_saved_state["frozen_config"] = deepcopy(self.frozen_config)
self._saved_state = new_saved_state
return new_saved_state
def fallback(self):
"""
Restore the state of the ConfigManager to what is was when ``save_fallback()`` was last
called. Includes the loaded config source, the validated config, any added config
specifications, and any frozen values.
"""
if not all(k in self._saved_state for k in ("config_source", "root_config",
"confspecs", "frozen_config")):
raise Exception("Can't fallback ConfigManager without calling save_fallback() first!")
self.config_source = self._saved_state["config_source"]
self.root_config = self._saved_state["root_config"]
self.confspecs = self._saved_state["confspecs"]
self.frozen_config = self._saved_state["frozen_config"]
@staticmethod @staticmethod
def _load_source(source): def _get_source(source):
""" """
Accept a filepath or opened file representing a TOML file, or a direct dict, Accept a filepath or opened file representing a TOML file, or a direct dict,
and return a plain parsed dict. and return a plain parsed dict.
""" """
if isinstance(source, dict): # load from dict if isinstance(source, dict): # load from dict
return source return source
elif isinstance(source, str): # load from pathname
if isinstance(source, str): # load from pathname
with open(source, 'r') as conf_file: with open(source, 'r') as conf_file:
return toml.load(conf_file) return toml.load(conf_file)
else: # load from file else: # load from file
return toml.load(source) return toml.load(source)
@ -32,8 +65,10 @@ class ConfigManager():
source: Either a dict config to load directly, a filepath to a TOML file, source: Either a dict config to load directly, a filepath to a TOML file,
or an open file. or an open file.
""" """
self.root_config = self._load_source(source) self.config_source = self._get_source(source)
self._overlay(self.frozen_config, self.root_config) self._overlay(self.frozen_config, self.config_source)
# New source, so wipe validated config
self.root_config = {}
def load_overlay(self, source): def load_overlay(self, source):
""" """
@ -46,8 +81,10 @@ class ConfigManager():
source: Either the root dict of a data structure to load directly, a filepath to a TOML source: Either the root dict of a data structure to load directly, a filepath to a TOML
file, or an open TOML file. file, or an open TOML file.
""" """
self._overlay(self._load_source(source), self.root_config) self._overlay(self._get_source(source), self.config_source)
self._overlay(self.frozen_config, self.root_config) self._overlay(self.frozen_config, self.config_source)
# New source, so wipe validated config
self.root_config = {}
def freeze_value(self, bundle_name, *field_names): def freeze_value(self, bundle_name, *field_names):
""" """
@ -101,7 +138,7 @@ class ConfigManager():
Returns a list of config bundle names that do not have a corresponding ConfigSpecification Returns a list of config bundle names that do not have a corresponding ConfigSpecification
stored in the ConfigManager. stored in the ConfigManager.
""" """
return list(self.root_config.keys() - self.confspecs.keys()) return list(self.config_source.keys() - self.confspecs.keys())
def _overlay(self, src, dest): def _overlay(self, src, dest):
for key in src: for key in src:
@ -112,65 +149,137 @@ class ConfigManager():
# Otherwise it's either an existing value to be replaced or needs to be added. # Otherwise it's either an existing value to be replaced or needs to be added.
dest[key] = src[key] dest[key] = src[key]
def get_config_bundle(self, bundle_name, conf_spec=None): def validate_bundle(self, bundle_name, conf_spec=None):
""" """
Get a config bundle called ``bundle_name`` and validate Validate the config bundle called ``bundle_name`` against the corresponding specification
it against the corresponding config specification stored in the ConfigManager. stored in the ConfigManager. If ``conf_spec`` is supplied, it gets used instead.
If ``conf_spec`` is supplied, it gets used instead. Returns a copy of the validated
config bundle dict.
Note that as part of validation, optional keys that are missing will be Stores the resulting validated config bundle for later retrieval with
filled in with their default values (see ``DictSpec``). This function will copy ``get_config_bundle()``.
the config bundle *after* validation, and so config loaded in the ConfManager will
be modified, but future ConfigManager manipulations won't change the returned config Note that as part of validation, optional keys that are missing will be filled in with
bundle. their default values (see ``DictSpec``).
Args: Args:
config_name: (str) Name of the config dict to find. bundle_name: (str) Name of the config dict to find.
conf_spec: (ConfigSpecification) Optional config specification to validate against. conf_spec: (ConfigSpecification) Optional config specification to validate against.
Returns:
dict: The validated config bundle.
Raises:
InvalidConfigError: If the canfig source fails validation, or a matching config
specification can't be found.
""" """
if not isinstance(conf_spec, ConfigSpecification): if not isinstance(conf_spec, ConfigSpecification):
if bundle_name not in self.confspecs:
raise InvalidConfigError(
"No ConfigSpecification supplied for bundle: " + bundle_name)
conf_spec = self.confspecs[bundle_name] conf_spec = self.confspecs[bundle_name]
if bundle_name not in self.root_config: if bundle_name not in self.config_source:
raise InvalidConfigError( raise InvalidConfigError("Config source must contain dict: " + bundle_name)
"Config must contain dict: " + bundle_name)
bundle_source = deepcopy(self.config_source[bundle_name])
try: try:
conf_spec.validate(self.root_config[bundle_name]) conf_spec.validate(bundle_source)
except InvalidConfigError as e: except InvalidConfigError as e:
e.args = ("Bundle: " + bundle_name,) + e.args e.args = ("Bundle: " + bundle_name,) + e.args
raise raise
return deepcopy(self.root_config[bundle_name])
def get_config_bundles(self, bundle_names): self.root_config[bundle_name] = bundle_source
return self.root_config[bundle_name]
def validate_bundles(self, bundle_names=None):
""" """
Get multiple config bundles from the root dict at once, validating each one with the Validate multiple config bundles at once, validating each one with the corresponding
corresponding ConfigSpecification stored in the ConfigManager. See ``get_config_bundle`` ConfigSpecification stored in the ConfigManager. See ``validate_bundle()``.
Args: Args:
bundle_names: A list of config bundle names to get. If dictionary is supplied, uses bundle_names: A list of config bundle names to get. If dictionary is supplied, uses
the values as ConfigSpecifications rather than looking up a stored one in the the values as ConfigSpecifications rather than looking up the ones stored in the
ConfigManager. ConfigManager. If None, will use all bundle names in the config source.
Returns: Returns:
A dict of config dicts, with keys matching those passed in ``bundle_names``. dict: A dict of the config bundles, with keys matching those passed in
``bundle_names``.
""" """
if bundle_names is None:
bundle_names = self.get_bundle_names()
config_values = {} config_values = {}
if isinstance(bundle_names, dict): if isinstance(bundle_names, dict):
for name, conf_spec in bundle_names.items(): for name, conf_spec in bundle_names.items():
config_values[name] = self.get_config_bundle(name, conf_spec) config_values[name] = self.validate_bundle(name, conf_spec)
elif isinstance(bundle_names, str):
# Protection against a single name being passed anyway and otherwise
# being parsed as letters
config_values[bundle_names] = self.validate_bundle(bundle_names)
else:
for name in bundle_names:
config_values[name] = self.validate_bundle(name)
return config_values
def get_config_bundle(self, bundle_name):
"""
Get a validated config bundle called ``bundle_name``. If not yet validated, will validate
the config source against the corresponding config specification stored in the
ConfigManager (see ``validate_bundle()``).
Args:
bundle_name: (str) Name of the config bundle to find.
Returns:
dict: The validated config bundle.
"""
if bundle_name not in self.root_config:
return self.validate_bundle(bundle_name)
return self.root_config[bundle_name]
def get_config_bundles(self, bundle_names=None):
"""
Get multiple config bundles at once. If not yet validated, each will validate
their config source against the corresponding config specification stored in the
ConfigManager (see ``validate_bundle()``).
Args:
bundle_names: A list of config bundle names to get. If None, will use all bundle
names in the config source.
Returns:
dict: A dict of the validated config bundles, with keys matching those passed in
``bundle_names``.
"""
if bundle_names is None:
bundle_names = self.get_bundle_names()
config_values = {}
if isinstance(bundle_names, str):
# Protection against a single name being passed anyway and otherwise
# being parsed as letters
config_values[bundle_names] = self.get_config_bundle(bundle_names)
else: else:
for name in bundle_names: for name in bundle_names:
config_values[name] = self.get_config_bundle(name) config_values[name] = self.get_config_bundle(name)
return config_values return config_values
def get_bundle_names(self): def get_bundle_names(self):
""" """
Returns a list of names of top level config bundles Returns a list of config bundle names contained in the source.
Note that this may include bundles that have not been verified yet. Calling
`validate_bundles()` or `get_config_bundles()` first will make sure all config source
bundles are verified.
""" """
return list(self.root_config.keys()) return list(self.config_source.keys())
def dump_toml(self): def dump_toml(self):
if self.root_config.keys() != self.config_source.keys():
raise Exception("Can't dump an unvalidated config table!")
return toml.dumps(self.root_config) return toml.dumps(self.root_config)
def dump_to_file(self, filepath, message=None): def dump_to_file(self, filepath, message=None):
@ -182,8 +291,7 @@ class ConfigManager():
def strip_toml_message(string): def strip_toml_message(string):
print("stripping...") return re.sub(r"(?m)^#\\ config-spec_message:[^\\n]*$\\n?(?:^#[^\\n]+$\\n?)*",
return re.sub("(?m)^#\\ shepherd_message:[^\\n]*$\\n?(?:^#[^\\n]+$\\n?)*",
'', string) '', string)

@ -4,6 +4,8 @@ being loaded and used.
""" """
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
import re
import itertools
from preserve import preservable from preserve import preservable
@ -16,9 +18,12 @@ class InvalidConfigError(Exception):
class _ValueSpecification(ABC): class _ValueSpecification(ABC):
def __init__(self, default=None, optional=False, helptext=""): """
self.default = default The abstract base class for all ConfigSpecifications. `default` values are used to ensure
self.optional = optional that a config structure exists even if optional fields are not supplied.
"""
def __init__(self, helptext=""):
self.helptext = helptext self.helptext = helptext
@abstractmethod @abstractmethod
@ -32,8 +37,8 @@ class _ValueSpecification(ABC):
@preservable @preservable
class BoolSpec(_ValueSpecification): class BoolSpec(_ValueSpecification):
def __init__(self, default=None, optional=False, helptext=""): def __init__(self, helptext=""):
super().__init__(default, optional, helptext) super().__init__(helptext)
def validate(self, value): def validate(self, value):
if not isinstance(value, bool): if not isinstance(value, bool):
@ -42,9 +47,8 @@ class BoolSpec(_ValueSpecification):
@preservable @preservable
class IntSpec(_ValueSpecification): class IntSpec(_ValueSpecification):
def __init__(self, default=None, minval=None, maxval=None, def __init__(self, minval=None, maxval=None, helptext=""):
optional=False, helptext=""): super().__init__(helptext)
super().__init__(default, optional, helptext)
self.minval = minval self.minval = minval
self.maxval = maxval self.maxval = maxval
@ -59,65 +63,145 @@ class IntSpec(_ValueSpecification):
str(self.maxval)) str(self.maxval))
@preservable
class FloatSpec(_ValueSpecification):
def __init__(self, minval=None, maxval=None, helptext=""):
super().__init__(helptext)
self.minval = minval
self.maxval = maxval
def validate(self, value):
if not isinstance(value, float):
raise InvalidConfigError("Config value must be a float")
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 @preservable
class StringSpec(_ValueSpecification): class StringSpec(_ValueSpecification):
def __init__(self, default="", minlength=None, maxlength=None, def __init__(self, regex=None, allowempty=False, helptext=""):
optional=False, helptext=""): """
super().__init__(default, optional, helptext) Specify a string to be provided. If `regex` is not None, validation requires that the
self.minlength = minlength config value directly matches the supplied regex string (calls `re.fullmatch`).
self.maxlength = maxlength If `allowempty` is False, validation requires that the string not be an empty or blank.
"""
super().__init__(helptext)
self.regex = regex
self.allowempty = allowempty
def validate(self, value): def validate(self, value):
if not isinstance(value, str): if not isinstance(value, str):
raise InvalidConfigError(F"Config value must be a string and is {value}") raise InvalidConfigError(F"Config value must be a string and is {value}")
if self.minlength is not None and len(value) < self.minlength: if not self.allowempty and (value == ""):
raise InvalidConfigError("Config string length must be >= " + raise InvalidConfigError("Config value cannot be a blank string")
str(self.minlength)) if self.regex and (re.fullmatch(self.regex, value) is None):
if self.maxlength is not None and len(value) > self.maxlength: raise InvalidConfigError(F"Config string '{value}' must match regex"
raise InvalidConfigError("Config string length must be <= " + F" pattern '{self.regex}'")
str(self.maxlength))
@preservable @preservable
class DictSpec(_ValueSpecification): class DictSpec(_ValueSpecification):
def __init__(self, default=None, optional=False, helptext=""): def __init__(self, helptext=""):
super().__init__(default, optional, helptext) super().__init__(helptext)
self.spec_dict = {} self.spec_dict = {}
self.optional_spec_dict = {}
self.default_values = {}
def add_spec(self, name, newspec): def add_spec(self, name, value_spec, optional=False, default=None):
if not isinstance(newspec, _ValueSpecification): """
Adds a specification to this dict with the given `name`. To validate, corresponding fields
must be present with matching keys. Only supports string names/keys.
Specifications marked as `optional` will be filled with their default value if not present
during validation (note, this _will_ modify the data validated to fill in defaults), and
will also pass validation if the value is None. Providing `default` as anything other than
None will implicitly set `optional` to True.
The default value must be None, or a value that will pass validation with the spec.
To allow concise access to the value_spec passed in, returns `value_spec`. This beahviour
has an exception for ListSpecs, for which it will return the value_spec passed to the
ListSpec itself (also to allow concise access to further define the spec).
Note that nested optional dicts _are_ possible by passing `default` as an empty dict `{}`,
and passing `value_spec` a DictSpec where every field is itself optional. If this entire
field is missing during validation it will then attempt to validate an empty dict,
populating it with all of the nested optional fields.
"""
if not isinstance(value_spec, _ValueSpecification):
raise TypeError("Config specification must be an instance of a " raise TypeError("Config specification must be an instance of a "
"_ValueSpecification subclass") "_ValueSpecification subclass")
if not isinstance(name, str): if not isinstance(name, str):
raise TypeError("Config specification name must be a string") raise TypeError("Config specification name must be a string")
self.spec_dict[name] = newspec if (name in self.spec_dict) or (name in self.optional_spec_dict):
return newspec raise TypeError(F"Name '{name}' already exists in this DictSpec")
if default is not None:
optional = True
if not optional:
self.spec_dict[name] = value_spec
else:
self.optional_spec_dict[name] = value_spec
self.default_values[name] = default
if isinstance(value_spec, ListSpec):
return value_spec.spec
return value_spec
def add_specs(self, spec_dict, optional=False):
"""
Convenience method to pass a series of specifications to 'add_spec()'.
Args:
spec_dict: A dict of specs, where each key is the name of the spec stored as the
corresponding value. If `optional` is true, the value may be a tuple with the
first item being the spec and the second being the default value.
optional: Same behaviour as `add_spec()`. Applies to all specs passed in, and will use
a default value of None.
"""
for name, spec in spec_dict.items():
if optional and isinstance(spec, tuple):
self.add_spec(name, spec[0], optional=True, default=spec[1])
else:
self.add_spec(name, spec, optional)
def validate(self, value_dict): def validate(self, value_dict):
""" """
Checks the supplied value to confirm that it complies with this specification. Checks the supplied values to confirm that it complies with this specification.
Raises InvalidConfigError on failure. Raises InvalidConfigError on failure.
This *can* modify the supplied value dict, inserting defaults for any child This _can_ modify the supplied value dict, inserting defaults for any child
config specifications that are marked as optional. config specifications that are marked as optional. Optional values (after defaults are
inserted) must either be None or pass validation as usual.
""" """
spec_set = set(self.spec_dict.keys()) for missing_key in (self.spec_dict.keys() - value_dict.keys()):
value_set = set(value_dict.keys()) raise InvalidConfigError(F"Dict must contain key '{missing_key}'")
for missing_key in spec_set - value_set: for extra_key in ((value_dict.keys() - self.spec_dict.keys()) -
if not self.spec_dict[missing_key].optional: self.optional_spec_dict.keys()):
raise InvalidConfigError("Dict must contain key: " + raise InvalidConfigError(F"Dict contains unknown key '{extra_key}'")
missing_key)
else:
value_dict[missing_key] = self.spec_dict[missing_key].default
for extra_key in value_set - spec_set: for default_key in (self.optional_spec_dict.keys() - value_dict.keys()):
raise InvalidConfigError("Dict contains unknown key: " + value_dict[default_key] = self.default_values[default_key]
extra_key)
for key, spec in self.spec_dict.items():
try:
spec.validate(value_dict[key])
except InvalidConfigError as e:
e.args = ("Key: " + key,) + e.args
raise
for key, value in value_dict.items(): for key, spec in self.optional_spec_dict.items():
if value_dict[key] is None:
continue
try: try:
self.spec_dict[key].validate(value) spec.validate(value_dict[key])
except InvalidConfigError as e: except InvalidConfigError as e:
e.args = ("Key: " + key,) + e.args e.args = ("Key: " + key,) + e.args
raise raise
@ -136,53 +220,58 @@ class DictSpec(_ValueSpecification):
with this config specification. with this config specification.
""" """
template = {} template = {}
for key, confspec in self.spec_dict.items(): if include_optional:
if confspec.optional and (not include_optional): spec_items = itertools.chain(self.spec_dict.items(), self.optional_spec_dict.items())
continue else:
spec_items = self.spec_dict.items()
for key, confspec in spec_items:
if hasattr(confspec, "get_template"): if hasattr(confspec, "get_template"):
template[key] = confspec.get_template(include_optional) template[key] = confspec.get_template(include_optional)
else: else:
template[key] = confspec.default template[key] = self.default_values.get(key, None)
return template return template
class _ListSpecMixin(): @preservable
class ListSpec(_ValueSpecification):
"""
Specify a list of values
"""
def __init__(self, value_spec, min_values=None, max_values=None, helptext=""):
"""
Make a new ListSpec. Requires a child spec to be applied to all list values.
Min and max limits can be supplied to ensure a certain range of list sizes.
Returns the value_spec passed in, to allow easy additions to it (if it's a DictSpec)
"""
super().__init__(helptext)
self.spec = value_spec
self.min_values = min_values
self.max_values = max_values
def validate(self, value_list): def validate(self, value_list):
if not isinstance(value_list, list): if not isinstance(value_list, list):
raise InvalidConfigError("Config item must be a list") raise InvalidConfigError("Config item must be a list")
if self.min_values and (len(value_list) < self.min_values):
raise InvalidConfigError(F"Config list requires {self.min_values} values, and only"
F" has {len(value_list)}")
if self.max_values and (len(value_list) > self.max_values):
raise InvalidConfigError(F"Config list can have no more than {self.max_values} values,"
F" and has {len(value_list)}")
for index, value in enumerate(value_list): for index, value in enumerate(value_list):
try: try:
super().validate(value) self.spec.validate(value)
except InvalidConfigError as e: except InvalidConfigError as e:
e.args = ("List index: " + str(index),) + e.args e.args = ("List index: " + str(index),) + e.args
raise raise
def get_template(self, include_optional=False): def get_template(self, include_optional=False):
if hasattr(super(), "get_template"): if hasattr(self.spec, "get_template"):
return [super().get_template(include_optional)] return [self.spec.get_template(include_optional)]
else: else:
return [self.default] return [None]
@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 @preservable

@ -1,7 +1,7 @@
from setuptools import setup from setuptools import setup
setup(name='config-spec', setup(name='config-spec',
version='0.3', version='0.4dev',
description='', description='',
url='https://git.distreon.net/novirium/config-spec', url='https://git.distreon.net/novirium/config-spec',
author='novirium', author='novirium',
@ -9,11 +9,14 @@ setup(name='config-spec',
license='MIT', license='MIT',
packages=['configspec'], packages=['configspec'],
install_requires=[ install_requires=[
"toml",
"preserve@git+https://git.distreon.net/novirium/python-preserve.git" "preserve@git+https://git.distreon.net/novirium/python-preserve.git"
], ],
extras_require={ extras_require={
'manager': [
'toml',
],
'dev': [ 'dev': [
'toml',
'autopep8', 'autopep8',
'pytest', 'pytest',
'pytest-flake8', 'pytest-flake8',
@ -22,6 +25,7 @@ setup(name='config-spec',
'tox' 'tox'
], ],
'test': [ 'test': [
'toml',
'pytest', 'pytest',
'pytest-flake8' 'pytest-flake8'
] ]

@ -1,41 +1,35 @@
import pytest # pylint: disable=redefined-outer-name
import configspec
from copy import deepcopy from copy import deepcopy
import pytest
import preserve
from configspec import *
@pytest.fixture @pytest.fixture
def example_spec(): def example_spec():
confspec = configspec.ConfigSpecification() confspec = ConfigSpecification()
confspec.add_spec("bool_a", confspec.add_spec("bool_a", BoolSpec(helptext="Bool A"))
configspec.BoolSpec(False, False, "Bool A")) confspec.add_spec("boollist_a", ListSpec(BoolSpec(), helptext="Bool List A"))
confspec.add_spec("boollist_a", confspec.add_spec("int_a", IntSpec(-4, 4, helptext="Int A"))
configspec.BoolListSpec(False, False, "Bool A")) confspec.add_spec("intlist_a", ListSpec(IntSpec(-4, 4, "Int A")))
confspec.add_spec("int_a", confspec.add_spec("float_a", FloatSpec(-4, 4, helptext="Float A"))
configspec.IntSpec(0, -4, 4, False, "Int A")) confspec.add_spec("floatlist_a", ListSpec(FloatSpec(-4, 4, "Float A")))
confspec.add_spec("intlist_a", confspec.add_spec("string_a", StringSpec(helptext="String A"))
configspec.IntListSpec(0, -4, 4, False, "Int A")) confspec.add_spec("string_a_blank", StringSpec(allowempty=True, helptext="String A Blank"))
confspec.add_spec("string_a", confspec.add_spec("stringlist_a", ListSpec(StringSpec(), helptext="String List A"))
configspec.StringSpec("ThisIsAString", 4, 15, False, "String A"))
confspec.add_spec("stringlist_a", confdictspec = confspec.add_spec("dict_a", DictSpec(helptext="Dict A"))
configspec.StringListSpec("ThisIsAString", 4, 15, False, "String A")) confdictspec.add_spec("bool_b", BoolSpec(helptext="Bool B"))
confdictspec.add_spec("int_b", IntSpec(helptext="Int B"))
confdictspec = confspec.add_spec("dict_a", confdictspec.add_spec("float_b", FloatSpec(helptext="Float B"))
configspec.DictSpec(None, False, "Dict A")) confdictspec.add_spec("string_b", StringSpec(helptext="String B"))
confdictspec.add_spec("bool_b",
configspec.BoolSpec(False, False, "Bool B")) confdictlist = confspec.add_spec("dictlist_a", ListSpec(
confdictspec.add_spec("int_b", DictSpec(), helptext="List of Dicts B"))
configspec.IntSpec(0, -4, 4, False, "Int B")) confdictlist.add_specs({"bool_c": BoolSpec(helptext="Bool C"),
confdictspec.add_spec("string_b", "int_c": IntSpec(helptext="Int C"),
configspec.StringSpec("ThisIsAString", 4, 15, False, "String B")) "float_c": FloatSpec(helptext="Float C"),
"string_c": StringSpec(helptext="String C")})
confdictlist = confspec.add_spec("dictlist_a",
configspec.DictListSpec(None, False, "List of Dicts B"))
confdictlist.add_spec("bool_c",
configspec.BoolSpec(False, False, "Bool C"))
confdictlist.add_spec("int_c",
configspec.IntSpec(0, -4, 4, False, "Int C"))
confdictlist.add_spec("string_c",
configspec.StringSpec("ThisIsAString", 4, 15, False, "String C"))
return confspec return confspec
@ -46,61 +40,94 @@ def example_values():
'boollist_a': [False, True], 'boollist_a': [False, True],
'int_a': 1, 'int_a': 1,
'intlist_a': [1, 2, 3, 4], 'intlist_a': [1, 2, 3, 4],
'float_a': 1.5,
'floatlist_a': [1.2, 2.1, 3.7, 2.8],
'string_a': 'sometext', 'string_a': 'sometext',
'string_a_blank': '',
'stringlist_a': ['somemoretext', 'yetmoretext'], 'stringlist_a': ['somemoretext', 'yetmoretext'],
'dict_a': { 'dict_a': {
'bool_b': False, 'bool_b': False,
'int_b': 0, 'int_b': 0,
'float_b': 0.5,
'string_b': 'texttesttext'}, 'string_b': 'texttesttext'},
'dictlist_a': [{'bool_c': False, 'dictlist_a': [{'bool_c': False,
'int_c': 0, 'int_c': 3,
'float_c': 3.4,
'string_c': 'string1'}, 'string_c': 'string1'},
{'bool_c': False, {'bool_c': True,
'int_c': 0, 'int_c': 0,
'float_c': 0.4,
'string_c': 'string2'}]} 'string_c': 'string2'}]}
return vals return vals
def test_addspec(example_spec):
# Most of the test is really just forming the fixture without exception
assert isinstance(example_spec, ConfigSpecification)
def test_preserveable(example_spec):
assert isinstance(preserve.preserve(example_spec), dict)
def test_config_validate(example_spec, example_values): def test_config_validate(example_spec, example_values):
spec = example_spec example_spec.validate(example_values)
spec.validate(example_values)
def test_invalid_bool(): def test_invalid_bool():
spec = configspec.BoolSpec() spec = BoolSpec()
with pytest.raises(configspec.InvalidConfigError, match="value must be a boolean"): with pytest.raises(InvalidConfigError, match="value must be a boolean"):
spec.validate("false") spec.validate("false")
with pytest.raises(configspec.InvalidConfigError, match="value must be a boolean"): with pytest.raises(InvalidConfigError, match="value must be a boolean"):
spec.validate(None) spec.validate(None)
def test_invalid_int(): def test_invalid_int():
spec = configspec.IntSpec(minval=-4, maxval=4) spec = IntSpec(minval=-4, maxval=4)
with pytest.raises(configspec.InvalidConfigError, match="value must be >="): with pytest.raises(InvalidConfigError, match="value must be >="):
spec.validate(-5) spec.validate(-5)
with pytest.raises(configspec.InvalidConfigError, match="value must be <="): with pytest.raises(InvalidConfigError, match="value must be <="):
spec.validate(5) spec.validate(5)
with pytest.raises(configspec.InvalidConfigError, match="value must be an integer"): with pytest.raises(InvalidConfigError, match="value must be an integer"):
spec.validate("5") spec.validate("5")
with pytest.raises(configspec.InvalidConfigError, match="value must be an integer"): with pytest.raises(InvalidConfigError, match="value must be an integer"):
spec.validate(None) spec.validate(None)
def test_invalid_string(): def test_invalid_string():
spec = configspec.StringSpec(minlength=4, maxlength=15) spec = StringSpec()
with pytest.raises(configspec.InvalidConfigError, match="string length must be >="): # TODO add regex check
spec.validate("123") with pytest.raises(InvalidConfigError, match="value must be a string"):
with pytest.raises(configspec.InvalidConfigError, match="string length must be <="):
spec.validate("1234567890abcdef")
with pytest.raises(configspec.InvalidConfigError, match="value must be a string"):
spec.validate(1) spec.validate(1)
with pytest.raises(configspec.InvalidConfigError, match="value must be a string"): with pytest.raises(InvalidConfigError, match="value must be a string"):
spec.validate(None) spec.validate(None)
with pytest.raises(InvalidConfigError, match="value cannot be a blank string"):
spec.validate("")
def test_dict_optional():
spec = DictSpec()
spec.add_spec("int1", IntSpec())
spec.add_spec("int2", IntSpec(), optional=True)
spec.add_spec("int3", IntSpec(), optional=True, default=1)
with pytest.raises(InvalidConfigError, match="must contain key 'int1'"):
spec.validate({})
with pytest.raises(InvalidConfigError, match="contains unknown key"):
spec.validate({"int1": 1, "int5": 0})
d = {"int1": 1, "int2": 2, "int3": 3}
spec.validate(d)
assert d == {"int1": 1, "int2": 2, "int3": 3}
d = {"int1": 1}
spec.validate(d)
assert d == {"int1": 1, "int2": None, "int3": 1}
def test_man(example_spec, example_values): def test_man(example_spec, example_values):
confman = configspec.ConfigManager() confman = ConfigManager()
confman.add_confspec("test_bundle", example_spec) confman.add_confspec("test_bundle", example_spec)
confman.load({"test_bundle": example_values}) confman.load({"test_bundle": example_values})
assert confman.get_config_bundle("test_bundle") == example_values assert confman.get_config_bundle("test_bundle") == example_values

Loading…
Cancel
Save