Skip to content

Commit aad5f80

Browse files
committed
Updates: Rename new field to build_config_file, add on Build and Version data, relax user input validation, introduce very basic reproducibility of builds
1 parent 1bd6a69 commit aad5f80

File tree

12 files changed

+195
-90
lines changed

12 files changed

+195
-90
lines changed

readthedocs/api/v2/serializers.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ class Meta(ProjectSerializer.Meta):
9292
"show_advertising",
9393
"environment_variables",
9494
"max_concurrent_builds",
95-
"rtd_conf_file",
95+
"build_config_file",
9696
)
9797

9898

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Generated by Django 3.2.18 on 2023-03-10 16:20
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("builds", "0047_build_default_triggered"),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name="build",
15+
name="build_config_file",
16+
field=models.CharField(
17+
blank=True,
18+
default=None,
19+
max_length=1024,
20+
null=True,
21+
verbose_name="Non-default build configuration file used",
22+
),
23+
),
24+
migrations.AddField(
25+
model_name="version",
26+
name="build_config_file",
27+
field=models.CharField(
28+
blank=True,
29+
default=None,
30+
max_length=1024,
31+
null=True,
32+
verbose_name="Non-default build configuration file used",
33+
),
34+
),
35+
]

readthedocs/builds/models.py

+15-2
Original file line numberDiff line numberDiff line change
@@ -65,11 +65,9 @@
6565
BITBUCKET_COMMIT_URL,
6666
BITBUCKET_URL,
6767
DOCTYPE_CHOICES,
68-
GITHUB_BRAND,
6968
GITHUB_COMMIT_URL,
7069
GITHUB_PULL_REQUEST_COMMIT_URL,
7170
GITHUB_URL,
72-
GITLAB_BRAND,
7371
GITLAB_COMMIT_URL,
7472
GITLAB_MERGE_REQUEST_COMMIT_URL,
7573
GITLAB_URL,
@@ -192,6 +190,14 @@ class Version(TimeStampedModel):
192190
# Only include EXTERNAL type Versions.
193191
external = ExternalVersionManager.from_queryset(partial(VersionQuerySet, external_only=True))()
194192

193+
build_config_file = models.CharField(
194+
_("Non-default build configuration file used"),
195+
max_length=1024,
196+
default=None,
197+
blank=True,
198+
null=True,
199+
)
200+
195201
class Meta:
196202
unique_together = [('project', 'slug')]
197203
ordering = ['-verbose_name']
@@ -702,6 +708,13 @@ class Build(models.Model):
702708
null=True,
703709
blank=True,
704710
)
711+
build_config_file = models.CharField(
712+
_("Non-default build configuration file used"),
713+
max_length=1024,
714+
default=None,
715+
blank=True,
716+
null=True,
717+
)
705718

706719
length = models.IntegerField(_('Build Length'), null=True, blank=True)
707720

readthedocs/config/config.py

+31-18
Original file line numberDiff line numberDiff line change
@@ -43,17 +43,18 @@
4343
)
4444

4545
__all__ = (
46-
'ALL',
47-
'load',
48-
'BuildConfigV1',
49-
'BuildConfigV2',
50-
'ConfigError',
51-
'ConfigOptionNotSupportedError',
52-
'ConfigFileNotFound',
53-
'InvalidConfig',
54-
'PIP',
55-
'SETUPTOOLS',
56-
'LATEST_CONFIGURATION_VERSION',
46+
"ALL",
47+
"load",
48+
"BuildConfigV1",
49+
"BuildConfigV2",
50+
"ConfigError",
51+
"ConfigOptionNotSupportedError",
52+
"ConfigFileNotFound",
53+
"DefaultConfigFileNotFound",
54+
"InvalidConfig",
55+
"PIP",
56+
"SETUPTOOLS",
57+
"LATEST_CONFIGURATION_VERSION",
5758
)
5859

5960
ALL = 'all'
@@ -96,6 +97,17 @@ def __init__(self, directory):
9697
)
9798

9899

100+
class DefaultConfigFileNotFound(ConfigError):
101+
102+
"""Error when we can't find a configuration file."""
103+
104+
def __init__(self, directory):
105+
super().__init__(
106+
f"No default configuration file in: {directory}",
107+
CONFIG_FILE_REQUIRED,
108+
)
109+
110+
99111
class ConfigOptionNotSupportedError(ConfigError):
100112

101113
"""Error for unsupported configuration options in a version."""
@@ -1377,17 +1389,18 @@ def load(path, env_config, config_file=None):
13771389
the version of the configuration a build object would be load and validated,
13781390
``BuildConfigV1`` is the default.
13791391
"""
1380-
if config_file is None or config_file == "":
1392+
if not config_file:
13811393
filename = find_one(path, CONFIG_FILENAME_REGEX)
1394+
if not filename:
1395+
# This exception is current caught higher up and will result in an attempt
1396+
# to load the v1 config schema.
1397+
raise DefaultConfigFileNotFound(os.path.relpath(filename, path))
13821398
else:
13831399
filename = os.path.join(path, config_file)
1400+
# When a config file is specified and not found, we raise ConfigError
1401+
# because ConfigFileNotFound
13841402
if not os.path.exists(filename):
1385-
raise ConfigError(
1386-
f".readthedocs.yml not found at {config_file}", CONFIG_FILE_REQUIRED
1387-
)
1388-
1389-
if not filename:
1390-
raise ConfigFileNotFound(path)
1403+
raise ConfigFileNotFound(os.path.relpath(filename, path))
13911404

13921405
# Allow symlinks, but only the ones that resolve inside the base directory.
13931406
with safe_open(

readthedocs/doc_builder/backends/mkdocs.py

+2
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ class BaseMkdocs(BaseBuilder):
4545

4646
def __init__(self, *args, **kwargs):
4747
super().__init__(*args, **kwargs)
48+
49+
# This is the *MkDocs* yaml file
4850
self.yaml_file = self.get_yaml_config()
4951

5052
# README: historically, the default theme was ``readthedocs`` but in

readthedocs/doc_builder/config.py

+8-5
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,22 @@
22

33
from os import path
44

5-
from readthedocs.config import BuildConfigV1, ConfigFileNotFound
5+
from readthedocs.config import BuildConfigV1
66
from readthedocs.config import load as load_config
77
from readthedocs.projects.models import ProjectConfigurationError
88

9+
from ..config.config import DefaultConfigFileNotFound
910
from .constants import DOCKER_IMAGE, DOCKER_IMAGE_SETTINGS
1011

1112

12-
def load_yaml_config(version):
13+
def load_yaml_config(version, config_file=None):
1314
"""
14-
Load a configuration from `readthedocs.yml` file.
15+
Load a build configuration file.
1516
1617
This uses the configuration logic from `readthedocs-build`, which will keep
1718
parsing consistent between projects.
19+
20+
:param config_file: Optionally, we are told which config_file to load instead of using defaults.
1821
"""
1922
checkout_path = version.project.checkout_path(version.slug)
2023
project = version.project
@@ -56,9 +59,9 @@ def load_yaml_config(version):
5659
config = load_config(
5760
path=checkout_path,
5861
env_config=env_config,
59-
config_file=project.rtd_conf_file,
62+
config_file=config_file,
6063
)
61-
except ConfigFileNotFound:
64+
except DefaultConfigFileNotFound:
6265
# Default to use v1 with some defaults from the web interface
6366
# if we don't find a configuration file.
6467
config = BuildConfigV1(

readthedocs/doc_builder/director.py

+33-4
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,12 @@
1+
"""
2+
The ``director`` module can be seen as the entrypoint of the build process.
3+
4+
It "directs" all of the high-level build jobs:
5+
6+
* checking out the repo
7+
* setting up the environment
8+
* fetching instructions etc.
9+
"""
110
import os
211
import tarfile
312

@@ -194,17 +203,36 @@ def build(self):
194203

195204
# VCS checkout
196205
def checkout(self):
197-
log.info(
198-
"Clonning repository.",
199-
)
206+
"""Checkout Git repo and load build config file."""
207+
208+
log.info("Cloning repository.")
200209
self.vcs_repository.update()
201210

202211
identifier = self.data.build_commit or self.data.version.identifier
203212
log.info("Checking out.", identifier=identifier)
204213
self.vcs_repository.checkout(identifier)
205214

206-
self.data.config = load_yaml_config(version=self.data.version)
215+
# The director is responsible for understanding which config file to use for a build.
216+
# Reproducibility: Build already has a config_file set.
217+
non_default_config_file = self.data.build.get("build_config_file", None)
218+
219+
# This logic can be extended with version-specific config files
220+
if not non_default_config_file and self.data.version.project.build_config_file:
221+
non_default_config_file = self.data.version.project.build_config_file
222+
223+
if non_default_config_file:
224+
log.info(f"Using a non-default config file {non_default_config_file}.")
225+
self.data.config = load_yaml_config(
226+
version=self.data.version,
227+
config_file=non_default_config_file,
228+
)
207229
self.data.build["config"] = self.data.config.as_dict()
230+
self.data.build["build_config_file"] = non_default_config_file
231+
232+
# Config file was successfully loaded and a custom path was used
233+
if non_default_config_file and not self.data.version.build_config_file:
234+
self.data.version.build_config_file = non_default_config_file
235+
# TODO: Do we call self.data.version.save() here?
208236

209237
if self.vcs_repository.supports_submodules:
210238
self.vcs_repository.update_submodules(self.data.config)
@@ -357,6 +385,7 @@ def check_old_output_directory(self):
357385
raise BuildUserError(BuildUserError.BUILD_OUTPUT_OLD_DIRECTORY_USED)
358386

359387
def run_build_commands(self):
388+
"""Runs each build command in the build environment."""
360389
reshim_commands = (
361390
{"pip", "install"},
362391
{"conda", "create"},

readthedocs/projects/forms.py

+13-11
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ class Meta:
207207
"single_version",
208208
"external_builds_enabled",
209209
"external_builds_privacy_level",
210-
"rtd_conf_file",
210+
"build_config_file",
211211
)
212212
# These that can be set per-version using a config file.
213213
per_version_settings = (
@@ -358,15 +358,8 @@ def clean_conf_py_file(self):
358358
) # yapf: disable
359359
return filename
360360

361-
def clean_rtd_conf_file(self):
362-
filename = self.cleaned_data.get("rtd_conf_file", "").strip()
363-
if filename and ".readthedocs.yml" not in filename:
364-
raise forms.ValidationError(
365-
_(
366-
"Your configuration file is invalid, make sure it contains "
367-
".readthedocs.yml in it.",
368-
),
369-
) # yapf: disable
361+
def clean_build_config_file(self):
362+
filename = self.cleaned_data.get("build_config_file", "").strip()
370363
return filename
371364

372365
def get_all_active_versions(self):
@@ -391,7 +384,9 @@ class UpdateProjectForm(
391384
ProjectExtraForm,
392385
):
393386

394-
class Meta:
387+
"""Basic project settings form for Admin."""
388+
389+
class Meta: # noqa
395390
model = Project
396391
fields = (
397392
# Basics
@@ -407,6 +402,7 @@ class Meta:
407402
)
408403

409404
def clean_language(self):
405+
"""Ensure that language isn't already active."""
410406
language = self.cleaned_data['language']
411407
project = self.instance
412408
if project:
@@ -540,6 +536,8 @@ def save(self):
540536

541537
class WebHookForm(forms.ModelForm):
542538

539+
"""Webhook form."""
540+
543541
project = forms.CharField(widget=forms.HiddenInput(), required=False)
544542

545543
class Meta:
@@ -607,6 +605,8 @@ def get_choices(self):
607605
) for project in self.get_translation_queryset().all()]
608606

609607
def clean_project(self):
608+
"""Ensures that selected project is valid as a translation."""
609+
610610
translation_project_slug = self.cleaned_data['project']
611611

612612
# Ensure parent project isn't already itself a translation
@@ -726,6 +726,7 @@ def clean_project(self):
726726
return self.project
727727

728728
def clean_domain(self):
729+
"""Validates domain."""
729730
domain = self.cleaned_data['domain'].lower()
730731
parsed = urlparse(domain)
731732

@@ -864,6 +865,7 @@ def clean_project(self):
864865
return self.project
865866

866867
def clean_name(self):
868+
"""Validate environment variable name chosen."""
867869
name = self.cleaned_data['name']
868870
if name.startswith('__'):
869871
raise forms.ValidationError(

readthedocs/projects/migrations/0096_auto_20230207_1642.py

-35
This file was deleted.

0 commit comments

Comments
 (0)