diff --git a/common b/common index e38599c2c5e..ae892294342 160000 --- a/common +++ b/common @@ -1 +1 @@ -Subproject commit e38599c2c5e40d026d33528a61a1815c421a98be +Subproject commit ae892294342da555c90f69c6594277b0c546ede3 diff --git a/readthedocs/config/config.py b/readthedocs/config/config.py index 30101b58f0f..10a32f5b945 100644 --- a/readthedocs/config/config.py +++ b/readthedocs/config/config.py @@ -1,8 +1,13 @@ +# -*- coding: utf-8 -*- + +# pylint: disable=too-many-lines + """Build configuration for rtd.""" from __future__ import division, print_function, unicode_literals import os import re +from collections import namedtuple from contextlib import contextmanager import six @@ -10,19 +15,26 @@ from .find import find_one from .parser import ParseError, parse from .validation import ( - ValidationError, validate_bool, validate_choice, validate_directory, - validate_file, validate_list, validate_string) + ValidationError, validate_bool, validate_choice, validate_dict, + validate_directory, validate_file, validate_list, validate_string, + validate_value_exists) __all__ = ( - 'load', 'BuildConfig', 'ConfigError', 'ConfigOptionNotSupportedError', - 'InvalidConfig', 'ProjectConfig' + 'ALL', + 'load', + 'BuildConfigV1', + 'BuildConfigV2', + 'ConfigError', + 'ConfigOptionNotSupportedError', + 'InvalidConfig', + 'ProjectConfig', ) - +ALL = 'all' CONFIG_FILENAMES = ('readthedocs.yml', '.readthedocs.yml') - CONFIG_NOT_SUPPORTED = 'config-not-supported' +VERSION_INVALID = 'version-invalid' BASE_INVALID = 'base-invalid' BASE_NOT_A_DIR = 'base-not-a-directory' CONFIG_SYNTAX_INVALID = 'config-syntax-invalid' @@ -30,8 +42,9 @@ NAME_REQUIRED = 'name-required' NAME_INVALID = 'name-invalid' CONF_FILE_REQUIRED = 'conf-file-required' -TYPE_REQUIRED = 'type-required' PYTHON_INVALID = 'python-invalid' +SUBMODULES_INVALID = 'submodules-invalid' +INVALID_KEYS_COMBINATION = 'invalid-keys-combination' DOCKER_DEFAULT_IMAGE = 'readthedocs/build' DOCKER_DEFAULT_VERSION = '2.0' @@ -45,6 +58,12 @@ 'readthedocs/build:2.0': { 'python': {'supported_versions': [2, 2.7, 3, 3.5]}, }, + 'readthedocs/build:3.0': { + 'python': {'supported_versions': [2, 2.7, 3, 3.3, 3.4, 3.5, 3.6]}, + }, + 'readthedocs/build:stable': { + 'python': {'supported_versions': [2, 2.7, 3, 3.3, 3.4, 3.5, 3.6]}, + }, 'readthedocs/build:latest': { 'python': {'supported_versions': [2, 2.7, 3, 3.3, 3.4, 3.5, 3.6]}, }, @@ -90,7 +109,8 @@ def __init__(self, key, code, error_message, source_file=None, message = self.message_template.format( key=key, code=code, - error=error_message) + error=error_message, + ) super(InvalidConfig, self).__init__(message, code=code) @@ -99,8 +119,8 @@ class BuildConfigBase(object): """ Config that handles the build of one particular documentation. - You need to call ``validate`` before the config is ready to use. - Also setting the ``output_base`` is required before using it for a build. + You need to call ``validate`` before the config is ready to use. Also + setting the ``output_base`` is required before using it for a build. """ version = None @@ -110,6 +130,7 @@ def __init__(self, env_config, raw_config, source_file, source_position): self.raw_config = raw_config self.source_file = source_file self.source_position = source_position + self.base_path = os.path.dirname(self.source_file) self.defaults = self.env_config.get('defaults', {}) self._config = {} @@ -118,18 +139,18 @@ def error(self, key, message, code): """Raise an error related to ``key``.""" source = '{file} [{pos}]'.format( file=self.source_file, - pos=self.source_position + pos=self.source_position, ) error_message = '{source}: {message}'.format( source=source, - message=message + message=message, ) raise InvalidConfig( key=key, code=code, error_message=error_message, source_file=self.source_file, - source_position=self.source_position + source_position=self.source_position, ) @contextmanager @@ -143,7 +164,7 @@ def catch_validation_error(self, key): code=error.code, error_message=str(error), source_file=self.source_file, - source_position=self.source_position + source_position=self.source_position, ) def validate(self): @@ -172,7 +193,7 @@ def __getattr__(self, name): raise ConfigOptionNotSupportedError(name) -class BuildConfig(BuildConfigBase): +class BuildConfigV1(BuildConfigBase): """Version 1 of the configuration file.""" @@ -180,24 +201,19 @@ class BuildConfig(BuildConfigBase): BASE_NOT_A_DIR_MESSAGE = '"base" is not a directory: {base}' NAME_REQUIRED_MESSAGE = 'Missing key "name"' NAME_INVALID_MESSAGE = ( - 'Invalid name "{name}". Valid values must match {name_re}') - TYPE_REQUIRED_MESSAGE = 'Missing key "type"' + 'Invalid name "{name}". Valid values must match {name_re}' + ) CONF_FILE_REQUIRED_MESSAGE = 'Missing key "conf_file"' PYTHON_INVALID_MESSAGE = '"python" section must be a mapping.' PYTHON_EXTRA_REQUIREMENTS_INVALID_MESSAGE = ( - '"python.extra_requirements" section must be a list.') + '"python.extra_requirements" section must be a list.' + ) PYTHON_SUPPORTED_VERSIONS = [2, 2.7, 3, 3.5] DOCKER_SUPPORTED_VERSIONS = ['1.0', '2.0', 'latest'] version = '1' - def get_valid_types(self): # noqa - """Get all valid types.""" - return ( - 'sphinx', - ) - def get_valid_python_versions(self): """Get all valid python versions.""" try: @@ -220,7 +236,6 @@ def validate(self): It makes sure that: - - ``type`` is set and is a valid builder - ``base`` is a valid directory and defaults to the directory of the ``readthedocs.yml`` config file if not set """ @@ -236,16 +251,12 @@ def validate(self): # TODO: this isn't used self._config['name'] = self.validate_name() # TODO: this isn't used - self._config['type'] = self.validate_type() - # TODO: this isn't used self._config['base'] = self.validate_base() self._config['python'] = self.validate_python() self._config['formats'] = self.validate_formats() self._config['conda'] = self.validate_conda() self._config['requirements_file'] = self.validate_requirements_file() - # TODO: this isn't used - self._config['conf_file'] = self.validate_conf_file() def validate_output_base(self): """Validates that ``output_base`` exists and set its absolute path.""" @@ -277,19 +288,6 @@ def validate_name(self): return name - def validate_type(self): - """Validates that type is a valid choice.""" - type_ = self.raw_config.get('type', None) - if not type_: - type_ = self.env_config.get('type', None) - if not type_: - self.error('type', self.TYPE_REQUIRED_MESSAGE, code=TYPE_REQUIRED) - - with self.catch_validation_error('type'): - validate_choice(type_, self.get_valid_types()) - - return type_ - def validate_base(self): """Validates that path is a valid directory.""" if 'base' in self.raw_config: @@ -405,7 +403,8 @@ def validate_python(self): with self.catch_validation_error( 'python.extra_requirements'): python['extra_requirements'].append( - validate_string(extra_name)) + validate_string(extra_name) + ) # Validate setup_py_install. if 'setup_py_install' in raw_python: @@ -474,17 +473,6 @@ def validate_requirements_file(self): validate_file(requirements_file, base_path) return requirements_file - def validate_conf_file(self): - """Validates the conf.py file for sphinx.""" - if 'conf_file' not in self.raw_config: - return None - - conf_file = self.raw_config['conf_file'] - base_path = os.path.dirname(self.source_file) - with self.catch_validation_error('conf_file'): - validate_file(conf_file, base_path) - return conf_file - def validate_formats(self): """Validates that formats contains only valid formats.""" formats = self.raw_config.get('formats') @@ -512,14 +500,9 @@ def base(self): @property def output_base(self): - """The output base""" + """The output base.""" return self._config['output_base'] - @property - def type(self): - """The documentation type.""" - return self._config['type'] - @property def formats(self): """The documentation formats to be built.""" @@ -582,6 +565,389 @@ def build_image(self): return self._config['build']['image'] +class BuildConfigV2(BuildConfigBase): + + """Version 2 of the configuration file.""" + + version = '2' + valid_formats = ['htmlzip', 'pdf', 'epub'] + valid_build_images = ['1.0', '2.0', '3.0', 'stable', 'latest'] + default_build_image = 'latest' + valid_install_options = ['pip', 'setup.py'] + valid_sphinx_builders = { + 'html': 'sphinx', + 'htmldir': 'sphinx_htmldir', + 'singlehtml': 'sphinx_singlehtml', + } + + def validate(self): + """ + Validates and process ``raw_config`` and ``env_config``. + + Sphinx is the default doc type to be built. We don't merge some values + from the database (like formats or python.version) to allow us set + default values. + """ + self._config['formats'] = self.validate_formats() + self._config['conda'] = self.validate_conda() + # This should be called before validate_python + self._config['build'] = self.validate_build() + self._config['python'] = self.validate_python() + # Call this before validate sphinx and mkdocs + self.validate_doc_types() + self._config['mkdocs'] = self.validate_mkdocs() + self._config['sphinx'] = self.validate_sphinx() + self._config['submodules'] = self.validate_submodules() + + def validate_formats(self): + """ + Validates that formats contains only valid formats. + + 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', []) + if formats == ALL: + return self.valid_formats + with self.catch_validation_error('formats'): + validate_list(formats) + for format_ in formats: + validate_choice(format_, self.valid_formats) + return formats + + def validate_conda(self): + """Validates the conda key.""" + raw_conda = self.raw_config.get('conda') + if raw_conda is None: + return None + + with self.catch_validation_error('conda'): + validate_dict(raw_conda) + + conda = {} + with self.catch_validation_error('conda.environment'): + environment = validate_value_exists('environment', raw_conda) + conda['environment'] = validate_file(environment, self.base_path) + return conda + + def validate_build(self): + """ + Validates the build object. + + 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 = raw_build.get('image', self.default_build_image) + build['image'] = '{}:{}'.format( + DOCKER_DEFAULT_IMAGE, + validate_choice( + image, + self.valid_build_images, + ), + ) + + # Allow to override specific project + config_image = self.defaults.get('build_image') + if config_image: + build['image'] = config_image + return build + + def validate_python(self): + """ + Validates the python key. + + validate_build should be called before this, since it initialize the + build.image attribute. + + Fall back to the defaults of: + - ``requirements`` + - ``install`` (only for setup.py method) + - ``system_packages`` + + .. note:: + - ``version`` can be a string or number type. + - ``extra_requirements`` needs to be used with ``install: 'pip'``. + """ + 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 = raw_python.get('version', 3) + if isinstance(version, six.string_types): + try: + version = int(version) + except ValueError: + try: + version = float(version) + except ValueError: + pass + python['version'] = validate_choice( + version, + self.get_valid_python_versions(), + ) + + with self.catch_validation_error('python.requirements'): + requirements = self.defaults.get('requirements_file') + requirements = raw_python.get('requirements', requirements) + if requirements != '' and requirements is not None: + requirements = validate_file(requirements, self.base_path) + python['requirements'] = requirements + + with self.catch_validation_error('python.install'): + install = ( + 'setup.py' if self.defaults.get('install_project') else None + ) + install = raw_python.get('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 = validate_list(extra_requirements) + if extra_requirements and not python['install_with_pip']: + self.error( + 'python.extra_requirements', + 'You need to install your project with pip ' + 'to use extra_requirements', + code=PYTHON_INVALID, + ) + python['extra_requirements'] = [ + validate_string(extra) for extra in extra_requirements + ] + + with self.catch_validation_error('python.system_packages'): + system_packages = self.defaults.get( + 'use_system_packages', + False, + ) + system_packages = raw_python.get( + 'system_packages', + system_packages, + ) + python['use_system_site_packages'] = validate_bool(system_packages) + + return python + + def get_valid_python_versions(self): + """ + Get the valid python versions for the current docker image. + + This should be called after ``validate_build()``. + """ + build_image = self.build.image + if build_image not in DOCKER_IMAGE_SETTINGS: + build_image = '{}:{}'.format( + DOCKER_DEFAULT_IMAGE, + self.default_build_image, + ) + python = DOCKER_IMAGE_SETTINGS[build_image]['python'] + return python['supported_versions'] + + def validate_doc_types(self): + """ + Validates that the user only have one type of documentation. + + This should be called before validating ``sphinx`` or ``mkdocs`` to + avoid innecessary validations. + """ + with self.catch_validation_error('.'): + if 'sphinx' in self.raw_config and 'mkdocs' in self.raw_config: + self.error( + '.', + 'You can not have the ``sphinx`` and ``mkdocs`` ' + 'keys at the same time', + code=INVALID_KEYS_COMBINATION, + ) + + def validate_mkdocs(self): + """ + Validates the mkdocs key. + + It makes sure we are using an existing configuration file. + """ + raw_mkdocs = self.raw_config.get('mkdocs') + if raw_mkdocs is None: + return None + + with self.catch_validation_error('mkdocs'): + validate_dict(raw_mkdocs) + + mkdocs = {} + with self.catch_validation_error('mkdocs.configuration'): + configuration = raw_mkdocs.get('configuration') + 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) + mkdocs['fail_on_warning'] = validate_bool(fail_on_warning) + + return mkdocs + + def validate_sphinx(self): + """ + Validates the sphinx key. + + It makes sure we are using an existing configuration file. + + .. note:: + It should be called after ``validate_mkdocs``. That way + we can default to sphinx if ``mkdocs`` is not given. + """ + raw_sphinx = self.raw_config.get('sphinx') + if raw_sphinx is None: + if self.mkdocs is None: + raw_sphinx = {} + else: + return None + + with self.catch_validation_error('sphinx'): + validate_dict(raw_sphinx) + + sphinx = {} + with self.catch_validation_error('sphinx.builder'): + builder = validate_choice( + raw_sphinx.get('builder', 'html'), + self.valid_sphinx_builders.keys(), + ) + sphinx['builder'] = self.valid_sphinx_builders[builder] + + with self.catch_validation_error('sphinx.configuration'): + configuration = self.defaults.get('sphinx_configuration') + # The default value can be empty + if not configuration: + configuration = None + configuration = raw_sphinx.get('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) + sphinx['fail_on_warning'] = validate_bool(fail_on_warning) + + return sphinx + + def validate_submodules(self): + """ + Validates the submodules key. + + - We can use the ``ALL`` keyword in include or exlude. + - We can't exlude and include submodules at the same time. + """ + raw_submodules = self.raw_config.get('submodules', {}) + with self.catch_validation_error('submodules'): + validate_dict(raw_submodules) + + submodules = {} + with self.catch_validation_error('submodules.include'): + include = raw_submodules.get('include', []) + if include != ALL: + include = [ + validate_string(submodule) + for submodule in validate_list(include) + ] + submodules['include'] = include + + with self.catch_validation_error('submodules.exclude'): + exclude = raw_submodules.get('exclude', []) + if exclude != ALL: + exclude = [ + validate_string(submodule) + for submodule in validate_list(exclude) + ] + submodules['exclude'] = exclude + + with self.catch_validation_error('submodules'): + if submodules['exclude'] and submodules['include']: + self.error( + 'submodules', + 'You can not exclude and include submodules ' + 'at the same time', + code=SUBMODULES_INVALID, + ) + + with self.catch_validation_error('submodules.recursive'): + recursive = raw_submodules.get('recursive', False) + submodules['recursive'] = validate_bool(recursive) + + return submodules + + @property + def formats(self): + return self._config['formats'] + + @property + def conda(self): + Conda = namedtuple('Conda', ['environment']) # noqa + if self._config['conda']: + return Conda(**self._config['conda']) + return None + + @property + def build(self): + Build = namedtuple('Build', ['image']) # noqa + return Build(**self._config['build']) + + @property + def python(self): + Python = namedtuple( # noqa + 'Python', + [ + 'version', + 'requirements', + 'install_with_pip', + 'install_with_setup', + 'extra_requirements', + 'use_system_site_packages', + ], + ) + return Python(**self._config['python']) + + @property + def sphinx(self): + Sphinx = namedtuple( # noqa + 'Sphinx', + ['builder', 'configuration', 'fail_on_warning'], + ) + if self._config['sphinx']: + return Sphinx(**self._config['sphinx']) + return None + + @property + def mkdocs(self): + Mkdocs = namedtuple( # noqa + 'Mkdocs', + ['configuration', 'fail_on_warning'], + ) + if self._config['mkdocs']: + return Mkdocs(**self._config['mkdocs']) + return None + + @property + def doctype(self): + if self.mkdocs: + return 'mkdocs' + return self.sphinx.builder + + @property + def submodules(self): + Submodules = namedtuple( # noqa + 'Submodules', + ['include', 'exclude', 'recursive'], + ) + return Submodules(**self._config['submodules']) + + class ProjectConfig(list): """Wrapper for multiple build configs.""" @@ -596,8 +962,9 @@ def load(path, env_config): """ Load a project configuration and the top-most build config for a given path. - That is usually the root of the project, but will look deeper. - The config will be validated. + That is usually the root of the project, but will look deeper. According to + the version of the configuration a build object would be load and validated, + ``BuildConfigV1`` is the default. """ filename = find_one(path, CONFIG_FILENAMES) @@ -606,8 +973,10 @@ def load(path, env_config): if files: files += ' or ' files += '{!r}'.format(CONFIG_FILENAMES[-1]) - raise ConfigError('No files {} found'.format(files), - code=CONFIG_REQUIRED) + raise ConfigError( + 'No files {} found'.format(files), + code=CONFIG_REQUIRED, + ) build_configs = [] with open(filename, 'r') as configuration_file: try: @@ -616,16 +985,44 @@ def load(path, env_config): raise ConfigError( 'Parse error in {filename}: {message}'.format( filename=filename, - message=str(error)), - code=CONFIG_SYNTAX_INVALID) + message=str(error), + ), + code=CONFIG_SYNTAX_INVALID, + ) for i, config in enumerate(configs): - build_config = BuildConfig( + allow_v2 = env_config.get('allow_v2') + if allow_v2: + version = config.get('version', 1) + else: + version = 1 + build_config = get_configuration_class(version)( env_config, config, source_file=filename, - source_position=i) + source_position=i, + ) build_configs.append(build_config) project_config = ProjectConfig(build_configs) project_config.validate() return project_config + + +def get_configuration_class(version): + """ + Get the appropriate config class for ``version``. + + :type version: str or int + """ + configurations_class = { + 1: BuildConfigV1, + 2: BuildConfigV2, + } + try: + version = int(version) + return configurations_class[version] + except (KeyError, ValueError): + raise ConfigError( + 'Invalid version of the configuration file', + code=VERSION_INVALID, + ) diff --git a/readthedocs/config/tests/test_config.py b/readthedocs/config/tests/test_config.py index a8c308643e2..ddbf59c2eac 100644 --- a/readthedocs/config/tests/test_config.py +++ b/readthedocs/config/tests/test_config.py @@ -1,57 +1,50 @@ +# -*- coding: utf-8 -*- from __future__ import division, print_function, unicode_literals import os +import textwrap import pytest from mock import DEFAULT, patch from pytest import raises from readthedocs.config import ( - BuildConfig, ConfigError, ConfigOptionNotSupportedError, InvalidConfig, - ProjectConfig, load) + ALL, BuildConfigV1, BuildConfigV2, ConfigError, + ConfigOptionNotSupportedError, InvalidConfig, ProjectConfig, load) from readthedocs.config.config import ( CONFIG_NOT_SUPPORTED, NAME_INVALID, NAME_REQUIRED, PYTHON_INVALID, - TYPE_REQUIRED) + VERSION_INVALID) from readthedocs.config.validation import ( INVALID_BOOL, INVALID_CHOICE, INVALID_LIST, INVALID_PATH, INVALID_STRING) from .utils import apply_fs env_config = { - 'output_base': '/tmp' + 'output_base': '/tmp', } - minimal_config = { 'name': 'docs', - 'type': 'sphinx', } - config_with_explicit_empty_list = { 'readthedocs.yml': ''' name: docs -type: sphinx formats: [] -''' +''', } - minimal_config_dir = { 'readthedocs.yml': '''\ name: docs -type: sphinx -''' +''', } - multiple_config_dir = { 'readthedocs.yml': ''' name: first -type: sphinx --- name: second -type: sphinx ''', 'nested': minimal_config_dir, } @@ -59,11 +52,12 @@ def get_build_config(config, env_config=None, source_file='readthedocs.yml', source_position=0): - return BuildConfig( + return BuildConfigV1( env_config or {}, config, source_file=source_file, - source_position=source_position) + source_position=source_position, + ) def get_env_config(extra=None): @@ -71,7 +65,6 @@ def get_env_config(extra=None): defaults = { 'output_base': '', 'name': 'name', - 'type': 'sphinx', } if extra is None: extra = {} @@ -101,7 +94,47 @@ def test_minimal_config(tmpdir): assert isinstance(config, ProjectConfig) assert len(config) == 1 build = config[0] - assert isinstance(build, BuildConfig) + assert isinstance(build, BuildConfigV1) + + +def test_load_version1(tmpdir): + apply_fs(tmpdir, { + 'readthedocs.yml': textwrap.dedent(''' + version: 1 + ''') + }) + base = str(tmpdir) + config = load(base, get_env_config({'allow_v2': True})) + assert isinstance(config, ProjectConfig) + assert len(config) == 1 + build = config[0] + assert isinstance(build, BuildConfigV1) + + +def test_load_version2(tmpdir): + apply_fs(tmpdir, { + 'readthedocs.yml': textwrap.dedent(''' + version: 2 + ''') + }) + base = str(tmpdir) + config = load(base, get_env_config({'allow_v2': True})) + assert isinstance(config, ProjectConfig) + assert len(config) == 1 + build = config[0] + assert isinstance(build, BuildConfigV2) + + +def test_load_unknow_version(tmpdir): + apply_fs(tmpdir, { + 'readthedocs.yml': textwrap.dedent(''' + version: 9 + ''') + }) + base = str(tmpdir) + with raises(ConfigError) as excinfo: + load(base, get_env_config({'allow_v2': True})) + assert excinfo.value.code == VERSION_INVALID def test_build_config_has_source_file(tmpdir): @@ -117,7 +150,8 @@ def test_build_config_has_source_position(tmpdir): assert len(builds) == 2 first, second = filter( lambda b: not b.source_file.endswith('nested/readthedocs.yml'), - builds) + builds, + ) assert first.source_position == 0 assert second.source_position == 1 @@ -125,15 +159,16 @@ def test_build_config_has_source_position(tmpdir): def test_build_config_has_list_with_single_empty_value(tmpdir): base = str(apply_fs(tmpdir, config_with_explicit_empty_list)) build = load(base, env_config)[0] - assert isinstance(build, BuildConfig) + assert isinstance(build, BuildConfigV1) assert build.formats == [] def test_config_requires_name(): - build = BuildConfig( - {'output_base': ''}, {}, + build = BuildConfigV1( + {'output_base': ''}, + {}, source_file='readthedocs.yml', - source_position=0 + source_position=0, ) with raises(InvalidConfig) as excinfo: build.validate() @@ -142,11 +177,11 @@ def test_config_requires_name(): def test_build_requires_valid_name(): - build = BuildConfig( + build = BuildConfigV1( {'output_base': ''}, {'name': 'with/slashes'}, source_file='readthedocs.yml', - source_position=0 + source_position=0, ) with raises(InvalidConfig) as excinfo: build.validate() @@ -154,31 +189,6 @@ def test_build_requires_valid_name(): assert excinfo.value.code == NAME_INVALID -def test_config_requires_type(): - build = BuildConfig( - {'output_base': ''}, {'name': 'docs'}, - source_file='readthedocs.yml', - source_position=0 - ) - with raises(InvalidConfig) as excinfo: - build.validate() - assert excinfo.value.key == 'type' - assert excinfo.value.code == TYPE_REQUIRED - - -def test_build_requires_valid_type(): - build = BuildConfig( - {'output_base': ''}, - {'name': 'docs', 'type': 'unknown'}, - source_file='readthedocs.yml', - source_position=0 - ) - with raises(InvalidConfig) as excinfo: - build.validate() - assert excinfo.value.key == 'type' - assert excinfo.value.code == INVALID_CHOICE - - def test_version(): build = get_build_config({}, get_env_config()) assert build.version == '1' @@ -233,7 +243,7 @@ def it_defaults_to_list(): def it_validates_is_a_list(): build = get_build_config( {'python': {'extra_requirements': 'invalid'}}, - get_env_config() + get_env_config(), ) with raises(InvalidConfig) as excinfo: build.validate() @@ -245,13 +255,14 @@ def it_uses_validate_string(validate_string): validate_string.return_value = True build = get_build_config( {'python': {'extra_requirements': ['tests']}}, - get_env_config() + get_env_config(), ) build.validate() validate_string.assert_any_call('tests') def describe_validate_use_system_site_packages(): + def it_defaults_to_false(): build = get_build_config({'python': {}}, get_env_config()) build.validate() @@ -260,7 +271,7 @@ def it_defaults_to_false(): def it_validates_value(): build = get_build_config( {'python': {'use_system_site_packages': 'invalid'}}, - get_env_config() + get_env_config(), ) with raises(InvalidConfig) as excinfo: build.validate() @@ -272,7 +283,7 @@ def it_uses_validate_bool(validate_bool): validate_bool.return_value = True build = get_build_config( {'python': {'use_system_site_packages': 'to-validate'}}, - get_env_config() + get_env_config(), ) build.validate() validate_bool.assert_any_call('to-validate') @@ -288,7 +299,7 @@ def it_defaults_to_false(): def it_validates_value(): build = get_build_config( {'python': {'setup_py_install': 'this-is-string'}}, - get_env_config() + get_env_config(), ) with raises(InvalidConfig) as excinfo: build.validate() @@ -300,7 +311,7 @@ def it_uses_validate_bool(validate_bool): validate_bool.return_value = True build = get_build_config( {'python': {'setup_py_install': 'to-validate'}}, - get_env_config() + get_env_config(), ) build.validate() validate_bool.assert_any_call('to-validate') @@ -318,7 +329,7 @@ def it_defaults_to_a_valid_version(): def it_supports_other_versions(): build = get_build_config( {'python': {'version': 3.5}}, - get_env_config() + get_env_config(), ) build.validate() assert build.python_version == 3.5 @@ -328,7 +339,7 @@ def it_supports_other_versions(): def it_validates_versions_out_of_range(): build = get_build_config( {'python': {'version': 1.0}}, - get_env_config() + get_env_config(), ) with raises(InvalidConfig) as excinfo: build.validate() @@ -338,7 +349,7 @@ def it_validates_versions_out_of_range(): def it_validates_wrong_type(): build = get_build_config( {'python': {'version': 'this-is-string'}}, - get_env_config() + get_env_config(), ) with raises(InvalidConfig) as excinfo: build.validate() @@ -348,7 +359,7 @@ def it_validates_wrong_type(): def it_validates_wrong_type_right_value(): build = get_build_config( {'python': {'version': '3.5'}}, - get_env_config() + get_env_config(), ) build.validate() assert build.python_version == 3.5 @@ -357,7 +368,7 @@ def it_validates_wrong_type_right_value(): build = get_build_config( {'python': {'version': '3'}}, - get_env_config() + get_env_config(), ) build.validate() assert build.python_version == 3 @@ -396,11 +407,11 @@ def it_validates_env_supported_versions(): @pytest.mark.parametrize('value', [2, 3]) def it_respects_default_value(value): defaults = { - 'python_version': value + 'python_version': value, } build = get_build_config( {}, - get_env_config({'defaults': defaults}) + get_env_config({'defaults': defaults}), ) build.validate() assert build.python_version == value @@ -479,7 +490,7 @@ def it_defaults_to_source_file_directory(tmpdir): 'readthedocs.yml': '', 'setup.py': '', }, - } + }, ) with tmpdir.as_cwd(): source_file = tmpdir.join('subdir', 'readthedocs.yml') @@ -487,13 +498,16 @@ def it_defaults_to_source_file_directory(tmpdir): build = get_build_config( {}, env_config=get_env_config(), - source_file=str(source_file)) + source_file=str(source_file), + ) build.validate() assert build.python['setup_py_path'] == str(setup_py) def it_validates_value(tmpdir): with tmpdir.as_cwd(): - build = get_build_config({'python': {'setup_py_path': 'this-is-string'}}) + build = get_build_config({ + 'python': {'setup_py_path': 'this-is-string'} + }) with raises(InvalidConfig) as excinfo: build.validate_python() assert excinfo.value.key == 'python.setup_py_path' @@ -506,21 +520,21 @@ def it_uses_validate_file(tmpdir): patcher = patch('readthedocs.config.config.validate_file') with patcher as validate_file: validate_file.return_value = path - build = get_build_config( - {'python': {'setup_py_path': 'setup.py'}}) + build = get_build_config({'python': {'setup_py_path': 'setup.py'}},) build.validate_python() args, kwargs = validate_file.call_args assert args[0] == 'setup.py' def test_valid_build_config(): - build = BuildConfig(env_config, - minimal_config, - source_file='readthedocs.yml', - source_position=0) + build = BuildConfigV1( + env_config, + minimal_config, + source_file='readthedocs.yml', + source_position=0, + ) build.validate() assert build.name == 'docs' - assert build.type == 'sphinx' assert build.base assert build.python assert 'setup_py_install' in build.python @@ -534,11 +548,12 @@ def it_validates_to_abspath(tmpdir): apply_fs(tmpdir, {'configs': minimal_config, 'docs': {}}) with tmpdir.as_cwd(): source_file = str(tmpdir.join('configs', 'readthedocs.yml')) - build = BuildConfig( + build = BuildConfigV1( get_env_config(), {'base': '../docs'}, source_file=source_file, - source_position=0) + source_position=0, + ) build.validate() assert build.base == str(tmpdir.join('docs')) @@ -554,11 +569,12 @@ def it_uses_validate_directory(validate_directory): def it_fails_if_base_is_not_a_string(tmpdir): apply_fs(tmpdir, minimal_config) with tmpdir.as_cwd(): - build = BuildConfig( + build = BuildConfigV1( get_env_config(), {'base': 1}, source_file=str(tmpdir.join('readthedocs.yml')), - source_position=0) + source_position=0, + ) with raises(InvalidConfig) as excinfo: build.validate() assert excinfo.value.key == 'base' @@ -566,11 +582,12 @@ def it_fails_if_base_is_not_a_string(tmpdir): def it_fails_if_base_does_not_exist(tmpdir): apply_fs(tmpdir, minimal_config) - build = BuildConfig( + build = BuildConfigV1( get_env_config(), {'base': 'docs'}, source_file=str(tmpdir.join('readthedocs.yml')), - source_position=0) + source_position=0, + ) with raises(InvalidConfig) as excinfo: build.validate() assert excinfo.value.key == 'base' @@ -581,11 +598,12 @@ def describe_validate_build(): def it_fails_if_build_is_invalid_option(tmpdir): apply_fs(tmpdir, minimal_config) - build = BuildConfig( + build = BuildConfigV1( get_env_config(), {'build': {'image': 3.0}}, source_file=str(tmpdir.join('readthedocs.yml')), - source_position=0) + source_position=0, + ) with raises(InvalidConfig) as excinfo: build.validate() assert excinfo.value.key == 'build' @@ -593,14 +611,15 @@ def it_fails_if_build_is_invalid_option(tmpdir): def it_fails_on_python_validation(tmpdir): apply_fs(tmpdir, minimal_config) - build = BuildConfig( + build = BuildConfigV1( {}, { 'build': {'image': 1.0}, 'python': {'version': '3.3'}, }, source_file=str(tmpdir.join('readthedocs.yml')), - source_position=0) + source_position=0, + ) build.validate_build() with raises(InvalidConfig) as excinfo: build.validate_python() @@ -609,34 +628,37 @@ def it_fails_on_python_validation(tmpdir): def it_works_on_python_validation(tmpdir): apply_fs(tmpdir, minimal_config) - build = BuildConfig( + build = BuildConfigV1( {}, { 'build': {'image': 'latest'}, 'python': {'version': '3.3'}, }, source_file=str(tmpdir.join('readthedocs.yml')), - source_position=0) + source_position=0, + ) build.validate_build() build.validate_python() def it_works(tmpdir): apply_fs(tmpdir, minimal_config) - build = BuildConfig( + build = BuildConfigV1( get_env_config(), {'build': {'image': 'latest'}}, source_file=str(tmpdir.join('readthedocs.yml')), - source_position=0) + source_position=0, + ) build.validate() assert build.build_image == 'readthedocs/build:latest' def default(tmpdir): apply_fs(tmpdir, minimal_config) - build = BuildConfig( + build = BuildConfigV1( get_env_config(), {}, source_file=str(tmpdir.join('readthedocs.yml')), - source_position=0) + source_position=0, + ) build.validate() assert build.build_image == 'readthedocs/build:2.0' @@ -647,11 +669,11 @@ def it_priorities_image_from_env_config(tmpdir, image): defaults = { 'build_image': image, } - build = BuildConfig( + build = BuildConfigV1( get_env_config({'defaults': defaults}), {'build': {'image': 'latest'}}, source_file=str(tmpdir.join('readthedocs.yml')), - source_position=0 + source_position=0, ) build.validate() assert build.build_image == image @@ -666,7 +688,7 @@ def test_use_conda_default_false(): def test_use_conda_respects_config(): build = get_build_config( {'conda': {}}, - get_env_config() + get_env_config(), ) build.validate() assert build.use_conda is True @@ -677,7 +699,7 @@ def test_validates_conda_file(tmpdir): build = get_build_config( {'conda': {'file': 'environment.yml'}}, get_env_config(), - source_file=str(tmpdir.join('readthedocs.yml')) + source_file=str(tmpdir.join('readthedocs.yml')), ) build.validate() assert build.use_conda is True @@ -693,12 +715,12 @@ def test_requirements_file_empty(): def test_requirements_file_repects_default_value(tmpdir): apply_fs(tmpdir, {'myrequirements.txt': ''}) defaults = { - 'requirements_file': 'myrequirements.txt' + 'requirements_file': 'myrequirements.txt', } build = get_build_config( {}, get_env_config({'defaults': defaults}), - source_file=str(tmpdir.join('readthedocs.yml')) + source_file=str(tmpdir.join('readthedocs.yml')), ) build.validate() assert build.requirements_file == 'myrequirements.txt' @@ -709,42 +731,43 @@ def test_requirements_file_respects_configuration(tmpdir): build = get_build_config( {'requirements_file': 'requirements.txt'}, get_env_config(), - source_file=str(tmpdir.join('readthedocs.yml')) + source_file=str(tmpdir.join('readthedocs.yml')), ) build.validate() assert build.requirements_file == 'requirements.txt' - def test_build_validate_calls_all_subvalidators(tmpdir): apply_fs(tmpdir, minimal_config) - build = BuildConfig( + build = BuildConfigV1( {}, {}, source_file=str(tmpdir.join('readthedocs.yml')), - source_position=0) - with patch.multiple(BuildConfig, - validate_base=DEFAULT, - validate_name=DEFAULT, - validate_type=DEFAULT, - validate_python=DEFAULT, - validate_output_base=DEFAULT): + source_position=0, + ) + with patch.multiple( + BuildConfigV1, + validate_base=DEFAULT, + validate_name=DEFAULT, + validate_python=DEFAULT, + validate_output_base=DEFAULT, + ): build.validate() - BuildConfig.validate_base.assert_called_with() - BuildConfig.validate_name.assert_called_with() - BuildConfig.validate_type.assert_called_with() - BuildConfig.validate_python.assert_called_with() - BuildConfig.validate_output_base.assert_called_with() + BuildConfigV1.validate_base.assert_called_with() + BuildConfigV1.validate_name.assert_called_with() + BuildConfigV1.validate_python.assert_called_with() + BuildConfigV1.validate_output_base.assert_called_with() def test_validate_project_config(): - with patch.object(BuildConfig, 'validate') as build_validate: + with patch.object(BuildConfigV1, 'validate') as build_validate: project = ProjectConfig([ - BuildConfig( + BuildConfigV1( env_config, minimal_config, source_file='readthedocs.yml', - source_position=0) + source_position=0, + ), ]) project.validate() assert build_validate.call_count == 1 @@ -753,7 +776,7 @@ def test_validate_project_config(): def test_load_calls_validate(tmpdir): apply_fs(tmpdir, minimal_config_dir) base = str(tmpdir) - with patch.object(BuildConfig, 'validate') as build_validate: + with patch.object(BuildConfigV1, 'validate') as build_validate: load(base, env_config) assert build_validate.call_count == 1 @@ -765,3 +788,835 @@ def test_raise_config_not_supported(): build.redirects assert excinfo.value.configuration == 'redirects' assert excinfo.value.code == CONFIG_NOT_SUPPORTED + + +class TestBuildConfigV2(object): + + def get_build_config(self, config, env_config=None, + source_file='readthedocs.yml', source_position=0): + return BuildConfigV2( + env_config or {}, + config, + source_file=source_file, + source_position=source_position, + ) + + def test_version(self): + build = self.get_build_config({}) + assert build.version == '2' + + def test_formats_check_valid(self): + build = self.get_build_config({'formats': ['htmlzip', 'pdf', 'epub']}) + build.validate() + assert build.formats == ['htmlzip', 'pdf', 'epub'] + + @pytest.mark.parametrize('value', [3, 'invalid', {'other': 'value'}]) + def test_formats_check_invalid_value(self, value): + build = self.get_build_config({'formats': value}) + with raises(InvalidConfig) as excinfo: + build.validate() + assert excinfo.value.key == 'formats' + + def test_formats_check_invalid_type(self): + build = self.get_build_config( + {'formats': ['htmlzip', 'invalid', 'epub']} + ) + with raises(InvalidConfig) as excinfo: + build.validate() + assert excinfo.value.key == 'formats' + + def test_formats_default_value(self): + build = self.get_build_config({}) + build.validate() + assert build.formats == [] + + def test_formats_overrides_default_values(self): + build = self.get_build_config( + {}, + {'defaults': {'formats': ['htmlzip']}}, + ) + build.validate() + assert build.formats == [] + + def test_formats_priority_over_defaults(self): + build = self.get_build_config( + {'formats': []}, + {'defaults': {'formats': ['htmlzip']}}, + ) + build.validate() + assert build.formats == [] + + build = self.get_build_config( + {'formats': ['pdf']}, + {'defaults': {'formats': ['htmlzip']}}, + ) + build.validate() + assert build.formats == ['pdf'] + + def test_formats_allow_empty(self): + build = self.get_build_config({'formats': []}) + build.validate() + assert build.formats == [] + + def test_formats_allow_all_keyword(self): + build = self.get_build_config({'formats': 'all'}) + build.validate() + assert build.formats == ['htmlzip', 'pdf', 'epub'] + + def test_conda_check_valid(self, tmpdir): + apply_fs(tmpdir, {'environment.yml': ''}) + build = self.get_build_config( + {'conda': {'environment': 'environment.yml'}}, + source_file=str(tmpdir.join('readthedocs.yml')), + ) + build.validate() + assert build.conda.environment == str(tmpdir.join('environment.yml')) + + def test_conda_check_invalid(self, tmpdir): + apply_fs(tmpdir, {'environment.yml': ''}) + build = self.get_build_config( + {'conda': {'environment': 'no_existing_environment.yml'}}, + source_file=str(tmpdir.join('readthedocs.yml')), + ) + with raises(InvalidConfig) as excinfo: + build.validate() + assert excinfo.value.key == 'conda.environment' + + @pytest.mark.parametrize('value', [3, [], 'invalid']) + def test_conda_check_invalid_value(self, value): + build = self.get_build_config({'conda': value}) + with raises(InvalidConfig) as excinfo: + build.validate() + assert excinfo.value.key == 'conda' + + @pytest.mark.parametrize('value', [3, [], {}]) + def test_conda_check_invalid_file_value(self, value): + build = self.get_build_config({'conda': {'file': value}}) + with raises(InvalidConfig) as excinfo: + build.validate() + assert excinfo.value.key == 'conda.environment' + + def test_conda_check_file_required(self): + build = self.get_build_config({'conda': {'no-file': 'other'}}) + with raises(InvalidConfig) as excinfo: + build.validate() + assert excinfo.value.key == 'conda.environment' + + @pytest.mark.parametrize('value', ['stable', 'latest']) + def test_build_image_check_valid(self, value): + build = self.get_build_config({'build': {'image': value}}) + build.validate() + assert build.build.image == 'readthedocs/build:{}'.format(value) + + @pytest.mark.parametrize('value', ['readthedocs/build:latest', 'one']) + def test_build_image_check_invalid(self, value): + build = self.get_build_config({'build': {'image': value}}) + with raises(InvalidConfig) as excinfo: + build.validate() + assert excinfo.value.key == 'build.image' + + @pytest.mark.parametrize( + 'image', ['latest', 'readthedocs/build:3.0', 'rtd/build:latest']) + def test_build_image_priorities_default(self, image): + build = self.get_build_config( + {'build': {'image': 'latest'}}, + {'defaults': {'build_image': image}}, + ) + build.validate() + assert build.build.image == image + + @pytest.mark.parametrize('image', ['', None]) + def test_build_image_over_empty_default(self, image): + build = self.get_build_config( + {'build': {'image': 'latest'}}, + {'defaults': {'build_image': image}}, + ) + build.validate() + assert build.build.image == 'readthedocs/build:latest' + + def test_build_image_default_value(self): + build = self.get_build_config({}) + build.validate() + assert build.build.image == 'readthedocs/build:latest' + + @pytest.mark.parametrize('value', [3, [], 'invalid']) + def test_build_check_invalid_type(self, value): + build = self.get_build_config({'build': value}) + with raises(InvalidConfig) as excinfo: + build.validate() + assert excinfo.value.key == 'build' + + @pytest.mark.parametrize('value', [3, [], {}]) + def test_build_image_check_invalid_type(self, value): + build = self.get_build_config({'build': {'image': value}}) + with raises(InvalidConfig) as excinfo: + build.validate() + assert excinfo.value.key == 'build.image' + + @pytest.mark.parametrize('value', [3, [], 'invalid']) + def test_python_check_invalid_types(self, value): + build = self.get_build_config({'python': value}) + with raises(InvalidConfig) as excinfo: + build.validate() + assert excinfo.value.key == 'python' + + @pytest.mark.parametrize('image,versions', + [('latest', [2, 2.7, 3, 3.5, 3.6]), + ('stable', [2, 2.7, 3, 3.5, 3.6])]) + def test_python_version(self, image, versions): + for version in versions: + build = self.get_build_config({ + 'build': { + 'image': image, + }, + 'python': { + 'version': version, + }, + }) + build.validate() + assert build.python.version == version + + def test_python_version_accepts_string(self): + build = self.get_build_config({ + 'build': { + 'image': 'latest', + }, + 'python': { + 'version': '3.6', + }, + }) + build.validate() + assert build.python.version == 3.6 + + @pytest.mark.parametrize('image,versions', + [('latest', [1, 2.8, 4, 3.8]), + ('stable', [1, 2.8, 4, 3.8])]) + def test_python_version_invalid(self, image, versions): + for version in versions: + build = self.get_build_config({ + 'build': { + 'image': image, + }, + 'python': { + 'version': version, + }, + }) + with raises(InvalidConfig) as excinfo: + build.validate() + assert excinfo.value.key == 'python.version' + + def test_python_version_default(self): + build = self.get_build_config({}) + build.validate() + assert build.python.version == 3 + + @pytest.mark.parametrize('value', [2, 3]) + def test_python_version_overrides_default(self, value): + build = self.get_build_config( + {}, + {'defaults': {'python_version': value}}, + ) + build.validate() + assert build.python.version == 3 + + @pytest.mark.parametrize('value', [2, 3, 3.6]) + def test_python_version_priority_over_default(self, value): + build = self.get_build_config( + {'python': {'version': value}}, + {'defaults': {'python_version': 3}}, + ) + build.validate() + assert build.python.version == value + + @pytest.mark.parametrize('value', [[], {}]) + def test_python_version_check_invalid_types(self, value): + build = self.get_build_config({'python': {'version': value}}) + with raises(InvalidConfig) as excinfo: + build.validate() + assert excinfo.value.key == 'python.version' + + def test_python_requirements_check_valid(self, tmpdir): + apply_fs(tmpdir, {'requirements.txt': ''}) + build = self.get_build_config( + {'python': {'requirements': 'requirements.txt'}}, + source_file=str(tmpdir.join('readthedocs.yml')), + ) + build.validate() + assert build.python.requirements == str(tmpdir.join('requirements.txt')) + + def test_python_requirements_check_invalid(self, tmpdir): + apply_fs(tmpdir, {'requirements.txt': ''}) + build = self.get_build_config( + {'python': {'requirements': 'invalid'}}, + source_file=str(tmpdir.join('readthedocs.yml')), + ) + with raises(InvalidConfig) as excinfo: + build.validate() + assert excinfo.value.key == 'python.requirements' + + def test_python_requirements_default_value(self): + build = self.get_build_config({}) + build.validate() + assert build.python.requirements is None + + def test_python_requirements_allow_null(self): + build = self.get_build_config({'python': {'requirements': None}},) + build.validate() + assert build.python.requirements is None + + def test_python_requirements_allow_empty_string(self): + build = self.get_build_config({'python': {'requirements': ''}},) + build.validate() + assert build.python.requirements == '' + + def test_python_requirements_respects_default(self, tmpdir): + apply_fs(tmpdir, {'requirements.txt': ''}) + build = self.get_build_config( + {}, + {'defaults': {'requirements_file': 'requirements.txt'}}, + source_file=str(tmpdir.join('readthedocs.yml')), + ) + build.validate() + assert build.python.requirements == str(tmpdir.join('requirements.txt')) + + def test_python_requirements_priority_over_default(self, tmpdir): + apply_fs(tmpdir, {'requirements.txt': ''}) + build = self.get_build_config( + {'python': {'requirements': 'requirements.txt'}}, + {'defaults': {'requirements_file': 'requirements-default.txt'}}, + source_file=str(tmpdir.join('readthedocs.yml')), + ) + build.validate() + assert build.python.requirements == str(tmpdir.join('requirements.txt')) + + @pytest.mark.parametrize('value', [3, [], {}]) + def test_python_requirements_check_invalid_types(self, value): + build = self.get_build_config({'python': {'requirements': value}},) + with raises(InvalidConfig) as excinfo: + build.validate() + assert excinfo.value.key == 'python.requirements' + + def test_python_install_pip_check_valid(self): + build = self.get_build_config({'python': {'install': 'pip'}},) + build.validate() + assert build.python.install_with_pip is True + assert build.python.install_with_setup is False + + def test_python_install_pip_priority_over_default(self): + build = self.get_build_config( + {'python': {'install': 'pip'}}, + {'defaults': {'install_project': True}}, + ) + build.validate() + assert build.python.install_with_pip is True + assert build.python.install_with_setup is False + + def test_python_install_setuppy_check_valid(self): + build = self.get_build_config({'python': {'install': 'setup.py'}},) + build.validate() + assert build.python.install_with_setup is True + assert build.python.install_with_pip is False + + def test_python_install_setuppy_respects_default(self): + build = self.get_build_config( + {}, + {'defaults': {'install_project': True}}, + ) + build.validate() + assert build.python.install_with_pip is False + assert build.python.install_with_setup is True + + def test_python_install_setuppy_priority_over_default(self): + build = self.get_build_config( + {'python': {'install': 'setup.py'}}, + {'defaults': {'install_project': False}}, + ) + build.validate() + assert build.python.install_with_pip is False + assert build.python.install_with_setup is True + + @pytest.mark.parametrize('value', ['invalid', 'apt']) + def test_python_install_check_invalid(self, value): + build = self.get_build_config({'python': {'install': value}},) + with raises(InvalidConfig) as excinfo: + build.validate() + assert excinfo.value.key == 'python.install' + + def test_python_install_allow_null(self): + build = self.get_build_config({'python': {'install': None}},) + build.validate() + assert build.python.install_with_pip is False + assert build.python.install_with_setup is False + + def test_python_install_default(self): + build = self.get_build_config({'python': {}}) + build.validate() + assert build.python.install_with_pip is False + assert build.python.install_with_setup is False + + @pytest.mark.parametrize('value', [2, [], {}]) + def test_python_install_check_invalid_type(self, value): + build = self.get_build_config({'python': {'install': value}},) + with raises(InvalidConfig) as excinfo: + build.validate() + assert excinfo.value.key == 'python.install' + + def test_python_extra_requirements_and_pip(self): + build = self.get_build_config({ + 'python': { + 'install': 'pip', + 'extra_requirements': ['docs', 'tests'], + } + }) + build.validate() + assert build.python.extra_requirements == ['docs', 'tests'] + + def test_python_extra_requirements_not_install(self): + build = self.get_build_config({ + 'python': { + 'extra_requirements': ['docs', 'tests'], + } + }) + with raises(InvalidConfig) as excinfo: + build.validate() + assert excinfo.value.key == 'python.extra_requirements' + + def test_python_extra_requirements_and_setup(self): + build = self.get_build_config({ + 'python': { + 'install': 'setup.py', + 'extra_requirements': ['docs', 'tests'], + } + }) + with raises(InvalidConfig) as excinfo: + build.validate() + assert excinfo.value.key == 'python.extra_requirements' + + @pytest.mark.parametrize('value', [2, 'invalid', {}]) + def test_python_extra_requirements_check_type(self, value): + build = self.get_build_config({ + 'python': { + 'install': 'pip', + 'extra_requirements': value, + }, + }) + with raises(InvalidConfig) as excinfo: + build.validate() + assert excinfo.value.key == 'python.extra_requirements' + + def test_python_extra_requirements_allow_empty(self): + build = self.get_build_config({ + 'python': { + 'install': 'pip', + 'extra_requirements': [], + }, + }) + build.validate() + assert build.python.extra_requirements == [] + + def test_python_extra_requirements_check_default(self): + build = self.get_build_config({}) + build.validate() + assert build.python.extra_requirements == [] + + @pytest.mark.parametrize('value', [True, False]) + def test_python_system_packages_check_valid(self, value): + build = self.get_build_config({ + 'python': { + 'system_packages': value, + }, + }) + build.validate() + assert build.python.use_system_site_packages is value + + @pytest.mark.parametrize('value', [[], 'invalid', 5]) + def test_python_system_packages_check_invalid(self, value): + build = self.get_build_config({ + 'python': { + 'system_packages': value, + }, + }) + with raises(InvalidConfig) as excinfo: + build.validate() + assert excinfo.value.key == 'python.system_packages' + + def test_python_system_packages_check_default(self): + build = self.get_build_config({}) + build.validate() + assert build.python.use_system_site_packages is False + + def test_python_system_packages_respects_default(self): + build = self.get_build_config( + {}, + {'defaults': {'use_system_packages': True}}, + ) + build.validate() + assert build.python.use_system_site_packages is True + + def test_python_system_packages_priority_over_default(self): + build = self.get_build_config( + {'python': {'system_packages': False}}, + {'defaults': {'use_system_packages': True}}, + ) + build.validate() + assert build.python.use_system_site_packages is False + + build = self.get_build_config( + {'python': {'system_packages': True}}, + {'defaults': {'use_system_packages': False}}, + ) + build.validate() + assert build.python.use_system_site_packages is True + + @pytest.mark.parametrize('value', [[], True, 0, 'invalid']) + def test_sphinx_validate_type(self, value): + build = self.get_build_config({'sphinx': value}) + with raises(InvalidConfig) as excinfo: + build.validate() + assert excinfo.value.key == 'sphinx' + + def test_sphinx_is_default_doc_type(self): + build = self.get_build_config({}) + build.validate() + assert build.sphinx is not None + assert build.mkdocs is None + assert build.doctype == 'sphinx' + + @pytest.mark.parametrize('value,expected', + [('html', 'sphinx'), + ('htmldir', 'sphinx_htmldir'), + ('singlehtml', 'sphinx_singlehtml')]) + def test_sphinx_builder_check_valid(self, value, expected): + build = self.get_build_config({'sphinx': {'builder': value}}) + build.validate() + assert build.sphinx.builder == expected + assert build.doctype == expected + + @pytest.mark.parametrize('value', [[], True, 0, 'invalid']) + def test_sphinx_builder_check_invalid(self, value): + build = self.get_build_config({'sphinx': {'builder': value}}) + with raises(InvalidConfig) as excinfo: + build.validate() + assert excinfo.value.key == 'sphinx.builder' + + def test_sphinx_builder_default(self): + build = self.get_build_config({}) + build.validate() + build.sphinx.builder == 'sphinx' + + def test_sphinx_builder_ignores_default(self): + build = self.get_build_config( + {}, + {'defaults': {'doctype': 'sphinx_singlehtml'}}, + ) + build.validate() + build.sphinx.builder == 'sphinx' + + def test_sphinx_configuration_check_valid(self, tmpdir): + apply_fs(tmpdir, {'conf.py': ''}) + build = self.get_build_config( + {'sphinx': {'configuration': 'conf.py'}}, + source_file=str(tmpdir.join('readthedocs.yml')), + ) + build.validate() + assert build.sphinx.configuration == str(tmpdir.join('conf.py')) + + def test_sphinx_configuration_check_invalid(self, tmpdir): + apply_fs(tmpdir, {'conf.py': ''}) + build = self.get_build_config( + {'sphinx': {'configuration': 'invalid.py'}}, + source_file=str(tmpdir.join('readthedocs.yml')), + ) + with raises(InvalidConfig) as excinfo: + build.validate() + assert excinfo.value.key == 'sphinx.configuration' + + def test_sphinx_cant_be_used_with_mkdocs(self, tmpdir): + apply_fs(tmpdir, {'conf.py': ''}) + build = self.get_build_config( + { + 'sphinx': {'configuration': 'conf.py'}, + 'mkdocs': {}, + }, + source_file=str(tmpdir.join('readthedocs.yml')), + ) + with raises(InvalidConfig) as excinfo: + build.validate() + assert excinfo.value.key == '.' + + def test_sphinx_configuration_allow_null(self): + build = self.get_build_config({'sphinx': {'configuration': None}},) + build.validate() + assert build.sphinx.configuration is None + + def test_sphinx_configuration_check_default(self): + build = self.get_build_config({}) + build.validate() + assert build.sphinx.configuration is None + + def test_sphinx_configuration_respects_default(self, tmpdir): + apply_fs(tmpdir, {'conf.py': ''}) + build = self.get_build_config( + {}, + {'defaults': {'sphinx_configuration': 'conf.py'}}, + source_file=str(tmpdir.join('readthedocs.yml')), + ) + build.validate() + assert build.sphinx.configuration == str(tmpdir.join('conf.py')) + + def test_sphinx_configuration_default_can_be_none(self, tmpdir): + apply_fs(tmpdir, {'conf.py': ''}) + build = self.get_build_config( + {}, + {'defaults': {'sphinx_configuration': None}}, + source_file=str(tmpdir.join('readthedocs.yml')), + ) + build.validate() + assert build.sphinx.configuration is None + + def test_sphinx_configuration_priorities_over_default(self, tmpdir): + apply_fs(tmpdir, {'conf.py': '', 'conf-default.py': ''}) + build = self.get_build_config( + {'sphinx': {'configuration': 'conf.py'}}, + {'defaults': {'sphinx_configuration': 'conf-default.py'}}, + source_file=str(tmpdir.join('readthedocs.yml')), + ) + build.validate() + assert build.sphinx.configuration == str(tmpdir.join('conf.py')) + + @pytest.mark.parametrize('value', [[], True, 0, {}]) + def test_sphinx_configuration_validate_type(self, value): + build = self.get_build_config({'sphinx': {'configuration': value}},) + with raises(InvalidConfig) as excinfo: + build.validate() + assert excinfo.value.key == 'sphinx.configuration' + + @pytest.mark.parametrize('value', [True, False]) + def test_sphinx_fail_on_warning_check_valid(self, value): + build = self.get_build_config({'sphinx': {'fail_on_warning': value}}) + build.validate() + assert build.sphinx.fail_on_warning is value + + @pytest.mark.parametrize('value', [[], 'invalid', 5]) + def test_sphinx_fail_on_warning_check_invalid(self, value): + build = self.get_build_config({'sphinx': {'fail_on_warning': value}}) + with raises(InvalidConfig) as excinfo: + build.validate() + assert excinfo.value.key == 'sphinx.fail_on_warning' + + def test_sphinx_fail_on_warning_check_default(self): + build = self.get_build_config({}) + build.validate() + assert build.sphinx.fail_on_warning is False + + @pytest.mark.parametrize('value', [[], True, 0, 'invalid']) + def test_mkdocs_validate_type(self, value): + build = self.get_build_config({'mkdocs': value}) + with raises(InvalidConfig) as excinfo: + build.validate() + assert excinfo.value.key == 'mkdocs' + + def test_mkdocs_default(self): + build = self.get_build_config({}) + build.validate() + assert build.mkdocs is None + + def test_mkdocs_configuration_check_valid(self, tmpdir): + apply_fs(tmpdir, {'mkdocs.yml': ''}) + build = self.get_build_config( + {'mkdocs': {'configuration': 'mkdocs.yml'}}, + source_file=str(tmpdir.join('readthedocs.yml')), + ) + build.validate() + assert build.mkdocs.configuration == str(tmpdir.join('mkdocs.yml')) + assert build.doctype == 'mkdocs' + assert build.sphinx is None + + def test_mkdocs_configuration_check_invalid(self, tmpdir): + apply_fs(tmpdir, {'mkdocs.yml': ''}) + build = self.get_build_config( + {'mkdocs': {'configuration': 'invalid.yml'}}, + source_file=str(tmpdir.join('readthedocs.yml')), + ) + with raises(InvalidConfig) as excinfo: + build.validate() + assert excinfo.value.key == 'mkdocs.configuration' + + def test_mkdocs_configuration_allow_null(self): + build = self.get_build_config({'mkdocs': {'configuration': None}},) + build.validate() + assert build.mkdocs.configuration is None + + def test_mkdocs_configuration_check_default(self): + build = self.get_build_config({'mkdocs': {}}) + build.validate() + assert build.mkdocs.configuration is None + + @pytest.mark.parametrize('value', [[], True, 0, {}]) + def test_mkdocs_configuration_validate_type(self, value): + build = self.get_build_config({'mkdocs': {'configuration': value}},) + with raises(InvalidConfig) as excinfo: + build.validate() + assert excinfo.value.key == 'mkdocs.configuration' + + @pytest.mark.parametrize('value', [True, False]) + def test_mkdocs_fail_on_warning_check_valid(self, value): + build = self.get_build_config({'mkdocs': {'fail_on_warning': value}}) + build.validate() + assert build.mkdocs.fail_on_warning is value + + @pytest.mark.parametrize('value', [[], 'invalid', 5]) + def test_mkdocs_fail_on_warning_check_invalid(self, value): + build = self.get_build_config({'mkdocs': {'fail_on_warning': value}}) + with raises(InvalidConfig) as excinfo: + build.validate() + assert excinfo.value.key == 'mkdocs.fail_on_warning' + + def test_mkdocs_fail_on_warning_check_default(self): + build = self.get_build_config({'mkdocs': {}}) + build.validate() + assert build.mkdocs.fail_on_warning is False + + @pytest.mark.parametrize('value', [[], 'invalid', 0]) + def test_submodules_check_invalid_type(self, value): + build = self.get_build_config({'submodules': value}) + with raises(InvalidConfig) as excinfo: + build.validate() + assert excinfo.value.key == 'submodules' + + def test_submodules_include_check_valid(self): + build = self.get_build_config({ + 'submodules': { + 'include': ['one', 'two'] + }, + }) + build.validate() + assert build.submodules.include == ['one', 'two'] + assert build.submodules.exclude == [] + assert build.submodules.recursive is False + + @pytest.mark.parametrize('value', ['invalid', True, 0, {}]) + def test_submodules_include_check_invalid(self, value): + build = self.get_build_config({ + 'submodules': { + 'include': value, + }, + }) + with raises(InvalidConfig) as excinfo: + build.validate() + assert excinfo.value.key == 'submodules.include' + + def test_submodules_include_allows_all_keyword(self): + build = self.get_build_config({ + 'submodules': { + 'include': 'all', + }, + }) + build.validate() + assert build.submodules.include == ALL + assert build.submodules.exclude == [] + assert build.submodules.recursive is False + + def test_submodules_exclude_check_valid(self): + build = self.get_build_config({ + 'submodules': { + 'exclude': ['one', 'two'] + } + }) + build.validate() + assert build.submodules.include == [] + assert build.submodules.exclude == ['one', 'two'] + assert build.submodules.recursive is False + + @pytest.mark.parametrize('value', ['invalid', True, 0, {}]) + def test_submodules_exclude_check_invalid(self, value): + build = self.get_build_config({ + 'submodules': { + 'exclude': value, + }, + }) + with raises(InvalidConfig) as excinfo: + build.validate() + assert excinfo.value.key == 'submodules.exclude' + + def test_submodules_exclude_allows_all_keyword(self): + build = self.get_build_config({ + 'submodules': { + 'exclude': 'all', + }, + }) + build.validate() + assert build.submodules.include == [] + assert build.submodules.exclude == ALL + assert build.submodules.recursive is False + + def test_submodules_cant_exclude_and_include(self): + build = self.get_build_config({ + 'submodules': { + 'include': ['two'], + 'exclude': ['one'], + }, + }) + with raises(InvalidConfig) as excinfo: + build.validate() + assert excinfo.value.key == 'submodules' + + def test_submodules_can_exclude_include_be_empty(self): + build = self.get_build_config({ + 'submodules': { + 'exclude': 'all', + 'include': [], + }, + }) + build.validate() + assert build.submodules.include == [] + assert build.submodules.exclude == ALL + assert build.submodules.recursive is False + + @pytest.mark.parametrize('value', [True, False]) + def test_submodules_recursive_check_valid(self, value): + build = self.get_build_config({ + 'submodules': { + 'include': ['one', 'two'], + 'recursive': value, + }, + }) + build.validate() + assert build.submodules.include == ['one', 'two'] + assert build.submodules.exclude == [] + assert build.submodules.recursive is value + + @pytest.mark.parametrize('value', [[], 'invalid', 5]) + def test_submodules_recursive_check_invalid(self, value): + build = self.get_build_config({ + 'submodules': { + 'include': ['one', 'two'], + 'recursive': value, + }, + }) + with raises(InvalidConfig) as excinfo: + build.validate() + assert excinfo.value.key == 'submodules.recursive' + + def test_submodules_recursive_explict_default(self): + build = self.get_build_config({ + 'submodules': { + 'include': [], + 'recursive': False, + }, + }) + build.validate() + assert build.submodules.include == [] + assert build.submodules.exclude == [] + assert build.submodules.recursive is False + + build = self.get_build_config({ + 'submodules': { + 'exclude': [], + 'recursive': False, + }, + }) + build.validate() + assert build.submodules.include == [] + assert build.submodules.exclude == [] + assert build.submodules.recursive is False diff --git a/readthedocs/config/validation.py b/readthedocs/config/validation.py index b317612dd2c..ab9164f335e 100644 --- a/readthedocs/config/validation.py +++ b/readthedocs/config/validation.py @@ -8,10 +8,12 @@ INVALID_BOOL = 'invalid-bool' INVALID_CHOICE = 'invalid-choice' INVALID_LIST = 'invalid-list' +INVALID_DICT = 'invalid-dictionary' INVALID_DIRECTORY = 'invalid-directory' INVALID_FILE = 'invalid-file' INVALID_PATH = 'invalid-path' INVALID_STRING = 'invalid-string' +VALUE_NOT_FOUND = 'value-not-found' class ValidationError(Exception): @@ -23,9 +25,11 @@ class ValidationError(Exception): INVALID_CHOICE: 'expected one of ({choices}), got {value}', INVALID_DIRECTORY: '{value} is not a directory', INVALID_FILE: '{value} is not a file', + INVALID_DICT: '{value} is not a dictionary', INVALID_PATH: 'path {value} does not exist', INVALID_STRING: 'expected string', INVALID_LIST: 'expected list', + VALUE_NOT_FOUND: '{value} not found' } def __init__(self, value, code, format_kwargs=None): @@ -42,13 +46,19 @@ def __init__(self, value, code, format_kwargs=None): def validate_list(value): """Check if ``value`` is an iterable.""" - if isinstance(value, str): + if isinstance(value, (dict, string_types)): raise ValidationError(value, INVALID_LIST) if not hasattr(value, '__iter__'): raise ValidationError(value, INVALID_LIST) return list(value) +def validate_dict(value): + """Check if ``value`` is a dictionary.""" + if not isinstance(value, dict): + raise ValidationError(value, INVALID_DICT) + + def validate_choice(value, choices): """Check that ``value`` is in ``choices``.""" choices = validate_list(choices) @@ -59,6 +69,15 @@ def validate_choice(value, choices): return value +def validate_value_exists(value, container): + """Check that ``value`` exists in ``container``.""" + if value not in container: + raise ValidationError(value, VALUE_NOT_FOUND) + if isinstance(container, dict): + return container[value] + return value + + def validate_bool(value): """Check that ``value`` is an boolean value.""" if value not in (0, 1, False, True): diff --git a/readthedocs/doc_builder/config.py b/readthedocs/doc_builder/config.py index 8cfc8e9eab4..289d0c85eb5 100644 --- a/readthedocs/doc_builder/config.py +++ b/readthedocs/doc_builder/config.py @@ -6,8 +6,9 @@ from os import path -from readthedocs.config import BuildConfig, ConfigError, InvalidConfig +from readthedocs.config import BuildConfigV1, ConfigError, InvalidConfig from readthedocs.config import load as load_config +from readthedocs.projects.models import Feature, ProjectConfigurationError from .constants import DOCKER_IMAGE, DOCKER_IMAGE_SETTINGS @@ -28,17 +29,28 @@ def load_yaml_config(version): img_name = project.container_image or DOCKER_IMAGE python_version = 3 if project.python_interpreter == 'python3' else 2 + allow_v2 = project.has_feature(Feature.ALLOW_V2_CONFIG_FILE) + try: + sphinx_configuration = version.get_conf_py_path() + except ProjectConfigurationError: + sphinx_configuration = None + env_config = { + 'allow_v2': allow_v2, 'build': { 'image': img_name, }, + 'output_base': '', + 'name': version.slug, 'defaults': { 'install_project': project.install_project, 'formats': get_default_formats(project), 'use_system_packages': project.use_system_packages, 'requirements_file': project.requirements_file, 'python_version': python_version, + 'sphinx_configuration': sphinx_configuration, 'build_image': project.container_image, + 'doctype': project.documentation_type, } } img_settings = DOCKER_IMAGE_SETTINGS.get(img_name, None) @@ -47,27 +59,15 @@ def load_yaml_config(version): env_config['DOCKER_IMAGE_SETTINGS'] = img_settings try: - sphinx_env_config = env_config.copy() - sphinx_env_config.update({ - 'output_base': '', - 'type': 'sphinx', - 'name': version.slug, - }) config = load_config( path=checkout_path, - env_config=sphinx_env_config, + env_config=env_config, )[0] except InvalidConfig: # This is a subclass of ConfigError, so has to come first raise except ConfigError: - # TODO: this shouldn't be hardcoded here - env_config.update({ - 'output_base': '', - 'type': 'sphinx', - 'name': version.slug, - }) - config = BuildConfig( + config = BuildConfigV1( env_config=env_config, raw_config={}, source_file=path.join(checkout_path, 'empty'), diff --git a/readthedocs/projects/models.py b/readthedocs/projects/models.py index 59fd74a96f9..7a53c4482d8 100644 --- a/readthedocs/projects/models.py +++ b/readthedocs/projects/models.py @@ -1034,6 +1034,7 @@ def add_features(sender, **kwargs): SKIP_SUBMODULES = 'skip_submodules' BUILD_JSON_ARTIFACTS_WITH_HTML = 'build_json_artifacts_with_html' DONT_OVERWRITE_SPHINX_CONTEXT = 'dont_overwrite_sphinx_context' + ALLOW_V2_CONFIG_FILE = 'allow_v2_config_file' FEATURES = ( (USE_SPHINX_LATEST, _('Use latest version of Sphinx')), @@ -1045,6 +1046,8 @@ def add_features(sender, **kwargs): 'Build the json artifacts with the html build step')), (DONT_OVERWRITE_SPHINX_CONTEXT, _( 'Do not overwrite context vars in conf.py with Read the Docs context',)), + (ALLOW_V2_CONFIG_FILE, _( + 'Allow to use the v2 of the configuration file')), ) projects = models.ManyToManyField( diff --git a/readthedocs/rtd_tests/fixtures/spec/v2/schema.yml b/readthedocs/rtd_tests/fixtures/spec/v2/schema.yml index 6570a895cd2..d192f8c3b76 100644 --- a/readthedocs/rtd_tests/fixtures/spec/v2/schema.yml +++ b/readthedocs/rtd_tests/fixtures/spec/v2/schema.yml @@ -1,4 +1,8 @@ # Read the Docs configuration file +# This schema uses https://github.com/23andMe/Yamale +# for the validation. +# Default values are indicated with a comment (``Default: ...``). +# Some values are default to the project config (settings from the web panel). # The version of the spec to be use version: enum('2') @@ -17,6 +21,7 @@ build: include('build', required=False) python: include('python', required=False) # Configuration for sphinx documentation +# Default documentation type sphinx: include('sphinx', required=False) # Configuration for mkdocs documentation @@ -40,6 +45,7 @@ conda: build: # The build docker image to be used # Default: 'latest' + # Note: it can be overriden by a project image: enum('stable', 'latest', required=False) python: @@ -48,11 +54,11 @@ python: version: enum('2', '2.7', '3', '3.3', '3.4', '3.5', '3.6', required=False) # The path to the requirements file from the root of the project - # Default: null + # Default: null | project config requirements: path(required=False) # Install the project using python setup.py install or pip - # Default: null + # Default: null | project config install: enum('pip', 'setup.py', required=False) # Extra requirements sections to install in addition to the package dependencies @@ -60,12 +66,16 @@ python: extra_requirements: list(str(), required=False) # Give the virtual environment access to the global site-packages dir - # Default: false + # Default: false | project config system_packages: bool(required=False) sphinx: + # The builder type for the sphinx documentation + # Default: 'html' + builder: enum('html', 'htmldir', 'singlehtml', required=False) + # The path to the conf.py file - # Default: rtd will try to find it + # Default: rtd will try to find it | project config configuration: path(required=False) # Add the -W option to sphinx-build @@ -81,7 +91,6 @@ mkdocs: # Default: false fail_on_warning: bool(required=False) - submodules: # List of submodules to be included # Default: [] diff --git a/readthedocs/rtd_tests/tests/test_config_integration.py b/readthedocs/rtd_tests/tests/test_config_integration.py index 934b886acac..a1fa306c28f 100644 --- a/readthedocs/rtd_tests/tests/test_config_integration.py +++ b/readthedocs/rtd_tests/tests/test_config_integration.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- from __future__ import ( absolute_import, division, print_function, unicode_literals) @@ -6,15 +7,16 @@ from django_dynamic_fixture import get from readthedocs.builds.models import Version -from readthedocs.config import BuildConfig, InvalidConfig, ProjectConfig +from readthedocs.config import BuildConfigV1, InvalidConfig, ProjectConfig from readthedocs.doc_builder.config import load_yaml_config from readthedocs.projects.models import Project def create_load(config=None): - """Mock out the function of the build load function + """ + Mock out the function of the build load function. - This will create a ProjectConfig list of BuildConfig objects and validate + This will create a ProjectConfig list of BuildConfigV1 objects and validate them. The default load function iterates over files and builds up a list of objects. Instead of mocking all of this, just mock the end result. """ @@ -25,18 +27,20 @@ def inner(path=None, env_config=None): env_config_defaults = { 'output_base': '', 'name': '1', - 'type': 'sphinx', } if env_config is not None: env_config_defaults.update(env_config) yaml_config = ProjectConfig([ - BuildConfig(env_config_defaults, - config, - source_file='readthedocs.yml', - source_position=0) + BuildConfigV1( + env_config_defaults, + config, + source_file='readthedocs.yml', + source_position=0, + ), ]) yaml_config.validate() return yaml_config + return inner @@ -44,8 +48,12 @@ def inner(path=None, env_config=None): class LoadConfigTests(TestCase): def setUp(self): - self.project = get(Project, main_language_project=None, - install_project=False, requirements_file='__init__.py') + self.project = get( + Project, + main_language_project=None, + install_project=False, + requirements_file='__init__.py' + ) self.version = get(Version, project=self.project) def test_python_supported_versions_default_image_1_0(self, load_config): @@ -57,20 +65,29 @@ def test_python_supported_versions_default_image_1_0(self, load_config): config = load_yaml_config(self.version) self.assertEqual(load_config.call_count, 1) load_config.assert_has_calls([ - mock.call(path=mock.ANY, env_config={ - 'build': {'image': 'readthedocs/build:1.0'}, - 'type': 'sphinx', - 'output_base': '', - 'name': mock.ANY, - 'defaults': { - 'install_project': self.project.install_project, - 'formats': ['htmlzip', 'epub', 'pdf'], - 'use_system_packages': self.project.use_system_packages, - 'requirements_file': self.project.requirements_file, - 'python_version': 2, - 'build_image': 'readthedocs/build:1.0', + mock.call( + path=mock.ANY, + env_config={ + 'allow_v2': mock.ANY, + 'build': {'image': 'readthedocs/build:1.0'}, + 'output_base': '', + 'name': mock.ANY, + 'defaults': { + 'install_project': self.project.install_project, + 'formats': [ + 'htmlzip', + 'epub', + 'pdf' + ], + 'use_system_packages': self.project.use_system_packages, + 'requirements_file': self.project.requirements_file, + 'python_version': 2, + 'sphinx_configuration': mock.ANY, + 'build_image': 'readthedocs/build:1.0', + 'doctype': self.project.documentation_type, + }, }, - }), + ), ]) self.assertEqual(config.python_version, 2) @@ -115,7 +132,7 @@ def test_python_set_python_version_on_project(self, load_config): def test_python_set_python_version_in_config(self, load_config): load_config.side_effect = create_load({ - 'python': {'version': 3.5} + 'python': {'version': 3.5}, }) self.project.container_image = 'readthedocs/build:2.0' self.project.save()