Skip to content

Commit 758b2b9

Browse files
ewdurbinbenjaomingericholscherhumitos
authored
implement multiple .readthedocs.yml files per repo (#10001)
* initial work at multiple .readthedocs.yml files per repo * darker * Updates: Rename new field to build_config_file, add on Build and Version data, relax user input validation, introduce very basic reproducibility of builds * Fix for test failures * Fix up tests to expect no build_config_file when default is used * Trimming down the scope by not storing a Version.build_config_path * DDD = Documentation-driven-development: Adding the first draft for a how-to before moving on :) * How-to: Rephrase bad intro * Basic test that a custom path is applied and loaded, default is then ignored * Test that a project-defined build config is read by build tasks * Validate properly in clean_conf_py_file * Validate input, check that we can also handle non-yaml extensions, add test case for Advanced Project page * Handle unrelated linting errors * Update migration * New validation for build config file * Don't fix clean_conf_py_file * Update help_text and test case name * Apply suggestions from @ericholscher's code review Co-authored-by: Eric Holscher <[email protected]> * Disable fetching per-build build_config_file + clarify logic about reproduciblity * New validator, configurable with allowed file names and no regex @humitos :) * Update readthedocs/doc_builder/director.py Co-authored-by: Manuel Kaufmann <[email protected]> * Use new validator on model fields and add database assertion on view test * Swap around if condition blocks * Apply suggestions from @humitos code review Co-authored-by: Manuel Kaufmann <[email protected]> * Update docs/user/guides/setup/monorepo.rst * Improve "next steps" * Unrelated: Fix breakage from similar issue as executablebooks/MyST-Parser#731 * I have no idea about why pre-commit in Circle CI wants this and my local conf does. The line is not > 100 chars! * Update migrations + refactor validator, it wasn't serializable for the migration introspect * Add a seealso for subprojects * Refactor build_config_file=>readthedocs_yaml_path, config_file=>readthedocs_yaml_path * Remove view test from projecs/tests/test_views, this module seems to be mis-located, mosts tests are in rtd_tests * Anohter renaming fix config_file=>readthedocs_yaml_path * Improve help text * Fix a strange formulation in docs * Mark validators as safe strings * Clarify paths that are tested wrt. validation * Update readthedocs/doc_builder/director.py Co-authored-by: Manuel Kaufmann <[email protected]> * Removing pattern from a more generic validator * Add link to Wikipedia definition * Mention ability to use different documentation tools. Clarify introduction text, don't say "nested". * Arrested Software Development: Hello darker my old friend, you've come to talk with me again --------- Co-authored-by: Benjamin Bach <[email protected]> Co-authored-by: Benjamin Bach <[email protected]> Co-authored-by: Benjamin Balder Bach <[email protected]> Co-authored-by: Eric Holscher <[email protected]> Co-authored-by: Manuel Kaufmann <[email protected]>
1 parent 0c614d1 commit 758b2b9

24 files changed

+650
-80
lines changed

docs/_static/js/readthedocs-doc-diff.js

+1-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/user/guides/setup/index.rst

+5
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ The following how-to guides help you solve common tasks and challenges in the se
2424
Need several projects under the same umbrella?
2525
Start using subprojects, which is a way to host multiple projects under a "main project".
2626

27+
⏩️ :doc:`Using a .readthedocs.yaml file in a sub-folder </guides/setup/monorepo>`
28+
This guide shows how to configure a Read the Docs project to use a custom path for the ``.readthedocs.yaml`` build configuration.
29+
*Monorepos* that have multiple documentation projects in the same Git repository can benefit from this feature.
30+
2731
⏩️ :doc:`Hiding a version </guides/hiding-a-version>`
2832
Is your version (flyout) menu overwhelmed and hard to navigate?
2933
Here's how to make it shorter.
@@ -44,4 +48,5 @@ The following how-to guides help you solve common tasks and challenges in the se
4448
Managing custom domains </guides/custom-domains>
4549
Managing subprojects </guides/subprojects>
4650
Hiding a version </guides/hiding-a-version>
51+
Using a .readthedocs.yaml file in a sub-folder </guides/setup/monorepo>
4752
Using custom URL redirects in documentation projects </guides/redirects>

docs/user/guides/setup/monorepo.rst

+97
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
.. Next steps: Show an example pattern for a monorepo layout or link to an example project
2+
3+
How to use a .readthedocs.yaml file in a sub-folder
4+
===================================================
5+
6+
This guide shows how to configure a Read the Docs project to use a custom path for the ``.readthedocs.yaml`` build configuration.
7+
`Monorepos <https://en.wikipedia.org/wiki/Monorepo>`__ that have multiple documentation projects in the same Git repository can benefit from this feature.
8+
9+
By default,
10+
Read the Docs will use the ``.readthedocs.yaml`` at the top level of your Git repository.
11+
But if a Git repository contains multiple documentation projects that need different build configurations,
12+
you will need to have a ``.readthedocs.yaml`` file in multiple sub-folders.
13+
14+
.. seealso::
15+
16+
`sphinx-multiproject <https://sphinx-multiproject.readthedocs.io/en/latest/>`__
17+
If you are only using Sphinx projects and want to share the same build configuration,
18+
you can also use the ``sphinx-multiproject`` extension.
19+
20+
:doc:`/guides/environment-variables`
21+
You might also be able to reuse the same configuration file across multiple projects,
22+
using only environment variables.
23+
This is possible if the configuration pattern is very similar and the documentation tool is the same.
24+
25+
Implementation considerations
26+
-----------------------------
27+
28+
This feature is currently *project-wide*.
29+
A custom build configuration file path is applied to all versions of your documentation.
30+
31+
.. warning::
32+
33+
Changing the configuration path will apply to all versions.
34+
Different versions of the project may not be able to build again if this path is changed.
35+
36+
Adding an additional project from the same repository
37+
-----------------------------------------------------
38+
39+
Once you have added the first project from the :ref:`Import Wizard <intro/import-guide:Automatically import your docs>`,
40+
it will show as if it has already been imported and cannot be imported again.
41+
In order to add another project with the same repository,
42+
you will need to use the :ref:`Manual Import <intro/import-guide:Manually import your docs>`.
43+
44+
Setting the custom build configuration file
45+
-------------------------------------------
46+
47+
Once you have added a Git repository to a project that needs a custom configuration file path,
48+
navigate to :menuselection:`Admin --> Advanced Settings` and add the path to the :guilabel:`Build configuration file` field.
49+
50+
.. image:: /img/screenshot-howto-build-configuration-file.png
51+
:alt: Screenshot of where to find the :guilabel:`Build configuration file` setting.
52+
53+
After pressing :guilabel:`Save`,
54+
you need to ensure that relevant versions of your documentation are built again.
55+
56+
.. tip::
57+
58+
Having multiple different build configuration files can be complex.
59+
We recommend setting up 1-2 projects in your Monorepo and getting them to build and publish successfully before adding additional projects to the equation.
60+
61+
Next steps
62+
----------
63+
64+
Once you have your monorepo pattern implemented and tested and it's ready to roll out to all your projects,
65+
you should also consider the Read the Docs project setup for these individual projects.
66+
67+
Having individual projects gives you the full flexibility of the Read the Docs platform to make individual setups for each project.
68+
69+
For each project, it's now possible to configure:
70+
71+
* Sets of maintainers (or :doc:`organizations </commercial/organizations>` on |com_brand|)
72+
* :doc:`Custom redirect rules </guides/custom-domains>`
73+
* :doc:`Custom domains </guides/custom-domains>`
74+
* :doc:`Automation rules </automation-rules>`
75+
* :doc:`Traffic and search analytics </reference/analytics>`
76+
* Additional documentation tools with individual :doc:`build processes </build-customization>`:
77+
One project might use :doc:`Sphinx <sphinx:index>`,
78+
while another project setup might use `Asciidoctor <https://asciidoctor.org/>`__.
79+
80+
...and much more. *All* settings for a Read the Docs project is available for each individual project.
81+
82+
.. seealso::
83+
84+
:doc:`/guides/subprojects`
85+
More information on nesting one project inside another project.
86+
In this setup, it is still possible to use the same monorepo for each subproject.
87+
88+
Other tips
89+
----------
90+
91+
For a monorepo,
92+
it's not desirable to have changes in unrelated sub-folders trigger new builds.
93+
94+
Therefore,
95+
you should consider setting up :ref:`conditional build cancellation rules <build-customization:Cancel build based on a condition>`.
96+
The configuration is added in each ``.readthedocs.yaml``,
97+
making it possible to write one conditional build rules per documentation project in the Monorepo 💯️
Loading

readthedocs/api/v2/serializers.py

+21-20
Original file line numberDiff line numberDiff line change
@@ -73,26 +73,27 @@ def get_skip(self, obj):
7373

7474
class Meta(ProjectSerializer.Meta):
7575
fields = ProjectSerializer.Meta.fields + (
76-
'enable_epub_build',
77-
'enable_pdf_build',
78-
'conf_py_file',
79-
'analytics_code',
80-
'analytics_disabled',
81-
'cdn_enabled',
82-
'container_image',
83-
'container_mem_limit',
84-
'container_time_limit',
85-
'install_project',
86-
'use_system_packages',
87-
'skip',
88-
'requirements_file',
89-
'python_interpreter',
90-
'features',
91-
'has_valid_clone',
92-
'has_valid_webhook',
93-
'show_advertising',
94-
'environment_variables',
95-
'max_concurrent_builds',
76+
"enable_epub_build",
77+
"enable_pdf_build",
78+
"conf_py_file",
79+
"analytics_code",
80+
"analytics_disabled",
81+
"cdn_enabled",
82+
"container_image",
83+
"container_mem_limit",
84+
"container_time_limit",
85+
"install_project",
86+
"use_system_packages",
87+
"skip",
88+
"requirements_file",
89+
"python_interpreter",
90+
"features",
91+
"has_valid_clone",
92+
"has_valid_webhook",
93+
"show_advertising",
94+
"environment_variables",
95+
"max_concurrent_builds",
96+
"readthedocs_yaml_path",
9697
)
9798

9899

readthedocs/builds/admin.py

+1
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ class BuildAdmin(admin.ModelAdmin):
3333
"date",
3434
"builder",
3535
"length",
36+
"readthedocs_yaml_path",
3637
"pretty_config",
3738
)
3839
readonly_fields = (
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Generated by Django 3.2.18 on 2023-04-04 13:03
2+
3+
from django.db import migrations, models
4+
5+
import readthedocs.projects.validators
6+
7+
8+
class Migration(migrations.Migration):
9+
10+
dependencies = [
11+
("builds", "0049_automation_rule_copy"),
12+
]
13+
14+
operations = [
15+
migrations.AddField(
16+
model_name="build",
17+
name="readthedocs_yaml_path",
18+
field=models.CharField(
19+
blank=True,
20+
default=None,
21+
max_length=1024,
22+
null=True,
23+
validators=[readthedocs.projects.validators.validate_build_config_file],
24+
verbose_name="Custom build configuration file path used in this build",
25+
),
26+
),
27+
]

readthedocs/builds/models.py

+9
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@
8181
SPHINX_SINGLEHTML,
8282
)
8383
from readthedocs.projects.models import APIProject, Project
84+
from readthedocs.projects.validators import validate_build_config_file
8485
from readthedocs.projects.version_handling import determine_stable_version
8586

8687
log = structlog.get_logger(__name__)
@@ -707,6 +708,14 @@ class Build(models.Model):
707708
null=True,
708709
blank=True,
709710
)
711+
readthedocs_yaml_path = models.CharField(
712+
_("Custom build configuration file path used in this build"),
713+
max_length=1024,
714+
default=None,
715+
blank=True,
716+
null=True,
717+
validators=[validate_build_config_file],
718+
)
710719

711720
length = models.IntegerField(_('Build Length'), null=True, blank=True)
712721

readthedocs/config/config.py

+38-16
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."""
@@ -1369,18 +1381,28 @@ def search(self):
13691381
return Search(**self._config['search'])
13701382

13711383

1372-
def load(path, env_config):
1384+
def load(path, env_config, readthedocs_yaml_path=None):
13731385
"""
13741386
Load a project configuration and the top-most build config for a given path.
13751387
13761388
That is usually the root of the project, but will look deeper. According to
13771389
the version of the configuration a build object would be load and validated,
13781390
``BuildConfigV1`` is the default.
13791391
"""
1380-
filename = find_one(path, CONFIG_FILENAME_REGEX)
1381-
1382-
if not filename:
1383-
raise ConfigFileNotFound(path)
1392+
# Custom non-default config file location
1393+
if readthedocs_yaml_path:
1394+
filename = os.path.join(path, readthedocs_yaml_path)
1395+
# When a config file is specified and not found, we raise ConfigError
1396+
# because ConfigFileNotFound
1397+
if not os.path.exists(filename):
1398+
raise ConfigFileNotFound(os.path.relpath(filename, path))
1399+
# Default behavior
1400+
else:
1401+
filename = find_one(path, CONFIG_FILENAME_REGEX)
1402+
if not filename:
1403+
# This exception is current caught higher up and will result in an attempt
1404+
# to load the v1 config schema.
1405+
raise DefaultConfigFileNotFound(path)
13841406

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

readthedocs/config/tests/test_config.py

+63-2
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@
1616
BuildConfigV1,
1717
BuildConfigV2,
1818
ConfigError,
19-
ConfigFileNotFound,
2019
ConfigOptionNotSupportedError,
20+
DefaultConfigFileNotFound,
2121
InvalidConfig,
2222
load,
2323
)
@@ -80,7 +80,7 @@ def get_build_config(config, env_config=None, source_file='readthedocs.yml'):
8080
def test_load_no_config_file(tmpdir, files):
8181
apply_fs(tmpdir, files)
8282
base = str(tmpdir)
83-
with raises(ConfigFileNotFound) as e:
83+
with raises(DefaultConfigFileNotFound) as e:
8484
with override_settings(DOCROOT=tmpdir):
8585
load(base, {})
8686
assert e.value.code == CONFIG_FILE_REQUIRED
@@ -196,6 +196,67 @@ def test_build_config_has_source_file(tmpdir):
196196
assert build.source_file == os.path.join(base, 'readthedocs.yml')
197197

198198

199+
def test_load_non_default_filename(tmpdir):
200+
"""
201+
Load a config file name with a non-default name.
202+
203+
Verifies that we can load a custom config path and that an existing default config file is
204+
correctly ignored.
205+
206+
Note: Our CharField validator for readthedocs_yaml_path currently ONLY allows a file to be
207+
called .readthedocs.yaml.
208+
This test just verifies that the loader doesn't care since we support different file names
209+
in the backend.
210+
"""
211+
non_default_filename = "myconfig.yaml"
212+
apply_fs(
213+
tmpdir,
214+
{
215+
non_default_filename: textwrap.dedent(
216+
"""
217+
version: 2
218+
"""
219+
),
220+
".readthedocs.yaml": "illegal syntax but should not load",
221+
},
222+
)
223+
base = str(tmpdir)
224+
with override_settings(DOCROOT=tmpdir):
225+
build = load(base, {}, readthedocs_yaml_path="myconfig.yaml")
226+
assert isinstance(build, BuildConfigV2)
227+
assert build.source_file == os.path.join(base, non_default_filename)
228+
229+
230+
def test_load_non_yaml_extension(tmpdir):
231+
"""
232+
Load a config file name from non-default path.
233+
234+
In this version, we verify that we can handle non-yaml extensions
235+
because we allow the user to do that.
236+
237+
See docstring of test_load_non_default_filename.
238+
"""
239+
non_default_filename = ".readthedocs.skrammel"
240+
apply_fs(
241+
tmpdir,
242+
{
243+
"subdir": {
244+
non_default_filename: textwrap.dedent(
245+
"""
246+
version: 2
247+
"""
248+
),
249+
},
250+
".readthedocs.yaml": "illegal syntax but should not load",
251+
},
252+
)
253+
base = str(tmpdir)
254+
with override_settings(DOCROOT=tmpdir):
255+
build = load(base, {}, readthedocs_yaml_path="subdir/.readthedocs.skrammel")
256+
assert isinstance(build, BuildConfigV2)
257+
assert build.source_file == os.path.join(base, "subdir/.readthedocs.skrammel")
258+
259+
199260
def test_build_config_has_list_with_single_empty_value(tmpdir):
200261
base = str(apply_fs(
201262
tmpdir, {

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

0 commit comments

Comments
 (0)