Repository: beetbox/confuse

Issue:
AttributeError when validating dict with sequence template
I get the following exception when trying to validate a dictionary against Sequence template.

```
File "<path>/site-packages/confuse/core.py", line 351, in get
    return templates.as_template(template).value(self, template)
  File "<path>/lib/python3.6/site-packages/confuse/templates.py", line 153, in value
    out[key] = typ.value(view[key], self)
  File "<path>/lib/python3.6/site-packages/confuse/templates.py", line 175, in value
    out.append(self.subtemplate.value(item, self))
  File "<path>/lib/python3.6/site-packages/confuse/templates.py", line 58, in value
    value, _ = view.first()
AttributeError: 'str' object has no attribute 'first'
```

config.yaml:
```yaml
test:
  one: 123
```

Python code
```python
import confuse

config = confuse.Configuration('ConfuseExample')
config.set_file("config.yaml")
valid = config.get({
    "test": confuse.Sequence(confuse.Integer())
})

print(valid)
```

Expect to get a proper error telling the user that a sequence is needed.

Discussion:

Patch:
diff --git a/confuse/core.py b/confuse/core.py
index c63b1a9..fbe4bdc 100644
--- a/confuse/core.py
+++ b/confuse/core.py
@@ -106,26 +106,25 @@ class ConfigView(object):
         """Iterate over the keys of a dictionary view or the *subviews*
         of a list view.
         """
-        # Try getting the keys, if this is a dictionary view.
+        # Try iterating over the keys, if this is a dictionary view.
         try:
-            keys = self.keys()
-            for key in keys:
+            for key in self.keys():
                 yield key
 
         except ConfigTypeError:
-            # Otherwise, try iterating over a list.
-            collection = self.get()
-            if not isinstance(collection, (list, tuple)):
+            # Otherwise, try iterating over a list view.
+            try:
+                for subview in self.sequence():
+                    yield subview
+
+            except ConfigTypeError:
+                item, _ = self.first()
                 raise ConfigTypeError(
                     u'{0} must be a dictionary or a list, not {1}'.format(
-                        self.name, type(collection).__name__
+                        self.name, type(item).__name__
                     )
                 )
 
-            # Yield all the indices in the list.
-            for index in range(len(collection)):
-                yield self[index]
-
     def __getitem__(self, key):
         """Get a subview of this view."""
         return Subview(self, key)
@@ -295,6 +294,26 @@ class ConfigView(object):
 
     # List/sequence emulation.
 
+    def sequence(self):
+        """Iterates over the subviews contained in lists from the *first*
+        source at this view. If the object for this view in the first source
+        is not a list or tuple, then a `ConfigTypeError` is raised.
+        """
+        try:
+            collection, _ = self.first()
+        except NotFoundError:
+            return
+        if not isinstance(collection, (list, tuple)):
+            raise ConfigTypeError(
+                u'{0} must be a list, not {1}'.format(
+                    self.name, type(collection).__name__
+                )
+            )
+
+        # Yield all the indices in the sequence.
+        for index in range(len(collection)):
+            yield self[index]
+
     def all_contents(self):
         """Iterates over all subviews from collections at this view from
         *all* sources. If the object for this view in any source is not
diff --git a/confuse/templates.py b/confuse/templates.py
index 1afa92b..49edb2b 100644
--- a/confuse/templates.py
+++ b/confuse/templates.py
@@ -181,7 +181,7 @@ class Sequence(Template):
         """Get a list of items validated against the template.
         """
         out = []
-        for item in view:
+        for item in view.sequence():
             out.append(self.subtemplate.value(item, self))
         return out
 
diff --git a/docs/examples.rst b/docs/examples.rst
index 5013fb1..7b508ae 100644
--- a/docs/examples.rst
+++ b/docs/examples.rst
@@ -5,6 +5,91 @@ These examples demonstrate how the confuse templates work to validate
 configuration values.
 
 
+Sequence
+--------
+
+A ``Sequence`` template allows validation of a sequence of configuration items
+that all must match a subtemplate. The items in the sequence can be simple
+values or more complex objects, as defined by the subtemplate. When the view
+is defined in multiple sources, the highest priority source will override the
+entire list of items, rather than appending new items to the list from lower
+sources. If the view is not defined in any source of the configuration, an
+empty list will be returned.
+
+As an example of using the ``Sequence`` template, consider a configuration that
+includes a list of servers, where each server is required to have a host string
+and an optional port number that defaults to 80. For this example, an initial
+configuration file named ``servers_example.yaml`` has the following contents:
+
+.. code-block:: yaml
+
+    servers:
+      - host: one.example.com
+      - host: two.example.com
+        port: 8000
+      - host: three.example.com
+        port: 8080
+
+Validation of this configuration could be performed like this:
+
+>>> import confuse
+>>> import pprint
+>>> source = confuse.YamlSource('servers_example.yaml')
+>>> config = confuse.RootView([source])
+>>> template = {
+...     'servers': confuse.Sequence({
+...         'host': str,
+...         'port': 80,
+...     }),
+... }
+>>> valid_config = config.get(template)
+>>> pprint.pprint(valid_config)
+{'servers': [{'host': 'one.example.com', 'port': 80},
+             {'host': 'two.example.com', 'port': 8000},
+             {'host': 'three.example.com', 'port': 8080}]}
+
+The list of items in the initial configuration can be overridden by setting a
+higher priority source. Continuing the previous example:
+
+>>> config.set({
+...     'servers': [
+...         {'host': 'four.example.org'},
+...         {'host': 'five.example.org', 'port': 9000},
+...     ],
+... })
+>>> updated_config = config.get(template)
+>>> pprint.pprint(updated_config)
+{'servers': [{'host': 'four.example.org', 'port': 80},
+             {'host': 'five.example.org', 'port': 9000}]}
+
+If the requested view is missing, ``Sequence`` returns an empty list:
+
+>>> config.clear()
+>>> config.get(template)
+{'servers': []}
+
+However, if an item within the sequence does not match the subtemplate
+provided to ``Sequence``, then an error will be raised:
+
+>>> config.set({
+...     'servers': [
+...         {'host': 'bad_port.example.net', 'port': 'default'}
+...     ]
+... })
+>>> try:
+...     config.get(template)
+... except confuse.ConfigError as err:
+...     print(err)
+...
+servers#0.port: must be a number
+
+.. note::
+    A python list is not the shortcut for defining a ``Sequence`` template but
+    will instead produce a ``OneOf`` template. For example,
+    ``config.get([str])`` is equivalent to ``config.get(confuse.OneOf([str]))``
+    and *not* ``config.get(confuse.Sequence(str))``.
+
+
 MappingValues
 -------------
 


Test patch:
diff --git a/test/test_valid.py b/test/test_valid.py
index b071d36..3fe82ba 100644
--- a/test/test_valid.py
+++ b/test/test_valid.py
@@ -524,6 +524,16 @@ class SequenceTest(unittest.TestCase):
                 {'bar': int, 'baz': int}
             ))
 
+    def test_wrong_type(self):
+        config = _root({'foo': {'one': 1, 'two': 2, 'three': 3}})
+        with self.assertRaises(confuse.ConfigTypeError):
+            config['foo'].get(confuse.Sequence(int))
+
+    def test_missing(self):
+        config = _root({'foo': [1, 2, 3]})
+        valid = config['bar'].get(confuse.Sequence(int))
+        self.assertEqual(valid, [])
+
 
 class MappingValuesTest(unittest.TestCase):
     def test_int_dict(self):
diff --git a/test/test_views.py b/test/test_views.py
index 860633c..a54aba8 100644
--- a/test/test_views.py
+++ b/test/test_views.py
@@ -31,6 +31,21 @@ class SingleSourceTest(unittest.TestCase):
         with self.assertRaises(confuse.NotFoundError):
             config['l'][5].get()
 
+    def test_dict_iter(self):
+        config = _root({'foo': 'bar', 'baz': 'qux'})
+        keys = [key for key in config]
+        self.assertEqual(set(keys), set(['foo', 'baz']))
+
+    def test_list_iter(self):
+        config = _root({'l': ['foo', 'bar']})
+        items = [subview.get() for subview in config['l']]
+        self.assertEqual(items, ['foo', 'bar'])
+
+    def test_int_iter(self):
+        config = _root({'n': 2})
+        with self.assertRaises(confuse.ConfigTypeError):
+            [item for item in config['n']]
+
     def test_dict_keys(self):
         config = _root({'foo': 'bar', 'baz': 'qux'})
         keys = config.keys()
@@ -51,6 +66,16 @@ class SingleSourceTest(unittest.TestCase):
         with self.assertRaises(confuse.ConfigTypeError):
             config['l'].keys()
 
+    def test_list_sequence(self):
+        config = _root({'l': ['foo', 'bar']})
+        items = [item.get() for item in config['l'].sequence()]
+        self.assertEqual(items, ['foo', 'bar'])
+
+    def test_dict_sequence_error(self):
+        config = _root({'foo': 'bar', 'baz': 'qux'})
+        with self.assertRaises(confuse.ConfigTypeError):
+            list(config.sequence())
+
     def test_dict_contents(self):
         config = _root({'foo': 'bar', 'baz': 'qux'})
         contents = config.all_contents()
@@ -182,6 +207,16 @@ class MultipleSourceTest(unittest.TestCase):
         items = [(key, value.get()) for (key, value) in config['foo'].items()]
         self.assertEqual(list(items), [('bar', 'baz')])
 
+    def test_list_sequence_shadowed(self):
+        config = _root({'l': ['a', 'b']}, {'l': ['c', 'd', 'e']})
+        items = [item.get() for item in config['l'].sequence()]
+        self.assertEqual(items, ['a', 'b'])
+
+    def test_list_sequence_shadowed_by_dict(self):
+        config = _root({'foo': {'bar': 'baz'}}, {'foo': ['qux', 'fred']})
+        with self.assertRaises(confuse.ConfigTypeError):
+            list(config['foo'].sequence())
+
     def test_dict_contents_concatenated(self):
         config = _root({'foo': {'bar': 'baz'}}, {'foo': {'qux': 'fred'}})
         contents = config['foo'].all_contents()