Skip to content

Commit

Permalink
enhancements:
Browse files Browse the repository at this point in the history
- Array items of type object are defaulted as type _Dict and marked with 'from_default'
- 'required' properties are only validated for properties that have no default
- Better test coverage
  • Loading branch information
stuarteberg committed Jan 2, 2019
1 parent e0abcff commit a38f2ed
Show file tree
Hide file tree
Showing 3 changed files with 208 additions and 31 deletions.
1 change: 1 addition & 0 deletions confiddler/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
from jsonschema import ValidationError
from .core import load_config, dump_default_config, validate, emit_defaults, flow_style
from . import json
40 changes: 36 additions & 4 deletions confiddler/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from collections.abc import Mapping

from jsonschema import validators
from jsonschema.exceptions import _Error
from jsonschema.exceptions import _Error, ValidationError
from ruamel.yaml.compat import ordereddict
from ruamel.yaml.comments import CommentedMap, CommentedSeq
from ruamel.yaml import YAML
Expand Down Expand Up @@ -229,8 +229,9 @@ def extend_with_default(validator_class):
(The results of this function are not meant for pretty-printing.)
"""
validate_properties = validator_class.VALIDATORS["properties"]
validate_items = validator_class.VALIDATORS["items"]

def _set_defaults(properties, instance):
def _set_property_defaults(properties, instance):
for property_name, subschema in properties.items():
if "default" in subschema:
default = copy.deepcopy(subschema["default"])
Expand All @@ -240,11 +241,42 @@ def _set_defaults(properties, instance):
instance.setdefault(property_name, default)

def set_defaults_and_validate(validator, properties, instance, schema):
_set_defaults(properties, instance)
_set_property_defaults(properties, instance)
for error in validate_properties(validator, properties, instance, schema):
yield error

return validators.extend(validator_class, {"properties" : set_defaults_and_validate})
def fill_in_default_array_items(validator, items_schema, instance, schema):
new_items = []
for item in instance:
if "default" in items_schema:
default = copy.deepcopy(items_schema["default"])
if isinstance(default, dict):
default = _Dict(default)
if item == {}:
default.from_default = True
default.update(item)
new_items.append(default)

instance.clear()
instance.extend(new_items)

# Descend into array list
for error in validate_items(validator, items_schema, instance, schema):
yield error


def check_required(validator, required, instance, schema):
# We only check 'required' properties that don't have specified defaults
for prop in required:
if prop in instance:
continue
if prop not in schema['properties'] or 'default' not in schema['properties'][prop]:
yield ValidationError("%r is a required property and has no default value in your schema" % prop)


return validators.extend(validator_class, {"properties" : set_defaults_and_validate,
"items": fill_in_default_array_items,
"required": check_required})


def extend_with_default_without_validation(validator_class, include_yaml_comments=False, yaml_indent=2):
Expand Down
198 changes: 171 additions & 27 deletions confiddler/tests/test_core.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,35 @@
import copy
import tempfile
from io import StringIO

import pytest
from ruamel.yaml import YAML

from confiddler import load_config, emit_defaults, validate
from confiddler import load_config, emit_defaults, validate, flow_style, ValidationError

yaml = YAML()
yaml.default_flow_style = False

TEST_SCHEMA = {
'type': 'object',
'required': ['mystring', 'mynumber'],
'default': {},
'properties': {
'mystring': {
'type': 'string',
'default': 'DEFAULT'
},
'mynumber': {
'type': 'number',
'default': 42
},
'myobject': {
'type': 'object',
'default': {'inner-string': 'INNER_DEFAULT'}
}
}
}


def test_load_empty():
f = StringIO('{}')
Expand All @@ -17,8 +39,10 @@ def test_load_empty():

def test_validate():
schema = {
'mystring': {
'type': 'string'
'properties': {
'mystring': {
'type': 'string'
}
}
}

Expand All @@ -32,31 +56,98 @@ def test_validate():
assert cfg['mystring'] == 'Test'


def test_emit_defaults():
def test_failed_validate():
schema = {
'type': 'object',
'properties': {
'mystring': {
'type': 'string'
}
}
}

data = {"mystring": 123}

f = StringIO()
yaml.dump(data, f)
f.seek(0)

with pytest.raises(ValidationError):
load_config(f, schema)


def test_missing_required_property_with_default():
schema = {
'required': ['mystring'],
'properties': {
'mystring': {
'type': 'string',
'default': 'DEFAULT'
},
'mynumber': {
'type': 'number',
'default': 42
}
},
'default': {}
}
}

data = {}

f = StringIO()
yaml.dump(data, f)
f.seek(0)

cfg = load_config(f, schema)
assert cfg['mystring'] == "DEFAULT"


def test_missing_required_property_no_default():
schema = {
'required': ['mystring'],
'properties': {
'mystring': {
'type': 'string',

# NO DEFAULT -- really required
#'default': 'DEFAULT'
}
}
}

data = {}

f = StringIO()
yaml.dump(data, f)
f.seek(0)

with pytest.raises(ValidationError):
load_config(f, schema)


def test_emit_defaults():
schema = copy.deepcopy(TEST_SCHEMA)
defaults = emit_defaults(schema)
assert defaults == { 'mystring': 'DEFAULT',
'mynumber': 42 }
'mynumber': 42,
'myobject': {'inner-string': 'INNER_DEFAULT'} }

# Make sure defaults still validate
# (despite being yaml CommentedMap or whatever)
validate(defaults, schema)


def test_emit_incomplete_defaults():
schema = copy.deepcopy(TEST_SCHEMA)

# Delete the default for 'mynumber'
del schema['properties']['mynumber']['default']

defaults = emit_defaults(schema)
assert defaults == { 'mystring': 'DEFAULT',
'mynumber': '{{NO_DEFAULT}}',
'myobject': {'inner-string': 'INNER_DEFAULT'} }

# The '{{NO_DEFAULT}}' setting doesn't validate.
# That's okay.
with pytest.raises(ValidationError):
validate(defaults, schema)


def test_emit_defaults_with_comments():
schema = {
'type': 'object',
Expand Down Expand Up @@ -87,22 +178,21 @@ def test_emit_defaults_with_comments():
assert 'MYNUMBER_DESCRIPTION_TEXT' in f.getvalue()


def test_inject_default():
schema = {
'type': 'object',
'properties': {
'mystring': {
'type': 'string',
'default': 'DEFAULT'
},
'mynumber': {
'type': 'number',
'default': 42
}
},
'default': {}
}
def test_emit_defaults_with_flow_style():
schema = copy.deepcopy(TEST_SCHEMA)
d = schema['properties']['myobject']['default']
schema['properties']['myobject']['default'] = flow_style(d)

defaults = emit_defaults(schema)
assert defaults['myobject'].fa.flow_style()

# Make sure defaults still validate
# (despite being yaml CommentedMap or whatever)
validate(defaults, schema)


def test_inject_default():
schema = copy.deepcopy(TEST_SCHEMA)
data = {'mynumber': 10}

f = StringIO()
Expand All @@ -111,8 +201,62 @@ def test_inject_default():

cfg = load_config(f, schema)
assert cfg['mystring'] == 'DEFAULT'
assert cfg['myobject']['inner-string'] == 'INNER_DEFAULT'
assert cfg['myobject'].from_default == True
validate(cfg, schema)


def test_inject_default_array_item_objects():
"""
Users can specify that items of an array should be objects,
with a particular schema. If that item schema specifies default properties,
then those properties will be injected into any objects in the list (if the user ommitted them).
The NUMBER of items must be chosen by the user,
but the contents of the items is determined by the default schema.
"""
schema = {
'type': 'array',
'items': {
'type': 'object',
'default': {},
'properties': {
'foo': {
'default': 'bar'
}
}
}
}

# The first object in this array is completely specified
# by the user, but the remaining two will be "filled in"
# with the defaults from the item schema.
data = [{'foo': 'MYFOO'}, {}, {}]

f = StringIO()
yaml.dump(data, f)
f.seek(0)

cfg = load_config(f, schema)
assert cfg == [{'foo': 'MYFOO'},
{'foo': 'bar'},
{'foo': 'bar'}]
assert not cfg[0].from_default
assert cfg[1].from_default
assert cfg[2].from_default


def test_load_from_path():
d = tempfile.mkdtemp()
config = {'mynumber': 99}
path = f'{d}/test_load_from_path.yaml'
with open(path, 'w') as f:
yaml.dump(config, f)

loaded = load_config(path, TEST_SCHEMA, True)
assert loaded['mynumber'] == 99
assert loaded['mystring'] == "DEFAULT"


if __name__ == "__main__":
pytest.main(['-s', '--tb=native', '--pyargs', 'confiddler.tests.test_core'])

0 comments on commit a38f2ed

Please sign in to comment.