Skip to content

Commit

Permalink
Merge pull request #129 from arroyoj/mapping_values_template
Browse files Browse the repository at this point in the history
Template for validating mappings of similar items
  • Loading branch information
sampsyo committed Apr 30, 2021
2 parents 046ecb1 + 4b767ee commit f0485fa
Show file tree
Hide file tree
Showing 4 changed files with 184 additions and 0 deletions.
27 changes: 27 additions & 0 deletions confuse/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,33 @@ def __repr__(self):
return 'Sequence({0})'.format(repr(self.subtemplate))


class MappingValues(Template):
"""A template used to validate mappings of similar items,
based on a given subtemplate applied to the values.
All keys in the mapping are considered valid, but values
must pass validation by the subtemplate. Similar to the
Sequence template but for mappings.
"""
def __init__(self, subtemplate):
"""Create a template for a mapping with variable keys
and item values validated on a given subtemplate.
"""
self.subtemplate = as_template(subtemplate)

def value(self, view, template=None):
"""Get a dict with the same keys as the view and the
value of each item validated against the subtemplate.
"""
out = {}
for key, item in view.items():
out[key] = self.subtemplate.value(item, self)
return out

def __repr__(self):
return 'MappingValues({0})'.format(repr(self.subtemplate))


class String(Template):
"""A string configuration value template.
"""
Expand Down
120 changes: 120 additions & 0 deletions docs/examples.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
Template Examples
=================

These examples demonstrate how the confuse templates work to validate
configuration values.


MappingValues
-------------

A ``MappingValues`` template allows validation of a mapping of configuration
items where the keys can be arbitrary but all the values need to match a
subtemplate. Use cases include simple user-defined key:value pairs or larger
configuration blocks that all follow the same structure, but where the keys
naming each block are user-defined. In addition, individual items in the
mapping can be overridden and new items can be added by higher priority
configuration sources. This is in contrast to the ``Sequence`` template, in
which a higher priority source overrides the entire list of configuration items
provided by a lower source.

In the following example, a hypothetical todo list program can be configured
with user-defined colors and category labels. Colors are required to be in hex
format. For each category, a description is required and a priority level is
optional, with a default value of 0. An initial configuration file named
``todo_example.yaml`` has the following contents:

.. code-block:: yaml
colors:
red: '#FF0000'
green: '#00FF00'
blue: '#0000FF'
categories:
default:
description: Things to do
high:
description: These are important
priority: 50
low:
description: Will get to it eventually
priority: -10
Validation of this configuration could be performed like this:

>>> import confuse
>>> import pprint
>>> source = confuse.YamlSource('todo_example.yaml')
>>> config = confuse.RootView([source])
>>> template = {
... 'colors': confuse.MappingValues(
... confuse.String(pattern='#[0-9a-fA-F]{6,6}')
... ),
... 'categories': confuse.MappingValues({
... 'description': str,
... 'priority': 0,
... }),
... }
>>> valid_config = config.get(template)
>>> pprint.pprint(valid_config)
{'categories': {'default': {'description': 'Things to do', 'priority': 0},
'high': {'description': 'These are important', 'priority': 50},
'low': {'description': 'Will get to it eventually',
'priority': -10}},
'colors': {'blue': '#0000FF', 'green': '#00FF00', 'red': '#FF0000'}}

Items in the initial configuration can be overridden and the mapping can be
extended by setting a higher priority source. Continuing the previous example:

>>> config.set({
... 'colors': {
... 'green': '#008000',
... 'orange': '#FFA500',
... },
... 'categories': {
... 'urgent': {
... 'description': 'Must get done now',
... 'priority': 100,
... },
... 'high': {
... 'description': 'Important, but not urgent',
... 'priority': 20,
... },
... },
... })
>>> updated_config = config.get(template)
>>> pprint.pprint(updated_config)
{'categories': {'default': {'description': 'Things to do', 'priority': 0},
'high': {'description': 'Important, but not urgent',
'priority': 20},
'low': {'description': 'Will get to it eventually',
'priority': -10},
'urgent': {'description': 'Must get done now',
'priority': 100}},
'colors': {'blue': '#0000FF',
'green': '#008000',
'orange': '#FFA500',
'red': '#FF0000'}}

If the requested view is missing, ``MappingValues`` returns an empty dict:

>>> config.clear()
>>> config.get(template)
{'colors': {}, 'categories': {}}

However, if an item within the mapping does not match the subtemplate
provided to ``MappingValues``, then an error will be raised:

>>> config.set({
... 'categories': {
... 'no_description': {
... 'priority': 10,
... },
... },
... })
>>> try:
... config.get(template)
... except confuse.ConfigError as err:
... print(err)
...
categories.no_description.description not found
1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@
:hidden:

usage
examples
changelog
api
36 changes: 36 additions & 0 deletions test/test_valid.py
Original file line number Diff line number Diff line change
Expand Up @@ -523,3 +523,39 @@ def test_invalid_item(self):
config['foo'].get(confuse.Sequence(
{'bar': int, 'baz': int}
))


class MappingValuesTest(unittest.TestCase):
def test_int_dict(self):
config = _root({'foo': {'one': 1, 'two': 2, 'three': 3}})
valid = config['foo'].get(confuse.MappingValues(int))
self.assertEqual(valid, {'one': 1, 'two': 2, 'three': 3})

def test_dict_dict(self):
config = _root({'foo': {'first': {'bar': 1, 'baz': 2},
'second': {'bar': 3, 'baz': 4}}})
valid = config['foo'].get(confuse.MappingValues(
{'bar': int, 'baz': int}
))
self.assertEqual(valid, {
'first': {'bar': 1, 'baz': 2},
'second': {'bar': 3, 'baz': 4},
})

def test_invalid_item(self):
config = _root({'foo': {'first': {'bar': 1, 'baz': 2},
'second': {'bar': 3, 'bak': 4}}})
with self.assertRaises(confuse.NotFoundError):
config['foo'].get(confuse.MappingValues(
{'bar': int, 'baz': int}
))

def test_wrong_type(self):
config = _root({'foo': [1, 2, 3]})
with self.assertRaises(confuse.ConfigTypeError):
config['foo'].get(confuse.MappingValues(int))

def test_missing(self):
config = _root({'foo': {'one': 1, 'two': 2, 'three': 3}})
valid = config['bar'].get(confuse.MappingValues(int))
self.assertEqual(valid, {})

0 comments on commit f0485fa

Please sign in to comment.