Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Provide a project's user roles via the JSON API #17000

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
4 changes: 4 additions & 0 deletions docs/dev/api-reference/json.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
253 changes: 250 additions & 3 deletions tests/unit/legacy/api/test_json.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
JournalEntryFactory,
ProjectFactory,
ReleaseFactory,
RoleFactory,
)


Expand Down Expand Up @@ -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://[email protected]: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)

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand Down
39 changes: 39 additions & 0 deletions warehouse/legacy/api/json.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -25,6 +27,8 @@
Project,
Release,
ReleaseURL,
Role,
User,
)
from warehouse.utils.cors import _CORS_HEADERS

Expand Down Expand Up @@ -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

Expand Down