Skip to content

Commit

Permalink
change config validation to schema validation with decorators
Browse files Browse the repository at this point in the history
  • Loading branch information
ameily committed Aug 16, 2019
1 parent ba0031e commit bbefd6a
Show file tree
Hide file tree
Showing 5 changed files with 101 additions and 32 deletions.
10 changes: 9 additions & 1 deletion cincoconfig/abc.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,14 @@ def __init__(self, key: str = None, dynamic: bool = False):
self._key = key
self._dynamic = dynamic
self._fields = dict() # type: Dict[str, SchemaField]
self.__post_init__()

def __post_init__(self) -> None:
'''
Subclass hook that is called at the end of ``__init__``. This allows subclasses to perform
additional initialization without overriding the ``__init__`` method. The default
implementation does nothing.
'''

def __setkey__(self, parent: 'BaseSchema', key: str) -> None:
'''
Expand Down Expand Up @@ -347,7 +355,7 @@ def _get_field(self, key: str) -> Optional[SchemaField]:
'''
return self._schema._get_field(key) or super()._get_field(key)

def _validate(self) -> None:
def validate(self) -> None:
'''
Validate the configuration. The default implementation does nothing.
'''
Expand Down
77 changes: 62 additions & 15 deletions cincoconfig/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
#

import sys
from typing import Union, Any, Iterator, Tuple, Callable
from typing import Union, Any, Iterator, Tuple, Callable, List
from itertools import chain
from .abc import Field, BaseConfig, BaseSchema, SchemaField, AnyField
from .fields import IncludeField
Expand Down Expand Up @@ -40,6 +40,12 @@ class Schema(BaseSchema):
configuration from a file.
'''

def __post_init__(self) -> None:
'''
Initialize the schema.
'''
self._validators = [] # type: List[ConfigValidator]

def __setattr__(self, name: str, value: SchemaField):
'''
:param name: attribute name
Expand Down Expand Up @@ -68,14 +74,13 @@ def __iter__(self) -> Iterator[Tuple[str, SchemaField]]:
for key, field in self._fields.items():
yield key, field

def __call__(self, validator: ConfigValidator = None) -> 'Config':
def __call__(self) -> 'Config':
'''
Compile the schema into an initial config with default values set.
'''
return Config(self, validator=validator)
return Config(self)

def make_type(self, name: str, module: str = None, key_filename: str = None,
validator: ConfigValidator = None) -> type:
def make_type(self, name: str, module: str = None, key_filename: str = None) -> type:
'''
Create a new type that wraps this schema. This method should only be called once per
schema object.
Expand Down Expand Up @@ -124,7 +129,7 @@ def make_type(self, name: str, module: str = None, key_filename: str = None,
schema = self

def init_method(self, **kwargs):
Config.__init__(self, schema, key_filename=key_filename, validator=validator)
Config.__init__(self, schema, key_filename=key_filename)
for key, value in kwargs.items():
self.__setattr__(key, value)

Expand All @@ -141,6 +146,53 @@ def init_method(self, **kwargs):

return result

def validator(self, func: ConfigValidator) -> ConfigValidator:
'''
Decorator to register a new validator with the schema. All validators will be run against
the configuration whenever the configuration is loaded from disk. Multiple validators can
be registered by using the decorator multiple times. Subconfigs can also be validated by
using the decorateor on the sub schema.
.. code-block:: python
schema = Schema()
schema.x = IntField()
schema.y = IntField()
schema.db.username = StringField()
schema.db.password = StringField()
@schema.validator
def validate_x_lt_y(cfg):
if cfg.x and cfg.y and cfg.x >= cfg.y:
raise ValueError('x must be less-than y')
@schema.db.validator
def validate_db_creds(cfg):
if cfg.username and not db.password:
raise ValueError('db.password is required when username is specified')
config = schema()
config.load('mycfg.json', format='json') # will call the above validators
# .....
The validator function needs to raise an exception, preferably a :class:`ValueError`, if
the validation fails.
:param func: validator function that accepts a single argument: :class:`Config`.
:returns: ``func``
'''
self._validators.append(func)
return func

def _validate(self, config: 'Config') -> None:
'''
Validate the configuration by running any registered validators against it.
:param config: config to validate
'''
for validator in self._validators:
validator(config)


class Config(BaseConfig):
'''
Expand Down Expand Up @@ -181,18 +233,14 @@ class Config(BaseConfig):
# config = schema()
'''

def __init__(self, schema: BaseSchema, parent: 'Config' = None, key_filename: str = None,
validator: ConfigValidator = None):
def __init__(self, schema: BaseSchema, parent: 'Config' = None, key_filename: str = None):
'''
:param schema: backing schema, stored as *_schema*
:param parent: parent config instance, only set when this config is a field of another
config, stored as *_parent*
:param key_filename: path to key file
:param validator: callback method that performs custom validation after the config is
loaded
'''
super().__init__(schema, parent, key_filename)
self._validator = validator

for key, field in schema._fields.items():
if isinstance(field, BaseSchema):
Expand Down Expand Up @@ -374,7 +422,7 @@ def load_tree(self, tree: dict):

self.__setattr__(key, value)

self._validate()
self.validate()

def __iter__(self) -> Iterator[Tuple[str, Any]]:
'''
Expand Down Expand Up @@ -405,9 +453,8 @@ def to_tree(self) -> dict:

return tree

def _validate(self) -> None:
def validate(self) -> None:
'''
Perform validation on the entire config.
'''
if self._validator:
self._validator(self)
self._schema._validate(self) # type: ignore
2 changes: 1 addition & 1 deletion cincoconfig/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -572,7 +572,7 @@ def _validate(self, value: Any) -> Any:
cfg.load_tree(value)
elif isinstance(value, BaseConfig):
value._parent = self.cfg
value._validate()
value.validate()
cfg = value
else:
raise ValueError('invalid configuration object')
Expand Down
33 changes: 21 additions & 12 deletions docs/recipes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,34 @@ Recipes
Validate Configuration On Load
------------------------------

When creating the configuration, by calling the :class:`~cincoconfig.Schema` object, you can pass in
a callback method that will be called whenever the configuration file is loaded from disk. The
callback accepts a single argument: the configuration.
The :class:`~cincoconfig.Schema` object has a :meth:`~cincoconfig.Schema.validator` decorator that
registers a method as a validator. Whenever the configuration is loaded, the schema's validators
are run against the configuration. Custom config validators can perform advanced validations, like
the following:

.. code-block:: python
def validate_config(config: Config) -> None:
'''
Validates that if both x and y are specified in the config that x is less than y.
'''
if config.x and config.y and config.x < config.y:
raise ValueError('x must be < y')
schema = Schema()
schema.x = IntField()
schema.y = IntField()
schema.db.username = StringField()
schema.db.password = StringField()
@schema.validator
def validate_x_lt_y(cfg):
# validates that x < y
if cfg.x and cfg.y and cfg.x >= cfg.y:
raise ValueError('x must be less-than y')
@schema.db.validator
def validate_db_creds(cfg):
# validates that if the db username is specifed then the password must
# also be specified.
if cfg.username and not db.password:
raise ValueError('db.password is required when username is specified')
config = schema(validator=validate_config)
config.load('myconfig.json', format='json')
config = schema()
config.load('mycfg.json', format='json') # will call the above validators
Allow Multiple Configuration Files
Expand Down
11 changes: 8 additions & 3 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,14 +247,19 @@ def test_load_tree_validate(self):
config = schema()

mock_validate = MagicMock()
object.__setattr__(config, '_validate', mock_validate)
object.__setattr__(config, 'validate', mock_validate)
config.load_tree({'x': 1})
mock_validate.assert_called_once_with()

def test_validator(self):
validator = MagicMock()
schema = Schema()
schema.x = Field()
config = schema(validator=validator)
config._validate()

@schema.validator
def validate(cfg):
validator(cfg)

config = schema()
config.validate()
validator.assert_called_once_with(config)

0 comments on commit bbefd6a

Please sign in to comment.