diff --git a/readthedocs/projects/exceptions.py b/readthedocs/projects/exceptions.py index 5209472aa3a..5376db4ad8e 100644 --- a/readthedocs/projects/exceptions.py +++ b/readthedocs/projects/exceptions.py @@ -1,5 +1,8 @@ +# -*- coding: utf-8 -*- """Project exceptions.""" +from __future__ import division, print_function, unicode_literals + from django.conf import settings from django.utils.translation import ugettext_noop as _ @@ -15,6 +18,12 @@ class ProjectConfigurationError(BuildEnvironmentError): 'Make sure you have a conf.py file in your repository.' ) + MULTIPLE_CONF_FILES = _( + 'We found more than one conf.py and are not sure which one to use. ' + 'Please, specify the correct file under the Advanced settings tab ' + "in the project's Admin." + ) + class RepositoryError(BuildEnvironmentError): diff --git a/readthedocs/projects/models.py b/readthedocs/projects/models.py index 112e458c194..005fee58d88 100644 --- a/readthedocs/projects/models.py +++ b/readthedocs/projects/models.py @@ -1,15 +1,17 @@ +# -*- coding: utf-8 -*- """Project models.""" -from __future__ import absolute_import +from __future__ import ( + absolute_import, division, print_function, unicode_literals) import fnmatch import logging import os - from builtins import object # pylint: disable=redefined-builtin + from django.conf import settings from django.contrib.auth.models import User -from django.core.urlresolvers import reverse, NoReverseMatch +from django.core.urlresolvers import NoReverseMatch, reverse from django.db import models from django.utils.encoding import python_2_unicode_compatible from django.utils.translation import ugettext_lazy as _ @@ -24,19 +26,16 @@ from readthedocs.projects import constants from readthedocs.projects.exceptions import ProjectConfigurationError from readthedocs.projects.querysets import ( - ProjectQuerySet, - RelatedProjectQuerySet, - ChildRelatedProjectQuerySet, - FeatureQuerySet, -) + ChildRelatedProjectQuerySet, FeatureQuerySet, ProjectQuerySet, + RelatedProjectQuerySet) from readthedocs.projects.templatetags.projects_tags import sort_version_aware -from readthedocs.projects.version_handling import determine_stable_version, version_windows +from readthedocs.projects.version_handling import ( + determine_stable_version, version_windows) from readthedocs.restapi.client import api from readthedocs.vcs_support.backends import backend_cls from readthedocs.vcs_support.base import VCSProject from readthedocs.vcs_support.utils import Lock, NonBlockingLock - log = logging.getLogger(__name__) @@ -58,7 +57,7 @@ class ProjectRelationship(models.Model): objects = ChildRelatedProjectQuerySet.as_manager() def __str__(self): - return "%s -> %s" % (self.parent, self.child) + return '%s -> %s' % (self.parent, self.child) def save(self, *args, **kwargs): # pylint: disable=arguments-differ if not self.alias: @@ -139,26 +138,27 @@ class Project(models.Model): # Project features allow_comments = models.BooleanField(_('Allow Comments'), default=False) - comment_moderation = models.BooleanField(_('Comment Moderation'), default=False) + comment_moderation = models.BooleanField( + _('Comment Moderation'), default=False,) cdn_enabled = models.BooleanField(_('CDN Enabled'), default=False) analytics_code = models.CharField( _('Analytics code'), max_length=50, null=True, blank=True, - help_text=_("Google Analytics Tracking ID " - "(ex. UA-22345342-1). " - "This may slow down your page loads.")) + help_text=_('Google Analytics Tracking ID ' + '(ex. UA-22345342-1). ' + 'This may slow down your page loads.')) container_image = models.CharField( _('Alternative container image'), max_length=64, null=True, blank=True) container_mem_limit = models.CharField( _('Container memory limit'), max_length=10, null=True, blank=True, - help_text=_("Memory limit in Docker format " - "-- example: 512m or 1g")) + help_text=_('Memory limit in Docker format ' + '-- example: 512m or 1g')) container_time_limit = models.CharField( _('Container time limit'), max_length=10, null=True, blank=True) build_queue = models.CharField( _('Alternate build queue id'), max_length=32, null=True, blank=True) allow_promos = models.BooleanField( _('Allow paid advertising'), default=True, help_text=_( - "If unchecked, users will still see community ads.")) + 'If unchecked, users will still see community ads.')) # Sphinx specific build options. enable_epub_build = models.BooleanField( @@ -172,8 +172,8 @@ class Project(models.Model): # Other model data. path = models.CharField(_('Path'), max_length=255, editable=False, - help_text=_("The directory where " - "conf.py lives")) + help_text=_('The directory where ' + 'conf.py lives')) conf_py_file = models.CharField( _('Python configuration file'), max_length=255, default='', blank=True, help_text=_('Path from project root to conf.py file ' @@ -185,8 +185,8 @@ class Project(models.Model): mirror = models.BooleanField(_('Mirror'), default=False) install_project = models.BooleanField( _('Install Project'), - help_text=_("Install your project inside a virtualenv using setup.py " - "install"), + help_text=_('Install your project inside a virtualenv using setup.py ' + 'install'), default=False ) @@ -197,13 +197,13 @@ class Project(models.Model): max_length=20, choices=constants.PYTHON_CHOICES, default='python', - help_text=_("(Beta) The Python interpreter used to create the virtual " - "environment.")) + help_text=_('(Beta) The Python interpreter used to create the virtual ' + 'environment.')) use_system_packages = models.BooleanField( _('Use system packages'), - help_text=_("Give the virtual environment access to the global " - "site-packages dir."), + help_text=_('Give the virtual environment access to the global ' + 'site-packages dir.'), default=False ) django_packages_url = models.CharField(_('Django Packages URL'), @@ -211,14 +211,14 @@ class Project(models.Model): privacy_level = models.CharField( _('Privacy Level'), max_length=20, choices=constants.PRIVACY_CHOICES, default=getattr(settings, 'DEFAULT_PRIVACY_LEVEL', 'public'), - help_text=_("(Beta) Level of privacy that you want on the repository. " - "Protected means public but not in listings.")) + help_text=_('(Beta) Level of privacy that you want on the repository. ' + 'Protected means public but not in listings.')) version_privacy_level = models.CharField( _('Version Privacy Level'), max_length=20, choices=constants.PRIVACY_CHOICES, default=getattr( settings, 'DEFAULT_PRIVACY_LEVEL', 'public'), - help_text=_("(Beta) Default level of privacy you want on built " - "versions of documentation.")) + help_text=_('(Beta) Default level of privacy you want on built ' + 'versions of documentation.')) # Subprojects related_projects = models.ManyToManyField( @@ -227,8 +227,8 @@ class Project(models.Model): # Language bits language = models.CharField(_('Language'), max_length=20, default='en', - help_text=_("The language the project " - "documentation is rendered in. " + help_text=_('The language the project ' + 'documentation is rendered in. ' "Note: this affects your project's URL."), choices=constants.LANGUAGES) @@ -236,7 +236,8 @@ class Project(models.Model): _('Programming Language'), max_length=20, default='words', - help_text=_("The primary programming language the project is written in."), + help_text=_( + 'The primary programming language the project is written in.'), choices=constants.PROGRAMMING_LANGUAGES, blank=True) # A subproject pointed at its main language, so it can be tracked main_language_project = models.ForeignKey('self', @@ -250,21 +251,21 @@ class Project(models.Model): default=2, null=True, blank=True, - help_text=_("2 means supporting 3.X.X and 2.X.X, but not 1.X.X") + help_text=_('2 means supporting 3.X.X and 2.X.X, but not 1.X.X') ) num_minor = models.IntegerField( _('Number of Minor versions'), default=2, null=True, blank=True, - help_text=_("2 means supporting 2.2.X and 2.1.X, but not 2.0.X") + help_text=_('2 means supporting 2.2.X and 2.1.X, but not 2.0.X') ) num_point = models.IntegerField( _('Number of Point versions'), default=2, null=True, blank=True, - help_text=_("2 means supporting 2.2.2 and 2.2.1, but not 2.2.0") + help_text=_('2 means supporting 2.2.2 and 2.2.1, but not 2.2.0') ) has_valid_webhook = models.BooleanField( @@ -296,7 +297,8 @@ def sync_supported_versions(self): verbose_name__in=supported).update(supported=True) self.versions.exclude( verbose_name__in=supported).update(supported=False) - self.versions.filter(verbose_name=LATEST_VERBOSE_NAME).update(supported=True) + self.versions.filter( + verbose_name=LATEST_VERBOSE_NAME).update(supported=True) def save(self, *args, **kwargs): # pylint: disable=arguments-differ from readthedocs.projects import tasks @@ -305,7 +307,7 @@ def save(self, *args, **kwargs): # pylint: disable=arguments-differ # Subdomains can't have underscores in them. self.slug = slugify(self.name) if self.slug == '': - raise Exception(_("Model must have slug")) + raise Exception(_('Model must have slug')) super(Project, self).save(*args, **kwargs) for owner in self.users.all(): assign('view_project', owner, self) @@ -325,12 +327,14 @@ def save(self, *args, **kwargs): # pylint: disable=arguments-differ log.exception('failed to sync supported versions') try: if not first_save: - broadcast(type='app', task=tasks.symlink_project, args=[self.pk]) + broadcast(type='app', task=tasks.symlink_project, + args=[self.pk],) except Exception: log.exception('failed to symlink project') try: if not first_save: - broadcast(type='app', task=tasks.update_static_metadata, args=[self.pk]) + broadcast( + type='app', task=tasks.update_static_metadata, args=[self.pk],) except Exception: log.exception('failed to update static metadata') try: @@ -473,19 +477,19 @@ def full_doc_path(self, version=LATEST): def artifact_path(self, type_, version=LATEST): """The path to the build html docs in the project.""" - return os.path.join(self.doc_path, "artifacts", version, type_) + return os.path.join(self.doc_path, 'artifacts', version, type_) def full_build_path(self, version=LATEST): """The path to the build html docs in the project.""" - return os.path.join(self.conf_dir(version), "_build", "html") + return os.path.join(self.conf_dir(version), '_build', 'html') def full_latex_path(self, version=LATEST): """The path to the build LaTeX docs in the project.""" - return os.path.join(self.conf_dir(version), "_build", "latex") + return os.path.join(self.conf_dir(version), '_build', 'latex') def full_epub_path(self, version=LATEST): """The path to the build epub docs in the project.""" - return os.path.join(self.conf_dir(version), "_build", "epub") + return os.path.join(self.conf_dir(version), '_build', 'epub') # There is currently no support for building man/dash formats, but we keep # the support there for existing projects. They might have already existing @@ -493,22 +497,22 @@ def full_epub_path(self, version=LATEST): def full_man_path(self, version=LATEST): """The path to the build man docs in the project.""" - return os.path.join(self.conf_dir(version), "_build", "man") + return os.path.join(self.conf_dir(version), '_build', 'man') def full_dash_path(self, version=LATEST): """The path to the build dash docs in the project.""" - return os.path.join(self.conf_dir(version), "_build", "dash") + return os.path.join(self.conf_dir(version), '_build', 'dash') def full_json_path(self, version=LATEST): """The path to the build json docs in the project.""" if 'sphinx' in self.documentation_type: - return os.path.join(self.conf_dir(version), "_build", "json") + return os.path.join(self.conf_dir(version), '_build', 'json') elif 'mkdocs' in self.documentation_type: - return os.path.join(self.checkout_path(version), "_build", "json") + return os.path.join(self.checkout_path(version), '_build', 'json') def full_singlehtml_path(self, version=LATEST): """The path to the build singlehtml docs in the project.""" - return os.path.join(self.conf_dir(version), "_build", "singlehtml") + return os.path.join(self.conf_dir(version), '_build', 'singlehtml') def rtd_build_path(self, version=LATEST): """The destination path where the built docs are copied.""" @@ -521,7 +525,8 @@ def static_metadata_path(self): def conf_file(self, version=LATEST): """Find a ``conf.py`` file in the project checkout.""" if self.conf_py_file: - conf_path = os.path.join(self.checkout_path(version), self.conf_py_file) + conf_path = os.path.join( + self.checkout_path(version), self.conf_py_file,) if os.path.exists(conf_path): log.info('Inserting conf.py file path from model') return conf_path @@ -533,8 +538,18 @@ def conf_file(self, version=LATEST): if len(files) == 1: return files[0] for filename in files: + # When multiples conf.py files, we look up the first one that + # contains the `doc` word in its path and return this one if filename.find('doc', 70) != -1: return filename + + # If the project has more than one conf.py file but none of them have + # the `doc` word in the path, we raise an error informing this to the user + if len(files) > 1: + raise ProjectConfigurationError( + ProjectConfigurationError.MULTIPLE_CONF_FILES + ) + raise ProjectConfigurationError( ProjectConfigurationError.NOT_FOUND ) @@ -636,7 +651,7 @@ def get_latest_build(self, finished=True): """ Get latest build for project. - finished -- Return only builds that are in a finished state + :param finished: Return only builds that are in a finished state """ kwargs = {'type': 'html'} if finished: @@ -688,7 +703,8 @@ def supported_versions(self): """ if not self.num_major or not self.num_minor or not self.num_point: return [] - version_identifiers = self.versions.values_list('verbose_name', flat=True) + version_identifiers = self.versions.values_list( + 'verbose_name', flat=True,) return version_windows( version_identifiers, major=self.num_major, @@ -715,7 +731,7 @@ def update_stable_version(self): new_stable.identifier != current_stable.identifier) if identifier_updated and current_stable.active and current_stable.machine: log.info( - "Update stable version: {project}:{version}".format( + 'Update stable version: {project}:{version}'.format( project=self.slug, version=new_stable.identifier)) current_stable.identifier = new_stable.identifier @@ -723,7 +739,7 @@ def update_stable_version(self): return new_stable else: log.info( - "Creating new stable version: {project}:{version}".format( + 'Creating new stable version: {project}:{version}'.format( project=self.slug, version=new_stable.identifier)) current_stable = self.versions.create_stable( @@ -964,14 +980,17 @@ class Domain(models.Model): ) canonical = models.BooleanField( default=False, - help_text=_('This Domain is the primary one where the documentation is served from') + help_text=_( + 'This Domain is the primary one where the documentation is ' + 'served from') ) https = models.BooleanField( _('Use HTTPS'), default=False, help_text=_('SSL is enabled for this domain') ) - count = models.IntegerField(default=0, help_text=_('Number of times this domain has been hit')) + count = models.IntegerField(default=0, help_text=_( + 'Number of times this domain has been hit'),) objects = RelatedProjectQuerySet.as_manager() @@ -979,7 +998,7 @@ class Meta(object): ordering = ('-canonical', '-machine', 'domain') def __str__(self): - return "{domain} pointed at {project}".format(domain=self.domain, project=self.project.name) + return '{domain} pointed at {project}'.format(domain=self.domain, project=self.project.name) def save(self, *args, **kwargs): # pylint: disable=arguments-differ from readthedocs.projects import tasks @@ -989,11 +1008,13 @@ def save(self, *args, **kwargs): # pylint: disable=arguments-differ else: self.domain = parsed.path super(Domain, self).save(*args, **kwargs) - broadcast(type='app', task=tasks.symlink_domain, args=[self.project.pk, self.pk]) + broadcast(type='app', task=tasks.symlink_domain, + args=[self.project.pk, self.pk],) def delete(self, *args, **kwargs): # pylint: disable=arguments-differ from readthedocs.projects import tasks - broadcast(type='app', task=tasks.symlink_domain, args=[self.project.pk, self.pk, True]) + broadcast(type='app', task=tasks.symlink_domain, + args=[self.project.pk, self.pk, True],) super(Domain, self).delete(*args, **kwargs) @@ -1051,7 +1072,7 @@ def add_features(sender, **kwargs): objects = FeatureQuerySet.as_manager() def __str__(self): - return "{0} feature".format( + return '{0} feature'.format( self.get_feature_display(), ) diff --git a/readthedocs/rtd_tests/tests/test_project.py b/readthedocs/rtd_tests/tests/test_project.py index 88eb78ee20f..38e7943634d 100644 --- a/readthedocs/rtd_tests/tests/test_project.py +++ b/readthedocs/rtd_tests/tests/test_project.py @@ -7,11 +7,13 @@ from django.test import TestCase from django_dynamic_fixture import get +from mock import patch from rest_framework.reverse import reverse from readthedocs.builds.constants import ( BUILD_STATE_CLONING, BUILD_STATE_FINISHED, BUILD_STATE_TRIGGERED, LATEST) from readthedocs.builds.models import Build +from readthedocs.projects.exceptions import ProjectConfigurationError from readthedocs.projects.models import Project from readthedocs.projects.tasks import finish_inactive_builds from readthedocs.rtd_tests.mocks.paths import fake_paths_by_regex @@ -116,6 +118,45 @@ def test_has_epub_with_epub_build_disabled(self): with fake_paths_by_regex('\.epub$'): self.assertFalse(self.pip.has_epub(LATEST)) + @patch('readthedocs.projects.models.Project.find') + def test_conf_file_found(self, find_method): + find_method.return_value = [ + '/home/docs/rtfd/code/readthedocs.org/user_builds/pip/checkouts/latest/src/conf.py', + ] + self.assertEqual( + self.pip.conf_file(), + '/home/docs/rtfd/code/readthedocs.org/user_builds/pip/checkouts/latest/src/conf.py', + ) + + @patch('readthedocs.projects.models.Project.find') + def test_multiple_conf_file_one_doc_in_path(self, find_method): + find_method.return_value = [ + '/home/docs/rtfd/code/readthedocs.org/user_builds/pip/checkouts/latest/src/conf.py', + '/home/docs/rtfd/code/readthedocs.org/user_builds/pip/checkouts/latest/docs/conf.py', + ] + self.assertEqual( + self.pip.conf_file(), + '/home/docs/rtfd/code/readthedocs.org/user_builds/pip/checkouts/latest/docs/conf.py', + ) + + def test_conf_file_not_found(self): + with self.assertRaisesMessage( + ProjectConfigurationError, + ProjectConfigurationError.NOT_FOUND) as cm: + self.pip.conf_file() + + @patch('readthedocs.projects.models.Project.find') + def test_multiple_conf_files(self, find_method): + find_method.return_value = [ + '/home/docs/rtfd/code/readthedocs.org/user_builds/pip/checkouts/multi-conf.py/src/conf.py', + '/home/docs/rtfd/code/readthedocs.org/user_builds/pip/checkouts/multi-conf.py/src/sub/conf.py', + '/home/docs/rtfd/code/readthedocs.org/user_builds/pip/checkouts/multi-conf.py/src/sub/src/conf.py', + ] + with self.assertRaisesMessage( + ProjectConfigurationError, + ProjectConfigurationError.MULTIPLE_CONF_FILES) as cm: + self.pip.conf_file() + class TestFinishInactiveBuildsTask(TestCase): fixtures = ['eric', 'test_data']