from __future__ import annotations import json from unittest import mock import pytest from cfgv import apply_defaults from cfgv import Array from cfgv import check_and from cfgv import check_any from cfgv import check_array from cfgv import check_bool from cfgv import check_one_of from cfgv import check_regex from cfgv import check_type from cfgv import Conditional from cfgv import ConditionalOptional from cfgv import ConditionalRecurse from cfgv import In from cfgv import load_from_filename from cfgv import Map from cfgv import MISSING from cfgv import NoAdditionalKeys from cfgv import Not from cfgv import NotIn from cfgv import Optional from cfgv import OptionalNoDefault from cfgv import OptionalRecurse from cfgv import remove_defaults from cfgv import Required from cfgv import RequiredRecurse from cfgv import validate from cfgv import ValidationError from cfgv import WarnAdditionalKeys def _assert_exception_trace(e, trace): parts = [] while e.ctx is not None: parts.append(e.ctx) e = e.error_msg parts.append(e.error_msg) assert tuple(parts) == trace def test_ValidationError_simple_str(): assert str(ValidationError('error msg')) == ( '\n' '=====> error msg' ) def test_ValidationError_nested(): error = ValidationError( ValidationError( ValidationError('error msg'), ctx='At line 1', ), ctx='In file foo', ) assert str(error) == ( '\n' '==> In file foo\n' '==> At line 1\n' '=====> error msg' ) def test_check_one_of(): with pytest.raises(ValidationError) as excinfo: check_one_of((1, 2))(3) assert excinfo.value.error_msg == 'Expected one of 1, 2 but got: 3' def test_check_one_of_ok(): check_one_of((1, 2))(2) def test_check_regex(): with pytest.raises(ValidationError) as excinfo: check_regex('(') assert excinfo.value.error_msg == "'(' is not a valid python regex" def test_check_regex_ok(): check_regex('^$') def test_check_array_failed_inner_check(): check = check_array(check_bool) with pytest.raises(ValidationError) as excinfo: check([True, False, 5]) _assert_exception_trace( excinfo.value, ('At index 2', 'Expected bool got int'), ) def test_check_array_ok(): check_array(check_bool)([True, False]) def test_check_and(): check = check_and(check_type(str), check_regex) with pytest.raises(ValidationError) as excinfo: check(True) assert excinfo.value.error_msg == 'Expected str got bool' with pytest.raises(ValidationError) as excinfo: check('(') assert excinfo.value.error_msg == "'(' is not a valid python regex" def test_check_and_ok(): check = check_and(check_type(str), check_regex) check('^$') @pytest.mark.parametrize( ('val', 'expected'), (('bar', True), ('foo', False), (MISSING, False)), ) def test_not(val, expected): compared = Not('foo') assert (val == compared) is expected assert (compared == val) is expected @pytest.mark.parametrize( ('values', 'expected'), (('bar', True), ('foo', False), (MISSING, False)), ) def test_not_in(values, expected): compared = NotIn('baz', 'foo') assert (values == compared) is expected assert (compared == values) is expected @pytest.mark.parametrize( ('values', 'expected'), (('bar', False), ('foo', True), ('baz', True), (MISSING, False)), ) def test_in(values, expected): compared = In('baz', 'foo') assert (values == compared) is expected assert (compared == values) is expected trivial_array_schema = Array(Map('foo', 'id')) trivial_array_schema_nonempty = Array(Map('foo', 'id'), allow_empty=False) def test_validate_top_level_array_not_an_array(): with pytest.raises(ValidationError) as excinfo: validate({}, trivial_array_schema) assert excinfo.value.error_msg == "Expected array but got 'dict'" def test_validate_top_level_array_no_objects(): with pytest.raises(ValidationError) as excinfo: validate([], trivial_array_schema_nonempty) assert excinfo.value.error_msg == "Expected at least 1 'foo'" def test_trivial_array_schema_ok_empty(): validate([], trivial_array_schema) @pytest.mark.parametrize('v', (({},), [{}])) def test_ok_both_types(v): validate(v, trivial_array_schema) map_required = Map('foo', 'key', Required('key', check_bool)) map_optional = Map('foo', 'key', Optional('key', check_bool, False)) map_no_default = Map('foo', 'key', OptionalNoDefault('key', check_bool)) map_no_id_key = Map('foo', None, Required('key', check_bool)) def test_map_wrong_type(): with pytest.raises(ValidationError) as excinfo: validate([], map_required) assert excinfo.value.error_msg == 'Expected a foo map but got a list' def test_required_missing_key(): with pytest.raises(ValidationError) as excinfo: validate({}, map_required) expected = ('At foo(key=MISSING)', 'Missing required key: key') _assert_exception_trace(excinfo.value, expected) @pytest.mark.parametrize( 'schema', (map_required, map_optional, map_no_default), ) def test_map_value_wrong_type(schema): with pytest.raises(ValidationError) as excinfo: validate({'key': 5}, schema) expected = ('At foo(key=5)', 'At key: key', 'Expected bool got int') _assert_exception_trace(excinfo.value, expected) @pytest.mark.parametrize( 'schema', (map_required, map_optional, map_no_default), ) def test_map_value_correct_type(schema): validate({'key': True}, schema) @pytest.mark.parametrize('schema', (map_optional, map_no_default)) def test_optional_key_missing(schema): validate({}, schema) def test_error_message_no_id_key(): with pytest.raises(ValidationError) as excinfo: validate({'key': 5}, map_no_id_key) expected = ('At foo()', 'At key: key', 'Expected bool got int') _assert_exception_trace(excinfo.value, expected) map_conditional = Map( 'foo', 'key', Conditional( 'key2', check_bool, condition_key='key', condition_value=True, ), ) map_conditional_not = Map( 'foo', 'key', Conditional( 'key2', check_bool, condition_key='key', condition_value=Not(False), ), ) map_conditional_absent = Map( 'foo', 'key', Conditional( 'key2', check_bool, condition_key='key', condition_value=True, ensure_absent=True, ), ) map_conditional_absent_not = Map( 'foo', 'key', Conditional( 'key2', check_bool, condition_key='key', condition_value=Not(True), ensure_absent=True, ), ) map_conditional_absent_not_in = Map( 'foo', 'key', Conditional( 'key2', check_bool, condition_key='key', condition_value=NotIn(1, 2), ensure_absent=True, ), ) map_conditional_absent_in = Map( 'foo', 'key', Conditional( 'key2', check_bool, condition_key='key', condition_value=In(1, 2), ensure_absent=True, ), ) @pytest.mark.parametrize('schema', (map_conditional, map_conditional_not)) @pytest.mark.parametrize( 'v', ( # Conditional check passes, key2 is checked and passes {'key': True, 'key2': True}, # Conditional check fails, key2 is not checked {'key': False, 'key2': 'ohai'}, ), ) def test_ok_conditional_schemas(v, schema): validate(v, schema) @pytest.mark.parametrize('schema', (map_conditional, map_conditional_not)) def test_not_ok_conditional_schemas(schema): with pytest.raises(ValidationError) as excinfo: validate({'key': True, 'key2': 5}, schema) expected = ('At foo(key=True)', 'At key: key2', 'Expected bool got int') _assert_exception_trace(excinfo.value, expected) def test_ensure_absent_conditional(): with pytest.raises(ValidationError) as excinfo: validate({'key': False, 'key2': True}, map_conditional_absent) expected = ( 'At foo(key=False)', 'Expected key2 to be absent when key is not True, ' 'found key2: True', ) _assert_exception_trace(excinfo.value, expected) def test_ensure_absent_conditional_not(): with pytest.raises(ValidationError) as excinfo: validate({'key': True, 'key2': True}, map_conditional_absent_not) expected = ( 'At foo(key=True)', 'Expected key2 to be absent when key is True, ' 'found key2: True', ) _assert_exception_trace(excinfo.value, expected) def test_ensure_absent_conditional_not_in(): with pytest.raises(ValidationError) as excinfo: validate({'key': 1, 'key2': True}, map_conditional_absent_not_in) expected = ( 'At foo(key=1)', 'Expected key2 to be absent when key is any of (1, 2), ' 'found key2: True', ) _assert_exception_trace(excinfo.value, expected) def test_ensure_absent_conditional_in(): with pytest.raises(ValidationError) as excinfo: validate({'key': 3, 'key2': True}, map_conditional_absent_in) expected = ( 'At foo(key=3)', 'Expected key2 to be absent when key is not any of (1, 2), ' 'found key2: True', ) _assert_exception_trace(excinfo.value, expected) def test_no_error_conditional_absent(): validate({}, map_conditional_absent) validate({}, map_conditional_absent_not) validate({'key2': True}, map_conditional_absent) validate({'key2': True}, map_conditional_absent_not) def test_apply_defaults_copies_object(): val = {} ret = apply_defaults(val, map_optional) assert ret is not val def test_apply_defaults_sets_default(): ret = apply_defaults({}, map_optional) assert ret == {'key': False} def test_apply_defaults_does_not_change_non_default(): ret = apply_defaults({'key': True}, map_optional) assert ret == {'key': True} def test_apply_defaults_does_nothing_on_non_optional(): ret = apply_defaults({}, map_required) assert ret == {} def test_apply_defaults_map_in_list(): ret = apply_defaults([{}], Array(map_optional)) assert ret == [{'key': False}] def test_remove_defaults_copies_object(): val = {'key': False} ret = remove_defaults(val, map_optional) assert ret is not val def test_remove_defaults_removes_defaults(): ret = remove_defaults({'key': False}, map_optional) assert ret == {} def test_remove_defaults_nothing_to_remove(): ret = remove_defaults({}, map_optional) assert ret == {} def test_remove_defaults_does_not_change_non_default(): ret = remove_defaults({'key': True}, map_optional) assert ret == {'key': True} def test_remove_defaults_map_in_list(): ret = remove_defaults([{'key': False}], Array(map_optional)) assert ret == [{}] def test_remove_defaults_does_nothing_on_non_optional(): ret = remove_defaults({'key': True}, map_required) assert ret == {'key': True} nested_schema_required = Map( 'Repository', 'repo', Required('repo', check_any), RequiredRecurse('hooks', Array(map_required)), ) nested_schema_optional = Map( 'Repository', 'repo', Required('repo', check_any), RequiredRecurse('hooks', Array(map_optional)), ) def test_validate_failure_nested(): with pytest.raises(ValidationError) as excinfo: validate({'repo': 1, 'hooks': [{}]}, nested_schema_required) expected = ( 'At Repository(repo=1)', 'At key: hooks', 'At foo(key=MISSING)', 'Missing required key: key', ) _assert_exception_trace(excinfo.value, expected) def test_apply_defaults_nested(): val = {'repo': 'repo1', 'hooks': [{}]} ret = apply_defaults(val, nested_schema_optional) assert ret == {'repo': 'repo1', 'hooks': [{'key': False}]} def test_remove_defaults_nested(): val = {'repo': 'repo1', 'hooks': [{'key': False}]} ret = remove_defaults(val, nested_schema_optional) assert ret == {'repo': 'repo1', 'hooks': [{}]} link = Map('Link', 'key', Required('key', check_bool)) optional_nested_schema = Map( 'Config', None, OptionalRecurse('links', Array(link), []), ) def test_validate_failure_optional_recurse(): with pytest.raises(ValidationError) as excinfo: validate({'links': [{}]}, optional_nested_schema) expected = ( 'At Config()', 'At key: links', 'At Link(key=MISSING)', 'Missing required key: key', ) _assert_exception_trace(excinfo.value, expected) def test_optional_recurse_ok_missing(): validate({}, optional_nested_schema) def test_apply_defaults_optional_recurse_missing(): ret = apply_defaults({}, optional_nested_schema) assert ret == {'links': []} def test_apply_defaults_optional_recurse_already_present(): ret = apply_defaults({'links': [{'key': True}]}, optional_nested_schema) assert ret == {'links': [{'key': True}]} def test_remove_defaults_optional_recurse_not_present(): assert remove_defaults({}, optional_nested_schema) == {} def test_remove_defaults_optional_recurse_present_at_default(): assert remove_defaults({'links': []}, optional_nested_schema) == {} def test_remove_defaults_optional_recurse_non_default(): ret = remove_defaults({'links': [{'key': True}]}, optional_nested_schema) assert ret == {'links': [{'key': True}]} builder_opts = Map('BuilderOpts', None, Optional('noop', check_bool, True)) optional_nested_optional_schema = Map( 'Config', None, OptionalRecurse('builder', builder_opts, {}), ) def test_optional_optional_apply_defaults(): ret = apply_defaults({}, optional_nested_optional_schema) assert ret == {'builder': {'noop': True}} def test_optional_optional_remove_defaults(): val = {'builder': {'noop': True}} ret = remove_defaults(val, optional_nested_optional_schema) assert ret == {} params1_schema = Map('Params1', None, Required('p1', check_bool)) params2_schema = Map('Params2', None, Required('p2', check_bool)) conditional_nested_schema = Map( 'Config', None, Required('type', check_any), ConditionalRecurse('params', params1_schema, 'type', 'type1'), ConditionalRecurse('params', params2_schema, 'type', 'type2'), ) @pytest.mark.parametrize( 'val', ( {'type': 'type3'}, # matches no condition {'type': 'type1', 'params': {'p1': True}}, {'type': 'type2', 'params': {'p2': True}}, ), ) def test_conditional_recurse_ok(val): validate(val, conditional_nested_schema) def test_conditional_recurse_error(): with pytest.raises(ValidationError) as excinfo: val = {'type': 'type1', 'params': {'p2': True}} validate(val, conditional_nested_schema) expected = ( 'At Config()', 'At key: params', 'At Params1()', 'Missing required key: p1', ) _assert_exception_trace(excinfo.value, expected) class Error(Exception): pass def test_load_from_filename_file_does_not_exist(): with pytest.raises(Error) as excinfo: load_from_filename('does_not_exist', map_required, json.loads, Error) assert excinfo.value.args[0].error_msg == 'does_not_exist is not a file' def test_load_from_filename_not_a_file(tmpdir): with tmpdir.as_cwd(): tmpdir.join('f').ensure_dir() with pytest.raises(Error) as excinfo: load_from_filename('f', map_required, json.loads, Error) assert excinfo.value.args[0].error_msg == 'f is not a file' def test_load_from_filename_unicode_error(tmp_path): f = tmp_path.joinpath('f') f.write_bytes(b'\x98\xae\xfe') with pytest.raises(Error) as excinfo: load_from_filename(f, map_required, json.loads, Error) expected = (f'File {f}', mock.ANY) _assert_exception_trace(excinfo.value.args[0], expected) def test_load_from_filename_fails_load_strategy(tmpdir): f = tmpdir.join('foo.notjson') f.write('totes not json') with pytest.raises(Error) as excinfo: load_from_filename(f.strpath, map_required, json.loads, Error) # ANY is json's error message expected = (f'File {f.strpath}', mock.ANY) _assert_exception_trace(excinfo.value.args[0], expected) def test_load_from_filename_validation_error(tmpdir): f = tmpdir.join('foo.json') f.write('{}') with pytest.raises(Error) as excinfo: load_from_filename(f.strpath, map_required, json.loads, Error) expected = ( f'File {f.strpath}', 'At foo(key=MISSING)', 'Missing required key: key', ) _assert_exception_trace(excinfo.value.args[0], expected) def test_load_from_filename_applies_defaults(tmpdir): f = tmpdir.join('foo.json') f.write('{}') ret = load_from_filename(f.strpath, map_optional, json.loads, Error) assert ret == {'key': False} def test_load_from_filename_custom_display_no_file(tmp_path): with pytest.raises(ValidationError) as excinfo: load_from_filename( tmp_path.joinpath('cfg.json'), map_required, json.loads, display_filename='cfg.json', ) _assert_exception_trace(excinfo.value.args[0], ('cfg.json is not a file',)) def test_load_from_filename_custom_display_error(tmp_path): f = tmp_path.joinpath('cfg.json') f.write_text('{}') with pytest.raises(ValidationError) as excinfo: load_from_filename( f, map_required, json.loads, display_filename='cfg.json', ) expected = ( 'File cfg.json', 'At foo(key=MISSING)', 'Missing required key: key', ) _assert_exception_trace(excinfo.value.args[0], expected) conditional_recurse = Map( 'Map', None, Required('t', check_bool), ConditionalRecurse( 'v', Map('Inner', 'k', Optional('k', check_bool, True)), 't', True, ), ConditionalRecurse( 'v', Map('Inner', 'k', Optional('k', check_bool, False)), 't', False, ), ) @pytest.mark.parametrize('tvalue', (True, False)) def test_conditional_recurse_apply_defaults(tvalue): val = {'t': tvalue, 'v': {}} ret = apply_defaults(val, conditional_recurse) assert ret == {'t': tvalue, 'v': {'k': tvalue}} val = {'t': tvalue, 'v': {'k': not tvalue}} ret = apply_defaults(val, conditional_recurse) assert ret == {'t': tvalue, 'v': {'k': not tvalue}} @pytest.mark.parametrize('tvalue', (True, False)) def test_conditional_recurse_remove_defaults(tvalue): val = {'t': tvalue, 'v': {'k': tvalue}} ret = remove_defaults(val, conditional_recurse) assert ret == {'t': tvalue, 'v': {}} val = {'t': tvalue, 'v': {'k': not tvalue}} ret = remove_defaults(val, conditional_recurse) assert ret == {'t': tvalue, 'v': {'k': not tvalue}} conditional_optional = Map( 'Map', None, Required('t', check_bool), ConditionalOptional('v', check_bool, True, 't', True), ConditionalOptional('v', check_bool, False, 't', False), ) @pytest.mark.parametrize('tvalue', (True, False)) def test_conditional_optional_check(tvalue): with pytest.raises(ValidationError) as excinfo: validate({'t': tvalue, 'v': 2}, conditional_optional) expected = ( 'At Map()', 'At key: v', 'Expected bool got int', ) _assert_exception_trace(excinfo.value, expected) validate({'t': tvalue, 'v': tvalue}, conditional_optional) @pytest.mark.parametrize('tvalue', (True, False)) def test_conditional_optional_apply_default(tvalue): ret = apply_defaults({'t': tvalue}, conditional_optional) assert ret == {'t': tvalue, 'v': tvalue} @pytest.mark.parametrize('tvalue', (True, False)) def test_conditional_optional_remove_default(tvalue): ret = remove_defaults({'t': tvalue, 'v': tvalue}, conditional_optional) assert ret == {'t': tvalue} ret = remove_defaults({'t': tvalue, 'v': not tvalue}, conditional_optional) assert ret == {'t': tvalue, 'v': not tvalue} no_additional_keys = Map( 'Map', None, Required(True, check_bool), NoAdditionalKeys((True,)), ) def test_no_additional_keys(): with pytest.raises(ValidationError) as excinfo: validate({True: True, False: False}, no_additional_keys) expected = ( 'At Map()', 'Additional keys found: False. Only these keys are allowed: True', ) _assert_exception_trace(excinfo.value, expected) validate({True: True}, no_additional_keys) @pytest.fixture def warn_additional_keys(): ret = mock.Mock() def callback(extra, keys, dct): return ret.record(extra, keys, dct) ret.schema = Map( 'Map', None, Required(True, check_bool), WarnAdditionalKeys((True,), callback), ) yield ret def test_warn_additional_keys_when_has_extra_keys(warn_additional_keys): validate({True: True, False: False}, warn_additional_keys.schema) assert warn_additional_keys.record.called def test_warn_additional_keys_when_no_extra_keys(warn_additional_keys): validate({True: True}, warn_additional_keys.schema) assert not warn_additional_keys.record.called