Skip to content

Commit 641559f

Browse files
committed
add KeyValueMap
1 parent 82e2f93 commit 641559f

File tree

3 files changed

+122
-0
lines changed

3 files changed

+122
-0
lines changed

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,15 @@ Used to nest maps inside of arrays. For arrays of scalars, see `check_array`.
116116

117117
When validated, this will check that each element adheres to the sub-schema.
118118

119+
### `KeyValueMap(object_name, key_check_fn, value_schema)`
120+
121+
Used to make a schema representing a homogenous mapping where the keys are
122+
of a specific type and the values match a schema
123+
124+
- `object_name`: will be displayed in error messages
125+
- `key_check_fn`: a [check function](#check-functions) for the key
126+
- `value_schema`: a `Map` / `Array` or other sub-schema.
127+
119128
## Validator objects
120129

121130
Validator objects are used to validate key-value-pairs of a `Map`.

cfgv.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,40 @@ def remove_defaults(self, v):
258258
return ret
259259

260260

261+
class KeyValueMap(
262+
collections.namedtuple(
263+
'KeyValueMap',
264+
('object_name', 'check_key_fn', 'value_schema'),
265+
),
266+
):
267+
__slots__ = ()
268+
269+
def check(self, v):
270+
if not isinstance(v, dict):
271+
raise ValidationError(
272+
f'Expected a {self.object_name} map but got a '
273+
f'{type(v).__name__}',
274+
)
275+
with validate_context(f'At {self.object_name}()'):
276+
for k, val in v.items():
277+
with validate_context(f'For key: {k}'):
278+
self.check_key_fn(k)
279+
with validate_context(f'At key: {k}'):
280+
validate(val, self.value_schema)
281+
282+
def apply_defaults(self, v):
283+
return {
284+
k: apply_defaults(val, self.value_schema)
285+
for k, val in v.items()
286+
}
287+
288+
def remove_defaults(self, v):
289+
return {
290+
k: remove_defaults(val, self.value_schema)
291+
for k, val in v.items()
292+
}
293+
294+
261295
class Array(collections.namedtuple('Array', ('of', 'allow_empty'))):
262296
__slots__ = ()
263297

tests/cfgv_test.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,16 @@
1111
from cfgv import check_any
1212
from cfgv import check_array
1313
from cfgv import check_bool
14+
from cfgv import check_int
1415
from cfgv import check_one_of
1516
from cfgv import check_regex
17+
from cfgv import check_string
1618
from cfgv import check_type
1719
from cfgv import Conditional
1820
from cfgv import ConditionalOptional
1921
from cfgv import ConditionalRecurse
2022
from cfgv import In
23+
from cfgv import KeyValueMap
2124
from cfgv import load_from_filename
2225
from cfgv import Map
2326
from cfgv import MISSING
@@ -728,3 +731,79 @@ def test_warn_additional_keys_when_has_extra_keys(warn_additional_keys):
728731
def test_warn_additional_keys_when_no_extra_keys(warn_additional_keys):
729732
validate({True: True}, warn_additional_keys.schema)
730733
assert not warn_additional_keys.record.called
734+
735+
736+
key_value_map_schema = KeyValueMap(
737+
'Container',
738+
check_string,
739+
Map(
740+
'Object', 'name',
741+
Required('name', check_string),
742+
Optional('setting', check_bool, False),
743+
),
744+
)
745+
key_value_map_ints_schema = KeyValueMap(
746+
'Container',
747+
check_int,
748+
Array(Map('Object', 'nane', Required('name', check_string))),
749+
)
750+
751+
752+
def test_key_value_map_schema_ok():
753+
validate(
754+
{'hello': {'name': 'hello'}, 'world': {'name': 'world'}},
755+
key_value_map_schema,
756+
)
757+
validate(
758+
{1: [{'name': 'hello'}], 2: [{'name': 'world'}]},
759+
key_value_map_ints_schema,
760+
)
761+
762+
763+
def test_key_value_map_apply_defaults():
764+
orig = {'hello': {'name': 'hello'}}
765+
ret = apply_defaults(orig, key_value_map_schema)
766+
assert orig == {'hello': {'name': 'hello'}}
767+
assert ret == {'hello': {'name': 'hello', 'setting': False}}
768+
769+
770+
def test_key_value_map_remove_defaults():
771+
orig = {'hello': {'name': 'hello', 'setting': False}}
772+
ret = remove_defaults(orig, key_value_map_schema)
773+
assert orig == {'hello': {'name': 'hello', 'setting': False}}
774+
assert ret == {'hello': {'name': 'hello'}}
775+
776+
777+
def test_key_value_map_not_a_map():
778+
with pytest.raises(ValidationError) as excinfo:
779+
validate([], key_value_map_schema)
780+
expected = (
781+
'Expected a Container map but got a list',
782+
)
783+
_assert_exception_trace(excinfo.value, expected)
784+
785+
786+
def test_key_value_map_wrong_key_type():
787+
with pytest.raises(ValidationError) as excinfo:
788+
val = {1: {'name': 'hello'}}
789+
validate(val, key_value_map_schema)
790+
expected = (
791+
'At Container()',
792+
'For key: 1',
793+
'Expected string got int',
794+
)
795+
_assert_exception_trace(excinfo.value, expected)
796+
797+
798+
def test_key_value_map_error_in_child_schema():
799+
with pytest.raises(ValidationError) as excinfo:
800+
val = {'hello': {'name': 1}}
801+
validate(val, key_value_map_schema)
802+
expected = (
803+
'At Container()',
804+
'At key: hello',
805+
'At Object(name=1)',
806+
'At key: name',
807+
'Expected string got int',
808+
)
809+
_assert_exception_trace(excinfo.value, expected)

0 commit comments

Comments
 (0)