Repository: beetbox/confuse

Issue:
confuse.Filename doesn't check "default" argument
#### The Problem
When you specify `confuse.Filename('/default/path/to/file')` you would expect that, if no config value was found, it would default to `'/default/path/to/file'`. Instead, it throws exceptions.

When I set `my_path:  null` in my `config_default.yaml`, it gives me:
```
  File "/usr/local/lib/python3.7/dist-packages/confuse.py", line 1218, in value
    out[key] = typ.value(view[key], self)
  File "/usr/local/lib/python3.7/dist-packages/confuse.py", line 1218, in value
    out[key] = typ.value(view[key], self)
  File "/usr/local/lib/python3.7/dist-packages/confuse.py", line 1586, in value
    True
  File "/usr/local/lib/python3.7/dist-packages/confuse.py", line 1158, in fail
    u'{0}: {1}'.format(view.name, message)
confuse.ConfigTypeError: rsa_key: must be a filename, not NoneType
```

and when I comment out the key entirely, I get:
```
  File "/usr/local/lib/python3.7/dist-packages/confuse.py", line 1218, in value
    out[key] = typ.value(view[key], self)
  File "/usr/local/lib/python3.7/dist-packages/confuse.py", line 1218, in value
    out[key] = typ.value(view[key], self)
  File "/usr/local/lib/python3.7/dist-packages/confuse.py", line 1581, in value
    path, source = view.first()
  File "/usr/local/lib/python3.7/dist-packages/confuse.py", line 201, in first
    raise NotFoundError(u"{0} not found".format(self.name))
confuse.NotFoundError: rsa_key not found
```

#### The Culprit
`Template` implements the `value` method which is what normally checks the default value:
https://github.com/beetbox/confuse/blob/5528e7ae70850be07ecaca25826c972c195ee90b/confuse.py#L1122-L1136

`Filename(Template)` overrides `value` and does nothing with the value of `default`.
https://github.com/beetbox/confuse/blob/5528e7ae70850be07ecaca25826c972c195ee90b/confuse.py#L1580-L1605

One option, though I admittedly a bit messy, would be to do what's done in `OneOf` and set template as an attribute and perform the logic in `convert`.
https://github.com/beetbox/confuse/blob/5528e7ae70850be07ecaca25826c972c195ee90b/confuse.py#L1356-L1363

Comments:

Patch:
diff --git a/confuse.py b/confuse.py
index 8d5d875..d15a7a7 100644
--- a/confuse.py
+++ b/confuse.py
@@ -1125,15 +1125,21 @@ class Template(object):
         May raise a `NotFoundError` if the value is missing (and the
         template requires it) or a `ConfigValueError` for invalid values.
         """
-        if view.exists():
+        try:
             value, _ = view.first()
             return self.convert(value, view)
-        elif self.default is REQUIRED:
+        except NotFoundError:
+            pass
+
+        # get default value, raise if required
+        return self.get_default_value(view.name)
+
+    def get_default_value(self, key_name='default'):
+        if self.default is REQUIRED:
             # Missing required value. This is an error.
-            raise NotFoundError(u"{0} not found".format(view.name))
-        else:
-            # Missing value, but not required.
-            return self.default
+            raise NotFoundError(u"{} not found".format(key_name))
+        # Missing value, but not required.
+        return self.default
 
     def convert(self, value, view):
         """Convert the YAML-deserialized value to a value of the desired
@@ -1578,7 +1584,11 @@ class Filename(Template):
         return view.parent.get(next_template)[self.relative_to]
 
     def value(self, view, template=None):
-        path, source = view.first()
+        try:
+            path, source = view.first()
+        except NotFoundError:
+            return self.get_default_value(view.name)
+
         if not isinstance(path, BASESTRING):
             self.fail(
                 u'must be a filename, not {0}'.format(type(path).__name__),
@@ -1615,8 +1625,11 @@ class Path(Filename):
     template.
     """
     def value(self, view, template=None):
+        value = super(Path, self).value(view, template)
+        if value is None:
+            return
         import pathlib
-        return pathlib.Path(super(Path, self).value(view, template))
+        return pathlib.Path(value)
 
 
 class TypeTemplate(Template):
@@ -1681,6 +1694,8 @@ def as_template(value):
         return OneOf(value)
     elif value is float:
         return Number()
+    elif isinstance(value, float):
+        return Number(value)
     elif value is None:
         return Template()
     elif value is dict:
diff --git a/docs/index.rst b/docs/index.rst
index 72dc12a..9070785 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -371,6 +371,12 @@ The resulting YAML will contain "key: REDACTED" instead of the original data.
 Changelog
 ---------
 
+v1.2.0
+''''''
+
+- `float` values (like ``4.2``) can now be used in templates (just like
+  ``42`` works as an `int` template).
+
 v1.1.0
 ''''''
 
diff --git a/setup.py b/setup.py
index b82a3ac..f49345c 100644
--- a/setup.py
+++ b/setup.py
@@ -81,7 +81,7 @@ setup(
     long_description=_read("README.rst"),
     long_description_content_type='text/x-rst',
     install_requires=['pyyaml'],
-    tests_require=['tox'],
+    tests_require=['tox', 'pathlib'],
     py_modules=['confuse'],
     cmdclass={'test': test},
     classifiers=[
diff --git a/tox.ini b/tox.ini
index 6466e85..6f3c9dc 100644
--- a/tox.ini
+++ b/tox.ini
@@ -15,6 +15,7 @@ deps =
     nose
     nose-show-skipped
     pyyaml
+    pathlib
 
 
 [_flake8]


Test patch:
diff --git a/test/test_valid.py b/test/test_valid.py
index f1452f3..ec7cf6b 100644
--- a/test/test_valid.py
+++ b/test/test_valid.py
@@ -163,6 +163,11 @@ class AsTemplateTest(unittest.TestCase):
         self.assertIsInstance(typ, confuse.Number)
         self.assertEqual(typ.default, confuse.REQUIRED)
 
+    def test_concrete_float_as_template(self):
+        typ = confuse.as_template(2.)
+        self.assertIsInstance(typ, confuse.Number)
+        self.assertEqual(typ.default, 2.)
+
     def test_none_as_template(self):
         typ = confuse.as_template(None)
         self.assertIs(type(typ), confuse.Template)
@@ -337,6 +342,21 @@ class StrSeqTest(unittest.TestCase):
 
 
 class FilenameTest(unittest.TestCase):
+    def test_default_value(self):
+        config = _root({})
+        valid = config['foo'].get(confuse.Filename('foo/bar'))
+        self.assertEqual(valid, 'foo/bar')
+
+    def test_default_none(self):
+        config = _root({})
+        valid = config['foo'].get(confuse.Filename(None))
+        self.assertEqual(valid, None)
+
+    def test_missing_required_value(self):
+        config = _root({})
+        with self.assertRaises(confuse.NotFoundError):
+            config['foo'].get(confuse.Filename())
+
     def test_filename_relative_to_working_dir(self):
         config = _root({'foo': 'bar'})
         valid = config['foo'].get(confuse.Filename(cwd='/dev/null'))
@@ -414,6 +434,30 @@ class FilenameTest(unittest.TestCase):
             config['foo'].get(confuse.Filename())
 
 
+class PathTest(unittest.TestCase):
+    def test_path_value(self):
+        import pathlib
+        config = _root({'foo': 'foo/bar'})
+        valid = config['foo'].get(confuse.Path())
+        self.assertEqual(valid, pathlib.Path(os.path.abspath('foo/bar')))
+
+    def test_default_value(self):
+        import pathlib
+        config = _root({})
+        valid = config['foo'].get(confuse.Path('foo/bar'))
+        self.assertEqual(valid, pathlib.Path('foo/bar'))
+
+    def test_default_none(self):
+        config = _root({})
+        valid = config['foo'].get(confuse.Path(None))
+        self.assertEqual(valid, None)
+
+    def test_missing_required_value(self):
+        config = _root({})
+        with self.assertRaises(confuse.NotFoundError):
+            config['foo'].get(confuse.Path())
+
+
 class BaseTemplateTest(unittest.TestCase):
     def test_base_template_accepts_any_value(self):
         config = _root({'foo': 4.2})