diff --git a/docs/config-file/v2.rst b/docs/config-file/v2.rst index 3baf8615dff..3619e175147 100644 --- a/docs/config-file/v2.rst +++ b/docs/config-file/v2.rst @@ -175,6 +175,27 @@ Example: manage the build, this setting will not have any effect. Instead add the extra requirements to the ``environment`` file of Conda. +Requirements List +''''''''''''''''' + +Install packages from a list of requirements as a string (similar to how they would be +listed on the command-line when invoking ``pip install``). + +:Key: `requires` +:Type: ``string`` +:Required: ``true`` + +Example: + +.. code-block:: yaml + + version: 2 + + python: + version: 3.7 + install: + - requires: mkdocs-material Pygments + Packages '''''''' diff --git a/readthedocs/config/config.py b/readthedocs/config/config.py index 244135277eb..9bc267fce4c 100644 --- a/readthedocs/config/config.py +++ b/readthedocs/config/config.py @@ -5,6 +5,7 @@ import copy import os import re +import shlex from contextlib import contextmanager from django.conf import settings @@ -19,6 +20,7 @@ Python, PythonInstall, PythonInstallRequirements, + PythonInstallRequirementsList, Search, Sphinx, Submodules, @@ -833,6 +835,9 @@ def validate_python_install(self, index): self.base_path, ) python_install['requirements'] = requirements + elif 'requires' in raw_install: + requires_key = key + 'requires' + python_install['requires'] = shlex.split(self.pop_config(requires_key)) elif 'path' in raw_install: path_key = key + '.path' with self.catch_validation_error(path_key): @@ -1086,9 +1091,10 @@ def _get_extra_key(self, value): Will return `['key', 'name']`. """ - if isinstance(value, dict) and value: - key_name = next(iter(value)) - return [key_name] + self._get_extra_key(value[key_name]) + if isinstance(value, dict): + key_name = next((k for k in value if not k.startswith('x-')), None) + if key_name is not None: + return [key_name] + self._get_extra_key(value[key_name]) return [] @property @@ -1112,6 +1118,8 @@ def python(self): for install in python['install']: if 'requirements' in install: python_install.append(PythonInstallRequirements(**install),) + elif 'requires' in install: + python_install.append(PythonInstallRequirementsList(install['requires']),) elif 'path' in install: python_install.append(PythonInstall(**install),) return Python( diff --git a/readthedocs/config/models.py b/readthedocs/config/models.py index 55932e991d4..4690511f82d 100644 --- a/readthedocs/config/models.py +++ b/readthedocs/config/models.py @@ -41,6 +41,11 @@ class PythonInstallRequirements(Base): __slots__ = ('requirements',) +class PythonInstallRequirementsList(Base): + + __slots__ = ('requirements',) + + class PythonInstall(Base): __slots__ = ( diff --git a/readthedocs/config/tests/test_config.py b/readthedocs/config/tests/test_config.py index 3ee20d1653c..1a8136dab50 100644 --- a/readthedocs/config/tests/test_config.py +++ b/readthedocs/config/tests/test_config.py @@ -33,6 +33,7 @@ Conda, PythonInstall, PythonInstallRequirements, + PythonInstallRequirementsList, ) from readthedocs.config.validation import ( INVALID_BOOL, @@ -1104,6 +1105,23 @@ def test_python_install_requirements_check_valid(self, tmpdir): assert isinstance(install[0], PythonInstallRequirements) assert install[0].requirements == 'requirements.txt' + def test_python_install_requires_check_valid(self, tmpdir): + build = self.get_build_config( + { + 'python': { + 'install': [{ + 'requires': 'package_a "package_b >=3.0.0,<4.0.0"', + }], + }, + }, + source_file=str(tmpdir.join('readthedocs.yml')), + ) + build.validate() + install = build.python.install + assert len(install) == 1 + assert isinstance(install[0], PythonInstallRequirementsList) + assert install[0].requirements == ['package_a', 'package_b >=3.0.0,<4.0.0'] + def test_python_install_requirements_does_not_allow_null(self, tmpdir): build = self.get_build_config( { diff --git a/readthedocs/doc_builder/python_environments.py b/readthedocs/doc_builder/python_environments.py index d165ab2213d..7a55d6e024f 100644 --- a/readthedocs/doc_builder/python_environments.py +++ b/readthedocs/doc_builder/python_environments.py @@ -14,7 +14,11 @@ from readthedocs.builds.constants import EXTERNAL from readthedocs.config import PIP, SETUPTOOLS, ParseError, parse as parse_yaml -from readthedocs.config.models import PythonInstall, PythonInstallRequirements +from readthedocs.config.models import ( + PythonInstall, + PythonInstallRequirements, + PythonInstallRequirementsList, +) from readthedocs.doc_builder.config import load_yaml_config from readthedocs.doc_builder.constants import DOCKER_IMAGE from readthedocs.doc_builder.environments import DockerBuildEnvironment @@ -77,6 +81,8 @@ def install_requirements(self): for install in self.config.python.install: if isinstance(install, PythonInstallRequirements): self.install_requirements_file(install) + if isinstance(install, PythonInstallRequirementsList): + self.install_requirements_list(install) if isinstance(install, PythonInstall): self.install_package(install) @@ -430,6 +436,33 @@ def install_requirements_file(self, install): bin_path=self.venv_bin(), ) + def install_requirements_list(self, install): + """ + Install requirements from a string specification using pip. + + :param install: A instal object from the config module. + :type install: readthedocs.config.modules.PythonInstallRequirementsList + """ + + args = [ + self.venv_bin(filename='python'), + '-m', + 'pip', + 'install', + ] + if self.project.has_feature(Feature.PIP_ALWAYS_UPGRADE): + args += ['--upgrade'] + args += [ + '--exists-action=w', + *self._pip_cache_cmd_argument(), + ] + args += install.requirements + self.build_env.run( + *args, + cwd=self.checkout_path, + bin_path=self.venv_bin(), + ) + def list_packages_installed(self): """List packages installed in pip.""" args = [