Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
66 changes: 66 additions & 0 deletions readthedocs/builds/migrations/0066_add_buildconfig_model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Generated by Django 5.2.9 on 2025-12-09 17:33

import django.db.models.deletion
import django_extensions.db.fields
from django.db import migrations
from django.db import models
from django_safemigrate import Safe


class Migration(migrations.Migration):
safe = Safe.before_deploy()

dependencies = [
("builds", "0065_task_executed_at"),
]

operations = [
migrations.CreateModel(
name="BuildConfig",
fields=[
(
"id",
models.AutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
(
"created",
django_extensions.db.fields.CreationDateTimeField(
auto_now_add=True, verbose_name="created"
),
),
(
"modified",
django_extensions.db.fields.ModificationDateTimeField(
auto_now=True, verbose_name="modified"
),
),
(
"data",
models.JSONField(
help_text="The rendered YAML configuration used in the build",
unique=True,
verbose_name="Configuration data",
),
),
],
options={
"verbose_name": "Build configuration",
"verbose_name_plural": "Build configurations",
},
),
migrations.AddField(
model_name="build",
name="readthedocs_yaml_data",
field=models.ForeignKey(
blank=True,
help_text="The rendered YAML configuration used in the build",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="builds",
to="builds.buildconfig",
verbose_name="Build configuration data",
),
),
]
53 changes: 53 additions & 0 deletions readthedocs/builds/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -607,6 +607,28 @@ def save(self, *args, **kwargs):
return 0


class BuildConfig(TimeStampedModel):
"""
Build configuration data.

Stores the rendered YAML configuration used in builds.
The unique constraint ensures we don't duplicate identical configs.
"""

data = models.JSONField(
_("Configuration data"),
unique=True,
help_text=_("The rendered YAML configuration used in the build"),
)

class Meta:
verbose_name = _("Build configuration")
verbose_name_plural = _("Build configurations")

def __str__(self):
return f"BuildConfig {self.pk}"


class Build(models.Model):
"""Build data."""

Expand Down Expand Up @@ -698,6 +720,15 @@ class Build(models.Model):
null=True,
blank=True,
)
readthedocs_yaml_data = models.ForeignKey(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we OK with this name? Other alternatives are:

  • readthedocs_yaml (no _data at the end)
  • yaml_config
  • config_yaml
  • config_file

cc @ericholscher what do you think?

"BuildConfig",
verbose_name=_("Build configuration data"),
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="builds",
help_text=_("The rendered YAML configuration used in the build"),
)
readthedocs_yaml_path = models.CharField(
_("Custom build configuration file path used in this build"),
max_length=1024,
Expand Down Expand Up @@ -823,12 +854,34 @@ def save(self, *args, **kwargs): # noqa

If the config is the same, we save the pk of the object
that has the **real** config under the `CONFIG_KEY` key.

Additionally, we create or get a BuildConfig object for the new
readthedocs_yaml_data field to facilitate the migration to the new model.
"""
if self.pk is None or self._config_changed:
previous = self.previous
if previous is not None and self._config and self._config == previous.config:
previous_pk = previous._config.get(self.CONFIG_KEY, previous.pk)
self._config = {self.CONFIG_KEY: previous_pk}
# When using reference style, follow the reference to get the actual config
# and create/get the corresponding BuildConfig
if previous.readthedocs_yaml_data:
# Previous build already has a BuildConfig, reuse it
self.readthedocs_yaml_data = previous.readthedocs_yaml_data
else:
# Previous build doesn't have BuildConfig yet, get its actual config
actual_config = previous.config
if actual_config:
build_config, created = BuildConfig.objects.get_or_create(
data=actual_config
)
self.readthedocs_yaml_data = build_config
elif self._config and self.CONFIG_KEY not in self._config:
# Populate the new readthedocs_yaml_data field
# We only create a BuildConfig when we have actual config data (not a reference)
# Use get_or_create to avoid duplicates and leverage the unique constraint
build_config, created = BuildConfig.objects.get_or_create(data=self._config)
self.readthedocs_yaml_data = build_config

if self.version:
self.version_name = self.version.verbose_name
Expand Down
104 changes: 104 additions & 0 deletions readthedocs/builds/tests/test_buildconfig.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
"""Tests for BuildConfig model and Build.readthedocs_yaml_data field."""

import django_dynamic_fixture as fixture
import pytest

from readthedocs.builds.models import Build
from readthedocs.builds.models import BuildConfig
from readthedocs.builds.models import Version
from readthedocs.projects.models import Project


@pytest.mark.django_db
class TestBuildReadthedocsYamlData:
"""Test Build.readthedocs_yaml_data field and integration."""

def test_build_saves_with_config_creates_buildconfig(self):
"""Test that saving a Build with config creates BuildConfig."""
project = fixture.get(Project)
config_data = {"build": {"os": "ubuntu-22.04"}, "python": {"version": "3.11"}}

build = fixture.get(Build, project=project)
build.config = config_data
build.save()

# Check that both old and new fields are populated
assert build._config == config_data
assert build.readthedocs_yaml_data.data == config_data

def test_build_with_same_config_reuses_buildconfig(self):
"""Test that builds with same config reuse the same BuildConfig."""
project = fixture.get(Project)
config_data = {"build": {"os": "ubuntu-22.04"}, "python": {"version": "3.11"}}

# Create first build
build1 = fixture.get(Build, project=project)
build1.config = config_data
build1.save()

# Create second build with same config
build2 = fixture.get(Build, project=project)
build2.config = config_data
build2.save()

# Both should reference the same BuildConfig
assert build1.readthedocs_yaml_data.pk == build2.readthedocs_yaml_data.pk
assert BuildConfig.objects.count() == 1

def test_build_with_different_config_creates_new_buildconfig(self):
"""Test that builds with different configs create separate BuildConfigs."""
project = fixture.get(Project)
config_data1 = {"build": {"os": "ubuntu-22.04"}, "python": {"version": "3.11"}}
config_data2 = {"build": {"os": "ubuntu-20.04"}, "python": {"version": "3.10"}}

# Create first build
build1 = fixture.get(Build, project=project)
build1.config = config_data1
build1.save()

# Create second build with different config
build2 = fixture.get(Build, project=project)
build2.config = config_data2
build2.save()

# Should have different BuildConfigs
assert build1.readthedocs_yaml_data.pk != build2.readthedocs_yaml_data.pk
assert BuildConfig.objects.count() == 2

def test_build_without_config_does_not_create_buildconfig(self):
"""Test that a Build without config doesn't create a BuildConfig."""
project = fixture.get(Project)
build = fixture.get(Build, project=project)

# Build has no config set
build.save()

assert build._config is None
assert build.readthedocs_yaml_data is None
assert BuildConfig.objects.count() == 0

def test_build_with_config_reference_uses_same_buildconfig(self):
"""Test that a Build with config reference (old style) reuses the same BuildConfig."""
project = fixture.get(Project)
version = fixture.get(Version, project=project)
config_data = {"build": {"os": "ubuntu-22.04"}}

# Create a build with actual config
build1 = fixture.get(Build, project=project, version=version)
build1.config = config_data
build1.save()

# Create a build with same config on the same version
# (which will use the reference style in _config)
build2 = fixture.get(Build, project=project, version=version)
build2.config = config_data
build2.save()

# build2 should have a reference in _config, not actual data
assert Build.CONFIG_KEY in build2._config
# Both builds should have the same BuildConfig
assert build1.readthedocs_yaml_data is not None
assert build2.readthedocs_yaml_data is not None
assert build1.readthedocs_yaml_data.pk == build2.readthedocs_yaml_data.pk
# There should only be one BuildConfig created
assert BuildConfig.objects.count() == 1