Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGES/1370.bugfix
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Prefer the PyPI Simple API JSON response when clients such as pip and uv advertise JSON alongside HTML.
13 changes: 11 additions & 2 deletions pulp_python/app/pypi/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -318,8 +318,17 @@ def get_renderers(self):
Uses custom renderers for PyPI Simple API endpoints, defaulting to standard ones.
"""
if self.action in ["list", "retrieve"]:
# Ordered by priority if multiple content types are present
return [TemplateHTMLRenderer(), PyPISimpleHTMLRenderer(), PyPISimpleJSONRenderer()]
# DRF resolves equally-specific media types in renderer order and does not
# account for q-values. Put the PyPI JSON renderer first when the client
# explicitly advertises it (as pip and uv do), otherwise retain HTML as the
# default for browser and legacy clients.
accept = self.request.META.get("HTTP_ACCEPT", "").lower()
renderers = [TemplateHTMLRenderer(), PyPISimpleHTMLRenderer()]
if PYPI_SIMPLE_V1_JSON in accept:
renderers.insert(0, PyPISimpleJSONRenderer())
else:
renderers.append(PyPISimpleJSONRenderer())
return renderers
else:
return [JSONRenderer(), BrowsableAPIRenderer()]

Expand Down
6 changes: 4 additions & 2 deletions pulp_python/tests/functional/api/test_pypi_simple_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,8 +169,8 @@ def test_simple_json_detail_api(
(PYPI_TEXT_HTML, PYPI_TEXT_HTML),
(PYPI_SIMPLE_V1_HTML, PYPI_SIMPLE_V1_HTML),
(PYPI_SIMPLE_V1_JSON, PYPI_SIMPLE_V1_JSON),
# Follows defined ordering (html, pypi html, pypi json)
(f"{PYPI_SIMPLE_V1_JSON}, {PYPI_SIMPLE_V1_HTML}", PYPI_SIMPLE_V1_HTML),
# Clients such as pip and uv advertise JSON first, with HTML as a fallback.
(f"{PYPI_SIMPLE_V1_JSON}, {PYPI_SIMPLE_V1_HTML}", PYPI_SIMPLE_V1_JSON),
# Everything else should be html
("", PYPI_TEXT_HTML),
("application/json", PYPI_TEXT_HTML),
Expand All @@ -191,3 +191,5 @@ def test_simple_api_content_headers(
response = requests.get(url, headers={"Accept": header})
assert response.status_code == 200
assert result in response.headers["Content-Type"]
if url == detail_url and result == PYPI_SIMPLE_V1_JSON:
assert all(file["upload-time"] for file in response.json()["files"])
15 changes: 7 additions & 8 deletions pulp_python/tests/functional/api/test_simple_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,14 +91,13 @@ def test_simple_cache_separate_accept_headers(synced_distro):


@pytest.mark.parallel
def test_simple_cache_format_json_does_not_poison_html(synced_distro):
def test_simple_cache_negotiated_media_types_are_separate(synced_distro):
"""
A ?format=json response must not poison a later request with the same Accept.
JSON and HTML responses must not poison each other in the cache.

Clients like uv/pip send an Accept that allows both JSON and HTML. DRF's
?format=json overrides negotiation to JSON, while the same Accept without
that query param selects HTML. Caching must key on the negotiated type so
the JSON entry is not served (and re-rendered) for the HTML request.
Clients like uv/pip send an Accept that allows both JSON and HTML. The
negotiated JSON response must be cached separately from an explicit HTML
response.
"""
url = f"{urljoin(synced_distro.base_url, 'simple/')}aiohttp"
# pip/uv-style Accept: JSON preferred, HTML still acceptable
Expand All @@ -112,13 +111,13 @@ def test_simple_cache_format_json_does_not_poison_html(synced_distro):
assert r_json.headers["X-PULP-CACHE"] == "MISS"
assert r_json.json()["name"] == "aiohttp"

r_html = requests.get(url, headers=headers)
r_html = requests.get(url, headers={"Accept": PYPI_TEXT_HTML})
assert r_html.status_code == 200
assert PYPI_TEXT_HTML in r_html.headers["Content-Type"]
assert r_html.headers["X-PULP-CACHE"] == "MISS"
assert b"<a href=" in r_html.content

r_html_hit = requests.get(url, headers=headers)
r_html_hit = requests.get(url, headers={"Accept": PYPI_TEXT_HTML})
assert r_html_hit.status_code == 200
assert r_html_hit.headers["X-PULP-CACHE"] == "HIT"
assert PYPI_TEXT_HTML in r_html_hit.headers["Content-Type"]
Expand Down
Loading