Skip to content

Commit 35695d1

Browse files
authored
Merge pull request #4607 from stsewd/strict-validation
Strict validation in configuration file (v2 only)
2 parents 1b80b42 + 997b5e2 commit 35695d1

File tree

2 files changed

+209
-23
lines changed

2 files changed

+209
-23
lines changed

readthedocs/config/config.py

Lines changed: 102 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from .models import Build, Conda, Mkdocs, Python, Sphinx, Submodules
1818
from .parser import ParseError, parse
1919
from .validation import (
20+
VALUE_NOT_FOUND,
2021
ValidationError,
2122
validate_bool,
2223
validate_choice,
@@ -25,7 +26,6 @@
2526
validate_file,
2627
validate_list,
2728
validate_string,
28-
validate_value_exists,
2929
)
3030

3131
__all__ = (
@@ -54,6 +54,7 @@
5454
PYTHON_INVALID = 'python-invalid'
5555
SUBMODULES_INVALID = 'submodules-invalid'
5656
INVALID_KEYS_COMBINATION = 'invalid-keys-combination'
57+
INVALID_KEY = 'invalid-key'
5758

5859
DOCKER_DEFAULT_IMAGE = 'readthedocs/build'
5960
DOCKER_DEFAULT_VERSION = '2.0'
@@ -176,6 +177,41 @@ def catch_validation_error(self, key):
176177
source_position=self.source_position,
177178
)
178179

180+
def pop(self, name, container, default, raise_ex):
181+
"""
182+
Search and pop a key inside a dict.
183+
184+
This will pop the keys recursively if the container is empty.
185+
186+
:param name: the key name in a list form (``['key', 'inner']``)
187+
:param container: a dictionary that contains the key
188+
:param default: default value to return if the key doesn't exists
189+
:param raise_ex: if True, raises an exception when a key is not found
190+
"""
191+
key = name[0]
192+
validate_dict(container)
193+
if key in container:
194+
if len(name) > 1:
195+
value = self.pop(name[1:], container[key], default, raise_ex)
196+
if not container[key]:
197+
container.pop(key)
198+
else:
199+
value = container.pop(key)
200+
return value
201+
if raise_ex:
202+
raise ValidationError(key, VALUE_NOT_FOUND)
203+
return default
204+
205+
def pop_config(self, key, default=None, raise_ex=False):
206+
"""
207+
Search and pop a key (recursively) from `self.raw_config`.
208+
209+
:param key: the key name in a dotted form (``key.innerkey``)
210+
:param default: Optionally, it can receive a default value
211+
:param raise_ex: If True, raises an exception when the key is not found
212+
"""
213+
return self.pop(key.split('.'), self.raw_config, default, raise_ex)
214+
179215
def validate(self):
180216
raise NotImplementedError()
181217

@@ -594,6 +630,7 @@ def validate(self):
594630
# TODO: remove later
595631
self.validate_final_doc_type()
596632
self._config['submodules'] = self.validate_submodules()
633+
self.validate_keys()
597634

598635
def validate_formats(self):
599636
"""
@@ -602,7 +639,7 @@ def validate_formats(self):
602639
The ``ALL`` keyword can be used to indicate that all formats are used.
603640
We ignore the default values here.
604641
"""
605-
formats = self.raw_config.get('formats', [])
642+
formats = self.pop_config('formats', [])
606643
if formats == ALL:
607644
return self.valid_formats
608645
with self.catch_validation_error('formats'):
@@ -622,7 +659,7 @@ def validate_conda(self):
622659

623660
conda = {}
624661
with self.catch_validation_error('conda.environment'):
625-
environment = validate_value_exists('environment', raw_conda)
662+
environment = self.pop_config('conda.environment', raise_ex=True)
626663
conda['environment'] = validate_file(environment, self.base_path)
627664
return conda
628665

@@ -637,7 +674,7 @@ def validate_build(self):
637674
validate_dict(raw_build)
638675
build = {}
639676
with self.catch_validation_error('build.image'):
640-
image = raw_build.get('image', self.default_build_image)
677+
image = self.pop_config('build.image', self.default_build_image)
641678
build['image'] = '{}:{}'.format(
642679
DOCKER_DEFAULT_IMAGE,
643680
validate_choice(
@@ -674,7 +711,7 @@ def validate_python(self):
674711

675712
python = {}
676713
with self.catch_validation_error('python.version'):
677-
version = raw_python.get('version', 3)
714+
version = self.pop_config('python.version', 3)
678715
if isinstance(version, six.string_types):
679716
try:
680717
version = int(version)
@@ -690,7 +727,7 @@ def validate_python(self):
690727

691728
with self.catch_validation_error('python.requirements'):
692729
requirements = self.defaults.get('requirements_file')
693-
requirements = raw_python.get('requirements', requirements)
730+
requirements = self.pop_config('python.requirements', requirements)
694731
if requirements != '' and requirements is not None:
695732
requirements = validate_file(requirements, self.base_path)
696733
python['requirements'] = requirements
@@ -699,14 +736,16 @@ def validate_python(self):
699736
install = (
700737
'setup.py' if self.defaults.get('install_project') else None
701738
)
702-
install = raw_python.get('install', install)
739+
install = self.pop_config('python.install', install)
703740
if install is not None:
704741
validate_choice(install, self.valid_install_options)
705742
python['install_with_setup'] = install == 'setup.py'
706743
python['install_with_pip'] = install == 'pip'
707744

708745
with self.catch_validation_error('python.extra_requirements'):
709-
extra_requirements = raw_python.get('extra_requirements', [])
746+
extra_requirements = self.pop_config(
747+
'python.extra_requirements', []
748+
)
710749
extra_requirements = validate_list(extra_requirements)
711750
if extra_requirements and not python['install_with_pip']:
712751
self.error(
@@ -724,8 +763,8 @@ def validate_python(self):
724763
'use_system_packages',
725764
False,
726765
)
727-
system_packages = raw_python.get(
728-
'system_packages',
766+
system_packages = self.pop_config(
767+
'python.system_packages',
729768
system_packages,
730769
)
731770
python['use_system_site_packages'] = validate_bool(system_packages)
@@ -778,13 +817,13 @@ def validate_mkdocs(self):
778817

779818
mkdocs = {}
780819
with self.catch_validation_error('mkdocs.configuration'):
781-
configuration = raw_mkdocs.get('configuration')
820+
configuration = self.pop_config('mkdocs.configuration', None)
782821
if configuration is not None:
783822
configuration = validate_file(configuration, self.base_path)
784823
mkdocs['configuration'] = configuration
785824

786825
with self.catch_validation_error('mkdocs.fail_on_warning'):
787-
fail_on_warning = raw_mkdocs.get('fail_on_warning', False)
826+
fail_on_warning = self.pop_config('mkdocs.fail_on_warning', False)
788827
mkdocs['fail_on_warning'] = validate_bool(fail_on_warning)
789828

790829
return mkdocs
@@ -812,7 +851,7 @@ def validate_sphinx(self):
812851
sphinx = {}
813852
with self.catch_validation_error('sphinx.builder'):
814853
builder = validate_choice(
815-
raw_sphinx.get('builder', 'html'),
854+
self.pop_config('sphinx.builder', 'html'),
816855
self.valid_sphinx_builders.keys(),
817856
)
818857
sphinx['builder'] = self.valid_sphinx_builders[builder]
@@ -822,13 +861,15 @@ def validate_sphinx(self):
822861
# The default value can be empty
823862
if not configuration:
824863
configuration = None
825-
configuration = raw_sphinx.get('configuration', configuration)
864+
configuration = self.pop_config(
865+
'sphinx.configuration', configuration
866+
)
826867
if configuration is not None:
827868
configuration = validate_file(configuration, self.base_path)
828869
sphinx['configuration'] = configuration
829870

830871
with self.catch_validation_error('sphinx.fail_on_warning'):
831-
fail_on_warning = raw_sphinx.get('fail_on_warning', False)
872+
fail_on_warning = self.pop_config('sphinx.fail_on_warning', False)
832873
sphinx['fail_on_warning'] = validate_bool(fail_on_warning)
833874

834875
return sphinx
@@ -870,7 +911,7 @@ def validate_submodules(self):
870911

871912
submodules = {}
872913
with self.catch_validation_error('submodules.include'):
873-
include = raw_submodules.get('include', [])
914+
include = self.pop_config('submodules.include', [])
874915
if include != ALL:
875916
include = [
876917
validate_string(submodule)
@@ -880,7 +921,7 @@ def validate_submodules(self):
880921

881922
with self.catch_validation_error('submodules.exclude'):
882923
default = [] if submodules['include'] else ALL
883-
exclude = raw_submodules.get('exclude', default)
924+
exclude = self.pop_config('submodules.exclude', default)
884925
if exclude != ALL:
885926
exclude = [
886927
validate_string(submodule)
@@ -902,11 +943,54 @@ def validate_submodules(self):
902943
)
903944

904945
with self.catch_validation_error('submodules.recursive'):
905-
recursive = raw_submodules.get('recursive', False)
946+
recursive = self.pop_config('submodules.recursive', False)
906947
submodules['recursive'] = validate_bool(recursive)
907948

908949
return submodules
909950

951+
def validate_keys(self):
952+
"""
953+
Checks that we don't have extra keys (invalid ones).
954+
955+
This should be called after all the validations are done
956+
and all keys are popped from `self.raw_config`.
957+
"""
958+
msg = (
959+
'Invalid configuration option: {}. '
960+
'Make sure the key name is correct.'
961+
)
962+
# The version key isn't popped, but it's
963+
# validated in `load`.
964+
self.pop_config('version', None)
965+
wrong_key = '.'.join(self._get_extra_key(self.raw_config))
966+
if wrong_key:
967+
self.error(
968+
wrong_key,
969+
msg.format(wrong_key),
970+
code=INVALID_KEY,
971+
)
972+
973+
def _get_extra_key(self, value):
974+
"""
975+
Get the extra keyname (list form) of a dict object.
976+
977+
If there is more than one extra key, the first one is returned.
978+
979+
Example::
980+
981+
{
982+
'key': {
983+
'name': 'inner',
984+
}
985+
}
986+
987+
Will return `['key', 'name']`.
988+
"""
989+
if isinstance(value, dict) and value:
990+
key_name = next(iter(value))
991+
return [key_name] + self._get_extra_key(value[key_name])
992+
return []
993+
910994
@property
911995
def formats(self):
912996
return self._config['formats']

readthedocs/config/tests/test_config.py

Lines changed: 107 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,42 @@
44
import os
55
import re
66
import textwrap
7+
from collections import OrderedDict
78

89
import pytest
910
from mock import DEFAULT, patch
1011
from pytest import raises
1112

1213
from readthedocs.config import (
13-
ALL, BuildConfigV1, BuildConfigV2, ConfigError,
14-
ConfigOptionNotSupportedError, InvalidConfig, ProjectConfig, load)
14+
ALL,
15+
BuildConfigV1,
16+
BuildConfigV2,
17+
ConfigError,
18+
ConfigOptionNotSupportedError,
19+
InvalidConfig,
20+
ProjectConfig,
21+
load,
22+
)
1523
from readthedocs.config.config import (
16-
CONFIG_FILENAME_REGEX, CONFIG_NOT_SUPPORTED, CONFIG_REQUIRED, NAME_INVALID,
17-
NAME_REQUIRED, PYTHON_INVALID, VERSION_INVALID)
24+
CONFIG_FILENAME_REGEX,
25+
CONFIG_NOT_SUPPORTED,
26+
CONFIG_REQUIRED,
27+
INVALID_KEY,
28+
NAME_INVALID,
29+
NAME_REQUIRED,
30+
PYTHON_INVALID,
31+
VERSION_INVALID,
32+
)
1833
from readthedocs.config.models import Conda
1934
from readthedocs.config.validation import (
20-
INVALID_BOOL, INVALID_CHOICE, INVALID_LIST, INVALID_PATH, INVALID_STRING)
35+
INVALID_BOOL,
36+
INVALID_CHOICE,
37+
INVALID_LIST,
38+
INVALID_PATH,
39+
INVALID_STRING,
40+
VALUE_NOT_FOUND,
41+
ValidationError,
42+
)
2143

2244
from .utils import apply_fs
2345

@@ -1702,3 +1724,83 @@ def test_submodules_recursive_explict_default(self):
17021724
assert build.submodules.include == []
17031725
assert build.submodules.exclude == []
17041726
assert build.submodules.recursive is False
1727+
1728+
@pytest.mark.parametrize('value,key', [
1729+
({'typo': 'something'}, 'typo'),
1730+
(
1731+
{
1732+
'pyton': {
1733+
'version': 'another typo',
1734+
}
1735+
},
1736+
'pyton.version'
1737+
),
1738+
(
1739+
{
1740+
'build': {
1741+
'image': 'latest',
1742+
'extra': 'key',
1743+
}
1744+
},
1745+
'build.extra'
1746+
)
1747+
])
1748+
def test_strict_validation(self, value, key):
1749+
build = self.get_build_config(value)
1750+
with raises(InvalidConfig) as excinfo:
1751+
build.validate()
1752+
assert excinfo.value.key == key
1753+
assert excinfo.value.code == INVALID_KEY
1754+
1755+
def test_strict_validation_pops_all_keys(self):
1756+
build = self.get_build_config({
1757+
'version': 2,
1758+
'python': {
1759+
'version': 3,
1760+
},
1761+
})
1762+
build.validate()
1763+
assert build.raw_config == {}
1764+
1765+
@pytest.mark.parametrize('value,expected', [
1766+
({}, []),
1767+
({'one': 1}, ['one']),
1768+
({'one': {'two': 3}}, ['one', 'two']),
1769+
(OrderedDict([('one', 1), ('two', 2)]), ['one']),
1770+
(OrderedDict([('one', {'two': 2}), ('three', 3)]), ['one', 'two']),
1771+
])
1772+
def test_get_extra_key(self, value, expected):
1773+
build = self.get_build_config({})
1774+
assert build._get_extra_key(value) == expected
1775+
1776+
def test_pop_config_single(self):
1777+
build = self.get_build_config({'one': 1})
1778+
build.pop_config('one')
1779+
assert build.raw_config == {}
1780+
1781+
def test_pop_config_nested(self):
1782+
build = self.get_build_config({'one': {'two': 2}})
1783+
build.pop_config('one.two')
1784+
assert build.raw_config == {}
1785+
1786+
def test_pop_config_nested_with_residue(self):
1787+
build = self.get_build_config({'one': {'two': 2, 'three': 3}})
1788+
build.pop_config('one.two')
1789+
assert build.raw_config == {'one': {'three': 3}}
1790+
1791+
def test_pop_config_default_none(self):
1792+
build = self.get_build_config({'one': {'two': 2, 'three': 3}})
1793+
assert build.pop_config('one.four') is None
1794+
assert build.raw_config == {'one': {'two': 2, 'three': 3}}
1795+
1796+
def test_pop_config_default(self):
1797+
build = self.get_build_config({'one': {'two': 2, 'three': 3}})
1798+
assert build.pop_config('one.four', 4) == 4
1799+
assert build.raw_config == {'one': {'two': 2, 'three': 3}}
1800+
1801+
def test_pop_config_raise_exception(self):
1802+
build = self.get_build_config({'one': {'two': 2, 'three': 3}})
1803+
with raises(ValidationError) as excinfo:
1804+
build.pop_config('one.four', raise_ex=True)
1805+
assert excinfo.value.value == 'four'
1806+
assert excinfo.value.code == VALUE_NOT_FOUND

0 commit comments

Comments
 (0)