Skip to content

Commit 0d7643a

Browse files
authored
Config file: use pydantic for all config models (#11798)
We aren't using the whole power of pydantic, but it's a good start.
1 parent 4767592 commit 0d7643a

File tree

3 files changed

+56
-91
lines changed

3 files changed

+56
-91
lines changed

readthedocs/config/config.py

Lines changed: 4 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@
77
from functools import lru_cache
88

99
from django.conf import settings
10+
from pydantic import BaseModel
1011

11-
from readthedocs.config.utils import list_to_dict, to_dict
12+
from readthedocs.config.utils import list_to_dict
1213
from readthedocs.core.utils.filesystem import safe_open
1314
from readthedocs.projects.constants import GENERIC
1415

@@ -23,7 +24,6 @@
2324
Mkdocs,
2425
Python,
2526
PythonInstall,
26-
PythonInstallRequirements,
2727
Search,
2828
Sphinx,
2929
Submodules,
@@ -207,7 +207,7 @@ def as_dict(self):
207207
config = {}
208208
for name in self.PUBLIC_ATTRIBUTES:
209209
attr = getattr(self, name)
210-
config[name] = to_dict(attr)
210+
config[name] = attr.model_dump() if isinstance(attr, BaseModel) else attr
211211
return config
212212

213213
def __getattr__(self, name):
@@ -793,21 +793,7 @@ def build(self):
793793

794794
@property
795795
def python(self):
796-
python_install = []
797-
python = self._config["python"]
798-
for install in python["install"]:
799-
if "requirements" in install:
800-
python_install.append(
801-
PythonInstallRequirements(**install),
802-
)
803-
elif "path" in install:
804-
python_install.append(
805-
PythonInstall(**install),
806-
)
807-
808-
return Python(
809-
install=python_install,
810-
)
796+
return Python(**self._config["python"])
811797

812798
@property
813799
def sphinx(self):

readthedocs/config/models.py

Lines changed: 52 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,59 +1,33 @@
1-
"""Models for the response of the configuration object."""
2-
from pydantic import BaseModel
3-
4-
from readthedocs.config.utils import to_dict
5-
6-
7-
class Base:
8-
9-
"""
10-
Base class for every configuration.
11-
12-
Each inherited class should define
13-
its attributes in the `__slots__` attribute.
14-
15-
We are using `__slots__` so we can't add more attributes by mistake,
16-
this is similar to a namedtuple.
17-
"""
18-
19-
def __init__(self, **kwargs):
20-
for name in self.__slots__:
21-
setattr(self, name, kwargs[name])
1+
"""
2+
Models for the response of the configuration object.
223
23-
def as_dict(self):
24-
return {name: to_dict(getattr(self, name)) for name in self.__slots__}
4+
We make use of pydantic to define the models/dataclasses for all the
5+
options that the user can define in the configuration file.
256
7+
Pydantic does runtime type checking and validation,
8+
but we aren't using it yet, and instead we are doing the validation
9+
in a separate step.
10+
"""
11+
from typing import Literal
2612

27-
# TODO: rename this class to `Build`
28-
class BuildWithOs(Base):
29-
__slots__ = ("os", "tools", "jobs", "apt_packages", "commands")
30-
31-
def __init__(self, **kwargs):
32-
kwargs.setdefault("apt_packages", [])
33-
kwargs.setdefault("commands", [])
34-
super().__init__(**kwargs)
13+
from pydantic import BaseModel
3514

3615

37-
class BuildTool(Base):
38-
__slots__ = ("version", "full_version")
16+
class BuildTool(BaseModel):
17+
version: str
18+
full_version: str
3919

4020

4121
class BuildJobsBuildTypes(BaseModel):
42-
4322
"""Object used for `build.jobs.build` key."""
4423

4524
html: list[str] | None = None
4625
pdf: list[str] | None = None
4726
epub: list[str] | None = None
4827
htmlzip: list[str] | None = None
4928

50-
def as_dict(self):
51-
# Just to keep compatibility with the old implementation.
52-
return self.model_dump()
53-
5429

5530
class BuildJobs(BaseModel):
56-
5731
"""Object used for `build.jobs` key."""
5832

5933
pre_checkout: list[str] = []
@@ -70,42 +44,58 @@ class BuildJobs(BaseModel):
7044
build: BuildJobsBuildTypes = BuildJobsBuildTypes()
7145
post_build: list[str] = []
7246

73-
def as_dict(self):
74-
# Just to keep compatibility with the old implementation.
75-
return self.model_dump()
47+
48+
# TODO: rename this class to `Build`
49+
class BuildWithOs(BaseModel):
50+
os: str
51+
tools: dict[str, BuildTool]
52+
jobs: BuildJobs = BuildJobs()
53+
apt_packages: list[str] = []
54+
commands: list[str] = []
7655

7756

78-
class Python(Base):
79-
__slots__ = ("install",)
57+
class PythonInstallRequirements(BaseModel):
58+
requirements: str
8059

8160

82-
class PythonInstallRequirements(Base):
83-
__slots__ = ("requirements",)
61+
class PythonInstall(BaseModel):
62+
path: str
63+
method: Literal["pip", "setuptools"] = "pip"
64+
extra_requirements: list[str] = []
8465

8566

86-
class PythonInstall(Base):
87-
__slots__ = (
88-
"path",
89-
"method",
90-
"extra_requirements",
91-
)
67+
class Python(BaseModel):
68+
install: list[PythonInstall | PythonInstallRequirements] = []
9269

9370

94-
class Conda(Base):
95-
__slots__ = ("environment",)
71+
class Conda(BaseModel):
72+
environment: str
9673

9774

98-
class Sphinx(Base):
99-
__slots__ = ("builder", "configuration", "fail_on_warning")
75+
class Sphinx(BaseModel):
76+
configuration: str | None
77+
# NOTE: This is how we save the object in the DB,
78+
# the actual options for users are "html", "htmldir", "singlehtml".
79+
builder: Literal["sphinx", "sphinx_htmldir", "sphinx_singlehtml"] = "sphinx"
80+
fail_on_warning: bool = False
10081

10182

102-
class Mkdocs(Base):
103-
__slots__ = ("configuration", "fail_on_warning")
83+
class Mkdocs(BaseModel):
84+
configuration: str | None
85+
fail_on_warning: bool = False
10486

10587

106-
class Submodules(Base):
107-
__slots__ = ("include", "exclude", "recursive")
88+
class Submodules(BaseModel):
89+
include: list[str] | Literal["all"] = []
90+
exclude: list[str] | Literal["all"] = []
91+
recursive: bool = False
10892

10993

110-
class Search(Base):
111-
__slots__ = ("ranking", "ignore")
94+
class Search(BaseModel):
95+
ranking: dict[str, int] = {}
96+
ignore: list[str] = [
97+
"search.html",
98+
"search/index.html",
99+
"404.html",
100+
"404/index.html",
101+
]

readthedocs/config/utils.py

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,6 @@
11
"""Shared functions for the config module."""
22

33

4-
def to_dict(value):
5-
"""Recursively transform a class from `config.models` to a dict."""
6-
if hasattr(value, "as_dict"):
7-
return value.as_dict()
8-
if isinstance(value, list):
9-
return [to_dict(v) for v in value]
10-
if isinstance(value, dict):
11-
return {k: to_dict(v) for k, v in value.items()}
12-
return value
13-
14-
154
def list_to_dict(list_):
165
"""Transform a list to a dictionary with its indices as keys."""
176
dict_ = {str(i): element for i, element in enumerate(list_)}

0 commit comments

Comments
 (0)