diff --git a/docs/dev/api-reference/json.rst b/docs/dev/api-reference/json.rst index 261e14d470de..21c47582ea61 100644 --- a/docs/dev/api-reference/json.rst +++ b/docs/dev/api-reference/json.rst @@ -117,6 +117,10 @@ Project "coverage ; extra == 'test'" ], "requires_python": ">=3.7", + "roles": { + "owner": ["alice", "bob"], + "maintainer": ["carol", "dave"], + }, "summary": "A sample Python project", "version": "3.0.0", "yanked": false, diff --git a/tests/unit/legacy/api/test_json.py b/tests/unit/legacy/api/test_json.py index 3cbad70d2795..f94b9ca044d8 100644 --- a/tests/unit/legacy/api/test_json.py +++ b/tests/unit/legacy/api/test_json.py @@ -26,6 +26,7 @@ JournalEntryFactory, ProjectFactory, ReleaseFactory, + RoleFactory, ) @@ -210,7 +211,235 @@ def test_renders(self, pyramid_config, db_request, db_session): ) for r in releases[1:] ] - user = UserFactory.create() + user = UserFactory.create(username="guido") + RoleFactory.create(user=user, project=project) + + JournalEntryFactory.reset_sequence() + je = JournalEntryFactory.create(name=project.name, submitted_by=user) + + db_request.route_url = pretend.call_recorder(lambda *args, **kw: url) + db_request.matchdict = {"name": project.normalized_name} + + result = json.json_project(releases[-1], 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[3].version + ), + pretend.call("legacy.docs", project=project.name), + } + + _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": description_content_type, + "description": releases[-1].description.raw, + "docs_url": "/the/fake/url/", + "download_url": None, + "downloads": {"last_day": -1, "last_week": -1, "last_month": -1}, + "dynamic": None, + "home_page": None, + "keywords": None, + "license": None, + "maintainer": None, + "maintainer_email": None, + "name": project.name, + "package_url": "/the/fake/url/", + "platform": None, + "project_url": "/the/fake/url/", + "project_urls": expected_urls, + "provides_extra": None, + "release_url": "/the/fake/url/", + "requires_dist": None, + "requires_python": None, + "roles": { + "owner": ["guido"], + }, + "summary": None, + "yanked": False, + "yanked_reason": None, + "version": "3.0", + }, + "releases": { + "0.1": [], + "1.0": [ + { + "comment_text": None, + "downloads": -1, + "filename": files[0].filename, + "has_sig": False, + "md5_digest": files[0].md5_digest, + "digests": { + "md5": files[0].md5_digest, + "sha256": files[0].sha256_digest, + "blake2b_256": files[0].blake2_256_digest, + }, + "packagetype": files[0].packagetype, + "python_version": "source", + "size": 200, + "upload_time": files[0].upload_time.strftime( + "%Y-%m-%dT%H:%M:%S" + ), + "upload_time_iso_8601": files[0].upload_time.isoformat() + "Z", + "url": "/the/fake/url/", + "requires_python": None, + "yanked": False, + "yanked_reason": None, + } + ], + "2.0": [ + { + "comment_text": None, + "downloads": -1, + "filename": files[1].filename, + "has_sig": False, + "md5_digest": files[1].md5_digest, + "digests": { + "md5": files[1].md5_digest, + "sha256": files[1].sha256_digest, + "blake2b_256": files[1].blake2_256_digest, + }, + "packagetype": files[1].packagetype, + "python_version": "source", + "size": 200, + "upload_time": files[1].upload_time.strftime( + "%Y-%m-%dT%H:%M:%S" + ), + "upload_time_iso_8601": files[1].upload_time.isoformat() + "Z", + "url": "/the/fake/url/", + "requires_python": None, + "yanked": False, + "yanked_reason": None, + } + ], + "3.0": [ + { + "comment_text": None, + "downloads": -1, + "filename": files[2].filename, + "has_sig": False, + "md5_digest": files[2].md5_digest, + "digests": { + "blake2b_256": files[2].blake2_256_digest, + "md5": files[2].md5_digest, + "sha256": files[2].sha256_digest, + }, + "packagetype": files[2].packagetype, + "python_version": "source", + "size": 200, + "upload_time": files[2].upload_time.strftime( + "%Y-%m-%dT%H:%M:%S" + ), + "upload_time_iso_8601": files[2].upload_time.isoformat() + "Z", + "url": "/the/fake/url/", + "requires_python": None, + "yanked": False, + "yanked_reason": None, + } + ], + }, + "urls": [ + { + "comment_text": None, + "downloads": -1, + "filename": files[2].filename, + "has_sig": False, + "md5_digest": files[2].md5_digest, + "digests": { + "md5": files[2].md5_digest, + "sha256": files[2].sha256_digest, + "blake2b_256": files[2].blake2_256_digest, + }, + "packagetype": files[2].packagetype, + "python_version": "source", + "size": 200, + "upload_time": files[2].upload_time.strftime("%Y-%m-%dT%H:%M:%S"), + "upload_time_iso_8601": files[2].upload_time.isoformat() + "Z", + "url": "/the/fake/url/", + "requires_python": None, + "yanked": False, + "yanked_reason": None, + } + ], + "last_serial": je.id, + "vulnerabilities": [], + } + + def test_project_with_multiple_roles(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! + r"unsafechars,http://example.com <>[]{}|\^%", + ] + expected_urls = [] + for project_url in sorted( + project_urls, key=lambda u: u.split(",", 1)[0].strip().lower() + ): + expected_urls.append(tuple(project_url.split(",", 1))) + expected_urls = dict(tuple(expected_urls)) + + releases = [ + ReleaseFactory.create(project=project, version=v) + for v in ["0.1", "1.0", "2.0"] + ] + releases += [ + ReleaseFactory.create( + project=project, + version="3.0", + description=DescriptionFactory.create( + content_type=description_content_type + ), + ) + ] + + for urlspec in project_urls: + label, _, purl = urlspec.partition(",") + db_session.add( + ReleaseURL( + release=releases[3], + name=label.strip(), + url=purl.strip(), + ) + ) + + files = [ + FileFactory.create( + release=r, + filename=f"{project.name}-{r.version}.tar.gz", + python_version="source", + size=200, + ) + for r in releases[1:] + ] + user = UserFactory.create(username="guido") + RoleFactory.create(user=user, project=project) + RoleFactory.create(user=UserFactory.create(username="dstufft"), project=project) + RoleFactory.create( + user=UserFactory.create(username="ewdurbin"), + project=project, + role_name="Maintainer", + ) + JournalEntryFactory.reset_sequence() je = JournalEntryFactory.create(name=project.name, submitted_by=user) @@ -259,6 +488,10 @@ def test_renders(self, pyramid_config, db_request, db_session): "release_url": "/the/fake/url/", "requires_dist": None, "requires_python": None, + "roles": { + "owner": ["dstufft", "guido"], + "maintainer": ["ewdurbin"], + }, "summary": None, "yanked": False, "yanked_reason": None, @@ -533,7 +766,16 @@ def test_detail_renders(self, pyramid_config, db_request, db_session): ) for r in releases[1:] ] - user = UserFactory.create() + + user = UserFactory.create(username="guido") + RoleFactory.create(user=user, project=project) + RoleFactory.create(user=UserFactory.create(username="dstufft"), project=project) + RoleFactory.create( + user=UserFactory.create(username="ewdurbin"), + project=project, + role_name="Maintainer", + ) + JournalEntryFactory.reset_sequence() je = JournalEntryFactory.create(name=project.name, submitted_by=user) @@ -625,7 +867,12 @@ def test_minimal_renders(self, pyramid_config, db_request): size=200, ) - user = UserFactory.create() + user = UserFactory.create(username="dstufft") + RoleFactory.create(user=user, project=project) + RoleFactory.create( + user=UserFactory.create(username="ewdurbin"), project=project + ) + JournalEntryFactory.reset_sequence() je = JournalEntryFactory.create(name=project.name, submitted_by=user) diff --git a/warehouse/legacy/api/json.py b/warehouse/legacy/api/json.py index 3fabc633bb95..6fda3e9c1b88 100644 --- 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 defaultdict + from packaging.utils import canonicalize_name, canonicalize_version from pyramid.httpexceptions import HTTPMovedPermanently, HTTPNotFound from pyramid.view import view_config @@ -25,6 +27,8 @@ Project, Release, ReleaseURL, + Role, + User, ) from warehouse.utils.cors import _CORS_HEADERS @@ -188,6 +192,41 @@ def _json_data(request, project, release, *, all_releases): if all_releases: data["releases"] = releases + # All roles and their users associated with this project. Note that + # unlike everything other than `project.name` (which is immutable), + # this data comes from the project itself, not the latest release. It + # should only be included in the response when `all_releases` is True, + # because that's a signal that we're asking for the project view, not a + # specific release view. + # + # 2024-10-30(warsaw): It's convenient to use a defaultdict here. We + # could have alternatively used a base dict and the .setdefault() + # method, but it doesn't matter too much because we still have to + # post-process the data structure before we can pass it to the "info" + # key. The reason is that the JSON serializer doesn't know how to + # serialize defaultdicts, but it also doesn't know how to serialize + # sets, and we want to use a set just for uniqueness. Thus below we + # turn the defaultdict mapping role names to sets, into a base dict + # mapping role names to lists. I think it's moderately more efficient + # because the defaultdict only instantiates sets when the key is + # missing. + roles = defaultdict(set) + # Get all of the maintainers for this project. + for role in ( + request.db.query(Role) + .join(User) + .filter(Role.project == project) + .distinct(User.username) + .order_by(User.username) + .all() + ): + # 2024-10-30(warsaw): Normalizing the role name to lower case, but only + # because I think that looks better. This could introduce some + # friction in the future if we ever have a writable API that lets us + # add and modify roles. + roles[role.role_name.lower()].add(role.user.username) + + data["info"]["roles"] = {key: sorted(value) for key, value in roles.items()} return data