Skip to content

Commit ebe3b2c

Browse files
authored
Build: allow partial override of build steps (#11710)
I was replacing some of the config models with dataclasses, but I found myself re-implementing some helpers that pydantic provides, so I'm introducing that new dep, it has everything we need, we may be able to use it to simplify our validation once all models are migrated to pydantic. ### About incompatible options I decided to just not allow formats when `build.jobs.build` is used, seems just easier that way. But not sure if users may want to just override the html build step while still using the default build pdf step, if that's the case, we may need to support using formats... or have another way of saying "use the default". build.jobs.create_environment is kind of incompatible with python/conda. Since users will need to manually create the environment, but they may still want to use the defaults from build.jobs.install, or maybe they can't even use the defaults, as they are really tied to the default create_environment step. Which bring us to the next point, if build.jobs.create_environment is overridden, users will likely need to override build.jobs.install and build.jobs.build as well... Maybe just save the user some time and require them to override all of them? Or maybe just let them figure it out by themselves with the errors? We could gather more information once we see some real usage of this... ### Override them all I chose to make build.html required if users override that step, seems logical to do so. ~~Do we want to allow users to build all formats? I'm starting with html and pdf, that seem the most used. But shouldn't be a problem to support all of them. I guess my only question would be about naming, `htmlzip` has always been a weird name, maybe just zip?~~ I just went ahead and allowed all, with the same name as formats. ### Docs I didn't add docs yet because there is the question... should we just expose this to users? Or maybe just test it internally for now? Closes #11551
1 parent b50b629 commit ebe3b2c

File tree

15 files changed

+494
-96
lines changed

15 files changed

+494
-96
lines changed

.circleci/config.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ jobs:
8181
- restore_cache:
8282
keys:
8383
- pre-commit-cache-{{ checksum "pre-commit-cache-key.txt" }}
84-
- run: pip install --user 'tox<5'
84+
- run: pip install --user tox
8585
- run: tox -e pre-commit
8686
- run: tox -e migrations
8787
- node/install:

readthedocs/config/config.py

+34-4
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from .find import find_one
1717
from .models import (
1818
BuildJobs,
19+
BuildJobsBuildTypes,
1920
BuildTool,
2021
BuildWithOs,
2122
Conda,
@@ -318,11 +319,9 @@ def validate_build_config_with_os(self):
318319
# ones, we could validate the value of each of them is a list of
319320
# commands. However, I don't think we should validate the "command"
320321
# looks like a real command.
322+
valid_jobs = list(BuildJobs.model_fields.keys())
321323
for job in jobs.keys():
322-
validate_choice(
323-
job,
324-
BuildJobs.__slots__,
325-
)
324+
validate_choice(job, valid_jobs)
326325

327326
commands = []
328327
with self.catch_validation_error("build.commands"):
@@ -346,6 +345,14 @@ def validate_build_config_with_os(self):
346345
)
347346

348347
build["jobs"] = {}
348+
349+
with self.catch_validation_error("build.jobs.build"):
350+
build["jobs"]["build"] = self.validate_build_jobs_build(jobs)
351+
# Remove the build.jobs.build key from the build.jobs dict,
352+
# since it's the only key that should be a dictionary,
353+
# it was already validated above.
354+
jobs.pop("build", None)
355+
349356
for job, job_commands in jobs.items():
350357
with self.catch_validation_error(f"build.jobs.{job}"):
351358
build["jobs"][job] = [
@@ -370,6 +377,29 @@ def validate_build_config_with_os(self):
370377
build["apt_packages"] = self.validate_apt_packages()
371378
return build
372379

380+
def validate_build_jobs_build(self, build_jobs):
381+
result = {}
382+
build_jobs_build = build_jobs.get("build", {})
383+
validate_dict(build_jobs_build)
384+
385+
allowed_build_types = list(BuildJobsBuildTypes.model_fields.keys())
386+
for build_type, build_commands in build_jobs_build.items():
387+
validate_choice(build_type, allowed_build_types)
388+
if build_type != "html" and build_type not in self.formats:
389+
raise ConfigError(
390+
message_id=ConfigError.BUILD_JOBS_BUILD_TYPE_MISSING_IN_FORMATS,
391+
format_values={
392+
"build_type": build_type,
393+
},
394+
)
395+
with self.catch_validation_error(f"build.jobs.build.{build_type}"):
396+
result[build_type] = [
397+
validate_string(build_command)
398+
for build_command in validate_list(build_commands)
399+
]
400+
401+
return result
402+
373403
def validate_apt_packages(self):
374404
apt_packages = []
375405
with self.catch_validation_error("build.apt_packages"):

readthedocs/config/exceptions.py

+3
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ class ConfigError(BuildUserError):
1313
INVALID_VERSION = "config:base:invalid-version"
1414
NOT_BUILD_TOOLS_OR_COMMANDS = "config:build:missing-build-tools-commands"
1515
BUILD_JOBS_AND_COMMANDS = "config:build:jobs-and-commands"
16+
BUILD_JOBS_BUILD_TYPE_MISSING_IN_FORMATS = (
17+
"config:build:jobs:build:missing-in-formats"
18+
)
1619
APT_INVALID_PACKAGE_NAME_PREFIX = "config:apt:invalid-package-name-prefix"
1720
APT_INVALID_PACKAGE_NAME = "config:apt:invalid-package-name"
1821
USE_PIP_FOR_EXTRA_REQUIREMENTS = "config:python:pip-required"

readthedocs/config/models.py

+32-23
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
"""Models for the response of the configuration object."""
2+
from pydantic import BaseModel
23

34
from readthedocs.config.utils import to_dict
45

@@ -37,33 +38,41 @@ class BuildTool(Base):
3738
__slots__ = ("version", "full_version")
3839

3940

40-
class BuildJobs(Base):
41+
class BuildJobsBuildTypes(BaseModel):
42+
43+
"""Object used for `build.jobs.build` key."""
44+
45+
html: list[str] | None = None
46+
pdf: list[str] | None = None
47+
epub: list[str] | None = None
48+
htmlzip: list[str] | None = None
49+
50+
def as_dict(self):
51+
# Just to keep compatibility with the old implementation.
52+
return self.model_dump()
53+
54+
55+
class BuildJobs(BaseModel):
4156

4257
"""Object used for `build.jobs` key."""
4358

44-
__slots__ = (
45-
"pre_checkout",
46-
"post_checkout",
47-
"pre_system_dependencies",
48-
"post_system_dependencies",
49-
"pre_create_environment",
50-
"post_create_environment",
51-
"pre_install",
52-
"post_install",
53-
"pre_build",
54-
"post_build",
55-
)
59+
pre_checkout: list[str] = []
60+
post_checkout: list[str] = []
61+
pre_system_dependencies: list[str] = []
62+
post_system_dependencies: list[str] = []
63+
pre_create_environment: list[str] = []
64+
create_environment: list[str] | None = None
65+
post_create_environment: list[str] = []
66+
pre_install: list[str] = []
67+
install: list[str] | None = None
68+
post_install: list[str] = []
69+
pre_build: list[str] = []
70+
build: BuildJobsBuildTypes = BuildJobsBuildTypes()
71+
post_build: list[str] = []
5672

57-
def __init__(self, **kwargs):
58-
"""
59-
Create an empty list as a default for all possible builds.jobs configs.
60-
61-
This is necessary because it makes the code cleaner when we add items to these lists,
62-
without having to check for a dict to be created first.
63-
"""
64-
for step in self.__slots__:
65-
kwargs.setdefault(step, [])
66-
super().__init__(**kwargs)
73+
def as_dict(self):
74+
# Just to keep compatibility with the old implementation.
75+
return self.model_dump()
6776

6877

6978
class Python(Base):

readthedocs/config/notifications.py

+12
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,18 @@
124124
),
125125
type=ERROR,
126126
),
127+
Message(
128+
id=ConfigError.BUILD_JOBS_BUILD_TYPE_MISSING_IN_FORMATS,
129+
header=_("Invalid configuration option"),
130+
body=_(
131+
textwrap.dedent(
132+
"""
133+
The <code>{{ build_type }}</code> build type was defined in <code>build.jobs.build</code>, but it wasn't included in <code>formats</code>.
134+
"""
135+
).strip(),
136+
),
137+
type=ERROR,
138+
),
127139
Message(
128140
id=ConfigError.APT_INVALID_PACKAGE_NAME_PREFIX,
129141
header=_("Invalid APT package name"),

readthedocs/config/tests/test_config.py

+112
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from readthedocs.config.exceptions import ConfigError, ConfigValidationError
1515
from readthedocs.config.models import (
1616
BuildJobs,
17+
BuildJobsBuildTypes,
1718
BuildWithOs,
1819
PythonInstall,
1920
PythonInstallRequirements,
@@ -627,17 +628,120 @@ def test_jobs_build_config(self):
627628
assert build.build.jobs.pre_create_environment == [
628629
"echo pre_create_environment"
629630
]
631+
assert build.build.jobs.create_environment is None
630632
assert build.build.jobs.post_create_environment == [
631633
"echo post_create_environment"
632634
]
633635
assert build.build.jobs.pre_install == ["echo pre_install", "echo `date`"]
636+
assert build.build.jobs.install is None
634637
assert build.build.jobs.post_install == ["echo post_install"]
635638
assert build.build.jobs.pre_build == [
636639
"echo pre_build",
637640
'sed -i -e "s|{VERSION}|${READTHEDOCS_VERSION_NAME}|g"',
638641
]
642+
assert build.build.jobs.build == BuildJobsBuildTypes()
639643
assert build.build.jobs.post_build == ["echo post_build"]
640644

645+
def test_build_jobs_partial_override(self):
646+
build = get_build_config(
647+
{
648+
"formats": ["pdf", "htmlzip", "epub"],
649+
"build": {
650+
"os": "ubuntu-20.04",
651+
"tools": {"python": "3"},
652+
"jobs": {
653+
"create_environment": ["echo make_environment"],
654+
"install": ["echo install"],
655+
"build": {
656+
"html": ["echo build html"],
657+
"pdf": ["echo build pdf"],
658+
"epub": ["echo build epub"],
659+
"htmlzip": ["echo build htmlzip"],
660+
},
661+
},
662+
},
663+
},
664+
)
665+
build.validate()
666+
assert isinstance(build.build, BuildWithOs)
667+
assert isinstance(build.build.jobs, BuildJobs)
668+
assert build.build.jobs.create_environment == ["echo make_environment"]
669+
assert build.build.jobs.install == ["echo install"]
670+
assert build.build.jobs.build.html == ["echo build html"]
671+
assert build.build.jobs.build.pdf == ["echo build pdf"]
672+
assert build.build.jobs.build.epub == ["echo build epub"]
673+
assert build.build.jobs.build.htmlzip == ["echo build htmlzip"]
674+
675+
def test_build_jobs_build_should_match_formats(self):
676+
build = get_build_config(
677+
{
678+
"formats": ["pdf"],
679+
"build": {
680+
"os": "ubuntu-24.04",
681+
"tools": {"python": "3"},
682+
"jobs": {
683+
"build": {
684+
"epub": ["echo build epub"],
685+
},
686+
},
687+
},
688+
},
689+
)
690+
with raises(ConfigError) as excinfo:
691+
build.validate()
692+
assert (
693+
excinfo.value.message_id
694+
== ConfigError.BUILD_JOBS_BUILD_TYPE_MISSING_IN_FORMATS
695+
)
696+
697+
def test_build_jobs_build_defaults(self):
698+
build = get_build_config(
699+
{
700+
"build": {
701+
"os": "ubuntu-24.04",
702+
"tools": {"python": "3"},
703+
"jobs": {
704+
"build": {
705+
"html": ["echo build html"],
706+
},
707+
},
708+
},
709+
},
710+
)
711+
build.validate()
712+
assert build.build.jobs.build.html == ["echo build html"]
713+
assert build.build.jobs.build.pdf is None
714+
assert build.build.jobs.build.htmlzip is None
715+
assert build.build.jobs.build.epub is None
716+
717+
def test_build_jobs_partial_override_empty_commands(self):
718+
build = get_build_config(
719+
{
720+
"formats": ["pdf"],
721+
"build": {
722+
"os": "ubuntu-24.04",
723+
"tools": {"python": "3"},
724+
"jobs": {
725+
"create_environment": [],
726+
"install": [],
727+
"build": {
728+
"html": [],
729+
"pdf": [],
730+
},
731+
},
732+
},
733+
},
734+
)
735+
build.validate()
736+
assert isinstance(build.build, BuildWithOs)
737+
assert isinstance(build.build.jobs, BuildJobs)
738+
assert build.build.jobs.create_environment == []
739+
assert build.build.jobs.install == []
740+
assert build.build.jobs.build.html == []
741+
assert build.build.jobs.build.pdf == []
742+
assert build.build.jobs.build.epub == None
743+
assert build.build.jobs.build.htmlzip == None
744+
641745
@pytest.mark.parametrize(
642746
"value",
643747
[
@@ -1757,10 +1861,18 @@ def test_as_dict_new_build_config(self, tmpdir):
17571861
"pre_system_dependencies": [],
17581862
"post_system_dependencies": [],
17591863
"pre_create_environment": [],
1864+
"create_environment": None,
17601865
"post_create_environment": [],
17611866
"pre_install": [],
1867+
"install": None,
17621868
"post_install": [],
17631869
"pre_build": [],
1870+
"build": {
1871+
"html": None,
1872+
"pdf": None,
1873+
"epub": None,
1874+
"htmlzip": None,
1875+
},
17641876
"post_build": [],
17651877
},
17661878
"apt_packages": [],

readthedocs/core/utils/objects.py

+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Sentinel value to check if a default value was provided,
2+
# so we can differentiate when None is provided as a default value
3+
# and when it was not provided at all.
4+
_DEFAULT = object()
5+
6+
7+
def get_dotted_attribute(obj, attribute, default=_DEFAULT):
8+
"""
9+
Allow to get nested attributes from an object using a dot notation.
10+
11+
This behaves similar to getattr, but allows to get nested attributes.
12+
Similar, if a default value is provided, it will be returned if the
13+
attribute is not found, otherwise it will raise an AttributeError.
14+
"""
15+
for attr in attribute.split("."):
16+
if hasattr(obj, attr):
17+
obj = getattr(obj, attr)
18+
elif default is not _DEFAULT:
19+
return default
20+
else:
21+
raise AttributeError(f"Object {obj} has no attribute {attr}")
22+
return obj

0 commit comments

Comments
 (0)