Repository: pypi/warehouse

Issue:
Fail upload if rST description does not render
If the reStructuredText `long_description` does not render, let's fail the upload instead of just rendering it as plaintext, and raise a meaningful error to the user.


Discussion:
cc @mblayman
#3760 should help - you can call `warehouse.utils.readme.render` to check and see if the description is valid.

Resolving patch:
diff --git a/warehouse/forklift/legacy.py b/warehouse/forklift/legacy.py
--- a/warehouse/forklift/legacy.py
+++ b/warehouse/forklift/legacy.py
@@ -52,7 +52,7 @@
     JournalEntry,
     BlacklistedProject,
 )
-from warehouse.utils import http
+from warehouse.utils import http, readme
 
 
 MAX_FILESIZE = 60 * 1024 * 1024  # 60M
@@ -891,6 +891,33 @@ def file_upload(request):
             ),
         )
 
+    # Uploading should prevent broken rendered descriptions.
+    if form.description.data:
+        description_content_type = form.description_content_type.data
+        if not description_content_type:
+            description_content_type = "text/x-rst"
+        rendered = readme.render(
+            form.description.data, description_content_type, use_fallback=False
+        )
+        if rendered is None:
+            if form.description_content_type.data:
+                message = (
+                    "The description failed to render "
+                    "for '{description_content_type}'."
+                ).format(description_content_type=description_content_type)
+            else:
+                message = (
+                    "The description failed to render "
+                    "in the default format of reStructuredText."
+                )
+            raise _exc_with_message(
+                HTTPBadRequest,
+                "{message} See {projecthelp} for more information.".format(
+                    message=message,
+                    projecthelp=request.help_url(_anchor="description-content-type"),
+                ),
+            ) from None
+
     try:
         canonical_version = packaging.utils.canonicalize_version(form.version.data)
         release = (
diff --git a/warehouse/utils/readme.py b/warehouse/utils/readme.py
--- a/warehouse/utils/readme.py
+++ b/warehouse/utils/readme.py
@@ -29,7 +29,7 @@
 }
 
 
-def render(value, content_type=None):
+def render(value, content_type=None, use_fallback=True):
     if value is None:
         return value
 
@@ -45,7 +45,8 @@ def render(value, content_type=None):
     # If the content was not rendered, we'll render as plaintext instead. The
     # reason it's necessary to do this instead of just accepting plaintext is
     # that readme_renderer will deal with sanitizing the content.
-    if rendered is None:
+    # Skip the fallback option when validating that rendered output is ok.
+    if use_fallback and rendered is None:
         rendered = readme_renderer.txt.render(value)
 
     return rendered


Test patch:
diff --git a/tests/unit/forklift/test_legacy.py b/tests/unit/forklift/test_legacy.py
--- a/tests/unit/forklift/test_legacy.py
+++ b/tests/unit/forklift/test_legacy.py
@@ -919,6 +919,64 @@ def test_fails_with_invalid_names(self, pyramid_config, db_request, name):
             "for more information."
         ).format(name)
 
+    @pytest.mark.parametrize(
+        ("description_content_type", "description", "message"),
+        [
+            (
+                "text/x-rst",
+                ".. invalid-directive::",
+                "400 The description failed to render for 'text/x-rst'. "
+                "See /the/help/url/ for more information.",
+            ),
+            (
+                "",
+                ".. invalid-directive::",
+                "400 The description failed to render in the default format "
+                "of reStructuredText. "
+                "See /the/help/url/ for more information.",
+            ),
+        ],
+    )
+    def test_fails_invalid_render(
+        self, pyramid_config, db_request, description_content_type, description, message
+    ):
+        pyramid_config.testing_securitypolicy(userid=1)
+        user = UserFactory.create()
+        EmailFactory.create(user=user)
+        db_request.user = user
+        db_request.remote_addr = "10.10.10.30"
+
+        db_request.POST = MultiDict(
+            {
+                "metadata_version": "1.2",
+                "name": "example",
+                "version": "1.0",
+                "filetype": "sdist",
+                "md5_digest": "a fake md5 digest",
+                "content": pretend.stub(
+                    filename="example-1.0.tar.gz",
+                    file=io.BytesIO(b"A fake file."),
+                    type="application/tar",
+                ),
+                "description_content_type": description_content_type,
+                "description": description,
+            }
+        )
+
+        db_request.help_url = pretend.call_recorder(lambda **kw: "/the/help/url/")
+
+        with pytest.raises(HTTPBadRequest) as excinfo:
+            legacy.file_upload(db_request)
+
+        resp = excinfo.value
+
+        assert db_request.help_url.calls == [
+            pretend.call(_anchor="description-content-type")
+        ]
+
+        assert resp.status_code == 400
+        assert resp.status == message
+
     @pytest.mark.parametrize(
         "name",
         [
@@ -1158,6 +1216,7 @@ def test_successful_upload(
                 "filetype": "sdist",
                 "pyversion": "source",
                 "content": content,
+                "description": "an example description",
             }
         )
         db_request.POST.extend([("classifiers", "Environment :: Other Environment")])