diff --git a/docs/config-file/v2.rst b/docs/config-file/v2.rst index 18487831589..510b702ecbc 100644 --- a/docs/config-file/v2.rst +++ b/docs/config-file/v2.rst @@ -228,6 +228,35 @@ With the previous settings, Read the Docs will execute the next commands: pip install .[docs] python package/setup.py install +Extra dependencies +'''''''''''''''''' + +Install packages from a list of requirements. +This allows you to declare dependencies in the ``.readthedocs.yml`` file instead of creating a separate file. + +:Key: `packages` +:Type: ``list`` +:Default: ``[]`` + +Example: + +.. code-block:: yaml + + version: 2 + + python: + version: 3.7 + install: + - packages: + - mkdocs-material + - Pygments + +With the previous settings, Read the Docs will execute the next commands: + +.. prompt:: bash $ + + pip install mkdocs-material Pygments + python.system_packages `````````````````````` diff --git a/readthedocs/config/config.py b/readthedocs/config/config.py index 244135277eb..d3f1dff88b7 100644 --- a/readthedocs/config/config.py +++ b/readthedocs/config/config.py @@ -19,6 +19,7 @@ Python, PythonInstall, PythonInstallRequirements, + PythonInstallPackages, Search, Sphinx, Submodules, @@ -833,6 +834,22 @@ def validate_python_install(self, index): self.base_path, ) python_install['requirements'] = requirements + elif 'packages' in raw_install: + packages_key = key + '.packages' + packages = self.pop_config(packages_key) + if not isinstance(packages, list): + self.error( + packages_key, + '"{}" key must be a list'.format(packages_key), + code=PYTHON_INVALID, + ) + if not packages: + self.error( + packages_key, + '"{}" cannot be empty'.format(packages_key), + code=PYTHON_INVALID, + ) + python_install['packages'] = packages elif 'path' in raw_install: path_key = key + '.path' with self.catch_validation_error(path_key): @@ -1112,6 +1129,8 @@ def python(self): for install in python['install']: if 'requirements' in install: python_install.append(PythonInstallRequirements(**install),) + elif 'packages' in install: + python_install.append(PythonInstallPackages(**install),) 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..8f9dce9fe91 100644 --- a/readthedocs/config/models.py +++ b/readthedocs/config/models.py @@ -41,6 +41,11 @@ class PythonInstallRequirements(Base): __slots__ = ('requirements',) +class PythonInstallPackages(Base): + + __slots__ = ('packages',) + + class PythonInstall(Base): __slots__ = ( diff --git a/readthedocs/config/tests/test_config.py b/readthedocs/config/tests/test_config.py index 3ee20d1653c..fe2474c2a46 100644 --- a/readthedocs/config/tests/test_config.py +++ b/readthedocs/config/tests/test_config.py @@ -33,6 +33,7 @@ Conda, PythonInstall, PythonInstallRequirements, + PythonInstallPackages, ) from readthedocs.config.validation import ( INVALID_BOOL, @@ -1104,6 +1105,58 @@ def test_python_install_requirements_check_valid(self, tmpdir): assert isinstance(install[0], PythonInstallRequirements) assert install[0].requirements == 'requirements.txt' + def test_python_install_packages_check_valid(self, tmpdir): + build = self.get_build_config( + { + 'python': { + 'install': [{ + 'packages': [ + '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], PythonInstallPackages) + assert install[0].packages == ['package_a', 'package_b >=3.0.0,<4.0.0'] + + def test_python_install_packages_must_be_list(self, tmpdir): + build = self.get_build_config( + { + 'python': { + 'install': [{ + 'packages': {}, + }], + }, + }, + source_file=str(tmpdir.join('readthedocs.yml')), + ) + with raises(InvalidConfig) as excinfo: + build.validate() + assert excinfo.value.key == 'python.install.0.packages' + assert '"python.install.0.packages" key must be a list' in str(excinfo.value) + + def test_python_install_packages_must_not_be_empty(self, tmpdir): + build = self.get_build_config( + { + 'python': { + 'install': [{ + 'packages': [], + }], + }, + }, + source_file=str(tmpdir.join('readthedocs.yml')), + ) + with raises(InvalidConfig) as excinfo: + build.validate() + assert excinfo.value.key == 'python.install.0.packages' + assert '"python.install.0.packages" cannot be empty' in str(excinfo.value) + 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..c8428fc83ae 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, + PythonInstallPackages, +) 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, PythonInstallPackages): + self.install_packages_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_packages_list(self, install): + """ + Install requirements from a list of strings using pip. + + :param install: A instal object from the config module. + :type install: readthedocs.config.modules.PythonInstallPackages + """ + + 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.packages + 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 = [