diff --git a/docs/user/config-file/v2.rst b/docs/user/config-file/v2.rst
index 48e3b465e42..30ee7bff304 100644
--- a/docs/user/config-file/v2.rst
+++ b/docs/user/config-file/v2.rst
@@ -493,8 +493,7 @@ The ``$READTHEDOCS_OUTPUT/html`` directory will be uploaded and hosted by Read t
sphinx
~~~~~~
-Configuration for Sphinx documentation
-(this is the default documentation type).
+Configuration for Sphinx documentation.
.. code-block:: yaml
@@ -535,10 +534,7 @@ sphinx.configuration
The path to the ``conf.py`` file, relative to the root of the project.
:Type: ``path``
-:Default: ``null``
-
-If the value is ``null``,
-Read the Docs will try to find a ``conf.py`` file in your project.
+:Required: ``true``
sphinx.fail_on_warning
``````````````````````
@@ -580,10 +576,7 @@ mkdocs.configuration
The path to the ``mkdocs.yml`` file, relative to the root of the project.
:Type: ``path``
-:Default: ``null``
-
-If the value is ``null``,
-Read the Docs will try to find a ``mkdocs.yml`` file in your project.
+:Required: ``true``
mkdocs.fail_on_warning
``````````````````````
diff --git a/readthedocs/config/config.py b/readthedocs/config/config.py
index e1cbbd60b33..e3c96d73e0f 100644
--- a/readthedocs/config/config.py
+++ b/readthedocs/config/config.py
@@ -1,11 +1,12 @@
"""Build configuration for rtd."""
-
import copy
+import datetime
import os
import re
from contextlib import contextmanager
from functools import lru_cache
+import pytz
from django.conf import settings
from pydantic import BaseModel
@@ -87,7 +88,13 @@ class BuildConfigBase:
version = None
- def __init__(self, raw_config, source_file, base_path=None):
+ def __init__(
+ self,
+ raw_config,
+ source_file,
+ base_path=None,
+ deprecate_implicit_keys=None,
+ ):
self._raw_config = copy.deepcopy(raw_config)
self.source_config = copy.deepcopy(raw_config)
self.source_file = source_file
@@ -102,6 +109,25 @@ def __init__(self, raw_config, source_file, base_path=None):
self._config = {}
+ if deprecate_implicit_keys is not None:
+ self.deprecate_implicit_keys = deprecate_implicit_keys
+ elif settings.RTD_ENFORCE_BROWNOUTS_FOR_DEPRECATIONS:
+ tzinfo = pytz.timezone("America/Los_Angeles")
+ now = datetime.datetime.now(tz=tzinfo)
+ # Dates as per https://about.readthedocs.com/blog/2024/12/deprecate-config-files-without-sphinx-or-mkdocs-config/
+ # fmt: off
+ self.deprecate_implicit_keys = (
+ # 12 hours brownout.
+ datetime.datetime(2025, 1, 6, 0, 0, 0, tzinfo=tzinfo) < now < datetime.datetime(2025, 1, 6, 12, 0, 0, tzinfo=tzinfo)
+ # 24 hours brownout.
+ or datetime.datetime(2025, 1, 13, 0, 0, 0, tzinfo=tzinfo) < now < datetime.datetime(2025, 1, 14, 0, 0, 0, tzinfo=tzinfo)
+ # Permanent removal.
+ or datetime.datetime(2025, 1, 20, 0, 0, 0, tzinfo=tzinfo) < now
+ )
+ # fmt: on
+ else:
+ self.deprecate_implicit_keys = False
+
@contextmanager
def catch_validation_error(self, key):
"""Catch a ``ConfigValidationError`` and raises a ``ConfigError`` error."""
@@ -219,7 +245,6 @@ def __getattr__(self, name):
class BuildConfigV2(BuildConfigBase):
-
"""Version 2 of the configuration file."""
version = "2"
@@ -251,6 +276,8 @@ def validate(self):
self._config["sphinx"] = self.validate_sphinx()
self._config["submodules"] = self.validate_submodules()
self._config["search"] = self.validate_search()
+ if self.deprecate_implicit_keys:
+ self.validate_deprecated_implicit_keys()
self.validate_keys()
def validate_formats(self):
@@ -722,6 +749,50 @@ def validate_search(self):
return search
+ def validate_deprecated_implicit_keys(self):
+ """
+ Check for deprecated usages and raise an exception if found.
+
+ - If the sphinx key is used, a path to the configuration file is required.
+ - If the mkdocs key is used, a path to the configuration file is required.
+ - If none of the sphinx or mkdocs keys are used,
+ and the user isn't overriding the new build jobs,
+ the sphinx key is explicitly required.
+ """
+ has_sphinx_key = "sphinx" in self.source_config
+ has_mkdocs_key = "mkdocs" in self.source_config
+ if has_sphinx_key and not self.sphinx.configuration:
+ raise ConfigError(
+ message_id=ConfigError.SPHINX_CONFIG_MISSING,
+ )
+
+ if has_mkdocs_key and not self.mkdocs.configuration:
+ raise ConfigError(
+ message_id=ConfigError.MKDOCS_CONFIG_MISSING,
+ )
+
+ if not self.new_jobs_overriden and not has_sphinx_key and not has_mkdocs_key:
+ raise ConfigError(
+ message_id=ConfigError.SPHINX_CONFIG_MISSING,
+ )
+
+ @property
+ def new_jobs_overriden(self):
+ """Check if any of the new (undocumented) build jobs are overridden."""
+ build_jobs = self.build.jobs
+ new_jobs = (
+ build_jobs.create_environment,
+ build_jobs.install,
+ build_jobs.build.html,
+ build_jobs.build.pdf,
+ build_jobs.build.epub,
+ build_jobs.build.htmlzip,
+ )
+ for job in new_jobs:
+ if job is not None:
+ return True
+ return False
+
def validate_keys(self):
"""
Checks that we don't have extra keys (invalid ones).
@@ -812,6 +883,11 @@ def doctype(self):
if "commands" in self._config["build"] and self._config["build"]["commands"]:
return GENERIC
+ has_sphinx_key = "sphinx" in self.source_config
+ has_mkdocs_key = "mkdocs" in self.source_config
+ if self.new_jobs_overriden and not has_sphinx_key and not has_mkdocs_key:
+ return GENERIC
+
if self.mkdocs:
return "mkdocs"
return self.sphinx.builder
diff --git a/readthedocs/config/exceptions.py b/readthedocs/config/exceptions.py
index d0fcb30b5f1..fe7c2f36580 100644
--- a/readthedocs/config/exceptions.py
+++ b/readthedocs/config/exceptions.py
@@ -26,6 +26,9 @@ class ConfigError(BuildUserError):
SYNTAX_INVALID = "config:base:invalid-syntax"
CONDA_KEY_REQUIRED = "config:conda:required"
+ SPHINX_CONFIG_MISSING = "config:sphinx:missing-config"
+ MKDOCS_CONFIG_MISSING = "config:mkdocs:missing-config"
+
# TODO: improve these error messages shown to the user
# See https://github.com/readthedocs/readthedocs.org/issues/10502
diff --git a/readthedocs/config/notifications.py b/readthedocs/config/notifications.py
index 954c3bd5f19..bf9c9f37876 100644
--- a/readthedocs/config/notifications.py
+++ b/readthedocs/config/notifications.py
@@ -361,5 +361,31 @@
),
type=ERROR,
),
+ Message(
+ id=ConfigError.SPHINX_CONFIG_MISSING,
+ header=_("Missing Sphinx configuration key"),
+ body=_(
+ textwrap.dedent(
+ """
+ The sphinx.configuration
key is missing.
+ This key is now required, see our blog post for more information.
+ """
+ ).strip(),
+ ),
+ type=ERROR,
+ ),
+ Message(
+ id=ConfigError.MKDOCS_CONFIG_MISSING,
+ header=_("Missing MkDocs configuration key"),
+ body=_(
+ textwrap.dedent(
+ """
+ The mkdocs.configuration
key is missing.
+ This key is now required, see our blog post for more information.
+ """
+ ).strip(),
+ ),
+ type=ERROR,
+ ),
]
registry.add(messages)
diff --git a/readthedocs/config/tests/test_config.py b/readthedocs/config/tests/test_config.py
index e61d76a5c7a..aca038771d3 100644
--- a/readthedocs/config/tests/test_config.py
+++ b/readthedocs/config/tests/test_config.py
@@ -23,7 +23,7 @@
from .utils import apply_fs
-def get_build_config(config, source_file="readthedocs.yml", validate=False):
+def get_build_config(config, source_file="readthedocs.yml", validate=False, **kwargs):
# I'm adding these defaults here to avoid modifying all the config file from all the tests
final_config = {
"version": "2",
@@ -39,6 +39,7 @@ def get_build_config(config, source_file="readthedocs.yml", validate=False):
build_config = BuildConfigV2(
final_config,
source_file=source_file,
+ **kwargs,
)
if validate:
build_config.validate()
@@ -1805,6 +1806,64 @@ def test_pop_config_raise_exception(self):
assert excinfo.value.format_values.get("value") == "invalid"
assert excinfo.value.message_id == ConfigValidationError.VALUE_NOT_FOUND
+ def test_sphinx_without_explicit_configuration(self):
+ data = {
+ "sphinx": {},
+ }
+ get_build_config(data, validate=True)
+
+ with raises(ConfigError) as excinfo:
+ get_build_config(data, validate=True, deprecate_implicit_keys=True)
+
+ assert excinfo.value.message_id == ConfigError.SPHINX_CONFIG_MISSING
+
+ data["sphinx"]["configuration"] = "conf.py"
+ get_build_config(data, validate=True, deprecate_implicit_keys=True)
+
+ def test_mkdocs_without_explicit_configuration(self):
+ data = {
+ "mkdocs": {},
+ }
+ get_build_config(data, validate=True)
+
+ with raises(ConfigError) as excinfo:
+ get_build_config(data, validate=True, deprecate_implicit_keys=True)
+
+ assert excinfo.value.message_id == ConfigError.MKDOCS_CONFIG_MISSING
+
+ data["mkdocs"]["configuration"] = "mkdocs.yml"
+ get_build_config(data, validate=True, deprecate_implicit_keys=True)
+
+ def test_config_without_sphinx_key(self):
+ data = {
+ "build": {
+ "os": "ubuntu-22.04",
+ "tools": {
+ "python": "3",
+ },
+ "jobs": {},
+ },
+ }
+ get_build_config(data, validate=True)
+
+ with raises(ConfigError) as excinfo:
+ get_build_config(data, validate=True, deprecate_implicit_keys=True)
+
+ assert excinfo.value.message_id == ConfigError.SPHINX_CONFIG_MISSING
+
+ # No exception should be raised when overriding any of the the new jobs.
+ data_copy = data.copy()
+ data_copy["build"]["jobs"]["create_environment"] = ["echo 'Hello World'"]
+ get_build_config(data_copy, validate=True, deprecate_implicit_keys=True)
+
+ data_copy = data.copy()
+ data_copy["build"]["jobs"]["install"] = ["echo 'Hello World'"]
+ get_build_config(data_copy, validate=True, deprecate_implicit_keys=True)
+
+ data_copy = data.copy()
+ data_copy["build"]["jobs"]["build"] = {"html": ["echo 'Hello World'"]}
+ get_build_config(data_copy, validate=True, deprecate_implicit_keys=True)
+
def test_as_dict_new_build_config(self, tmpdir):
build = get_build_config(
{
diff --git a/readthedocs/doc_builder/director.py b/readthedocs/doc_builder/director.py
index 0418a893a64..51b61fe6cf7 100644
--- a/readthedocs/doc_builder/director.py
+++ b/readthedocs/doc_builder/director.py
@@ -23,7 +23,7 @@
from readthedocs.doc_builder.exceptions import BuildUserError
from readthedocs.doc_builder.loader import get_builder_class
from readthedocs.doc_builder.python_environments import Conda, Virtualenv
-from readthedocs.projects.constants import BUILD_COMMANDS_OUTPUT_PATH_HTML
+from readthedocs.projects.constants import BUILD_COMMANDS_OUTPUT_PATH_HTML, GENERIC
from readthedocs.projects.exceptions import RepositoryError
from readthedocs.projects.signals import after_build, before_build, before_vcs
from readthedocs.storage import build_tools_storage
@@ -301,6 +301,12 @@ def create_environment(self):
if self.data.config.build.jobs.create_environment is not None:
self.run_build_job("create_environment")
return
+
+ # If the builder is generic, we have nothing to do here,
+ # as the commnads are provided by the user.
+ if self.data.config.doctype == GENERIC:
+ return
+
self.language_environment.setup_base()
# Install
@@ -309,6 +315,11 @@ def install(self):
self.run_build_job("install")
return
+ # If the builder is generic, we have nothing to do here,
+ # as the commnads are provided by the user.
+ if self.data.config.doctype == GENERIC:
+ return
+
self.language_environment.install_core_requirements()
self.language_environment.install_requirements()
@@ -642,6 +653,11 @@ def build_docs_class(self, builder_class):
only raise a warning exception here. A hard error will halt the build
process.
"""
+ # If the builder is generic, we have nothing to do here,
+ # as the commnads are provided by the user.
+ if builder_class == GENERIC:
+ return
+
builder = get_builder_class(builder_class)(
build_env=self.build_environment,
python_env=self.language_environment,
diff --git a/readthedocs/doc_builder/python_environments.py b/readthedocs/doc_builder/python_environments.py
index e92691c2d83..a70583d4705 100644
--- a/readthedocs/doc_builder/python_environments.py
+++ b/readthedocs/doc_builder/python_environments.py
@@ -11,6 +11,7 @@
from readthedocs.config.models import PythonInstall, PythonInstallRequirements
from readthedocs.core.utils.filesystem import safe_open
from readthedocs.doc_builder.config import load_yaml_config
+from readthedocs.projects.constants import GENERIC
from readthedocs.projects.exceptions import UserFileNotFound
from readthedocs.projects.models import Feature
@@ -168,6 +169,10 @@ def _install_latest_requirements(self, pip_install_cmd):
cwd=self.checkout_path,
)
+ # Nothing else to install for generic projects.
+ if self.config.doctype == GENERIC:
+ return
+
# Second, install all the latest core requirements
requirements = []
diff --git a/readthedocs/projects/tests/test_build_tasks.py b/readthedocs/projects/tests/test_build_tasks.py
index 32515c1a8aa..1dad403d2c2 100644
--- a/readthedocs/projects/tests/test_build_tasks.py
+++ b/readthedocs/projects/tests/test_build_tasks.py
@@ -1290,6 +1290,221 @@ def test_build_jobs_partial_build_override(self, load_yaml_config):
]
)
+ @mock.patch("readthedocs.doc_builder.director.load_yaml_config")
+ def test_build_jobs_partial_build_override_without_sphinx(self, load_yaml_config):
+ config = BuildConfigV2(
+ {
+ "version": 2,
+ "formats": ["pdf", "epub", "htmlzip"],
+ "build": {
+ "os": "ubuntu-24.04",
+ "tools": {"python": "3.12"},
+ "jobs": {
+ "build": {
+ "html": ["echo build html"],
+ },
+ "post_build": ["echo end of build"],
+ },
+ },
+ },
+ source_file="readthedocs.yml",
+ )
+ config.validate()
+ load_yaml_config.return_value = config
+ self._trigger_update_docs_task()
+
+ python_version = settings.RTD_DOCKER_BUILD_SETTINGS["tools"]["python"]["3.12"]
+ self.mocker.mocks["environment.run"].assert_has_calls(
+ [
+ mock.call("asdf", "install", "python", python_version),
+ mock.call("asdf", "global", "python", python_version),
+ mock.call("asdf", "reshim", "python", record=False),
+ mock.call(
+ "python",
+ "-mpip",
+ "install",
+ "-U",
+ "virtualenv",
+ "setuptools",
+ ),
+ mock.call(
+ "echo build html",
+ escape_command=False,
+ cwd=mock.ANY,
+ ),
+ mock.call(
+ "echo end of build",
+ escape_command=False,
+ cwd=mock.ANY,
+ ),
+ ]
+ )
+
+ @mock.patch("readthedocs.doc_builder.director.load_yaml_config")
+ def test_build_jobs_partial_build_override_sphinx(self, load_yaml_config):
+ config = BuildConfigV2(
+ {
+ "version": 2,
+ "sphinx": {
+ "configuration": "docs/conf.py",
+ },
+ "build": {
+ "os": "ubuntu-24.04",
+ "tools": {"python": "3.12"},
+ "jobs": {
+ "build": {
+ "html": ["echo build html"],
+ },
+ "post_build": ["echo end of build"],
+ },
+ },
+ },
+ source_file="readthedocs.yml",
+ )
+ config.validate()
+ load_yaml_config.return_value = config
+ self._trigger_update_docs_task()
+
+ python_version = settings.RTD_DOCKER_BUILD_SETTINGS["tools"]["python"]["3.12"]
+ self.mocker.mocks["environment.run"].assert_has_calls(
+ [
+ mock.call("asdf", "install", "python", python_version),
+ mock.call("asdf", "global", "python", python_version),
+ mock.call("asdf", "reshim", "python", record=False),
+ mock.call(
+ "python",
+ "-mpip",
+ "install",
+ "-U",
+ "virtualenv",
+ "setuptools",
+ ),
+ mock.call(
+ "python",
+ "-mvirtualenv",
+ "$READTHEDOCS_VIRTUALENV_PATH",
+ bin_path=None,
+ cwd=None,
+ ),
+ mock.call(
+ mock.ANY,
+ "-m",
+ "pip",
+ "install",
+ "--upgrade",
+ "--no-cache-dir",
+ "pip",
+ "setuptools",
+ bin_path=mock.ANY,
+ cwd=mock.ANY,
+ ),
+ mock.call(
+ mock.ANY,
+ "-m",
+ "pip",
+ "install",
+ "--upgrade",
+ "--no-cache-dir",
+ "sphinx",
+ bin_path=mock.ANY,
+ cwd=mock.ANY,
+ ),
+ mock.call(
+ "echo build html",
+ escape_command=False,
+ cwd=mock.ANY,
+ ),
+ mock.call(
+ "echo end of build",
+ escape_command=False,
+ cwd=mock.ANY,
+ ),
+ ]
+ )
+
+ @mock.patch("readthedocs.doc_builder.director.load_yaml_config")
+ def test_build_jobs_partial_build_override_mkdocs(self, load_yaml_config):
+ config = BuildConfigV2(
+ {
+ "version": 2,
+ "formats": ["pdf", "epub", "htmlzip"],
+ "mkdocs": {
+ "configuration": "mkdocs.yml",
+ },
+ "build": {
+ "os": "ubuntu-24.04",
+ "tools": {"python": "3.12"},
+ "jobs": {
+ "build": {
+ "html": ["echo build html"],
+ },
+ "post_build": ["echo end of build"],
+ },
+ },
+ },
+ source_file="readthedocs.yml",
+ )
+ config.validate()
+ load_yaml_config.return_value = config
+ self._trigger_update_docs_task()
+
+ python_version = settings.RTD_DOCKER_BUILD_SETTINGS["tools"]["python"]["3.12"]
+ self.mocker.mocks["environment.run"].assert_has_calls(
+ [
+ mock.call("asdf", "install", "python", python_version),
+ mock.call("asdf", "global", "python", python_version),
+ mock.call("asdf", "reshim", "python", record=False),
+ mock.call(
+ "python",
+ "-mpip",
+ "install",
+ "-U",
+ "virtualenv",
+ "setuptools",
+ ),
+ mock.call(
+ "python",
+ "-mvirtualenv",
+ "$READTHEDOCS_VIRTUALENV_PATH",
+ bin_path=None,
+ cwd=None,
+ ),
+ mock.call(
+ mock.ANY,
+ "-m",
+ "pip",
+ "install",
+ "--upgrade",
+ "--no-cache-dir",
+ "pip",
+ "setuptools",
+ bin_path=mock.ANY,
+ cwd=mock.ANY,
+ ),
+ mock.call(
+ mock.ANY,
+ "-m",
+ "pip",
+ "install",
+ "--upgrade",
+ "--no-cache-dir",
+ "mkdocs",
+ bin_path=mock.ANY,
+ cwd=mock.ANY,
+ ),
+ mock.call(
+ "echo build html",
+ escape_command=False,
+ cwd=mock.ANY,
+ ),
+ mock.call(
+ "echo end of build",
+ escape_command=False,
+ cwd=mock.ANY,
+ ),
+ ]
+ )
+
@mock.patch("readthedocs.doc_builder.director.load_yaml_config")
def test_build_jobs_partial_build_override_empty_commands(self, load_yaml_config):
config = BuildConfigV2(
diff --git a/readthedocs/rtd_tests/fixtures/spec/v2/schema.json b/readthedocs/rtd_tests/fixtures/spec/v2/schema.json
index 716b04e55ef..f11436c0ca6 100644
--- a/readthedocs/rtd_tests/fixtures/spec/v2/schema.json
+++ b/readthedocs/rtd_tests/fixtures/spec/v2/schema.json
@@ -337,7 +337,8 @@
"default": false
}
},
- "additionalProperties": false
+ "additionalProperties": false,
+ "required": ["configuration"]
},
"mkdocs": {
"title": "mkdocs",
@@ -356,7 +357,8 @@
"default": false
}
},
- "additionalProperties": false
+ "additionalProperties": false,
+ "required": ["configuration"]
},
"submodules": {
"title": "Submodules",