diff --git a/docs/spec.rst b/docs/spec.rst index c452a6b..810a120 100644 --- a/docs/spec.rst +++ b/docs/spec.rst @@ -105,6 +105,12 @@ Following mapping keys are supported (all but the marked once are optional): - ``pdf``, default: ``false`` - ``epub``, default: ``false`` +``build`` + Options for setting the docker configuration. + + ``image`` + This sets the build image to use on the build, as defined `here `_. + ``python`` Python specific configuration. All builds are executed inside a virtualenv. This config can customize the virtualenv before running the diff --git a/readthedocs_build/config/config.py b/readthedocs_build/config/config.py index 625fdd3..d1d16d6 100644 --- a/readthedocs_build/config/config.py +++ b/readthedocs_build/config/config.py @@ -5,12 +5,8 @@ from .find import find_one from .parser import ParseError from .parser import parse -from .validation import validate_bool -from .validation import validate_choice -from .validation import validate_directory -from .validation import validate_file -from .validation import validate_string -from .validation import ValidationError +from .validation import (validate_bool, validate_choice, validate_directory, + validate_file, validate_string, ValidationError) __all__ = ( @@ -30,6 +26,23 @@ TYPE_REQUIRED = 'type-required' PYTHON_INVALID = 'python-invalid' +DOCKER_DEFAULT_IMAGE = 'readthedocs/build' +DOCKER_DEFAULT_VERSION = '2.0' +# These map to coordisponding settings in the .org, +# so they haven't been renamed. +DOCKER_IMAGE = '{}:{}'.format(DOCKER_DEFAULT_IMAGE, DOCKER_DEFAULT_VERSION) +DOCKER_IMAGE_SETTINGS = { + 'readthedocs/build:1.0': { + 'python': {'supported_versions': [2, 2.7, 3, 3.4]}, + }, + 'readthedocs/build:2.0': { + 'python': {'supported_versions': [2, 2.7, 3, 3.5]}, + }, + 'readthedocs/build:latest': { + 'python': {'supported_versions': [2, 2.7, 3, 3.3, 3.4, 3.5, 3.6]}, + }, +} + class ConfigError(Exception): @@ -79,6 +92,7 @@ class BuildConfig(dict): '"python.extra_requirements" section must be a list.') PYTHON_SUPPORTED_VERSIONS = [2, 2.7, 3, 3.3, 3.4, 3.5, 3.6] + DOCKER_SUPPORTED_VERSIONS = ['1.0', '2.0', 'latest'] def __init__(self, env_config, raw_config, source_file, source_position): self.env_config = env_config @@ -145,6 +159,9 @@ def validate(self): # Validate env_config. self.validate_output_base() + # Validate the build environment first + self.validate_build() # Must happen before `validate_python`! + # Validate raw_config. Order matters. self.validate_name() self.validate_type() @@ -205,6 +222,56 @@ def validate_base(self): base = validate_directory(base, base_path) self['base'] = base + def validate_build(self): + """ + Validate the build config settings. + + This is a bit complex, + so here is the logic: + + * We take the default image & version if it's specific in the environment + * Then update the _version_ from the users config + * Then append the default _image_, since users can't change this + * Then update the env_config with the settings for that specific image + - This is currently used for a build image -> python version mapping + + This means we can use custom docker _images_, + but can't change the supported _versions_ that users have defined. + """ + # Defaults + if 'build' in self.env_config: + build = self.env_config['build'] + else: + build = {'image': DOCKER_IMAGE} + + # User specified + if 'build' in self.raw_config: + _build = self.raw_config['build'] + if 'image' in _build: + with self.catch_validation_error('build'): + build['image'] = validate_choice( + str(_build['image']), + self.DOCKER_SUPPORTED_VERSIONS, + ) + if ':' not in build['image']: + # Prepend proper image name to user's image name + build['image'] = '{}:{}'.format( + DOCKER_DEFAULT_IMAGE, + build['image'] + ) + # Update docker default settings from image name + if build['image'] in DOCKER_IMAGE_SETTINGS: + self.env_config.update( + DOCKER_IMAGE_SETTINGS[build['image']] + ) + # Update docker settings from user config + if 'DOCKER_IMAGE_SETTINGS' in self.env_config and \ + build['image'] in self.env_config['DOCKER_IMAGE_SETTINGS']: + self.env_config.update( + self.env_config['DOCKER_IMAGE_SETTINGS'][build['image']] + ) + self['build'] = build + def validate_python(self): python = { 'use_system_site_packages': False, @@ -280,7 +347,7 @@ def validate_python(self): pass python['version'] = validate_choice( version, - self.get_valid_python_versions() + self.get_valid_python_versions(), ) self['python'] = python diff --git a/readthedocs_build/config/test_config.py b/readthedocs_build/config/test_config.py index af12be8..ca41a37 100644 --- a/readthedocs_build/config/test_config.py +++ b/readthedocs_build/config/test_config.py @@ -413,6 +413,70 @@ def it_fails_if_base_does_not_exist(tmpdir): assert excinfo.value.code == INVALID_PATH +def describe_validate_build(): + + def it_fails_if_build_is_invalid_option(tmpdir): + apply_fs(tmpdir, minimal_config) + build = BuildConfig( + {}, + {'build': {'image': 3.0}}, + source_file=str(tmpdir.join('readthedocs.yml')), + source_position=0) + with raises(InvalidConfig) as excinfo: + build.validate_build() + assert excinfo.value.key == 'build' + assert excinfo.value.code == INVALID_CHOICE + + def it_fails_on_python_validation(tmpdir): + apply_fs(tmpdir, minimal_config) + build = BuildConfig( + {}, + { + 'build': {'image': 1.0}, + 'python': {'version': '3.3'}, + }, + source_file=str(tmpdir.join('readthedocs.yml')), + source_position=0) + build.validate_build() + with raises(InvalidConfig) as excinfo: + build.validate_python() + assert excinfo.value.key == 'python.version' + assert excinfo.value.code == INVALID_CHOICE + + def it_works_on_python_validation(tmpdir): + apply_fs(tmpdir, minimal_config) + build = BuildConfig( + {}, + { + 'build': {'image': 'latest'}, + 'python': {'version': '3.3'}, + }, + source_file=str(tmpdir.join('readthedocs.yml')), + source_position=0) + build.validate_build() + build.validate_python() + + def it_works(tmpdir): + apply_fs(tmpdir, minimal_config) + build = BuildConfig( + {}, + {'build': {'image': 'latest'}}, + source_file=str(tmpdir.join('readthedocs.yml')), + source_position=0) + build.validate_build() + assert build['build']['image'] == 'readthedocs/build:latest' + + def default(tmpdir): + apply_fs(tmpdir, minimal_config) + build = BuildConfig( + {}, + {}, + source_file=str(tmpdir.join('readthedocs.yml')), + source_position=0) + build.validate_build() + assert build['build']['image'] == 'readthedocs/build:2.0' + + def test_build_validate_calls_all_subvalidators(tmpdir): apply_fs(tmpdir, minimal_config) build = BuildConfig( diff --git a/requirements/tests.txt b/requirements/tests.txt index 9a2ccdc..a9fc582 100644 --- a/requirements/tests.txt +++ b/requirements/tests.txt @@ -1,5 +1,5 @@ mock==1.3.0 -pytest>=2.7 -pytest-describe>=0.10.1 +pytest==3.2.5 +pytest-describe==0.11.0 pytest-xdist>=1.12 tox>=2.1