Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

implement multiple .readthedocs.yml files per repo #10001

Merged
merged 51 commits into from
Apr 17, 2023
Merged
Show file tree
Hide file tree
Changes from 44 commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
308c60e
initial work at multiple .readthedocs.yml files per repo
ewdurbin Feb 7, 2023
89fc389
darker
ewdurbin Feb 7, 2023
1bd6a69
Merge branch 'main' of github.com:readthedocs/readthedocs.org into mu…
benjaoming Mar 10, 2023
80c3140
Updates: Rename new field to build_config_file, add on Build and Vers…
benjaoming Mar 10, 2023
59d3a31
Fix for test failures
benjaoming Mar 10, 2023
f91201b
Fix up tests to expect no build_config_file when default is used
benjaoming Mar 10, 2023
7ce5271
Trimming down the scope by not storing a Version.build_config_path
benjaoming Mar 13, 2023
0199252
Merge branch 'main' of github.com:readthedocs/readthedocs.org into mu…
benjaoming Mar 16, 2023
0683268
DDD = Documentation-driven-development: Adding the first draft for a …
benjaoming Mar 16, 2023
d16f694
How-to: Rephrase bad intro
benjaoming Mar 16, 2023
69c9bb3
Basic test that a custom path is applied and loaded, default is then …
benjaoming Mar 20, 2023
f66fc38
Test that a project-defined build config is read by build tasks
benjaoming Mar 20, 2023
7003d68
Validate properly in clean_conf_py_file
benjaoming Mar 20, 2023
7fd841f
Validate input, check that we can also handle non-yaml extensions, ad…
benjaoming Mar 20, 2023
ab15ce4
Handle unrelated linting errors
benjaoming Mar 20, 2023
50a83e6
Merge branch 'main' of github.com:readthedocs/readthedocs.org into mu…
benjaoming Mar 20, 2023
4999c24
Update migration
benjaoming Mar 20, 2023
78daf56
New validation for build config file
benjaoming Mar 21, 2023
b85ad31
Don't fix clean_conf_py_file
benjaoming Mar 21, 2023
e2f9231
Update help_text and test case name
benjaoming Mar 21, 2023
9653001
Apply suggestions from @ericholscher's code review
benjaoming Mar 21, 2023
3c76274
Disable fetching per-build build_config_file + clarify logic about re…
benjaoming Mar 21, 2023
412ba34
New validator, configurable with allowed file names and no regex @hum…
benjaoming Mar 21, 2023
d713461
Update readthedocs/doc_builder/director.py
benjaoming Mar 21, 2023
153a5fc
Use new validator on model fields and add database assertion on view …
benjaoming Mar 21, 2023
6f0e143
Merge branch 'multi_config' of github.com:ewdurbin/readthedocs.org in…
benjaoming Mar 21, 2023
1ee67fc
Swap around if condition blocks
benjaoming Mar 21, 2023
eb6f42b
Apply suggestions from @humitos code review
benjaoming Mar 21, 2023
1406939
Update docs/user/guides/setup/monorepo.rst
benjaoming Mar 21, 2023
7d89d69
Improve "next steps"
benjaoming Mar 21, 2023
6d420f1
Unrelated: Fix breakage from similar issue as https://github.com/exec…
benjaoming Mar 21, 2023
59e9cac
Merge branch 'main' of github.com:readthedocs/readthedocs.org into mu…
benjaoming Mar 21, 2023
3c8a61c
I have no idea about why pre-commit in Circle CI wants this and my lo…
benjaoming Mar 21, 2023
fbd82a0
Update migrations + refactor validator, it wasn't serializable for th…
benjaoming Mar 21, 2023
d568862
Add a seealso for subprojects
benjaoming Mar 21, 2023
7d9d830
Merge branch 'main' of github.com:readthedocs/readthedocs.org into mu…
benjaoming Mar 22, 2023
6e6c682
Merge branch 'main' of github.com:readthedocs/readthedocs.org into mu…
benjaoming Mar 28, 2023
9f07682
Merge branch 'main' of github.com:readthedocs/readthedocs.org into mu…
benjaoming Apr 4, 2023
dbc52a5
Refactor build_config_file=>readthedocs_yaml_path, config_file=>readt…
benjaoming Apr 4, 2023
bbd5809
Remove view test from projecs/tests/test_views, this module seems to …
benjaoming Apr 4, 2023
91cfda7
Anohter renaming fix config_file=>readthedocs_yaml_path
benjaoming Apr 4, 2023
7844c10
Improve help text
benjaoming Apr 4, 2023
9a09825
Fix a strange formulation in docs
benjaoming Apr 4, 2023
2b1521b
Mark validators as safe strings
benjaoming Apr 4, 2023
faa8f95
Clarify paths that are tested wrt. validation
benjaoming Apr 13, 2023
6eca6ee
Update readthedocs/doc_builder/director.py
benjaoming Apr 13, 2023
e0aa2c5
Removing pattern from a more generic validator
benjaoming Apr 13, 2023
03afea0
Add link to Wikipedia definition
benjaoming Apr 13, 2023
32c87a7
Mention ability to use different documentation tools. Clarify introdu…
benjaoming Apr 13, 2023
9667c19
Arrested Software Development: Hello darker my old friend, you've com…
benjaoming Apr 13, 2023
dbbbdec
Merge branch 'main' of github.com:readthedocs/readthedocs.org into mu…
benjaoming Apr 13, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/_static/js/readthedocs-doc-diff.js

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions docs/user/guides/setup/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ The following how-to guides help you solve common tasks and challenges in the se
Need several projects under the same umbrella?
Start using subprojects, which is a way to host multiple projects under a "main project".

⏩️ :doc:`Using a .readthedocs.yaml file in a sub-folder </guides/setup/monorepo>`
This guide shows how to configure a Read the Docs project to use a custom path for the ``.readthedocs.yaml`` build configuration.
*Monorepos* that have multiple documentation projects in the same Git repository can benefit from this feature.

⏩️ :doc:`Hiding a version </guides/hiding-a-version>`
Is your version (flyout) menu overwhelmed and hard to navigate?
Here's how to make it shorter.
Expand All @@ -44,4 +48,5 @@ The following how-to guides help you solve common tasks and challenges in the se
Managing custom domains </guides/custom-domains>
Managing subprojects </guides/subprojects>
Hiding a version </guides/hiding-a-version>
Using a .readthedocs.yaml file in a sub-folder </guides/setup/monorepo>
Using custom URL redirects in documentation projects </guides/redirects>
92 changes: 92 additions & 0 deletions docs/user/guides/setup/monorepo.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
How to use a .readthedocs.yaml file in a sub-folder
===================================================

This guide shows how to configure a Read the Docs project to use a custom path for the ``.readthedocs.yaml`` build configuration.
*Monorepos* that have multiple documentation projects in the same Git repository can benefit from this feature.
benjaoming marked this conversation as resolved.
Show resolved Hide resolved

By default,
Read the Docs will use the ``.readthedocs.yaml`` at the top level of your Git repository.
This is typically not sufficient for monorepo layouts
when their nested documentation projects need fundamentally different build configurations.

.. seealso::

`sphinx-multiproject <https://sphinx-multiproject.readthedocs.io/en/latest/>`__
If you are only using Sphinx projects and want to share the same build configuration,
you can also use the ``sphinx-multiproject`` extension.
benjaoming marked this conversation as resolved.
Show resolved Hide resolved

:doc:`/guides/environment-variables`
You might also be able to reuse the same configuration file across multiple projects,
using only environment variables.
This is possible if the configuration pattern is very similar and the documentation tool is the same.

Implementation considerations
-------------------------------

This feature is currently *project-wide*.
A custom build configuration file path is applied to all versions of your documentation.

.. warning::

Changing the configuration path will apply to all versions.
Different versions of the project may not be able to build again if this path is changed.

Adding an additional project from the same repository
-----------------------------------------------------

Once you have added the first project from the :ref:`Import Wizard <intro/import-guide:Automatically import your docs>`,
it will show as if it has already been imported and cannot be imported again.
In order to add another project with the same repository,
you will need to use the :ref:`Manual Import <intro/import-guide:Manually import your docs>`.
benjaoming marked this conversation as resolved.
Show resolved Hide resolved

Setting the custom build configuration file
-------------------------------------------

Once you have added a Git repository to a project that needs a custom configuration file path,
navigate to :menuselection:`Admin --> Advanced Settings` and add the path to the :guilabel:`Build configuration file` field.

.. image:: /img/screenshot-howto-build-configuration-file.png
:alt: Screenshot of where to find the :guilabel:`Build configuration file` setting.

After pressing :guilabel:`Save`,
you need to ensure that relevant versions of your documentation are built again.

.. tip::

Having multiple different build configuration files can be complex.
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.
benjaoming marked this conversation as resolved.
Show resolved Hide resolved

Next steps
----------

Once you have your monorepo pattern implemented and tested and it's ready to roll out to all your projects,
you should also consider the Read the Docs project setup for these individual projects.

Having individual projects gives you the full flexibility of the Read the Docs platform to make individual setups for each project.

For each project, it's now possible to configure:

* Sets of maintainers (or :doc:`organizations </commercial/organizations>` on |com_brand|)
* :doc:`Custom redirect rules </guides/custom-domains>`
* :doc:`Custom domains </guides/custom-domains>`
* :doc:`Automation rules </automation-rules>`
* :doc:`Traffic and search analytics </reference/analytics>`
benjaoming marked this conversation as resolved.
Show resolved Hide resolved

...and much more. All settings for a Read the Docs project is available for the individual project.

.. seealso::

:doc:`/guides/subprojects`
More information on nesting one project inside another project.
In this setup, it is still possible to use the same monorepo for each subproject.

Other tips
----------

For a monorepo,
it's not desirable to have changes in unrelated sub-folders trigger new builds.

Therefore,
you should consider setting up :ref:`conditional build cancellation rules <build-customization:Cancel build based on a condition>`.
The configuration is added in each ``.readthedocs.yaml``,
making it possible to write one conditional build rules per documentation project in the Monorepo 💯️
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
41 changes: 21 additions & 20 deletions readthedocs/api/v2/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,26 +73,27 @@ def get_skip(self, obj):

class Meta(ProjectSerializer.Meta):
fields = ProjectSerializer.Meta.fields + (
'enable_epub_build',
'enable_pdf_build',
'conf_py_file',
'analytics_code',
'analytics_disabled',
'cdn_enabled',
'container_image',
'container_mem_limit',
'container_time_limit',
'install_project',
'use_system_packages',
'skip',
'requirements_file',
'python_interpreter',
'features',
'has_valid_clone',
'has_valid_webhook',
'show_advertising',
'environment_variables',
'max_concurrent_builds',
"enable_epub_build",
"enable_pdf_build",
"conf_py_file",
"analytics_code",
"analytics_disabled",
"cdn_enabled",
"container_image",
"container_mem_limit",
"container_time_limit",
"install_project",
"use_system_packages",
"skip",
"requirements_file",
"python_interpreter",
"features",
"has_valid_clone",
"has_valid_webhook",
"show_advertising",
"environment_variables",
"max_concurrent_builds",
"readthedocs_yaml_path",
)


Expand Down
1 change: 1 addition & 0 deletions readthedocs/builds/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ class BuildAdmin(admin.ModelAdmin):
"date",
"builder",
"length",
"readthedocs_yaml_path",
"pretty_config",
)
readonly_fields = (
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Generated by Django 3.2.18 on 2023-04-04 13:03

from django.db import migrations, models

import readthedocs.projects.validators


class Migration(migrations.Migration):

dependencies = [
("builds", "0049_automation_rule_copy"),
]

operations = [
migrations.AddField(
model_name="build",
name="readthedocs_yaml_path",
field=models.CharField(
blank=True,
default=None,
max_length=1024,
null=True,
validators=[readthedocs.projects.validators.validate_build_config_file],
verbose_name="Custom build configuration file path used in this build",
),
),
]
9 changes: 9 additions & 0 deletions readthedocs/builds/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@
SPHINX_SINGLEHTML,
)
from readthedocs.projects.models import APIProject, Project
from readthedocs.projects.validators import validate_build_config_file
from readthedocs.projects.version_handling import determine_stable_version

log = structlog.get_logger(__name__)
Expand Down Expand Up @@ -707,6 +708,14 @@ class Build(models.Model):
null=True,
blank=True,
)
readthedocs_yaml_path = models.CharField(
_("Custom build configuration file path used in this build"),
max_length=1024,
default=None,
blank=True,
null=True,
validators=[validate_build_config_file],
)

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

Expand Down
54 changes: 38 additions & 16 deletions readthedocs/config/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,17 +43,18 @@
)

__all__ = (
'ALL',
'load',
'BuildConfigV1',
'BuildConfigV2',
'ConfigError',
'ConfigOptionNotSupportedError',
'ConfigFileNotFound',
'InvalidConfig',
'PIP',
'SETUPTOOLS',
'LATEST_CONFIGURATION_VERSION',
"ALL",
"load",
"BuildConfigV1",
"BuildConfigV2",
"ConfigError",
"ConfigOptionNotSupportedError",
"ConfigFileNotFound",
"DefaultConfigFileNotFound",
"InvalidConfig",
"PIP",
"SETUPTOOLS",
"LATEST_CONFIGURATION_VERSION",
)

ALL = 'all'
Expand Down Expand Up @@ -96,6 +97,17 @@ def __init__(self, directory):
)


class DefaultConfigFileNotFound(ConfigError):

"""Error when we can't find a configuration file."""

def __init__(self, directory):
super().__init__(
f"No default configuration file in: {directory}",
CONFIG_FILE_REQUIRED,
)


class ConfigOptionNotSupportedError(ConfigError):

"""Error for unsupported configuration options in a version."""
Expand Down Expand Up @@ -1369,18 +1381,28 @@ def search(self):
return Search(**self._config['search'])


def load(path, env_config):
def load(path, env_config, readthedocs_yaml_path=None):
"""
Load a project configuration and the top-most build config for a given path.

That is usually the root of the project, but will look deeper. According to
the version of the configuration a build object would be load and validated,
``BuildConfigV1`` is the default.
"""
filename = find_one(path, CONFIG_FILENAME_REGEX)

if not filename:
raise ConfigFileNotFound(path)
# Custom non-default config file location
if readthedocs_yaml_path:
filename = os.path.join(path, readthedocs_yaml_path)
# When a config file is specified and not found, we raise ConfigError
# because ConfigFileNotFound
if not os.path.exists(filename):
raise ConfigFileNotFound(os.path.relpath(filename, path))
# Default behavior
else:
filename = find_one(path, CONFIG_FILENAME_REGEX)
if not filename:
# This exception is current caught higher up and will result in an attempt
# to load the v1 config schema.
raise DefaultConfigFileNotFound(path)

# Allow symlinks, but only the ones that resolve inside the base directory.
with safe_open(
Expand Down
56 changes: 54 additions & 2 deletions readthedocs/config/tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@
BuildConfigV1,
BuildConfigV2,
ConfigError,
ConfigFileNotFound,
ConfigOptionNotSupportedError,
DefaultConfigFileNotFound,
InvalidConfig,
load,
)
Expand Down Expand Up @@ -80,7 +80,7 @@ def get_build_config(config, env_config=None, source_file='readthedocs.yml'):
def test_load_no_config_file(tmpdir, files):
apply_fs(tmpdir, files)
base = str(tmpdir)
with raises(ConfigFileNotFound) as e:
with raises(DefaultConfigFileNotFound) as e:
with override_settings(DOCROOT=tmpdir):
load(base, {})
assert e.value.code == CONFIG_FILE_REQUIRED
Expand Down Expand Up @@ -196,6 +196,58 @@ def test_build_config_has_source_file(tmpdir):
assert build.source_file == os.path.join(base, 'readthedocs.yml')


def test_load_non_default(tmpdir):
"""
Load a config file name from non-default path

Verifies that we can load a custom config path and that an existing default config file is
correctly ignored.
"""
non_default_filename = "myconfig.yaml"
benjaoming marked this conversation as resolved.
Show resolved Hide resolved
apply_fs(
tmpdir,
{
non_default_filename: textwrap.dedent(
"""
version: 2
"""
),
".readthedocs.yaml": "illegal syntax but should not load",
},
)
base = str(tmpdir)
with override_settings(DOCROOT=tmpdir):
build = load(base, {}, readthedocs_yaml_path="myconfig.yaml")
assert isinstance(build, BuildConfigV2)
assert build.source_file == os.path.join(base, non_default_filename)


def test_load_non_default_with_strange_extension(tmpdir):
"""
Load a config file name from non-default path

In this version, we verify that we can handle non-yaml extensions
because we allow the user to do that.
"""
benjaoming marked this conversation as resolved.
Show resolved Hide resolved
non_default_filename = "myconfig.unconventional"
apply_fs(
tmpdir,
{
non_default_filename: textwrap.dedent(
"""
version: 2
"""
),
".readthedocs.yaml": "illegal syntax but should not load",
},
)
base = str(tmpdir)
with override_settings(DOCROOT=tmpdir):
build = load(base, {}, readthedocs_yaml_path="myconfig.unconventional")
assert isinstance(build, BuildConfigV2)
assert build.source_file == os.path.join(base, non_default_filename)


def test_build_config_has_list_with_single_empty_value(tmpdir):
base = str(apply_fs(
tmpdir, {
Expand Down
2 changes: 2 additions & 0 deletions readthedocs/doc_builder/backends/mkdocs.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ class BaseMkdocs(BaseBuilder):

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)

# This is the *MkDocs* yaml file
self.yaml_file = self.get_yaml_config()

# README: historically, the default theme was ``readthedocs`` but in
Expand Down
Loading