Skip to content

New config for new docker build images #8478

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Sep 21, 2021
114 changes: 92 additions & 22 deletions readthedocs/config/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
Build,
Conda,
Mkdocs,
OldBuild,
Python,
PythonInstall,
PythonInstallRequirements,
Expand Down Expand Up @@ -620,7 +621,7 @@ def conda(self):
@property
def build(self):
"""The docker image used by the builders."""
return Build(**self._config['build'])
return OldBuild(**self._config['build'])

@property
def doctype(self):
Expand Down Expand Up @@ -670,6 +671,19 @@ class BuildConfigV2(BuildConfigBase):
'dirhtml': 'sphinx_htmldir',
'singlehtml': 'sphinx_singlehtml',
}
valid_os = ['ubuntu-20.04']
valid_languages = {
'python': [
'2.7',
'3', '3.8', '3.9', '3.10',
'pypy3.7',
'miniconda3-4.7',
'mambaforge-4.10',
],
'nodejs': ['14', '16'],
'rust': ['1.55'],
'golang': ['1.17'],
}

def validate(self):
"""
Expand Down Expand Up @@ -723,15 +737,52 @@ def validate_conda(self):
conda['environment'] = validate_path(environment, self.base_path)
return conda

def validate_build(self):
def validate_new_build_config(self):
"""
Validates the build object (new format).

At least one element must be provided in ``build.languages``.
"""
build = {}
with self.catch_validation_error('build.os'):
build_os = self.pop_config('build.os', raise_ex=True)
build['os'] = validate_choice(build_os, self.valid_os)

languages = {}
with self.catch_validation_error('build.languages'):
languages = self.pop_config('build.languages')
validate_dict(languages)
for lang in languages.keys():
validate_choice(lang, self.valid_languages.keys())

if not languages:
self.error(
key='build.languages',
message=(
'At least one language of [{}] must be provided.'.format(
' ,'.join(self.valid_languages.keys())
)
),
code=CONFIG_REQUIRED,
)

build['languages'] = {}
for lang, version in languages.items():
with self.catch_validation_error(f'build.languages.{lang}'):
build['languages'][lang] = validate_choice(
version,
self.valid_languages[lang],
)

build['apt_packages'] = self.validate_apt_packages()
return build

def validate_old_build_config(self):
"""
Validates the build object.
Validates the build object (old format).

It prioritizes the value from the default image if exists.
"""
raw_build = self._raw_config.get('build', {})
with self.catch_validation_error('build'):
validate_dict(raw_build)
build = {}
with self.catch_validation_error('build.image'):
image = self.pop_config('build.image', self.default_build_image)
Expand All @@ -748,6 +799,11 @@ def validate_build(self):
if config_image:
build['image'] = config_image

build['apt_packages'] = self.validate_apt_packages()
return build

def validate_apt_packages(self):
apt_packages = []
with self.catch_validation_error('build.apt_packages'):
raw_packages = self._raw_config.get('build', {}).get('apt_packages', [])
validate_list(raw_packages)
Expand All @@ -756,14 +812,22 @@ def validate_build(self):
list_to_dict(raw_packages)
)

build['apt_packages'] = [
apt_packages = [
self.validate_apt_package(index)
for index in range(len(raw_packages))
]
if not raw_packages:
self.pop_config('build.apt_packages')

return build
return apt_packages

def validate_build(self):
raw_build = self._raw_config.get('build', {})
with self.catch_validation_error('build'):
validate_dict(raw_build)
if 'os' in raw_build:
return self.validate_new_build_config()
return self.validate_old_build_config()

def validate_apt_package(self, index):
"""
Expand Down Expand Up @@ -821,24 +885,27 @@ def validate_python(self):
.. note::
- ``version`` can be a string or number type.
- ``extra_requirements`` needs to be used with ``install: 'pip'``.
- If the new build config is used (``build.os``),
``python.version`` shouldn't exist.
"""
raw_python = self._raw_config.get('python', {})
with self.catch_validation_error('python'):
validate_dict(raw_python)

python = {}
with self.catch_validation_error('python.version'):
version = self.pop_config('python.version', '3')
if version == 3.1:
# Special case for ``python.version: 3.10``,
# yaml will transform this to the numeric value of `3.1`.
# Save some frustration to users.
version = '3.10'
version = str(version)
python['version'] = validate_choice(
version,
self.get_valid_python_versions(),
)
if isinstance(self.build, OldBuild):
with self.catch_validation_error('python.version'):
version = self.pop_config('python.version', '3')
if version == 3.1:
# Special case for ``python.version: 3.10``,
# yaml will transform this to the numeric value of `3.1`.
# Save some frustration to users.
version = '3.10'
version = str(version)
python['version'] = validate_choice(
version,
self.get_valid_python_versions(),
)

with self.catch_validation_error('python.install'):
raw_install = self._raw_config.get('python', {}).get('install', [])
Expand Down Expand Up @@ -1173,7 +1240,10 @@ def conda(self):

@property
def build(self):
return Build(**self._config['build'])
klass = OldBuild
if 'os' in self._config['build']:
klass = Build
return klass(**self._config['build'])

@property
def python(self):
Expand All @@ -1185,7 +1255,7 @@ def python(self):
elif 'path' in install:
python_install.append(PythonInstall(**install),)
return Python(
version=python['version'],
version=python.get('version'),
install=python_install,
use_system_site_packages=python['use_system_site_packages'],
)
Expand Down
11 changes: 10 additions & 1 deletion readthedocs/config/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ def as_dict(self):
}


class Build(Base):
class OldBuild(Base):

__slots__ = ('image', 'apt_packages')

Expand All @@ -35,6 +35,15 @@ def __init__(self, **kwargs):
super().__init__(**kwargs)


class Build(Base):

__slots__ = ('os', 'languages', 'apt_packages')

def __init__(self, **kwargs):
kwargs.setdefault('apt_packages', [])
super().__init__(**kwargs)


class Python(Base):

__slots__ = ('version', 'install', 'use_system_site_packages')
Expand Down
150 changes: 150 additions & 0 deletions readthedocs/config/tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@
VERSION_INVALID,
)
from readthedocs.config.models import (
Build,
Conda,
OldBuild,
PythonInstall,
PythonInstallRequirements,
)
Expand Down Expand Up @@ -904,6 +906,7 @@ def test_build_image_over_empty_default(self, image):
def test_build_image_default_value(self):
build = self.get_build_config({})
build.validate()
assert isinstance(build.build, OldBuild)
assert build.build.image == 'readthedocs/build:latest'

@pytest.mark.parametrize('value', [3, [], 'invalid'])
Expand All @@ -920,6 +923,89 @@ def test_build_image_check_invalid_type(self, value):
build.validate()
assert excinfo.value.key == 'build.image'

@pytest.mark.parametrize('value', ['', None, 'latest'])
def test_new_build_config_invalid_os(self, value):
build = self.get_build_config(
{
'build': {
'os': value,
'languages': {'python': '3'},
},
},
)
with raises(InvalidConfig) as excinfo:
build.validate()
assert excinfo.value.key == 'build.os'

@pytest.mark.parametrize('value', ['', None, 'python', ['python', 'nodejs'], {}, {'cobol': '99'}])
def test_new_build_config_invalid_languages(self, value):
build = self.get_build_config(
{
'build': {
'os': 'ubuntu-20.04',
'languages': value,
},
},
)
with raises(InvalidConfig) as excinfo:
build.validate()
assert excinfo.value.key == 'build.languages'

def test_new_build_config_invalid_languages_version(self):
build = self.get_build_config(
{
'build': {
'os': 'ubuntu-20.04',
'languages': {'python': '2.6'},
},
},
)
with raises(InvalidConfig) as excinfo:
build.validate()
assert excinfo.value.key == 'build.languages.python'

def test_new_build_config(self):
build = self.get_build_config(
{
'build': {
'os': 'ubuntu-20.04',
'languages': {'python': '3.9'},
},
},
)
build.validate()
assert isinstance(build.build, Build)
assert build.build.os == 'ubuntu-20.04'
assert build.build.languages == {'python': '3.9'}

def test_new_build_config_conflict_with_build_image(self):
build = self.get_build_config(
{
'build': {
'image': 'latest',
'os': 'ubuntu-20.04',
'languages': {'python': '3.9'},
},
},
)
with raises(InvalidConfig) as excinfo:
build.validate()
assert excinfo.value.key == 'build.image'

def test_new_build_config_conflict_with_build_python_version(self):
build = self.get_build_config(
{
'build': {
'os': 'ubuntu-20.04',
'languages': {'python': '3.8'},
},
'python': {'version': '3.8'},
},
)
with raises(InvalidConfig) as excinfo:
build.validate()
assert excinfo.value.key == 'python.version'

@pytest.mark.parametrize(
'value',
[
Expand Down Expand Up @@ -2163,3 +2249,67 @@ def test_as_dict(self, tmpdir):
},
}
assert build.as_dict() == expected_dict

def test_as_dict_new_build_config(self, tmpdir):
build = self.get_build_config(
{
'version': 2,
'formats': ['pdf'],
'build': {
'os': 'ubuntu-20.04',
'languages': {
'python': '3.9',
'nodejs': '16',
},
},
'python': {
'install': [{
'requirements': 'requirements.txt',
}],
},
},
source_file=str(tmpdir.join('readthedocs.yml')),
)
build.validate()
expected_dict = {
'version': '2',
'formats': ['pdf'],
'python': {
'version': None,
'install': [{
'requirements': 'requirements.txt',
}],
'use_system_site_packages': False,
},
'build': {
'os': 'ubuntu-20.04',
'languages': {
'python': '3.9',
'nodejs': '16',
},
'apt_packages': [],
},
'conda': None,
'sphinx': {
'builder': 'sphinx',
'configuration': None,
'fail_on_warning': False,
},
'mkdocs': None,
'doctype': 'sphinx',
'submodules': {
'include': [],
'exclude': ALL,
'recursive': False,
},
'search': {
'ranking': {},
'ignore': [
'search.html',
'search/index.html',
'404.html',
'404/index.html',
],
},
}
assert build.as_dict() == expected_dict
Loading