From 0bbbc6a550cdb2834a67b721ee77db5c88d0cb1c Mon Sep 17 00:00:00 2001 From: Santos Gallegos Date: Wed, 5 Sep 2018 17:34:33 -0500 Subject: [PATCH 01/12] Tests --- readthedocs/config/tests/test_config.py | 26 +++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/readthedocs/config/tests/test_config.py b/readthedocs/config/tests/test_config.py index e2bfc37bf46..6adb33496c6 100644 --- a/readthedocs/config/tests/test_config.py +++ b/readthedocs/config/tests/test_config.py @@ -1702,3 +1702,29 @@ def test_submodules_recursive_explict_default(self): assert build.submodules.include == [] assert build.submodules.exclude == [] assert build.submodules.recursive is False + + @pytest.mark.parametrize('value,key', [ + ({'typo': 'something'}, 'typo'), + ( + { + 'pyton': { + 'version': 'another typo', + } + }, + 'pyton.version' + ), + ( + { + 'build': { + 'image': 'latest', + 'extra': 'key', + } + }, + 'build.extra' + ) + ]) + def test_strict_validation(self, value, key): + build = self.get_build_config(value) + with raises(InvalidConfig) as excinfo: + build.validate() + assert excinfo.value.key == key From 573c878afe2427986dd5eda306bf20eb4efd51d6 Mon Sep 17 00:00:00 2001 From: Santos Gallegos Date: Wed, 5 Sep 2018 17:36:08 -0500 Subject: [PATCH 02/12] Validation --- readthedocs/config/config.py | 97 +++++++++++++++++++++++++++++------- 1 file changed, 79 insertions(+), 18 deletions(-) diff --git a/readthedocs/config/config.py b/readthedocs/config/config.py index eb718df1b4e..b4169552b2f 100644 --- a/readthedocs/config/config.py +++ b/readthedocs/config/config.py @@ -25,7 +25,6 @@ validate_file, validate_list, validate_string, - validate_value_exists, ) __all__ = ( @@ -594,6 +593,28 @@ def validate(self): # TODO: remove later self.validate_final_doc_type() self._config['submodules'] = self.validate_submodules() + self.validate_keys() + + def pop(self, name, container, default, raise_ex): + key = name[0] + if not isinstance(container, dict): + raise ValidationError(container, INVALID_DICT) + if key in container: + if len(name) > 1: + value = self.pop(name[1:], container[key], default, raise_ex) + if not container[key]: + container.pop(key) + else: + value = container.pop(key) + return value + if raise_ex: + raise ValidationError(key, VALUE_NOT_FOUND) + return default + + def pop_config(self, key, *args): + raise_ex = not bool(args) + default = args[0] if args else None + return self.pop(key.split('.'), self.raw_config, default, raise_ex) def validate_formats(self): """ @@ -602,7 +623,7 @@ def validate_formats(self): The ``ALL`` keyword can be used to indicate that all formats are used. We ignore the default values here. """ - formats = self.raw_config.get('formats', []) + formats = self.pop_config('formats', []) if formats == ALL: return self.valid_formats with self.catch_validation_error('formats'): @@ -622,7 +643,7 @@ def validate_conda(self): conda = {} with self.catch_validation_error('conda.environment'): - environment = validate_value_exists('environment', raw_conda) + environment = self.pop_config('conda.environment') conda['environment'] = validate_file(environment, self.base_path) return conda @@ -637,7 +658,7 @@ def validate_build(self): validate_dict(raw_build) build = {} with self.catch_validation_error('build.image'): - image = raw_build.get('image', self.default_build_image) + image = self.pop_config('build.image', self.default_build_image) build['image'] = '{}:{}'.format( DOCKER_DEFAULT_IMAGE, validate_choice( @@ -674,7 +695,7 @@ def validate_python(self): python = {} with self.catch_validation_error('python.version'): - version = raw_python.get('version', 3) + version = self.pop_config('python.version', 3) if isinstance(version, six.string_types): try: version = int(version) @@ -690,7 +711,7 @@ def validate_python(self): with self.catch_validation_error('python.requirements'): requirements = self.defaults.get('requirements_file') - requirements = raw_python.get('requirements', requirements) + requirements = self.pop_config('python.requirements', requirements) if requirements != '' and requirements is not None: requirements = validate_file(requirements, self.base_path) python['requirements'] = requirements @@ -699,14 +720,16 @@ def validate_python(self): install = ( 'setup.py' if self.defaults.get('install_project') else None ) - install = raw_python.get('install', install) + install = self.pop_config('python.install', install) if install is not None: validate_choice(install, self.valid_install_options) python['install_with_setup'] = install == 'setup.py' python['install_with_pip'] = install == 'pip' with self.catch_validation_error('python.extra_requirements'): - extra_requirements = raw_python.get('extra_requirements', []) + extra_requirements = self.pop_config( + 'python.extra_requirements', [] + ) extra_requirements = validate_list(extra_requirements) if extra_requirements and not python['install_with_pip']: self.error( @@ -724,8 +747,8 @@ def validate_python(self): 'use_system_packages', False, ) - system_packages = raw_python.get( - 'system_packages', + system_packages = self.pop_config( + 'python.system_packages', system_packages, ) python['use_system_site_packages'] = validate_bool(system_packages) @@ -778,13 +801,13 @@ def validate_mkdocs(self): mkdocs = {} with self.catch_validation_error('mkdocs.configuration'): - configuration = raw_mkdocs.get('configuration') + configuration = self.pop_config('mkdocs.configuration', None) if configuration is not None: configuration = validate_file(configuration, self.base_path) mkdocs['configuration'] = configuration with self.catch_validation_error('mkdocs.fail_on_warning'): - fail_on_warning = raw_mkdocs.get('fail_on_warning', False) + fail_on_warning = self.pop_config('mkdocs.fail_on_warning', False) mkdocs['fail_on_warning'] = validate_bool(fail_on_warning) return mkdocs @@ -812,7 +835,7 @@ def validate_sphinx(self): sphinx = {} with self.catch_validation_error('sphinx.builder'): builder = validate_choice( - raw_sphinx.get('builder', 'html'), + self.pop_config('sphinx.builder', 'html'), self.valid_sphinx_builders.keys(), ) sphinx['builder'] = self.valid_sphinx_builders[builder] @@ -822,13 +845,15 @@ def validate_sphinx(self): # The default value can be empty if not configuration: configuration = None - configuration = raw_sphinx.get('configuration', configuration) + configuration = self.pop_config( + 'sphinx.configuration', configuration + ) if configuration is not None: configuration = validate_file(configuration, self.base_path) sphinx['configuration'] = configuration with self.catch_validation_error('sphinx.fail_on_warning'): - fail_on_warning = raw_sphinx.get('fail_on_warning', False) + fail_on_warning = self.pop_config('sphinx.fail_on_warning', False) sphinx['fail_on_warning'] = validate_bool(fail_on_warning) return sphinx @@ -870,7 +895,7 @@ def validate_submodules(self): submodules = {} with self.catch_validation_error('submodules.include'): - include = raw_submodules.get('include', []) + include = self.pop_config('submodules.include', []) if include != ALL: include = [ validate_string(submodule) @@ -880,7 +905,7 @@ def validate_submodules(self): with self.catch_validation_error('submodules.exclude'): default = [] if submodules['include'] else ALL - exclude = raw_submodules.get('exclude', default) + exclude = self.pop_config('submodules.exclude', default) if exclude != ALL: exclude = [ validate_string(submodule) @@ -902,11 +927,47 @@ def validate_submodules(self): ) with self.catch_validation_error('submodules.recursive'): - recursive = raw_submodules.get('recursive', False) + recursive = self.pop_config('submodules.recursive', False) submodules['recursive'] = validate_bool(recursive) return submodules + def validate_keys(self): + self.pop_config('version', None) + wrong_key = self.get_key_name(self.raw_config) + if wrong_key: + self.error( + wrong_key, + 'Unsuported configuration: {}. Maybe a typo?'.format(wrong_key), + code=SUBMODULES_INVALID, + ) + + def get_key_name(self, value): + """Get the complete keyname of a dict object. + + If there are more the one key, + the first one is returned. + + Example:: + + { + 'key': { + 'name': 'inner', + } + } + + Will return 'key.name'. + """ + if isinstance(value, dict) and value: + key_name = next(iter(value)) + inner_name = self.get_key_name(value[key_name]) + if inner_name: + full_name = '{}.{}'.format(key_name, inner_name) + else: + full_name = key_name + return full_name + return None + @property def formats(self): return self._config['formats'] From 2fc02e7502251fd05fdeb9e8057f1de9c5dd73da Mon Sep 17 00:00:00 2001 From: Santos Gallegos Date: Thu, 6 Sep 2018 12:26:06 -0500 Subject: [PATCH 03/12] Refactor --- readthedocs/config/config.py | 83 +++++++++++++++++++++--------------- 1 file changed, 49 insertions(+), 34 deletions(-) diff --git a/readthedocs/config/config.py b/readthedocs/config/config.py index b4169552b2f..08344d105bb 100644 --- a/readthedocs/config/config.py +++ b/readthedocs/config/config.py @@ -175,6 +175,42 @@ def catch_validation_error(self, key): source_position=self.source_position, ) + def pop(self, name, container, default, raise_ex): + """ + Search and pop a key inside a dict. + + :param name: the key name in a dotted form (key.innerkey) + :param container: a dictionary that contains the key + :param default: default value to return if the key doesn't exists + :param raise_ex: if True, raises an exception when a key is not found + """ + key = name[0] + validate_dict(container) + if key in container: + if len(name) > 1: + value = self.pop(name[1:], container[key], default, raise_ex) + if not container[key]: + container.pop(key) + else: + value = container.pop(key) + return value + if raise_ex: + raise ValidationError(key, VALUE_NOT_FOUND) + return default + + def pop_config(self, key, *args): + """ + Search and pop a key from `self.raw_config`. + + :param key: the key name in a dotted form (key.innerkey) + :param args: Optionally, it can receive a default value + after the key, if no value is passed, + it raises an exception when the key is not found. + """ + raise_ex = not bool(args) + default = args[0] if args else None + return self.pop(key.split('.'), self.raw_config, default, raise_ex) + def validate(self): raise NotImplementedError() @@ -595,27 +631,6 @@ def validate(self): self._config['submodules'] = self.validate_submodules() self.validate_keys() - def pop(self, name, container, default, raise_ex): - key = name[0] - if not isinstance(container, dict): - raise ValidationError(container, INVALID_DICT) - if key in container: - if len(name) > 1: - value = self.pop(name[1:], container[key], default, raise_ex) - if not container[key]: - container.pop(key) - else: - value = container.pop(key) - return value - if raise_ex: - raise ValidationError(key, VALUE_NOT_FOUND) - return default - - def pop_config(self, key, *args): - raise_ex = not bool(args) - default = args[0] if args else None - return self.pop(key.split('.'), self.raw_config, default, raise_ex) - def validate_formats(self): """ Validates that formats contains only valid formats. @@ -933,8 +948,13 @@ def validate_submodules(self): return submodules def validate_keys(self): + """ + Checks that we don't have extra keys. + + This should be called after all the validations are done. + """ self.pop_config('version', None) - wrong_key = self.get_key_name(self.raw_config) + wrong_key = '.'.join(self._get_extra_key(self.raw_config)) if wrong_key: self.error( wrong_key, @@ -942,11 +962,11 @@ def validate_keys(self): code=SUBMODULES_INVALID, ) - def get_key_name(self, value): - """Get the complete keyname of a dict object. + def _get_extra_key(self, value): + """ + Get the extra keyname (list form) of a dict object. - If there are more the one key, - the first one is returned. + If there are more the one key, the first one is returned. Example:: @@ -956,17 +976,12 @@ def get_key_name(self, value): } } - Will return 'key.name'. + Will return `['key', 'name']`. """ if isinstance(value, dict) and value: key_name = next(iter(value)) - inner_name = self.get_key_name(value[key_name]) - if inner_name: - full_name = '{}.{}'.format(key_name, inner_name) - else: - full_name = key_name - return full_name - return None + return [key_name] + self._get_extra_key(value[key_name]) + return [] @property def formats(self): From 28ccce3c4982db33a2dde55847ded2e6db215f0d Mon Sep 17 00:00:00 2001 From: Santos Gallegos Date: Thu, 6 Sep 2018 12:31:56 -0500 Subject: [PATCH 04/12] Better docs --- readthedocs/config/config.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/readthedocs/config/config.py b/readthedocs/config/config.py index 08344d105bb..3a74fa5e4dd 100644 --- a/readthedocs/config/config.py +++ b/readthedocs/config/config.py @@ -179,6 +179,8 @@ def pop(self, name, container, default, raise_ex): """ Search and pop a key inside a dict. + This will pop the keys recursively if the container is empty. + :param name: the key name in a dotted form (key.innerkey) :param container: a dictionary that contains the key :param default: default value to return if the key doesn't exists @@ -200,7 +202,7 @@ def pop(self, name, container, default, raise_ex): def pop_config(self, key, *args): """ - Search and pop a key from `self.raw_config`. + Search and pop a key (recursively) from `self.raw_config`. :param key: the key name in a dotted form (key.innerkey) :param args: Optionally, it can receive a default value From 06b40f87bd0cbb24e144ba8aa7b1bf316221af69 Mon Sep 17 00:00:00 2001 From: Santos Gallegos Date: Mon, 10 Sep 2018 15:32:34 -0500 Subject: [PATCH 05/12] Refactor --- readthedocs/config/config.py | 24 +++++++++++--------- readthedocs/config/tests/test_config.py | 30 ++++++++++++++++++++----- 2 files changed, 38 insertions(+), 16 deletions(-) diff --git a/readthedocs/config/config.py b/readthedocs/config/config.py index 3a74fa5e4dd..c8901e9389f 100644 --- a/readthedocs/config/config.py +++ b/readthedocs/config/config.py @@ -53,6 +53,7 @@ PYTHON_INVALID = 'python-invalid' SUBMODULES_INVALID = 'submodules-invalid' INVALID_KEYS_COMBINATION = 'invalid-keys-combination' +INVALID_KEY = 'invalid-key' DOCKER_DEFAULT_IMAGE = 'readthedocs/build' DOCKER_DEFAULT_VERSION = '2.0' @@ -181,7 +182,7 @@ def pop(self, name, container, default, raise_ex): This will pop the keys recursively if the container is empty. - :param name: the key name in a dotted form (key.innerkey) + :param name: the key name in a list form (``['key', 'inner']``) :param container: a dictionary that contains the key :param default: default value to return if the key doesn't exists :param raise_ex: if True, raises an exception when a key is not found @@ -200,17 +201,14 @@ def pop(self, name, container, default, raise_ex): raise ValidationError(key, VALUE_NOT_FOUND) return default - def pop_config(self, key, *args): + def pop_config(self, key, default=None, raise_ex=False): """ Search and pop a key (recursively) from `self.raw_config`. - :param key: the key name in a dotted form (key.innerkey) - :param args: Optionally, it can receive a default value - after the key, if no value is passed, - it raises an exception when the key is not found. + :param key: the key name in a dotted form (``key.innerkey``) + :param default: Optionally, it can receive a default value + :param raise_ex: If True, raises an exception when the key is not found """ - raise_ex = not bool(args) - default = args[0] if args else None return self.pop(key.split('.'), self.raw_config, default, raise_ex) def validate(self): @@ -660,7 +658,7 @@ def validate_conda(self): conda = {} with self.catch_validation_error('conda.environment'): - environment = self.pop_config('conda.environment') + environment = self.pop_config('conda.environment', raise_ex=True) conda['environment'] = validate_file(environment, self.base_path) return conda @@ -955,13 +953,17 @@ def validate_keys(self): This should be called after all the validations are done. """ + msg = ( + 'Unsupported configuration option: {}. ' + 'Make sure the key name is correct.' + ) self.pop_config('version', None) wrong_key = '.'.join(self._get_extra_key(self.raw_config)) if wrong_key: self.error( wrong_key, - 'Unsuported configuration: {}. Maybe a typo?'.format(wrong_key), - code=SUBMODULES_INVALID, + msg.format(wrong_key), + code=INVALID_KEY, ) def _get_extra_key(self, value): diff --git a/readthedocs/config/tests/test_config.py b/readthedocs/config/tests/test_config.py index 6adb33496c6..4b24c4c2dc1 100644 --- a/readthedocs/config/tests/test_config.py +++ b/readthedocs/config/tests/test_config.py @@ -10,14 +10,33 @@ from pytest import raises from readthedocs.config import ( - ALL, BuildConfigV1, BuildConfigV2, ConfigError, - ConfigOptionNotSupportedError, InvalidConfig, ProjectConfig, load) + ALL, + BuildConfigV1, + BuildConfigV2, + ConfigError, + ConfigOptionNotSupportedError, + InvalidConfig, + ProjectConfig, + load, +) from readthedocs.config.config import ( - CONFIG_FILENAME_REGEX, CONFIG_NOT_SUPPORTED, CONFIG_REQUIRED, NAME_INVALID, - NAME_REQUIRED, PYTHON_INVALID, VERSION_INVALID) + CONFIG_FILENAME_REGEX, + CONFIG_NOT_SUPPORTED, + CONFIG_REQUIRED, + INVALID_KEY, + NAME_INVALID, + NAME_REQUIRED, + PYTHON_INVALID, + VERSION_INVALID, +) from readthedocs.config.models import Conda from readthedocs.config.validation import ( - INVALID_BOOL, INVALID_CHOICE, INVALID_LIST, INVALID_PATH, INVALID_STRING) + INVALID_BOOL, + INVALID_CHOICE, + INVALID_LIST, + INVALID_PATH, + INVALID_STRING, +) from .utils import apply_fs @@ -1728,3 +1747,4 @@ def test_strict_validation(self, value, key): with raises(InvalidConfig) as excinfo: build.validate() assert excinfo.value.key == key + assert excinfo.value.code == INVALID_KEY From 1187efb2043e2a13fcb57eb98baf90fcafd1dbc1 Mon Sep 17 00:00:00 2001 From: Santos Gallegos Date: Thu, 27 Sep 2018 13:09:32 -0500 Subject: [PATCH 06/12] Isort --- readthedocs/config/config.py | 1 + 1 file changed, 1 insertion(+) diff --git a/readthedocs/config/config.py b/readthedocs/config/config.py index c8901e9389f..21d305cf4e4 100644 --- a/readthedocs/config/config.py +++ b/readthedocs/config/config.py @@ -17,6 +17,7 @@ from .models import Build, Conda, Mkdocs, Python, Sphinx, Submodules from .parser import ParseError, parse from .validation import ( + VALUE_NOT_FOUND, ValidationError, validate_bool, validate_choice, From 67d0420fee165533c26e43d9ae0d7443288e86b7 Mon Sep 17 00:00:00 2001 From: Santos Gallegos Date: Mon, 1 Oct 2018 16:39:47 -0500 Subject: [PATCH 07/12] One more test --- readthedocs/config/tests/test_config.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/readthedocs/config/tests/test_config.py b/readthedocs/config/tests/test_config.py index 4b24c4c2dc1..8917db0944c 100644 --- a/readthedocs/config/tests/test_config.py +++ b/readthedocs/config/tests/test_config.py @@ -1748,3 +1748,13 @@ def test_strict_validation(self, value, key): build.validate() assert excinfo.value.key == key assert excinfo.value.code == INVALID_KEY + + def test_strict_validation_pops_all_keys(self): + build = self.get_build_config({ + 'version': 2, + 'python': { + 'version': 3, + }, + }) + build.validate() + assert build.raw_config == {} From c60f59adb11609dd5a33a05d7abd8d5f651649c4 Mon Sep 17 00:00:00 2001 From: Santos Gallegos Date: Mon, 1 Oct 2018 16:40:42 -0500 Subject: [PATCH 08/12] Better docstrings --- readthedocs/config/config.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/readthedocs/config/config.py b/readthedocs/config/config.py index 21d305cf4e4..aa0825546f0 100644 --- a/readthedocs/config/config.py +++ b/readthedocs/config/config.py @@ -950,14 +950,17 @@ def validate_submodules(self): def validate_keys(self): """ - Checks that we don't have extra keys. + Checks that we don't have extra keys (invalid ones). - This should be called after all the validations are done. + This should be called after all the validations are done + and all keys are popped from `self.raw_config`. """ msg = ( - 'Unsupported configuration option: {}. ' + 'Invalid configuration option: {}. ' 'Make sure the key name is correct.' ) + # The version key isn't popped, but it's + # validated in `load`. self.pop_config('version', None) wrong_key = '.'.join(self._get_extra_key(self.raw_config)) if wrong_key: From a2c6c9deb940ef50ade682cc68402c71e24da078 Mon Sep 17 00:00:00 2001 From: Santos Gallegos Date: Wed, 3 Oct 2018 10:56:54 -0500 Subject: [PATCH 09/12] More tests --- readthedocs/config/tests/test_config.py | 27 +++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/readthedocs/config/tests/test_config.py b/readthedocs/config/tests/test_config.py index 8917db0944c..9e96e5b2bd5 100644 --- a/readthedocs/config/tests/test_config.py +++ b/readthedocs/config/tests/test_config.py @@ -4,6 +4,7 @@ import os import re import textwrap +from collections import OrderedDict import pytest from mock import DEFAULT, patch @@ -1758,3 +1759,29 @@ def test_strict_validation_pops_all_keys(self): }) build.validate() assert build.raw_config == {} + + @pytest.mark.parametrize('value,expected', [ + ({}, []), + ({'one': 1}, ['one']), + ({'one': {'two': 3}}, ['one', 'two']), + (OrderedDict([('one', 1), ('two', 2)]), ['one']), + (OrderedDict([('one', {'two': 2}), ('three', 3)]), ['one', 'two']), + ]) + def test_get_extra_key(self, value, expected): + build = self.get_build_config({}) + assert build._get_extra_key(value) == expected + + def test_pop_config_single(self): + build = self.get_build_config({'one': 1}) + build.pop_config('one') + assert build.raw_config == {} + + def test_pop_config_nested(self): + build = self.get_build_config({'one': {'two': 2}}) + build.pop_config('one.two') + assert build.raw_config == {} + + def test_pop_config_nested_with_residue(self): + build = self.get_build_config({'one': {'two': 2, 'three': 3}}) + build.pop_config('one.two') + assert build.raw_config == {'one': {'three': 3}} From 7ce0202a228759511b2a4457e339f5162d27a5fc Mon Sep 17 00:00:00 2001 From: Santos Gallegos Date: Wed, 3 Oct 2018 11:31:24 -0500 Subject: [PATCH 10/12] And more tests --- readthedocs/config/tests/test_config.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/readthedocs/config/tests/test_config.py b/readthedocs/config/tests/test_config.py index 9e96e5b2bd5..aab397f434e 100644 --- a/readthedocs/config/tests/test_config.py +++ b/readthedocs/config/tests/test_config.py @@ -1785,3 +1785,20 @@ def test_pop_config_nested_with_residue(self): build = self.get_build_config({'one': {'two': 2, 'three': 3}}) build.pop_config('one.two') assert build.raw_config == {'one': {'three': 3}} + + def test_pop_config_default_none(self): + build = self.get_build_config({'one': {'two': 2, 'three': 3}}) + assert build.pop_config('one.four') is None + assert build.raw_config == {'one': {'two': 2, 'three': 3}} + + def test_pop_config_default(self): + build = self.get_build_config({'one': {'two': 2, 'three': 3}}) + assert build.pop_config('one.four', 4) == 4 + assert build.raw_config == {'one': {'two': 2, 'three': 3}} + + def test_pop_config_raise_exception(self): + build = self.get_build_config({'one': {'two': 2, 'three': 3}}) + with raises(ValidationError) as excinfo: + build.pop_config('one.four', raise_ex=True) + assert excinfo.value.value == 'four' + assert excinfo.value.code == VALUE_NOT_FOUND From d33854a6ca70daf019c3fd4281b65b7dfb9c8e25 Mon Sep 17 00:00:00 2001 From: Santos Gallegos Date: Wed, 3 Oct 2018 11:31:50 -0500 Subject: [PATCH 11/12] Isort --- readthedocs/config/tests/test_config.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/readthedocs/config/tests/test_config.py b/readthedocs/config/tests/test_config.py index aab397f434e..4ba0b5c0982 100644 --- a/readthedocs/config/tests/test_config.py +++ b/readthedocs/config/tests/test_config.py @@ -37,6 +37,8 @@ INVALID_LIST, INVALID_PATH, INVALID_STRING, + VALUE_NOT_FOUND, + ValidationError, ) from .utils import apply_fs From 997b5e25abec57a633f198e459bb4aaefa8f9c62 Mon Sep 17 00:00:00 2001 From: Santos Gallegos Date: Thu, 4 Oct 2018 10:42:23 -0500 Subject: [PATCH 12/12] Typo --- readthedocs/config/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/readthedocs/config/config.py b/readthedocs/config/config.py index aa0825546f0..9d2217bd257 100644 --- a/readthedocs/config/config.py +++ b/readthedocs/config/config.py @@ -974,7 +974,7 @@ def _get_extra_key(self, value): """ Get the extra keyname (list form) of a dict object. - If there are more the one key, the first one is returned. + If there is more than one extra key, the first one is returned. Example::