diff --git a/readthedocs/builds/models.py b/readthedocs/builds/models.py index 38de3c525c2..a303c4af516 100644 --- a/readthedocs/builds/models.py +++ b/readthedocs/builds/models.py @@ -540,6 +540,12 @@ def get_conf_py_path(self): conf_py_path = os.path.relpath(conf_py_path, checkout_prefix) return conf_py_path + def get_mkdocs_yml_path(self): + mkdocs_yml_path = self.project.mkdocs_dir(self.slug) + checkout_prefix = self.project.checkout_path(self.slug) + mkdocs_yml_path = os.path.relpath(mkdocs_yml_path, checkout_prefix) + return mkdocs_yml_path + def get_storage_paths(self, version_slug=None): """ Return a list of all build artifact storage paths for this version. diff --git a/readthedocs/doc_builder/backends/mkdocs.py b/readthedocs/doc_builder/backends/mkdocs.py index 2cc81ef396a..8c7f989a0cc 100644 --- a/readthedocs/doc_builder/backends/mkdocs.py +++ b/readthedocs/doc_builder/backends/mkdocs.py @@ -12,6 +12,7 @@ from readthedocs.core.utils.filesystem import safe_open from readthedocs.doc_builder.base import BaseBuilder +from readthedocs.doc_builder.exceptions import MkDocsYAMLParseError from readthedocs.projects.constants import MKDOCS, MKDOCS_HTML log = structlog.get_logger(__name__) @@ -33,7 +34,6 @@ def get_absolute_static_url(): class BaseMkdocs(BaseBuilder): - """Mkdocs builder.""" # The default theme for mkdocs is the 'mkdocs' theme @@ -80,6 +80,11 @@ def get_yaml_config(self): def show_conf(self): """Show the current ``mkdocs.yaml`` being used.""" # Write the mkdocs.yml to the build logs + if not os.path.exists(self.yaml_file): + raise MkDocsYAMLParseError( + message_id=MkDocsYAMLParseError.NOT_FOUND, + ) + self.run( "cat", os.path.relpath(self.yaml_file, self.project_path), @@ -122,7 +127,6 @@ def __eq__(self, other): class SafeLoader(yaml.SafeLoader): # pylint: disable=too-many-ancestors - """ Safe YAML loader. @@ -141,7 +145,6 @@ def construct_python_name(self, suffix, node): # pylint: disable=unused-argumen class SafeDumper(yaml.SafeDumper): - """ Safe YAML dumper. diff --git a/readthedocs/projects/models.py b/readthedocs/projects/models.py index 1a521b7a7c7..11f2090d1bb 100644 --- a/readthedocs/projects/models.py +++ b/readthedocs/projects/models.py @@ -1,4 +1,5 @@ """Project models.""" + import fnmatch import hashlib import hmac @@ -34,6 +35,7 @@ from readthedocs.core.resolver import Resolver from readthedocs.core.utils import extract_valid_attributes_for_model, slugify from readthedocs.core.utils.url import unsafe_join_url_path +from readthedocs.doc_builder.exceptions import MkDocsYAMLParseError from readthedocs.domains.querysets import DomainQueryset from readthedocs.domains.validators import check_domains_limit from readthedocs.notifications.models import Notification as NewNotification @@ -80,7 +82,6 @@ def default_privacy_level(): class ProjectRelationship(models.Model): - """ Project to project relationship. @@ -135,7 +136,6 @@ def subproject_prefix(self): class AddonsConfig(TimeStampedModel): - """ Addons project configuration. @@ -266,7 +266,6 @@ class AddonsConfig(TimeStampedModel): class AddonSearchFilter(TimeStampedModel): - """ Addon search user defined filter. @@ -279,7 +278,6 @@ class AddonSearchFilter(TimeStampedModel): class Project(models.Model): - """Project model.""" # Auto fields @@ -962,7 +960,6 @@ def conf_file(self, version=LATEST): # 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: @@ -972,11 +969,27 @@ def conf_file(self, version=LATEST): raise ProjectConfigurationError(ProjectConfigurationError.NOT_FOUND) + def yml_file(self, version=LATEST): + """Find a Mkdocs ``mkdocs.yml`` file in the project checkout.""" + files = self.find("mkdocs.yml", version) + if not files: + files = self.full_find("mkdocs.yml", version) + if len(files) == 1: + return files[0] + # TODO: handle for multiple mkdocs.yml files + + raise MkDocsYAMLParseError(MkDocsYAMLParseError.NOT_FOUND) + def conf_dir(self, version=LATEST): conf_file = self.conf_file(version) if conf_file: return os.path.dirname(conf_file) + def mkdocs_dir(self, version=LATEST): + yml_file = self.yml_file(version) + if yml_file: + return os.path.dirname(yml_file) + @property def has_good_build(self): # Check if there is `_good_build` annotation in the Queryset. @@ -1432,7 +1445,6 @@ def organization(self): class APIProject(Project): - """ Project proxy model for API data deserialization. @@ -1506,7 +1518,6 @@ def environment_variables(self, *, public_only=True): class ImportedFile(models.Model): - """ Imported files model. @@ -1558,7 +1569,6 @@ def get_absolute_url(self): class HTMLFile(ImportedFile): - """ Imported HTML file Proxy model. @@ -1580,7 +1590,6 @@ def processed_json(self): class Notification(TimeStampedModel): - """WebHook / Email notification attached to a Project.""" # TODO: Overridden from TimeStampedModel just to allow null values, @@ -1745,7 +1754,6 @@ def sign_payload(self, payload): class Domain(TimeStampedModel): - """A custom domain name for a project.""" # TODO: Overridden from TimeStampedModel just to allow null values, @@ -1869,7 +1877,6 @@ def save(self, *args, **kwargs): class HTTPHeader(TimeStampedModel, models.Model): - """ Define a HTTP header for a user Domain. @@ -1912,7 +1919,6 @@ def __str__(self): class Feature(models.Model): - """ Project feature flags. diff --git a/readthedocs/rtd_tests/tests/test_doc_builder.py b/readthedocs/rtd_tests/tests/test_doc_builder.py index e510de3a45a..00b67fc3079 100644 --- a/readthedocs/rtd_tests/tests/test_doc_builder.py +++ b/readthedocs/rtd_tests/tests/test_doc_builder.py @@ -8,14 +8,16 @@ from django.test.utils import override_settings from readthedocs.config.tests.test_config import get_build_config +from readthedocs.doc_builder.backends.mkdocs import BaseMkdocs from readthedocs.doc_builder.backends.sphinx import BaseSphinx +from readthedocs.doc_builder.exceptions import MkDocsYAMLParseError from readthedocs.doc_builder.python_environments import Virtualenv from readthedocs.projects.exceptions import ProjectConfigurationError from readthedocs.projects.models import Project @override_settings(PRODUCTION_DOMAIN="readthedocs.org") -class SphinxBuilderTest(TestCase): +class BuilderTest(TestCase): fixtures = ["test_data", "eric"] def setUp(self): @@ -33,6 +35,9 @@ def setUp(self): BaseSphinx.type = "base" BaseSphinx.sphinx_build_dir = tempfile.mkdtemp() BaseSphinx.relative_output_dir = "_readthedocs/" + BaseMkdocs.type = "base" + BaseMkdocs.sphinx_build_dir = tempfile.mkdtemp() + BaseMkdocs.relative_output_dir = "_readthedocs/" @patch("readthedocs.doc_builder.backends.sphinx.BaseSphinx.docs_dir") @patch("readthedocs.doc_builder.backends.sphinx.BaseSphinx.run") @@ -112,3 +117,45 @@ def test_multiple_conf_py( with pytest.raises(ProjectConfigurationError): with override_settings(DOCROOT=tmp_docs_dir): base_sphinx.show_conf() + + @patch("readthedocs.doc_builder.backends.mkdocs.BaseMkdocs.docs_dir") + @patch("readthedocs.doc_builder.backends.mkdocs.BaseMkdocs.run") + @patch("readthedocs.builds.models.Version.get_mkdocs_yml_path") + @patch("readthedocs.projects.models.Project.checkout_path") + @patch("readthedocs.doc_builder.python_environments.load_yaml_config") + def test_project_without_mkdocs_yml( + self, + load_yaml_config, + checkout_path, + get_mkdocs_yml_path, + _, + docs_dir, + ): + """ + Test for a project without ``mkdocs.yml`` file. + + When this happen, the ``get_mkdocs_yml_path`` raises a + ``MkDocsYAMLParseError`` which is captured by our own code. + """ + tmp_dir = tempfile.mkdtemp() + checkout_path.return_value = tmp_dir + docs_dir.return_value = tmp_dir + get_mkdocs_yml_path.side_effect = MkDocsYAMLParseError + python_env = Virtualenv( + version=self.version, + build_env=self.build_env, + config=get_build_config( + {"mkdocs": {"configuration": "mkdocs.yml"}}, validate=True + ), + ) + base_mkdocs = BaseMkdocs( + build_env=self.build_env, + python_env=python_env, + ) + with self.assertRaises(MkDocsYAMLParseError) as e: + base_mkdocs.show_conf() + + self.assertEqual( + e.exception.message_id, + MkDocsYAMLParseError.NOT_FOUND, + )