Skip to content

Commit 1529e2d

Browse files
authored
Merge pull request #4749 from stsewd/save-config-on-build
Save config on build model
2 parents 064003a + f33d54a commit 1529e2d

File tree

9 files changed

+661
-13
lines changed

9 files changed

+661
-13
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# -*- coding: utf-8 -*-
2+
# Generated by Django 1.11.16 on 2018-11-02 13:24
3+
from __future__ import unicode_literals
4+
5+
from django.db import migrations
6+
import jsonfield.fields
7+
8+
9+
class Migration(migrations.Migration):
10+
11+
dependencies = [
12+
('builds', '0005_remove-version-alias'),
13+
]
14+
15+
operations = [
16+
migrations.AddField(
17+
model_name='build',
18+
name='_config',
19+
field=jsonfield.fields.JSONField(default=dict, verbose_name='Configuration used in the build'),
20+
),
21+
]

readthedocs/builds/models.py

+88
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,12 @@
1717
from django.conf import settings
1818
from django.core.urlresolvers import reverse
1919
from django.db import models
20+
from django.utils import timezone
2021
from django.utils.encoding import python_2_unicode_compatible
2122
from django.utils.translation import ugettext
2223
from django.utils.translation import ugettext_lazy as _
2324
from guardian.shortcuts import assign
25+
from jsonfield import JSONField
2426
from taggit.managers import TaggableManager
2527

2628
from readthedocs.core.utils import broadcast
@@ -128,6 +130,21 @@ def __str__(self):
128130
pk=self.pk,
129131
))
130132

133+
@property
134+
def config(self):
135+
"""
136+
Proxy to the configuration of the build.
137+
138+
:returns: The configuration used in the last successful build.
139+
:rtype: dict
140+
"""
141+
last_build = (
142+
self.builds.filter(state='finished', success=True)
143+
.order_by('-date')
144+
.first()
145+
)
146+
return last_build.config
147+
131148
@property
132149
def commit_name(self):
133150
"""
@@ -450,6 +467,7 @@ class Build(models.Model):
450467
exit_code = models.IntegerField(_('Exit code'), null=True, blank=True)
451468
commit = models.CharField(
452469
_('Commit'), max_length=255, null=True, blank=True)
470+
_config = JSONField(_('Configuration used in the build'), default=dict)
453471

454472
length = models.IntegerField(_('Build Length'), null=True, blank=True)
455473

@@ -463,11 +481,81 @@ class Build(models.Model):
463481

464482
objects = BuildQuerySet.as_manager()
465483

484+
CONFIG_KEY = '__config'
485+
466486
class Meta(object):
467487
ordering = ['-date']
468488
get_latest_by = 'date'
469489
index_together = [['version', 'state', 'type']]
470490

491+
def __init__(self, *args, **kwargs):
492+
super(Build, self).__init__(*args, **kwargs)
493+
self._config_changed = False
494+
495+
@property
496+
def previous(self):
497+
"""
498+
Returns the previous build to the current one.
499+
500+
Matching the project and version.
501+
"""
502+
date = self.date or timezone.now()
503+
if self.project is not None and self.version is not None:
504+
return (
505+
Build.objects
506+
.filter(
507+
project=self.project,
508+
version=self.version,
509+
date__lt=date,
510+
)
511+
.order_by('-date')
512+
.first()
513+
)
514+
return None
515+
516+
@property
517+
def config(self):
518+
"""
519+
Get the config used for this build.
520+
521+
Since we are saving the config into the JSON field only when it differs
522+
from the previous one, this helper returns the correct JSON used in
523+
this Build object (it could be stored in this object or one of the
524+
previous ones).
525+
"""
526+
if self.CONFIG_KEY in self._config:
527+
return Build.objects.get(pk=self._config[self.CONFIG_KEY])._config
528+
return self._config
529+
530+
@config.setter
531+
def config(self, value):
532+
"""
533+
Set `_config` to value.
534+
535+
`_config` should never be set directly from outside the class.
536+
"""
537+
self._config = value
538+
self._config_changed = True
539+
540+
def save(self, *args, **kwargs): # noqa
541+
"""
542+
Save object.
543+
544+
To save space on the db we only save the config if it's different
545+
from the previous one.
546+
547+
If the config is the same, we save the pk of the object
548+
that has the **real** config under the `CONFIG_KEY` key.
549+
"""
550+
if self.pk is None or self._config_changed:
551+
previous = self.previous
552+
if (previous is not None and
553+
self._config and self._config == previous.config):
554+
previous_pk = previous._config.get(self.CONFIG_KEY, previous.pk)
555+
self._config = {self.CONFIG_KEY: previous_pk}
556+
super(Build, self).save(*args, **kwargs)
557+
self._config_changed = False
558+
471559
def __str__(self):
472560
return ugettext(
473561
'Build {project} for {usernames} ({pk})'.format(

readthedocs/config/config.py

+15
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,11 @@ class BuildConfigBase(object):
142142
from another source (like the web admin).
143143
"""
144144

145+
PUBLIC_ATTRIBUTES = [
146+
'version', 'formats', 'python',
147+
'conda', 'build', 'doctype',
148+
'sphinx', 'mkdocs', 'submodules',
149+
]
145150
version = None
146151

147152
def __init__(self, env_config, raw_config, source_file, source_position):
@@ -248,6 +253,16 @@ def python_full_version(self):
248253
)
249254
return ver
250255

256+
def as_dict(self):
257+
config = {}
258+
for name in self.PUBLIC_ATTRIBUTES:
259+
attr = getattr(self, name)
260+
if hasattr(attr, '_asdict'):
261+
config[name] = attr._asdict()
262+
else:
263+
config[name] = attr
264+
return config
265+
251266
def __getattr__(self, name):
252267
"""Raise an error for unknown attributes."""
253268
raise ConfigOptionNotSupportedError(name)

readthedocs/config/tests/test_config.py

+98
Original file line numberDiff line numberDiff line change
@@ -840,6 +840,60 @@ def test_config_filenames_regex(correct_config_filename):
840840
assert re.match(CONFIG_FILENAME_REGEX, correct_config_filename)
841841

842842

843+
def test_as_dict(tmpdir):
844+
apply_fs(tmpdir, {'requirements.txt': ''})
845+
build = get_build_config(
846+
{
847+
'version': 1,
848+
'formats': ['pdf'],
849+
'python': {
850+
'version': 3.5,
851+
},
852+
'requirements_file': 'requirements.txt',
853+
},
854+
get_env_config({
855+
'defaults': {
856+
'doctype': 'sphinx',
857+
'sphinx_configuration': None,
858+
},
859+
}),
860+
source_file=str(tmpdir.join('readthedocs.yml')),
861+
)
862+
build.validate()
863+
expected_dict = {
864+
'version': '1',
865+
'formats': ['pdf'],
866+
'python': {
867+
'version': 3.5,
868+
'requirements': 'requirements.txt',
869+
'install_with_pip': False,
870+
'install_with_setup': False,
871+
'extra_requirements': [],
872+
'use_system_site_packages': False,
873+
},
874+
'build': {
875+
'image': 'readthedocs/build:2.0',
876+
},
877+
'conda': None,
878+
'sphinx': {
879+
'builder': 'sphinx',
880+
'configuration': None,
881+
'fail_on_warning': False,
882+
},
883+
'mkdocs': {
884+
'configuration': None,
885+
'fail_on_warning': False,
886+
},
887+
'doctype': 'sphinx',
888+
'submodules': {
889+
'include': ALL,
890+
'exclude': [],
891+
'recursive': True,
892+
},
893+
}
894+
assert build.as_dict() == expected_dict
895+
896+
843897
class TestBuildConfigV2(object):
844898

845899
def get_build_config(self, config, env_config=None,
@@ -1811,3 +1865,47 @@ def test_pop_config_raise_exception(self):
18111865
build.pop_config('one.four', raise_ex=True)
18121866
assert excinfo.value.value == 'four'
18131867
assert excinfo.value.code == VALUE_NOT_FOUND
1868+
1869+
def test_as_dict(self, tmpdir):
1870+
apply_fs(tmpdir, {'requirements.txt': ''})
1871+
build = self.get_build_config(
1872+
{
1873+
'version': 2,
1874+
'formats': ['pdf'],
1875+
'python': {
1876+
'version': 3.6,
1877+
'requirements': 'requirements.txt',
1878+
},
1879+
},
1880+
source_file=str(tmpdir.join('readthedocs.yml')),
1881+
)
1882+
build.validate()
1883+
expected_dict = {
1884+
'version': '2',
1885+
'formats': ['pdf'],
1886+
'python': {
1887+
'version': 3.6,
1888+
'requirements': str(tmpdir.join('requirements.txt')),
1889+
'install_with_pip': False,
1890+
'install_with_setup': False,
1891+
'extra_requirements': [],
1892+
'use_system_site_packages': False,
1893+
},
1894+
'build': {
1895+
'image': 'readthedocs/build:latest',
1896+
},
1897+
'conda': None,
1898+
'sphinx': {
1899+
'builder': 'sphinx',
1900+
'configuration': None,
1901+
'fail_on_warning': False,
1902+
},
1903+
'mkdocs': None,
1904+
'doctype': 'sphinx',
1905+
'submodules': {
1906+
'include': [],
1907+
'exclude': ALL,
1908+
'recursive': False,
1909+
},
1910+
}
1911+
assert build.as_dict() == expected_dict

readthedocs/projects/tasks.py

+20-4
Original file line numberDiff line numberDiff line change
@@ -406,8 +406,10 @@ def run_setup(self, record=True):
406406
raise YAMLParseError(
407407
YAMLParseError.GENERIC_WITH_PARSE_EXCEPTION.format(
408408
exception=str(e),
409-
))
409+
)
410+
)
410411

412+
self.save_build_config()
411413
self.additional_vcs_operations()
412414

413415
if self.setup_env.failure or self.config is None:
@@ -531,9 +533,14 @@ def get_build(build_pk):
531533
build = {}
532534
if build_pk:
533535
build = api_v2.build(build_pk).get()
534-
return dict((key, val) for (key, val) in list(build.items())
535-
if key not in ['project', 'version', 'resource_uri',
536-
'absolute_uri'])
536+
private_keys = [
537+
'project', 'version', 'resource_uri', 'absolute_uri'
538+
]
539+
return {
540+
key: val
541+
for key, val in build.items()
542+
if key not in private_keys
543+
}
537544

538545
def setup_vcs(self):
539546
"""
@@ -594,6 +601,15 @@ def set_valid_clone(self):
594601
self.project.has_valid_clone = True
595602
self.version.project.has_valid_clone = True
596603

604+
def save_build_config(self):
605+
"""Save config in the build object."""
606+
pk = self.build['id']
607+
config = self.config.as_dict()
608+
api_v2.build(pk).patch({
609+
'config': config,
610+
})
611+
self.build['config'] = config
612+
597613
def update_app_instances(self, html=False, localmedia=False, search=False,
598614
pdf=False, epub=False):
599615
"""

readthedocs/restapi/serializers.py

+7-2
Original file line numberDiff line numberDiff line change
@@ -112,18 +112,23 @@ class BuildSerializer(serializers.ModelSerializer):
112112
version_slug = serializers.ReadOnlyField(source='version.slug')
113113
docs_url = serializers.ReadOnlyField(source='version.get_absolute_url')
114114
state_display = serializers.ReadOnlyField(source='get_state_display')
115+
# Jsonfield needs an explicit serializer
116+
# https://github.com/dmkoch/django-jsonfield/issues/188#issuecomment-300439829
117+
config = serializers.JSONField(required=False)
115118

116119
class Meta(object):
117120
model = Build
118-
exclude = ('builder',)
121+
# `_config` should be excluded to avoid conflicts with `config`
122+
exclude = ('builder', '_config')
119123

120124

121125
class BuildAdminSerializer(BuildSerializer):
122126

123127
"""Build serializer for display to admin users and build instances."""
124128

125129
class Meta(BuildSerializer.Meta):
126-
exclude = ()
130+
# `_config` should be excluded to avoid conflicts with `config`
131+
exclude = ('_config',)
127132

128133

129134
class SearchIndexSerializer(serializers.Serializer):

0 commit comments

Comments
 (0)