Skip to content

Commit

Permalink
Implement Alternate Repository Location for PEP 708 (#15716)
Browse files Browse the repository at this point in the history
* initial attempt at adding alternate repository location details

* implement per-project alternate locations metadata

* starting to add tests

* starting to add tests

* added tests

Fixed rendering for detail.html.
Moved api mimetypes to const vars.
Check delete confirmation name matches.

* updated translations

* satisfy test coverage

* update translations

* update translations

* register cache and purge keys for AlternateRepository objects

* change db migration down revision to most recent migration

This allows the migrations to run.

* update test after adding alternate repository cache and purge key

* increment api version to 1.2

* add url the response was fetched from

* change db migration down revision to most recent migration

This allows the migrations to run.

* name is already normalized

* update translations

* match functionality between JSON and HTML simple API

- route_path -> route_url to get full URL rather than path
- move self reference to the _simple_detail helper

* update migration

* remove self-reference from Simple HTML and JSON

The PEP reads as though they can be implied "When using alternate locations, clients MUST implicitly assume that the url the response was fetched from was included in the list."

* add a callout in project management settings around Alternate Locations

* translations

---------

Co-authored-by: Ee Durbin <[email protected]>
  • Loading branch information
cofiem and ewdurbin authored Sep 19, 2024
1 parent 453244f commit d3ed6e0
Show file tree
Hide file tree
Showing 17 changed files with 1,101 additions and 101 deletions.
11 changes: 11 additions & 0 deletions tests/common/db/packaging.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@

from warehouse.observations.models import ObservationKind
from warehouse.packaging.models import (
AlternateRepository,
Dependency,
DependencyKind,
Description,
Expand Down Expand Up @@ -200,3 +201,13 @@ class Meta:
)
name = factory.Faker("pystr", max_chars=12)
prohibited_by = factory.SubFactory(UserFactory)


class AlternateRepositoryFactory(WarehouseFactory):
class Meta:
model = AlternateRepository

name = factory.Faker("word")
url = factory.Faker("uri")
description = factory.Faker("text")
project = factory.SubFactory(ProjectFactory)
72 changes: 51 additions & 21 deletions tests/unit/api/test_simple.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,11 @@
from pyramid.testing import DummyRequest

from warehouse.api import simple
from warehouse.packaging.utils import API_VERSION
from warehouse.packaging.utils import API_VERSION, _valid_simple_detail_context

from ...common.db.accounts import UserFactory
from ...common.db.packaging import (
AlternateRepositoryFactory,
FileFactory,
JournalEntryFactory,
ProjectFactory,
Expand All @@ -48,29 +49,30 @@ def test_defaults_text_html(self, header):
default to text/html.
"""
request = DummyRequest(accept=header)
assert simple._select_content_type(request) == "text/html"
assert simple._select_content_type(request) == simple.MIME_TEXT_HTML

@pytest.mark.parametrize(
("header", "expected"),
[
("text/html", "text/html"),
(simple.MIME_TEXT_HTML, simple.MIME_TEXT_HTML),
(
"application/vnd.pypi.simple.v1+html",
"application/vnd.pypi.simple.v1+html",
simple.MIME_PYPI_SIMPLE_V1_HTML,
simple.MIME_PYPI_SIMPLE_V1_HTML,
),
(
"application/vnd.pypi.simple.v1+json",
"application/vnd.pypi.simple.v1+json",
simple.MIME_PYPI_SIMPLE_V1_JSON,
simple.MIME_PYPI_SIMPLE_V1_JSON,
),
(
"text/html, application/vnd.pypi.simple.v1+html, "
"application/vnd.pypi.simple.v1+json",
"text/html",
f"{simple.MIME_TEXT_HTML}, {simple.MIME_PYPI_SIMPLE_V1_HTML}, "
f"{simple.MIME_PYPI_SIMPLE_V1_JSON}",
simple.MIME_TEXT_HTML,
),
(
"text/html;q=0.01, application/vnd.pypi.simple.v1+html;q=0.2, "
"application/vnd.pypi.simple.v1+json",
"application/vnd.pypi.simple.v1+json",
f"{simple.MIME_TEXT_HTML};q=0.01, "
f"{simple.MIME_PYPI_SIMPLE_V1_HTML};q=0.2, "
f"{simple.MIME_PYPI_SIMPLE_V1_JSON}",
simple.MIME_PYPI_SIMPLE_V1_JSON,
),
],
)
Expand All @@ -80,9 +82,9 @@ def test_selects(self, header, expected):


CONTENT_TYPE_PARAMS = [
("text/html", None),
("application/vnd.pypi.simple.v1+html", None),
("application/vnd.pypi.simple.v1+json", "json"),
(simple.MIME_TEXT_HTML, None),
(simple.MIME_PYPI_SIMPLE_V1_HTML, None),
(simple.MIME_PYPI_SIMPLE_V1_JSON, "json"),
]


Expand Down Expand Up @@ -211,12 +213,15 @@ def test_no_files_no_serial(self, db_request, content_type, renderer_override):
user = UserFactory.create()
JournalEntryFactory.create(submitted_by=user)

assert simple.simple_detail(project, db_request) == {
context = {
"meta": {"_last-serial": 0, "api-version": API_VERSION},
"name": project.normalized_name,
"files": [],
"versions": [],
"alternate-locations": [],
}
context = _update_context(context, content_type, renderer_override)
assert simple.simple_detail(project, db_request) == context

assert db_request.response.headers["X-PyPI-Last-Serial"] == "0"
assert db_request.response.content_type == content_type
Expand All @@ -235,13 +240,20 @@ def test_no_files_with_serial(self, db_request, content_type, renderer_override)
db_request.matchdict["name"] = project.normalized_name
user = UserFactory.create()
je = JournalEntryFactory.create(name=project.name, submitted_by=user)
als = [
AlternateRepositoryFactory.create(project=project),
AlternateRepositoryFactory.create(project=project),
]

assert simple.simple_detail(project, db_request) == {
context = {
"meta": {"_last-serial": je.id, "api-version": API_VERSION},
"name": project.normalized_name,
"files": [],
"versions": [],
"alternate-locations": sorted(al.url for al in als),
}
context = _update_context(context, content_type, renderer_override)
assert simple.simple_detail(project, db_request) == context

assert db_request.response.headers["X-PyPI-Last-Serial"] == str(je.id)
assert db_request.response.content_type == content_type
Expand Down Expand Up @@ -271,7 +283,7 @@ def test_with_files_no_serial(self, db_request, content_type, renderer_override)
user = UserFactory.create()
JournalEntryFactory.create(submitted_by=user)

assert simple.simple_detail(project, db_request) == {
context = {
"meta": {"_last-serial": 0, "api-version": API_VERSION},
"name": project.normalized_name,
"versions": release_versions,
Expand All @@ -289,7 +301,10 @@ def test_with_files_no_serial(self, db_request, content_type, renderer_override)
}
for f in files
],
"alternate-locations": [],
}
context = _update_context(context, content_type, renderer_override)
assert simple.simple_detail(project, db_request) == context

assert db_request.response.headers["X-PyPI-Last-Serial"] == "0"
assert db_request.response.content_type == content_type
Expand Down Expand Up @@ -319,7 +334,7 @@ def test_with_files_with_serial(self, db_request, content_type, renderer_overrid
user = UserFactory.create()
je = JournalEntryFactory.create(name=project.name, submitted_by=user)

assert simple.simple_detail(project, db_request) == {
context = {
"meta": {"_last-serial": je.id, "api-version": API_VERSION},
"name": project.normalized_name,
"versions": release_versions,
Expand All @@ -337,7 +352,10 @@ def test_with_files_with_serial(self, db_request, content_type, renderer_overrid
}
for f in files
],
"alternate-locations": [],
}
context = _update_context(context, content_type, renderer_override)
assert simple.simple_detail(project, db_request) == context

assert db_request.response.headers["X-PyPI-Last-Serial"] == str(je.id)
assert db_request.response.content_type == content_type
Expand Down Expand Up @@ -404,7 +422,7 @@ def test_with_files_with_version_multi_digit(
user = UserFactory.create()
je = JournalEntryFactory.create(name=project.name, submitted_by=user)

assert simple.simple_detail(project, db_request) == {
context = {
"meta": {"_last-serial": je.id, "api-version": API_VERSION},
"name": project.normalized_name,
"versions": release_versions,
Expand All @@ -430,7 +448,10 @@ def test_with_files_with_version_multi_digit(
}
for f in files
],
"alternate-locations": [],
}
context = _update_context(context, content_type, renderer_override)
assert simple.simple_detail(project, db_request) == context

assert db_request.response.headers["X-PyPI-Last-Serial"] == str(je.id)
assert db_request.response.content_type == content_type
Expand All @@ -439,6 +460,15 @@ def test_with_files_with_version_multi_digit(
if renderer_override is not None:
assert db_request.override_renderer == renderer_override


def _update_context(context, content_type, renderer_override):

This comment has been minimized.

Copy link
@woodruffw

woodruffw Sep 23, 2024

Member

I might be missing something, but I think this insertion causes the following test (test_with_files_quarantined_omitted_from_index) to be skipped since it's now indented within this helper function. I'll open a PR to fix that.

if renderer_override != "json" or content_type in [
simple.MIME_TEXT_HTML,
simple.MIME_PYPI_SIMPLE_V1_HTML,
]:
return _valid_simple_detail_context(context)
return context

def test_with_files_quarantined_omitted_from_index(self, db_request):
db_request.accept = "text/html"
project = ProjectFactory.create(lifecycle_status="quarantine-enter")
Expand Down
Loading

0 comments on commit d3ed6e0

Please sign in to comment.