-
-
Notifications
You must be signed in to change notification settings - Fork 3.6k
Save config on build model #4749
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 14 commits
6891db8
b1b3620
27d8251
ab5ea04
5f4fb8c
9d70812
13abeda
98d26a4
660c03a
d29b6d9
7fc1e2b
8f62f03
76929ac
7ade872
6f8becc
07efa1b
715e5ed
dab5860
5aacd63
822b793
8c60760
7ed110d
2ffdbf6
5cce5d8
9d8266f
28cd935
433b709
b88837d
4723705
043f4e6
79fbb8e
f33d54a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,67 @@ | ||
# -*- coding: utf-8 -*- | ||
# Generated by Django 1.9.13 on 2018-10-22 19:37 | ||
from __future__ import unicode_literals | ||
|
||
from django.db import migrations, models | ||
import jsonfield.fields | ||
import readthedocs.builds.version_slug | ||
|
||
|
||
class Migration(migrations.Migration): | ||
|
||
dependencies = [ | ||
('builds', '0004_add-apiversion-proxy-model'), | ||
] | ||
|
||
operations = [ | ||
migrations.AddField( | ||
model_name='build', | ||
name='config', | ||
field=jsonfield.fields.JSONField(default=dict, verbose_name='Configuration used in the build'), | ||
), | ||
migrations.AlterField( | ||
model_name='build', | ||
name='error', | ||
field=models.TextField(blank=True, default='', verbose_name='Error'), | ||
), | ||
migrations.AlterField( | ||
model_name='build', | ||
name='output', | ||
field=models.TextField(blank=True, default='', verbose_name='Output'), | ||
), | ||
migrations.AlterField( | ||
model_name='build', | ||
name='state', | ||
field=models.CharField(choices=[('triggered', 'Triggered'), ('cloning', 'Cloning'), ('installing', 'Installing'), ('building', 'Building'), ('finished', 'Finished')], default='finished', max_length=55, verbose_name='State'), | ||
), | ||
migrations.AlterField( | ||
model_name='build', | ||
name='type', | ||
field=models.CharField(choices=[('html', 'HTML'), ('pdf', 'PDF'), ('epub', 'Epub'), ('man', 'Manpage'), ('dash', 'Dash')], default='html', max_length=55, verbose_name='Type'), | ||
), | ||
migrations.AlterField( | ||
model_name='version', | ||
name='privacy_level', | ||
field=models.CharField(choices=[('public', 'Public'), ('protected', 'Protected'), ('private', 'Private')], default='public', help_text='Level of privacy for this Version.', max_length=20, verbose_name='Privacy Level'), | ||
), | ||
migrations.AlterField( | ||
model_name='version', | ||
name='slug', | ||
field=readthedocs.builds.version_slug.VersionSlugField(db_index=True, max_length=255, populate_from='verbose_name', verbose_name='Slug'), | ||
), | ||
migrations.AlterField( | ||
model_name='version', | ||
name='type', | ||
field=models.CharField(choices=[('branch', 'Branch'), ('tag', 'Tag'), ('unknown', 'Unknown')], default='unknown', max_length=20, verbose_name='Type'), | ||
), | ||
migrations.AlterField( | ||
model_name='versionalias', | ||
name='from_slug', | ||
field=models.CharField(default='', max_length=255, verbose_name='From slug'), | ||
), | ||
migrations.AlterField( | ||
model_name='versionalias', | ||
name='to_slug', | ||
field=models.CharField(blank=True, default='', max_length=255, verbose_name='To slug'), | ||
), | ||
] |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -2,36 +2,57 @@ | |
"""Models for the builds app.""" | ||
|
||
from __future__ import ( | ||
absolute_import, division, print_function, unicode_literals) | ||
absolute_import, | ||
division, | ||
print_function, | ||
unicode_literals, | ||
) | ||
|
||
import logging | ||
import os.path | ||
import re | ||
from builtins import object | ||
from shutil import rmtree | ||
|
||
from builtins import object | ||
from django.conf import settings | ||
from django.core.urlresolvers import reverse | ||
from django.db import models | ||
from django.utils import timezone | ||
from django.utils.encoding import python_2_unicode_compatible | ||
from django.utils.translation import ugettext_lazy as _ | ||
from django.utils.translation import ugettext | ||
from django.utils.translation import ugettext_lazy as _ | ||
from guardian.shortcuts import assign | ||
from jsonfield import JSONField | ||
from taggit.managers import TaggableManager | ||
|
||
from readthedocs.core.utils import broadcast | ||
from readthedocs.projects.constants import ( | ||
BITBUCKET_URL, GITHUB_URL, GITLAB_URL, PRIVACY_CHOICES, PRIVATE) | ||
BITBUCKET_URL, | ||
GITHUB_URL, | ||
GITLAB_URL, | ||
PRIVACY_CHOICES, | ||
PRIVATE, | ||
) | ||
from readthedocs.projects.models import APIProject, Project | ||
|
||
from .constants import ( | ||
BRANCH, BUILD_STATE, BUILD_STATE_FINISHED, BUILD_TYPES, LATEST, | ||
NON_REPOSITORY_VERSIONS, STABLE, TAG, VERSION_TYPES) | ||
BRANCH, | ||
BUILD_STATE, | ||
BUILD_STATE_FINISHED, | ||
BUILD_TYPES, | ||
LATEST, | ||
NON_REPOSITORY_VERSIONS, | ||
STABLE, | ||
TAG, | ||
VERSION_TYPES, | ||
) | ||
from .managers import VersionManager | ||
from .querysets import BuildQuerySet, RelatedBuildQuerySet, VersionQuerySet | ||
from .utils import ( | ||
get_bitbucket_username_repo, get_github_username_repo, | ||
get_gitlab_username_repo) | ||
get_bitbucket_username_repo, | ||
get_github_username_repo, | ||
get_gitlab_username_repo, | ||
) | ||
from .version_slug import VersionSlugField | ||
|
||
DEFAULT_VERSION_PRIVACY_LEVEL = getattr( | ||
|
@@ -109,6 +130,16 @@ def __str__(self): | |
pk=self.pk, | ||
)) | ||
|
||
@property | ||
def config(self): | ||
"""Returns the configuration used in the last successful build.""" | ||
last_build = ( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I wonder how long this query will take. Our build table is pretty large, but perhaps the filtering will make it a bit faster. Feels like something we might want to denormalize if we use it a lot. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We don't use this, for now, we'll see if we can cache this maybe There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This method is only used in the tests. Do we need this at all? Did you add it as a helper for debugging or so? Does it worth to keep it here? Where do you think it will be used? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This will be used on the api and resolver, it'll be used on the second part of this PR |
||
self.builds.filter(state='finished', success=True) | ||
.order_by('-date') | ||
.first() | ||
) | ||
return last_build.get_config() | ||
|
||
@property | ||
def commit_name(self): | ||
""" | ||
|
@@ -448,6 +479,7 @@ class Build(models.Model): | |
exit_code = models.IntegerField(_('Exit code'), null=True, blank=True) | ||
commit = models.CharField( | ||
_('Commit'), max_length=255, null=True, blank=True) | ||
config = JSONField(_('Configuration used in the build'), default=dict) | ||
|
||
length = models.IntegerField(_('Build Length'), null=True, blank=True) | ||
|
||
|
@@ -466,6 +498,47 @@ class Meta(object): | |
get_latest_by = 'date' | ||
index_together = [['version', 'state', 'type']] | ||
|
||
@property | ||
def previous(self): | ||
"""Returns the previous build to this one.""" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm trying to be more explicit here,
|
||
date = self.date or timezone.now() | ||
if self.project is not None and self.version is not None: | ||
return ( | ||
Build.objects | ||
.filter( | ||
project=self.project, | ||
version=self.version, | ||
date__lt=date, | ||
) | ||
.order_by('-date') | ||
.first() | ||
) | ||
return None | ||
|
||
def get_config(self): | ||
"""Get the config from correct object.""" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If we are not going to access the The docstring could be more explicit.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I like the idea of using |
||
if '__config' in self.config: | ||
return Build.objects.get(pk=self.config['__config']).config | ||
return self.config | ||
|
||
def save(self, *args, **kwargs): # noqa | ||
""" | ||
Save object. | ||
|
||
To save space on the db we only save the config if it's different | ||
from the previous one. | ||
|
||
If the config is the same, we save the pk of the object | ||
that has the **real** config under the ``__config`` key. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Also, it would be good if this is a CONSTANT defined in the class, instead something that it's written many times. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, I'm not convinced with the name either, not sure about |
||
""" | ||
previous = self.previous | ||
if previous is not None and self.config == previous.get_config(): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do you know how deep the Also, I think this comparison needs to be a method because it will be more explicit. Like, There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's recursive https://stackoverflow.com/a/17217255/5689214 |
||
previous_pk = previous.pk | ||
if '__config' in previous.config: | ||
previous_pk = previous.config['__config'] | ||
self.config = {'__config': previous_pk} | ||
super(Build, self).save(*args, **kwargs) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm a little worried that we're running this on each Is there a way to set the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't think there is an There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yea, I'm just trying to think if there is a way to run this logic just when we are trying to set a |
||
|
||
def __str__(self): | ||
return ugettext( | ||
'Build {project} for {usernames} ({pk})'.format( | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -233,6 +233,21 @@ def python_full_version(self): | |
) | ||
return ver | ||
|
||
def as_dict(self): | ||
attributes = [ | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I wonder if this logic should live somewhere else, as a |
||
'version', 'formats', 'python', | ||
'conda', 'build', 'doctype', | ||
'sphinx', 'mkdocs', 'submodules', | ||
] | ||
config = {} | ||
for name in attributes: | ||
attr = getattr(self, name) | ||
if hasattr(attr, '_asdict'): | ||
config[name] = attr._asdict() | ||
else: | ||
config[name] = attr | ||
return config | ||
|
||
def __getattr__(self, name): | ||
"""Raise an error for unknown attributes.""" | ||
raise ConfigOptionNotSupportedError(name) | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -841,6 +841,60 @@ def test_config_filenames_regex(correct_config_filename): | |
assert re.match(CONFIG_FILENAME_REGEX, correct_config_filename) | ||
|
||
|
||
def test_as_dict(tmpdir): | ||
apply_fs(tmpdir, {'requirements.txt': ''}) | ||
build = get_build_config( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The name of the variable is tricky. This is not a build object, but a config object. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It is a There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. OK. Feel free to do whatever you prefer. In case you think this should be renamed, open an issue in the Cleanup milestone for the "future". |
||
{ | ||
'version': 1, | ||
'formats': ['pdf'], | ||
'python': { | ||
'version': 3.5, | ||
}, | ||
'requirements_file': 'requirements.txt', | ||
}, | ||
get_env_config({ | ||
'defaults': { | ||
'doctype': 'sphinx', | ||
'sphinx_configuration': None, | ||
}, | ||
}), | ||
source_file=str(tmpdir.join('readthedocs.yml')), | ||
) | ||
build.validate() | ||
expected_dict = { | ||
'version': '1', | ||
'formats': ['pdf'], | ||
'python': { | ||
'version': 3.5, | ||
'requirements': 'requirements.txt', | ||
'install_with_pip': False, | ||
'install_with_setup': False, | ||
'extra_requirements': [], | ||
'use_system_site_packages': False, | ||
}, | ||
'build': { | ||
'image': 'readthedocs/build:2.0', | ||
}, | ||
'conda': None, | ||
'sphinx': { | ||
'builder': 'sphinx', | ||
'configuration': None, | ||
'fail_on_warning': False, | ||
}, | ||
'mkdocs': { | ||
'configuration': None, | ||
'fail_on_warning': False, | ||
}, | ||
'doctype': 'sphinx', | ||
'submodules': { | ||
'include': ALL, | ||
'exclude': [], | ||
'recursive': True, | ||
}, | ||
} | ||
assert build.as_dict() == expected_dict | ||
|
||
|
||
class TestBuildConfigV2(object): | ||
|
||
def get_build_config(self, config, env_config=None, | ||
|
@@ -1804,3 +1858,47 @@ def test_pop_config_raise_exception(self): | |
build.pop_config('one.four', raise_ex=True) | ||
assert excinfo.value.value == 'four' | ||
assert excinfo.value.code == VALUE_NOT_FOUND | ||
|
||
def test_as_dict(self, tmpdir): | ||
apply_fs(tmpdir, {'requirements.txt': ''}) | ||
build = self.get_build_config( | ||
{ | ||
'version': 2, | ||
'formats': ['pdf'], | ||
'python': { | ||
'version': 3.6, | ||
'requirements': 'requirements.txt', | ||
}, | ||
}, | ||
source_file=str(tmpdir.join('readthedocs.yml')), | ||
) | ||
build.validate() | ||
expected_dict = { | ||
'version': '2', | ||
'formats': ['pdf'], | ||
'python': { | ||
'version': 3.6, | ||
'requirements': str(tmpdir.join('requirements.txt')), | ||
'install_with_pip': False, | ||
'install_with_setup': False, | ||
'extra_requirements': [], | ||
'use_system_site_packages': False, | ||
}, | ||
'build': { | ||
'image': 'readthedocs/build:latest', | ||
}, | ||
'conda': None, | ||
'sphinx': { | ||
'builder': 'sphinx', | ||
'configuration': None, | ||
'fail_on_warning': False, | ||
}, | ||
'mkdocs': None, | ||
'doctype': 'sphinx', | ||
'submodules': { | ||
'include': [], | ||
'exclude': ALL, | ||
'recursive': False, | ||
}, | ||
} | ||
assert build.as_dict() == expected_dict |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -405,8 +405,10 @@ def run_setup(self, record=True): | |
raise YAMLParseError( | ||
YAMLParseError.GENERIC_WITH_PARSE_EXCEPTION.format( | ||
exception=str(e), | ||
)) | ||
) | ||
) | ||
|
||
self.save_build_config() | ||
self.additional_vcs_operations() | ||
|
||
if self.setup_env.failure or self.config is None: | ||
|
@@ -529,9 +531,14 @@ def get_build(build_pk): | |
build = {} | ||
if build_pk: | ||
build = api_v2.build(build_pk).get() | ||
return dict((key, val) for (key, val) in list(build.items()) | ||
if key not in ['project', 'version', 'resource_uri', | ||
'absolute_uri']) | ||
private_keys = [ | ||
'project', 'version', 'resource_uri', 'absolute_uri' | ||
] | ||
return { | ||
key: val | ||
for key, val in build.items() | ||
if key not in private_keys | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The blocks are the same, just refactored it to be more readable |
||
|
||
def setup_vcs(self): | ||
""" | ||
|
@@ -592,6 +599,15 @@ def set_valid_clone(self): | |
self.project.has_valid_clone = True | ||
self.version.project.has_valid_clone = True | ||
|
||
def save_build_config(self): | ||
"""Save config in the build object.""" | ||
pk = self.build['id'] | ||
build_data = api_v2.build(pk).get() | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This feels a bit overkill. Can we not use the |
||
config = self.config.as_dict() | ||
build_data['config'] = config | ||
api_v2.build(pk).put(build_data) | ||
self.build['config'] = config | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't think we are going to use this here, but just in case, it also helps when testing this, write a test for this was hard 😞 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yea, testing just about everything about our builds is tricky :/ |
||
|
||
def update_app_instances(self, html=False, localmedia=False, search=False, | ||
pdf=False, epub=False): | ||
""" | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -112,6 +112,7 @@ class BuildSerializer(serializers.ModelSerializer): | |
version_slug = serializers.ReadOnlyField(source='version.slug') | ||
docs_url = serializers.ReadOnlyField(source='version.get_absolute_url') | ||
state_display = serializers.ReadOnlyField(source='get_state_display') | ||
config = serializers.JSONField(required=False) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We need this because of rpkilby/jsonfield#188 (comment) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Probably should be a code comment |
||
|
||
class Meta(object): | ||
model = Build | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It would be good to explain a little more what this returns and what's its type. Probably, it's good to use
:returns:
and:rtype:
in the docstring. We do have many different types of Config objects and this will confuse us in the near future. In fact, I think this is not returning a Config object, but a simple dict 😕There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yep, it's just a dict