From f7ac5ef0c398d2f53287bfad9898e0d1be7b7b4a Mon Sep 17 00:00:00 2001 From: novirium Date: Mon, 23 Dec 2019 14:47:05 +0800 Subject: [PATCH] Split manager functionality out. Closes #5 --- configspec/__init__.py | 2 + configspec.py => configspec/manager.py | 224 +------------------------ configspec/specification.py | 222 ++++++++++++++++++++++++ setup.py | 4 +- tests/test_configmanager.py | 20 +++ 5 files changed, 247 insertions(+), 225 deletions(-) create mode 100644 configspec/__init__.py rename configspec.py => configspec/manager.py (50%) create mode 100644 configspec/specification.py create mode 100644 tests/test_configmanager.py diff --git a/configspec/__init__.py b/configspec/__init__.py new file mode 100644 index 0000000..43538e3 --- /dev/null +++ b/configspec/__init__.py @@ -0,0 +1,2 @@ +from .specification import * +from .manager import * diff --git a/configspec.py b/configspec/manager.py similarity index 50% rename from configspec.py rename to configspec/manager.py index a4317af..df31f73 100644 --- a/configspec.py +++ b/configspec/manager.py @@ -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(): diff --git a/configspec/specification.py b/configspec/specification.py new file mode 100644 index 0000000..e27249b --- /dev/null +++ b/configspec/specification.py @@ -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 diff --git a/setup.py b/setup.py index 86ed6a9..1406cd5 100644 --- a/setup.py +++ b/setup.py @@ -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={ diff --git a/tests/test_configmanager.py b/tests/test_configmanager.py new file mode 100644 index 0000000..0f157aa --- /dev/null +++ b/tests/test_configmanager.py @@ -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"