diff --git a/CHANGES/1370.bugfix b/CHANGES/1370.bugfix new file mode 100644 index 00000000..577cff67 --- /dev/null +++ b/CHANGES/1370.bugfix @@ -0,0 +1 @@ +Prefer the PyPI Simple API JSON response when clients such as pip and uv advertise JSON alongside HTML. diff --git a/pulp_python/app/pypi/views.py b/pulp_python/app/pypi/views.py index daa51b0d..a9ed6a6c 100644 --- a/pulp_python/app/pypi/views.py +++ b/pulp_python/app/pypi/views.py @@ -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()] diff --git a/pulp_python/tests/functional/api/test_pypi_simple_api.py b/pulp_python/tests/functional/api/test_pypi_simple_api.py index 7a7ff866..44e41dd5 100644 --- a/pulp_python/tests/functional/api/test_pypi_simple_api.py +++ b/pulp_python/tests/functional/api/test_pypi_simple_api.py @@ -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), @@ -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"]) diff --git a/pulp_python/tests/functional/api/test_simple_cache.py b/pulp_python/tests/functional/api/test_simple_cache.py index 3f449147..bb57b7d1 100644 --- a/pulp_python/tests/functional/api/test_simple_cache.py +++ b/pulp_python/tests/functional/api/test_simple_cache.py @@ -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 @@ -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"