Repository: pypi/warehouse

Issue:
Expose project_urls in JSON API
https://packaging.python.org/tutorials/distributing-packages/#project-urls

Related to #3798 / #3820

I realized project_urls is not currently exposed by the JSON API. I propose adding it.

Though the keys in the project_urls dict can be anything, they're fairly standardized, enough to be useful when querying for them over and API. For example, [Flask's API response](https://pypi.org/pypi/Flask/json) lists its home_page as https://www.palletsprojects.com/p/flask/ (not it's github account which is fairly typical), and puts it's GitHub link in `project_urls['Code']`, which is not currently in the API response.


Discussion:
@nixjdm Shall I assign this to you?
Sure :) It's so similar it'd be no problem, especially after the other PR get's in.
@nixjdm I was wondering how work was going on this feature?

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
@@ -10,6 +10,8 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+from collections import OrderedDict
+
 from pyramid.httpexceptions import HTTPMovedPermanently, HTTPNotFound
 from pyramid.view import view_config
 from sqlalchemy.orm import Load
@@ -161,6 +163,7 @@ def json_release(release, request):
             "downloads": {"last_day": -1, "last_week": -1, "last_month": -1},
             "package_url": request.route_url("packaging.project", name=project.name),
             "project_url": request.route_url("packaging.project", name=project.name),
+            "project_urls": OrderedDict(release.urls) if release.urls else None,
             "release_url": request.route_url(
                 "packaging.release", name=project.name, version=release.version
             ),


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
@@ -10,11 +10,14 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+from collections import OrderedDict
+
 import pretend
 
 from pyramid.httpexceptions import HTTPMovedPermanently, HTTPNotFound
 
 from warehouse.legacy.api import json
+from warehouse.packaging.models import Dependency, DependencyKind
 
 from ....common.db.accounts import UserFactory
 from ....common.db.packaging import (
@@ -138,9 +141,27 @@ def test_normalizing_redirects(self, db_request):
             pretend.call(name=release.project.name)
         ]
 
-    def test_detail_renders(self, pyramid_config, db_request):
+    def test_detail_renders(self, pyramid_config, db_request, db_session):
         project = ProjectFactory.create(has_docs=True)
         description_content_type = "text/x-rst"
+        url = "/the/fake/url/"
+        project_urls = [
+            "url," + url,
+            "Homepage,https://example.com/home2/",
+            "Source Code,https://example.com/source-code/",
+            "uri,http://john.doe@www.example.com:123/forum/questions/?tag=networking&order=newest#top",  # noqa: E501
+            "ldap,ldap://[2001:db8::7]/c=GB?objectClass?one",
+            "tel,tel:+1-816-555-1212",
+            "telnet,telnet://192.0.2.16:80/",
+            "urn,urn:oasis:names:specification:docbook:dtd:xml:4.1.2",
+            "reservedchars,http://example.com?&$+/:;=@#",  # Commas don't work!
+            "unsafechars,http://example.com <>[]{}|\^%",
+        ]
+        expected_urls = []
+        for project_url in reversed(project_urls):
+            expected_urls.append(tuple(project_url.split(",")))
+        expected_urls = OrderedDict(tuple(expected_urls))
+
         releases = [
             ReleaseFactory.create(project=project, version=v)
             for v in ["0.1", "1.0", "2.0"]
@@ -153,6 +174,16 @@ def test_detail_renders(self, pyramid_config, db_request):
             )
         ]
 
+        for urlspec in project_urls:
+            db_session.add(
+                Dependency(
+                    name=releases[3].project.name,
+                    version="3.0",
+                    kind=DependencyKind.project_url.value,
+                    specifier=urlspec,
+                )
+            )
+
         files = [
             FileFactory.create(
                 release=r,
@@ -167,7 +198,6 @@ def test_detail_renders(self, pyramid_config, db_request):
         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(releases[3], db_request)
@@ -203,9 +233,10 @@ def test_detail_renders(self, pyramid_config, db_request):
                 "maintainer": None,
                 "maintainer_email": None,
                 "name": project.name,
+                "package_url": "/the/fake/url/",
                 "platform": None,
                 "project_url": "/the/fake/url/",
-                "package_url": "/the/fake/url/",
+                "project_urls": expected_urls,
                 "release_url": "/the/fake/url/",
                 "requires_dist": None,
                 "requires_python": None,
@@ -344,9 +375,10 @@ def test_minimal_renders(self, pyramid_config, db_request):
                 "maintainer": None,
                 "maintainer_email": None,
                 "name": project.name,
+                "package_url": "/the/fake/url/",
                 "platform": None,
                 "project_url": "/the/fake/url/",
-                "package_url": "/the/fake/url/",
+                "project_urls": None,
                 "release_url": "/the/fake/url/",
                 "requires_dist": None,
                 "requires_python": None,