Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
35 changes: 35 additions & 0 deletions readthedocs/builds/migrations/0066_add_buildconfig_model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# 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, 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'),
),
]
44 changes: 44 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,25 @@ 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}
# Clear readthedocs_yaml_data when using reference style
self.readthedocs_yaml_data = None
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
160 changes: 160 additions & 0 deletions readthedocs/builds/tests/test_buildconfig.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
"""Tests for BuildConfig model and Build.readthedocs_yaml_data field."""

import django_dynamic_fixture as fixture
import pytest
from django.db import IntegrityError

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 TestBuildConfig:
"""Test BuildConfig model functionality."""

def test_buildconfig_creation(self):
"""Test that BuildConfig can be created with data."""
config_data = {"build": {"os": "ubuntu-22.04"}, "python": {"version": "3.11"}}
build_config = BuildConfig.objects.create(data=config_data)

assert build_config.pk is not None
assert build_config.data == config_data

def test_buildconfig_unique_constraint(self):
"""Test that BuildConfig enforces unique constraint on data."""
config_data = {"build": {"os": "ubuntu-22.04"}, "python": {"version": "3.11"}}

# Create first BuildConfig
BuildConfig.objects.create(data=config_data)

# Try to create another with the same data - should raise IntegrityError
with pytest.raises(IntegrityError):
BuildConfig.objects.create(data=config_data)

def test_buildconfig_get_or_create(self):
"""Test that get_or_create works correctly for deduplication."""
config_data = {"build": {"os": "ubuntu-22.04"}, "python": {"version": "3.11"}}

# First call creates
build_config1, created1 = BuildConfig.objects.get_or_create(data=config_data)
assert created1 is True

# Second call gets existing
build_config2, created2 = BuildConfig.objects.get_or_create(data=config_data)
assert created2 is False
assert build_config1.pk == build_config2.pk


@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 is not None
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) doesn't create a new 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
# build1 should have created a BuildConfig
assert build1.readthedocs_yaml_data is not None
# build2 should not create a new BuildConfig since it uses reference style
assert build2.readthedocs_yaml_data is None
# There should only be one BuildConfig created
assert BuildConfig.objects.count() == 1

def test_buildconfig_related_builds(self):
"""Test that BuildConfig.builds related manager works."""
project = fixture.get(Project)
config_data = {"build": {"os": "ubuntu-22.04"}}

# Create BuildConfig
build_config = BuildConfig.objects.create(data=config_data)

# Create builds that reference it
build1 = fixture.get(Build, project=project, readthedocs_yaml_data=build_config)
build2 = fixture.get(Build, project=project, readthedocs_yaml_data=build_config)

# Check related manager
assert build_config.builds.count() == 2
assert build1 in build_config.builds.all()
assert build2 in build_config.builds.all()