Repository: pypi/warehouse

Issue:
Expose long_description_content_type in JSON API
Feature request: Add  'long_description_content_type' to JSON API in 'info': section, alongside the existing 'description'

Now that PyPI supports Markdown and more than one kind of content type for long descriptions, it would be helpful to be able to see what the content type is with the JSON API. Otherwise it is more difficult to correctly interpret the 'description', since it isn't immediately obvious if it's reStructuredText or Markdown with which variant.

I'm willing to try this if I'm pointed in the right general direction :) If someone else can get it done trivially that's fine too.




Discussion:
@nixjdm Sounds like a good idea. Here's where the `description` field is added: https://github.com/pypa/warehouse/blob/338d6a102939c6452c0b6825924151d1b8b09301/warehouse/legacy/api/json.py#L157
@di should I leave the xmlrpc api for release_data alone, or update that as well?
@nixjdm I can't see any reason why it would hurt to add it there as well.
Alright, will do.

Resolving patch:
diff --git a/warehouse/legacy/api/json.py b/warehouse/legacy/api/json.py
--- a/warehouse/legacy/api/json.py
+++ b/warehouse/legacy/api/json.py
@@ -154,6 +154,7 @@ def json_release(release, request):
             "name": project.name,
             "version": release.version,
             "summary": release.summary,
+            "description_content_type": release.description_content_type,
             "description": release.description,
             "keywords": release.keywords,
             "license": release.license,


Test patch:
diff --git a/tests/unit/legacy/api/test_json.py b/tests/unit/legacy/api/test_json.py
--- a/tests/unit/legacy/api/test_json.py
+++ b/tests/unit/legacy/api/test_json.py
@@ -141,10 +141,19 @@ def test_normalizing_redirects(self, db_request):
 
     def test_detail_renders(self, pyramid_config, db_request):
         project = ProjectFactory.create(has_docs=True)
+        description_content_type = "text/x-rst"
         releases = [
             ReleaseFactory.create(project=project, version=v)
-            for v in ["1.0", "2.0", "3.0"]
+            for v in ["0.1", "1.0", "2.0"]
         ]
+        releases += [
+            ReleaseFactory.create(
+                project=project,
+                version="3.0",
+                description_content_type=description_content_type,
+            )
+        ]
+
         files = [
             FileFactory.create(
                 release=r,
@@ -153,7 +162,7 @@ def test_detail_renders(self, pyramid_config, db_request):
                 size=200,
                 has_signature=True,
             )
-            for r in releases[:-1]
+            for r in releases[1:]
         ]
         user = UserFactory.create()
         JournalEntryFactory.reset_sequence()
@@ -165,16 +174,17 @@ def test_detail_renders(self, pyramid_config, db_request):
         url = "/the/fake/url/"
         db_request.route_url = pretend.call_recorder(lambda *args, **kw: url)
 
-        result = json.json_release(releases[1], db_request)
+        result = json.json_release(releases[3], db_request)
 
         assert set(db_request.route_url.calls) == {
             pretend.call("packaging.file", path=files[0].path),
             pretend.call("packaging.file", path=files[1].path),
+            pretend.call("packaging.file", path=files[2].path),
             pretend.call("packaging.project", name=project.name),
             pretend.call(
                 "packaging.release",
                 name=project.name,
-                version=releases[1].version,
+                version=releases[3].version,
             ),
             pretend.call("legacy.docs", project=project.name),
         }
@@ -188,6 +198,7 @@ def test_detail_renders(self, pyramid_config, db_request):
                 "author_email": None,
                 "bugtrack_url": None,
                 "classifiers": [],
+                "description_content_type": description_content_type,
                 "description": None,
                 "docs_url": "/the/fake/url/",
                 "download_url": None,
@@ -209,9 +220,10 @@ def test_detail_renders(self, pyramid_config, db_request):
                 "requires_dist": None,
                 "requires_python": None,
                 "summary": None,
-                "version": "2.0",
+                "version": "3.0",
             },
             "releases": {
+                "0.1": [],
                 "1.0": [
                     {
                         "comment_text": None,
@@ -252,23 +264,153 @@ def test_detail_renders(self, pyramid_config, db_request):
                         "url": "/the/fake/url/",
                     },
                 ],
-                "3.0": [],
+                "3.0": [
+                    {
+                        "comment_text": None,
+                        "downloads": -1,
+                        "filename": files[2].filename,
+                        "has_sig": True,
+                        "md5_digest": files[2].md5_digest,
+                        "digests": {
+                            "md5": files[2].md5_digest,
+                            "sha256": files[2].sha256_digest,
+                        },
+                        "packagetype": None,
+                        "python_version": "source",
+                        "size": 200,
+                        "upload_time": files[2].upload_time.strftime(
+                            "%Y-%m-%dT%H:%M:%S",
+                        ),
+                        "url": "/the/fake/url/",
+                    },
+                ],
+            },
+            "urls": [
+                {
+                    "comment_text": None,
+                    "downloads": -1,
+                    "filename": files[2].filename,
+                    "has_sig": True,
+                    "md5_digest": files[2].md5_digest,
+                    "digests": {
+                        "md5": files[2].md5_digest,
+                        "sha256": files[2].sha256_digest,
+                    },
+                    "packagetype": None,
+                    "python_version": "source",
+                    "size": 200,
+                    "upload_time": files[2].upload_time.strftime(
+                        "%Y-%m-%dT%H:%M:%S",
+                    ),
+                    "url": "/the/fake/url/",
+                },
+            ],
+            "last_serial": je.id,
+        }
+
+    def test_minimal_renders(self, pyramid_config, db_request):
+        project = ProjectFactory.create(has_docs=False)
+        release = ReleaseFactory.create(project=project, version="0.1")
+        file = FileFactory.create(
+            release=release,
+            filename="{}-{}.tar.gz".format(project.name, release.version),
+            python_version="source",
+            size=200,
+            has_signature=True,
+        )
+
+        user = UserFactory.create()
+        JournalEntryFactory.reset_sequence()
+        je = JournalEntryFactory.create(
+            name=project.name,
+            submitted_by=user,
+        )
+
+        url = "/the/fake/url/"
+        db_request.route_url = pretend.call_recorder(lambda *args, **kw: url)
+
+        result = json.json_release(release, db_request)
+
+        assert set(db_request.route_url.calls) == {
+            pretend.call("packaging.file", path=file.path),
+            pretend.call("packaging.project", name=project.name),
+            pretend.call(
+                "packaging.release",
+                name=project.name,
+                version=release.version,
+            ),
+        }
+
+        _assert_has_cors_headers(db_request.response.headers)
+        assert db_request.response.headers["X-PyPI-Last-Serial"] == str(je.id)
+
+        assert result == {
+            "info": {
+                "author": None,
+                "author_email": None,
+                "bugtrack_url": None,
+                "classifiers": [],
+                "description_content_type": None,
+                "description": None,
+                "docs_url": None,
+                "download_url": None,
+                "downloads": {
+                    "last_day": -1,
+                    "last_week": -1,
+                    "last_month": -1,
+                },
+                "home_page": None,
+                "keywords": None,
+                "license": None,
+                "maintainer": None,
+                "maintainer_email": None,
+                "name": project.name,
+                "platform": None,
+                "project_url": "/the/fake/url/",
+                "package_url": "/the/fake/url/",
+                "release_url": "/the/fake/url/",
+                "requires_dist": None,
+                "requires_python": None,
+                "summary": None,
+                "version": "0.1",
+            },
+            "releases": {
+                "0.1": [
+                    {
+                        "comment_text": None,
+                        "downloads": -1,
+                        "filename": file.filename,
+                        "has_sig": True,
+                        "md5_digest": file.md5_digest,
+                        "digests": {
+                            "md5": file.md5_digest,
+                            "sha256": file.sha256_digest,
+                        },
+                        "packagetype": None,
+                        "python_version": "source",
+                        "size": 200,
+                        "upload_time": file.upload_time.strftime(
+                            "%Y-%m-%dT%H:%M:%S",
+                        ),
+                        "url": "/the/fake/url/",
+                    },
+                ],
             },
             "urls": [
                 {
                     "comment_text": None,
                     "downloads": -1,
-                    "filename": files[1].filename,
+                    "filename": file.filename,
                     "has_sig": True,
-                    "md5_digest": files[1].md5_digest,
+                    "md5_digest": file.md5_digest,
                     "digests": {
-                        "md5": files[1].md5_digest,
-                        "sha256": files[1].sha256_digest,
+                        "md5": file.md5_digest,
+                        "sha256": file.sha256_digest,
                     },
                     "packagetype": None,
                     "python_version": "source",
                     "size": 200,
-                    "upload_time": files[1].upload_time.strftime(
+                    "upload_time": file.upload_time.strftime(
                         "%Y-%m-%dT%H:%M:%S",
                     ),
                     "url": "/the/fake/url/",