From 0ec8768ef7141b4a3b0a4813eccb4f8d9a6d1fd8 Mon Sep 17 00:00:00 2001 From: Erick Daniszewski Date: Sat, 1 Sep 2018 15:31:53 -0400 Subject: [PATCH] remove implict 'required' from 'default' arg, add explicit 'required' arg --- Makefile | 2 +- bison/__version__.py | 2 +- bison/scheme.py | 65 +++++++------ tests/conftest.py | 16 ++++ tests/test_bison.py | 215 +++++++++++++++++++++++++++++++++++++++++-- tests/test_scheme.py | 26 +++++- 6 files changed, 287 insertions(+), 39 deletions(-) diff --git a/Makefile b/Makefile index 37664af..06c4719 100644 --- a/Makefile +++ b/Makefile @@ -17,7 +17,7 @@ init: requires-pipenv ## Initialize the project for development .PHONY: test test: ## Run the bison unit tests - pipenv run py.test + pipenv run py.test -vv .PHONY: ci ci: coverage lint ## Run the ci pipeline (test w/ coverage, lint) diff --git a/bison/__version__.py b/bison/__version__.py index 7228c96..d87fac0 100644 --- a/bison/__version__.py +++ b/bison/__version__.py @@ -3,7 +3,7 @@ __title__ = 'bison' __description__ = 'Python application configuration' __url__ = 'https://github.com/edaniszewski/bison' -__version__ = '0.0.5' +__version__ = '0.0.6' __author__ = 'Erick Daniszewski' __author_email__ = 'edaniszewski@gmail.com' __license__ = 'MIT' diff --git a/bison/scheme.py b/bison/scheme.py index a8179ff..df7c77a 100644 --- a/bison/scheme.py +++ b/bison/scheme.py @@ -54,7 +54,7 @@ class Scheme(object): defaults = {} for arg in self.args: if not isinstance(arg, _BaseOpt): - raise errors.InvalidSchemeError('') + raise errors.InvalidSchemeError('Unable to build default for non-Option type') # if there is a default set, add it to the defaults dict if not isinstance(arg.default, NoDefault): @@ -96,7 +96,7 @@ class Scheme(object): return self._flat def validate(self, config): - """Validate the given config against the `Schema`. + """Validate the given config against the `Scheme`. Args: config (dict): The configuration to validate. @@ -118,13 +118,11 @@ class Scheme(object): # the option does not exist in the config else: - # if the option has a default value, then it is - # considered optional and is fine to omit, otherwise - # it is considered to be required and its omission - # is a validation error. - if type(arg.default) == NoDefault: + # if the option is not required, then it is fine to omit. + # otherwise, its omission constitutes a validation error. + if arg.required: raise errors.SchemeValidationError( - 'Option "{}" is expected but not found.'.format(arg.name) + 'Option "{}" is required, but not found.'.format(arg.name) ) @@ -180,20 +178,23 @@ class Option(_BaseOpt): Args: name (str): The name of the option - this should correspond to the key for the option in a configuration file, e.g. - default: The default value to use. By default, this is the internal - `_NoDefault` type. If the value of this is anything other than - `_NoDefault`, this option is considered optional, so validation will - not fail if it is not present in the config. If this is left at its - default value of `_NoDefault`, then this option is considered required - and will fail validation if not present. + required (bool): A flag that determines whether the option is required + or not. If required and the option is not present in the config, + validation will fail. If optional and not present in the config, + validation will not fail. By default, an option is required. + default: The default value to use for the option. If the option is not + found in the config, the default will be used (regardless of whether + it is required or optional). By default, this is the internal + `_NoDefault` type (this allows setting `None` as a default). field_type: The type that the option value should have. choices (list|tuple): The valid options for the field. bind_env (bool|str|None): Bind the option to an environment variable. """ - def __init__(self, name, default=_no_default, field_type=None, choices=None, bind_env=None): + def __init__(self, name, required=True, default=_no_default, field_type=None, choices=None, bind_env=None): super(Option, self).__init__() self.name = name + self.required = required self.default = default self.type = field_type self.choices = choices @@ -310,12 +311,14 @@ class DictOption(_BaseOpt): for the option in a configuration file, e.g. scheme (Scheme|None): A Scheme that defines what the dictionary config should adhere to. This can be None if no validation is wanted on the option. - default: The default value to use. By default, this is the internal - `_NoDefault` type. If the value of this is anything other than - `_NoDefault`, this option is considered optional, so validation will - not fail if it is not present in the config. If this is left at its - default value of `_NoDefault`, then this option is considered required - and will fail validation if not present. + required (bool): A flag that determines whether the option is required + or not. If required and the option is not present in the config, + validation will fail. If optional and not present in the config, + validation will not fail. By default, an option is required. + default: The default value to use for the option. If the option is not + found in the config, the default will be used (regardless of whether + it is required or optional). By default, this is the internal + `_NoDefault` type (this allows setting `None` as a default). bind_env (bool): Bind the option to an environment variable. If False, the option will not be bound to env. If True, the key for the this DictOption will serve as an environment prefix. Any environment @@ -323,9 +326,10 @@ class DictOption(_BaseOpt): as a string. """ - def __init__(self, name, scheme, default=_no_default, bind_env=False): + def __init__(self, name, scheme, required=True, default=_no_default, bind_env=False): super(DictOption, self).__init__() self.name = name + self.required = required self.default = default self.scheme = scheme self.bind_env = bind_env @@ -382,12 +386,14 @@ class ListOption(_BaseOpt): Args: name (str): The name of the option - this should correspond to the key for the option in a configuration file, e.g. - default: The default value to use. By default, this is the internal - `_NoDefault` type. If the value of this is anything other than - `_NoDefault`, this option is considered optional, so validation will - not fail if it is not present in the config. If this is left at its - default value of `_NoDefault`, then this option is considered required - and will fail validation if not present. + required (bool): A flag that determines whether the option is required + or not. If required and the option is not present in the config, + validation will fail. If optional and not present in the config, + validation will not fail. By default, an option is required. + default: The default value to use for the option. If the option is not + found in the config, the default will be used (regardless of whether + it is required or optional). By default, this is the internal + `_NoDefault` type (this allows setting `None` as a default). member_type: The type that all members of the list should have. member_scheme (Scheme): The `Scheme` that all members of the list should fulfil. This should be used when the list members are dictionaries/maps. @@ -398,9 +404,10 @@ class ListOption(_BaseOpt): items being comma separated. """ - def __init__(self, name, default=_no_default, member_type=None, member_scheme=None, bind_env=False): + def __init__(self, name, required=True, default=_no_default, member_type=None, member_scheme=None, bind_env=False): super(ListOption, self).__init__() self.name = name + self.required = required self.default = default self.member_type = member_type self.member_scheme = member_scheme diff --git a/tests/conftest.py b/tests/conftest.py index 3400909..d3cd2dc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -21,6 +21,22 @@ def yaml_config(tmpdir): cfg.remove() +@pytest.fixture() +def yaml_optional_nested(tmpdir): + """Create a YAML config file with optional nested values.""" + cfg = tmpdir.join('config.yml') + cfg.write(""" + foo: True + nested1: + x: abc + y: def + """) + + yield cfg + + cfg.remove() + + @pytest.fixture() def bad_yaml_config(tmpdir): """Create a bad YAML config file.""" diff --git a/tests/test_bison.py b/tests/test_bison.py index 0d49351..4ac8c81 100644 --- a/tests/test_bison.py +++ b/tests/test_bison.py @@ -270,12 +270,6 @@ class TestBison: with pytest.raises(errors.SchemeValidationError): b.validate() - def test_parse_no_sources(self): - """Parse when there are no sources to parse from.""" - b = bison.Bison() - b.parse() - assert len(b.config) == 0 - def test_find_config(self, yaml_config): """Find a config file when it does exist""" b = bison.Bison() @@ -301,6 +295,12 @@ class TestBison: with pytest.raises(errors.BisonError): b._find_config() + def test_parse_no_sources(self): + """Parse when there are no sources to parse from.""" + b = bison.Bison() + b.parse() + assert len(b.config) == 0 + def test_parse_config_no_paths(self): """Parse the file config when no paths are specified""" b = bison.Bison() @@ -643,3 +643,206 @@ class TestBison: assert b.config == { 'foo': False, } + + def test_parse_validate_nested_optional(self, yaml_optional_nested): + """Parse a config for a scheme with a non-required option with + required nested options where the non-required option is not set. + """ + b = bison.Bison(scheme=bison.Scheme( + bison.Option('foo', bind_env=True), + bison.DictOption('nested2', required=False, bind_env=True, scheme=bison.Scheme( + bison.Option('x', field_type=str), + bison.Option('y', field_type=str) + )) + )) + b.add_config_paths(yaml_optional_nested.dirname) + + assert b.config_file is None + assert len(b._config) == 0 + + b.parse(requires_cfg=False) + + assert b.config_file == os.path.join(yaml_optional_nested.dirname, yaml_optional_nested.basename) + assert len(b._config) == 2 + assert b.config == { + 'foo': True, + 'nested1': { + 'x': 'abc', + 'y': 'def' + } + } + + b.validate() + + def test_parse_validate_nested_optional2(self, yaml_optional_nested): + """Parse a config for a scheme with a non-required option with + required nested options where the non-required option is set. + """ + b = bison.Bison(scheme=bison.Scheme( + bison.Option('foo', bind_env=True), + bison.DictOption('nested1', required=False, bind_env=True, scheme=bison.Scheme( + bison.Option('x', field_type=str), + bison.Option('y', field_type=str) + )) + )) + b.add_config_paths(yaml_optional_nested.dirname) + + assert b.config_file is None + assert len(b._config) == 0 + + b.parse(requires_cfg=False) + + assert b.config_file == os.path.join(yaml_optional_nested.dirname, yaml_optional_nested.basename) + assert len(b._config) == 2 + assert b.config == { + 'foo': True, + 'nested1': { + 'x': 'abc', + 'y': 'def' + } + } + + b.validate() + + def test_parse_validate_nested_optional3(self, yaml_optional_nested): + """Parse a config for a scheme with a non-required option with + required nested options where the non-required option is not set, + and the required options have defaults. + """ + b = bison.Bison(scheme=bison.Scheme( + bison.Option('foo', bind_env=True), + bison.DictOption('nested2', required=False, bind_env=True, scheme=bison.Scheme( + bison.Option('x', default="abc", field_type=str), + bison.Option('y', default="def", field_type=str) + )) + )) + b.add_config_paths(yaml_optional_nested.dirname) + + assert b.config_file is None + assert len(b._config) == 0 + + b.parse(requires_cfg=False) + + assert b.config_file == os.path.join(yaml_optional_nested.dirname, yaml_optional_nested.basename) + assert len(b._config) == 2 + assert b.config == { + 'foo': True, + 'nested1': { + 'x': 'abc', + 'y': 'def' + }, + 'nested2': { # defaults get picked up + 'x': 'abc', + 'y': 'def' + } + } + + b.validate() + + def test_parse_validate_nested_optional4(self, yaml_optional_nested): + """Parse a config for a scheme with a non-required option with + required nested options where the non-required option is not set + and it has a default value. + """ + b = bison.Bison(scheme=bison.Scheme( + bison.Option('foo', bind_env=True), + bison.DictOption('nested2', required=False, default={}, bind_env=True, scheme=bison.Scheme( + bison.Option('x', field_type=str), + bison.Option('y', field_type=str) + )) + )) + b.add_config_paths(yaml_optional_nested.dirname) + + assert b.config_file is None + assert len(b._config) == 0 + + b.parse(requires_cfg=False) + + assert b.config_file == os.path.join(yaml_optional_nested.dirname, yaml_optional_nested.basename) + assert len(b._config) == 2 + assert b.config == { + 'foo': True, + 'nested1': { + 'x': 'abc', + 'y': 'def' + }, + 'nested2': {} # default gets picked up + } + + # validation should fail -- the default value gets added, but the required + # options are not set in the default + with pytest.raises(errors.SchemeValidationError): + b.validate() + + def test_parse_validate_nested_optional5(self, yaml_optional_nested): + """Parse a config for a scheme with a non-required option with + required nested options where the non-required option is not set + and it has a default value. + """ + b = bison.Bison(scheme=bison.Scheme( + bison.Option('foo', bind_env=True), + bison.DictOption('nested2', required=False, default={'x': 'ghi', 'y': 'jkl'}, bind_env=True, scheme=bison.Scheme( + bison.Option('x', field_type=str), + bison.Option('y', field_type=str) + )) + )) + b.add_config_paths(yaml_optional_nested.dirname) + + assert b.config_file is None + assert len(b._config) == 0 + + b.parse(requires_cfg=False) + + assert b.config_file == os.path.join(yaml_optional_nested.dirname, yaml_optional_nested.basename) + assert len(b._config) == 2 + assert b.config == { + 'foo': True, + 'nested1': { + 'x': 'abc', + 'y': 'def' + }, + 'nested2': { # default gets picked up + 'x': 'ghi', + 'y': 'jkl' + } + } + + b.validate() + + def test_parse_validate_nested_optional6(self, yaml_optional_nested): + """Parse a config for a scheme with a non-required option with + required nested options where the non-required option is not set + and it has a default value. + + This test is the same as the one above, but the defaults are + specified differently. + """ + b = bison.Bison(scheme=bison.Scheme( + bison.Option('foo', bind_env=True), + bison.DictOption('nested2', required=False, default={}, bind_env=True, scheme=bison.Scheme( + bison.Option('x', default='ghi', field_type=str), + bison.Option('y', default='jkl', field_type=str) + )) + )) + b.add_config_paths(yaml_optional_nested.dirname) + + assert b.config_file is None + assert len(b._config) == 0 + + b.parse(requires_cfg=False) + + assert b.config_file == os.path.join(yaml_optional_nested.dirname, yaml_optional_nested.basename) + assert len(b._config) == 2 + assert b.config == { + 'foo': True, + 'nested1': { + 'x': 'abc', + 'y': 'def' + }, + 'nested2': { # default gets picked up + 'x': 'ghi', + 'y': 'jkl' + } + } + + b.validate() diff --git a/tests/test_scheme.py b/tests/test_scheme.py index ac20cfb..95a7630 100644 --- a/tests/test_scheme.py +++ b/tests/test_scheme.py @@ -671,8 +671,8 @@ class TestScheme: {'foo': 'baz'} ), ( - # option does not exist in config, but has default - (scheme.Option('foo', default='bar', field_type=str),), + # option does not exist in config and has default, but is not required + (scheme.Option('foo', default='bar', required=False, field_type=str),), {} ), ( @@ -683,6 +683,17 @@ class TestScheme: scheme.Option('baz', choices=['test']) ), {'foo': 'a', 'bar': 1, 'baz': 'test'} + ), + ( + # optional parent option not specified, required child option + # not specified + ( + scheme.DictOption('foo', required=False, scheme=scheme.Scheme( + scheme.Option('bar', field_type=str), + scheme.Option('baz', field_type=str), + )), + ), + {} ) ] ) @@ -711,6 +722,17 @@ class TestScheme: scheme.Option('baz', choices=['test']) ), {'foo': 'a', 'bar': 1, 'baz': 'something'} + ), + ( + # optional parent option specified, required child option + # not specified + ( + scheme.DictOption('foo', required=True, scheme=scheme.Scheme( + scheme.Option('bar', field_type=str), + scheme.Option('baz', field_type=str), + )), + ), + {'foo': {'baz': 2}} ) ] )