Skip to content

Commit 130b6b4

Browse files
authored
Build: allow to install packages with apt (#8065)
1 parent 1594e77 commit 130b6b4

File tree

9 files changed

+272
-12
lines changed

9 files changed

+272
-12
lines changed

docs/config-file/v2.rst

+30-1
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ This is to avoid typos and provide feedback on invalid configurations.
6969

7070
.. contents::
7171
:local:
72-
:depth: 1
72+
:depth: 3
7373

7474
version
7575
~~~~~~~
@@ -303,6 +303,9 @@ Configuration for the documentation build process.
303303
304304
build:
305305
image: latest
306+
apt_packages:
307+
- libclang
308+
- cmake
306309
307310
python:
308311
version: 3.7
@@ -323,6 +326,32 @@ as defined here:
323326
* `stable <https://github.com/readthedocs/readthedocs-docker-images/tree/releases/5.x>`_: :buildpyversions:`stable`
324327
* `latest <https://github.com/readthedocs/readthedocs-docker-images/tree/releases/6.x>`_: :buildpyversions:`latest`
325328

329+
build.apt_packages
330+
``````````````````
331+
332+
List of `APT packages`_ to install.
333+
Our build servers run Ubuntu 18.04, with the default set of package repositories installed.
334+
We don't currently support PPA's or other custom repositories.
335+
336+
.. _APT packages: https://packages.ubuntu.com/
337+
338+
:Type: ``list``
339+
:Default: ``[]``
340+
341+
.. code-block:: yaml
342+
343+
version: 2
344+
345+
build:
346+
apt_packages:
347+
- libclang
348+
- cmake
349+
350+
.. note::
351+
352+
When possible avoid installing Python packages using apt (``python3-numpy`` for example),
353+
:ref:`use pip or Conda instead <guides/reproducible-builds:pinning dependencies>`.
354+
326355
sphinx
327356
~~~~~~
328357

docs/guides/reproducible-builds.rst

+1-1
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,6 @@ or our Conda docs about :ref:`environment files <guides/conda:creating the \`\`e
173173
.. tip::
174174

175175
Remember to update your docs' dependencies from time to time to get new improvements and fixes.
176-
It also makes it easy to manage in case a version reaches it's end of support date.
176+
It also makes it easy to manage in case a version reaches its end of support date.
177177

178178
.. TODO: link to the supported versions policy.

readthedocs/config/config.py

+73-7
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
SUBMODULES_INVALID = 'submodules-invalid'
6565
INVALID_KEYS_COMBINATION = 'invalid-keys-combination'
6666
INVALID_KEY = 'invalid-key'
67+
INVALID_NAME = 'invalid-name'
6768

6869
LATEST_CONFIGURATION_VERSION = 2
6970

@@ -121,10 +122,18 @@ def __init__(self, key, code, error_message, source_file=None):
121122
super().__init__(message, code=code)
122123

123124
def _get_display_key(self):
124-
# Checks for patterns similar to `python.install.0.requirements`
125-
# if matched change to `python.install[0].requirements` using backreference.
125+
"""
126+
Display keys in a more friendly format.
127+
128+
Indexes are displayed like ``n``,
129+
but users may be more familiar with the ``[n]`` syntax.
130+
For example ``python.install.0.requirements``
131+
is changed to `python.install[0].requirements`.
132+
"""
126133
return re.sub(
127-
r'^(python\.install)(\.)(\d+)(\.\w+)$', r'\1[\3]\4', self.key
134+
r'^([a-zA-Z_.-]+)\.(\d+)([a-zA-Z_.-]*)$',
135+
r'\1[\2]\3',
136+
self.key
128137
)
129138

130139

@@ -745,12 +754,69 @@ def validate_build(self):
745754
),
746755
)
747756

748-
# Allow to override specific project
749-
config_image = self.defaults.get('build_image')
750-
if config_image:
751-
build['image'] = config_image
757+
# Allow to override specific project
758+
config_image = self.defaults.get('build_image')
759+
if config_image:
760+
build['image'] = config_image
761+
762+
with self.catch_validation_error('build.apt_packages'):
763+
raw_packages = self._raw_config.get('build', {}).get('apt_packages', [])
764+
validate_list(raw_packages)
765+
# Transform to a dict, so is easy to validate individual entries.
766+
self._raw_config.setdefault('build', {})['apt_packages'] = (
767+
list_to_dict(raw_packages)
768+
)
769+
770+
build['apt_packages'] = [
771+
self.validate_apt_package(index)
772+
for index in range(len(raw_packages))
773+
]
774+
if not raw_packages:
775+
self.pop_config('build.apt_packages')
776+
752777
return build
753778

779+
def validate_apt_package(self, index):
780+
"""
781+
Validate the package name to avoid injections of extra options.
782+
783+
We validate that they aren't interpreted as an option or file.
784+
See https://manpages.ubuntu.com/manpages/xenial/man8/apt-get.8.html
785+
and https://www.debian.org/doc/manuals/debian-reference/ch02.en.html#_debian_package_file_names # noqa
786+
for allowed chars in packages names.
787+
"""
788+
key = f'build.apt_packages.{index}'
789+
package = self.pop_config(key)
790+
with self.catch_validation_error(key):
791+
validate_string(package)
792+
package = package.strip()
793+
invalid_starts = [
794+
# Don't allow extra options.
795+
'-',
796+
# Don't allow to install from a path.
797+
'/',
798+
'.',
799+
]
800+
for start in invalid_starts:
801+
if package.startswith(start):
802+
self.error(
803+
key=key,
804+
message=(
805+
'Invalid package name. '
806+
f'Package can\'t start with {start}.',
807+
),
808+
code=INVALID_NAME,
809+
)
810+
# List of valid chars in packages names.
811+
pattern = re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9.+-]*$')
812+
if not pattern.match(package):
813+
self.error(
814+
key=key,
815+
message='Invalid package name.',
816+
code=INVALID_NAME,
817+
)
818+
return package
819+
754820
def validate_python(self):
755821
"""
756822
Validates the python key.

readthedocs/config/models.py

+5-1
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,11 @@ def as_dict(self):
2828

2929
class Build(Base):
3030

31-
__slots__ = ('image',)
31+
__slots__ = ('image', 'apt_packages')
32+
33+
def __init__(self, **kwargs):
34+
kwargs.setdefault('apt_packages', [])
35+
super().__init__(**kwargs)
3236

3337

3438
class Python(Base):

readthedocs/config/tests/test_config.py

+59
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
CONFIG_REQUIRED,
2727
CONFIG_SYNTAX_INVALID,
2828
INVALID_KEY,
29+
INVALID_NAME,
2930
PYTHON_INVALID,
3031
VERSION_INVALID,
3132
)
@@ -748,6 +749,7 @@ def test_as_dict(tmpdir):
748749
},
749750
'build': {
750751
'image': 'readthedocs/build:latest',
752+
'apt_packages': [],
751753
},
752754
'conda': None,
753755
'sphinx': {
@@ -935,6 +937,62 @@ def test_build_image_check_invalid_type(self, value):
935937
build.validate()
936938
assert excinfo.value.key == 'build.image'
937939

940+
@pytest.mark.parametrize(
941+
'value',
942+
[
943+
[],
944+
['cmatrix'],
945+
['Mysql', 'cmatrix', 'postgresql-dev'],
946+
],
947+
)
948+
def test_build_apt_packages_check_valid(self, value):
949+
build = self.get_build_config({'build': {'apt_packages': value}})
950+
build.validate()
951+
assert build.build.apt_packages == value
952+
953+
@pytest.mark.parametrize(
954+
'value',
955+
[3, 'string', {}],
956+
)
957+
def test_build_apt_packages_invalid_type(self, value):
958+
build = self.get_build_config({'build': {'apt_packages': value}})
959+
with raises(InvalidConfig) as excinfo:
960+
build.validate()
961+
assert excinfo.value.key == 'build.apt_packages'
962+
963+
@pytest.mark.parametrize(
964+
'error_index, value',
965+
[
966+
(0, ['/', 'cmatrix']),
967+
(1, ['cmatrix', '-q']),
968+
(1, ['cmatrix', ' -q']),
969+
(1, ['cmatrix', '\\-q']),
970+
(1, ['cmatrix', '--quiet']),
971+
(1, ['cmatrix', ' --quiet']),
972+
(2, ['cmatrix', 'quiet', './package.deb']),
973+
(2, ['cmatrix', 'quiet', ' ./package.deb ']),
974+
(2, ['cmatrix', 'quiet', '/home/user/package.deb']),
975+
(2, ['cmatrix', 'quiet', ' /home/user/package.deb']),
976+
(2, ['cmatrix', 'quiet', '../package.deb']),
977+
(2, ['cmatrix', 'quiet', ' ../package.deb']),
978+
(1, ['one', '$two']),
979+
(1, ['one', 'non-ascíí']),
980+
# We don't allow regex for now.
981+
(1, ['mysql', 'cmatrix$']),
982+
(0, ['^mysql-*', 'cmatrix$']),
983+
# We don't allow specifying versions for now.
984+
(0, ['postgresql=1.2.3']),
985+
# We don't allow specifying distributions for now.
986+
(0, ['cmatrix/bionic']),
987+
],
988+
)
989+
def test_build_apt_packages_invalid_value(self, error_index, value):
990+
build = self.get_build_config({'build': {'apt_packages': value}})
991+
with raises(InvalidConfig) as excinfo:
992+
build.validate()
993+
assert excinfo.value.key == f'build.apt_packages.{error_index}'
994+
assert excinfo.value.code == INVALID_NAME
995+
938996
@pytest.mark.parametrize('value', [3, [], 'invalid'])
939997
def test_python_check_invalid_types(self, value):
940998
build = self.get_build_config({'python': value})
@@ -2072,6 +2130,7 @@ def test_as_dict(self, tmpdir):
20722130
},
20732131
'build': {
20742132
'image': 'readthedocs/build:latest',
2133+
'apt_packages': [],
20752134
},
20762135
'conda': None,
20772136
'sphinx': {

readthedocs/projects/tasks.py

+29-1
Original file line numberDiff line numberDiff line change
@@ -794,7 +794,7 @@ def run_build(self, record):
794794
environment=self.build_env,
795795
)
796796
with self.project.repo_nonblockinglock(version=self.version):
797-
self.setup_python_environment()
797+
self.setup_build()
798798

799799
# TODO the build object should have an idea of these states,
800800
# extend the model to include an idea of these outcomes
@@ -1152,6 +1152,10 @@ def update_app_instances(
11521152
search_ignore=self.config.search.ignore,
11531153
)
11541154

1155+
def setup_build(self):
1156+
self.install_system_dependencies()
1157+
self.setup_python_environment()
1158+
11551159
def setup_python_environment(self):
11561160
"""
11571161
Build the virtualenv and install the project into it.
@@ -1177,6 +1181,30 @@ def setup_python_environment(self):
11771181
if self.project.has_feature(Feature.LIST_PACKAGES_INSTALLED_ENV):
11781182
self.python_env.list_packages_installed()
11791183

1184+
def install_system_dependencies(self):
1185+
"""
1186+
Install apt packages from the config file.
1187+
1188+
We don't allow to pass custom options or install from a path.
1189+
The packages names are already validated when reading the config file.
1190+
1191+
.. note::
1192+
1193+
``--quiet`` won't suppress the output,
1194+
it would just remove the progress bar.
1195+
"""
1196+
packages = self.config.build.apt_packages
1197+
if packages:
1198+
self.build_env.run(
1199+
'apt-get', 'update', '--assume-yes', '--quiet',
1200+
user=settings.RTD_DOCKER_SUPER_USER,
1201+
)
1202+
# put ``--`` to end all command arguments.
1203+
self.build_env.run(
1204+
'apt-get', 'install', '--assume-yes', '--quiet', '--', *packages,
1205+
user=settings.RTD_DOCKER_SUPER_USER,
1206+
)
1207+
11801208
def build_docs(self):
11811209
"""
11821210
Wrapper to all build functions.

readthedocs/rtd_tests/fixtures/spec/v2/schema.yml

+4
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@ build:
5050
# Note: it can be overridden by a project
5151
image: enum('stable', 'latest', required=False)
5252

53+
# List of packages to be installed with apt-get
54+
# Default: []
55+
apt_packages: list(str(), required=False)
56+
5357
python:
5458
# The Python version (this depends on the build image)
5559
# Default: '3'

0 commit comments

Comments
 (0)