Skip to content

Commit e842c0a

Browse files
dklibanclaude
andcommitted
Add conditional-request support to PyPI metadata APIs
Add Last-Modified header and If-Modified-Since handling to SimpleView and MetadataView for Akamai Centralized Authorization. Add ETag, Cache-Control, and conditional request support to MetadataView which previously had none. Closes: #1338 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent bb15488 commit e842c0a

3 files changed

Lines changed: 194 additions & 5 deletions

File tree

CHANGES/1338.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added conditional request support (`Last-Modified` / `If-Modified-Since` and `ETag` / `If-None-Match`) to the Simple API and JSON Metadata API for improved cache efficiency.

pulp_python/app/pypi/views.py

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -72,17 +72,32 @@
7272
PYPI_SIMPLE_V1_JSON = "application/vnd.pypi.simple.v1+json"
7373

7474

75-
def _etag_func(request, path, **kwargs):
76-
"""Compute unquoted ETag for the condition decorator. Returns None if no repo."""
75+
def _get_repo_version(path):
76+
"""Resolve path to a RepositoryVersion, or None if not found."""
7777
try:
7878
distro = PyPIMixin.get_distribution(path)
79-
repo_ver = PyPIMixin.get_repository_version(distro)
79+
return PyPIMixin.get_repository_version(distro)
8080
except Http404:
8181
return None
82+
83+
84+
def _etag_func(request, path, **kwargs):
85+
"""Compute unquoted ETag for the condition decorator. Returns None if no repo."""
86+
repo_ver = _get_repo_version(path)
87+
if repo_ver is None:
88+
return None
8289
raw = f"{repo_ver.number}:{repo_ver.pulp_created.isoformat()}"
8390
return hashlib.sha256(raw.encode()).hexdigest()[:16]
8491

8592

93+
def _last_modified_func(request, path, **kwargs):
94+
"""Return the repository version creation timestamp for Last-Modified."""
95+
repo_ver = _get_repo_version(path)
96+
if repo_ver is None:
97+
return None
98+
return repo_ver.pulp_created
99+
100+
86101
class PyPISimpleHTMLRenderer(TemplateHTMLRenderer):
87102
media_type = PYPI_SIMPLE_V1_HTML
88103

@@ -317,7 +332,7 @@ def get_provenance_url(self, package, version, filename):
317332

318333
@extend_schema(summary="Get index simple page")
319334
@method_decorator(cache_control(max_age=600, public=True))
320-
@method_decorator(condition(etag_func=_etag_func))
335+
@method_decorator(condition(etag_func=_etag_func, last_modified_func=_last_modified_func))
321336
@PythonApiCache(base_key=find_base_path_cached)
322337
def list(self, request, path):
323338
"""Gets the simple api html page for the index."""
@@ -379,7 +394,7 @@ def parse_package(release_package):
379394

380395
@extend_schema(operation_id="pypi_simple_package_read", summary="Get package simple page")
381396
@method_decorator(cache_control(max_age=600, public=True))
382-
@method_decorator(condition(etag_func=_etag_func))
397+
@method_decorator(condition(etag_func=_etag_func, last_modified_func=_last_modified_func))
383398
@PythonApiCache(base_key=find_base_path_cached)
384399
def retrieve(self, request, path, package):
385400
"""Retrieves the simple api html/json page for a package."""
@@ -482,6 +497,8 @@ class MetadataView(PyPIMixin, ViewSet):
482497
responses={200: PackageMetadataSerializer},
483498
summary="Get package metadata",
484499
)
500+
@method_decorator(cache_control(max_age=900, public=True))
501+
@method_decorator(condition(etag_func=_etag_func, last_modified_func=_last_modified_func))
485502
def retrieve(self, request, path, meta):
486503
"""
487504
Retrieves the package's core-metadata specified by

pulp_python/tests/functional/api/test_simple_cache.py

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from email.utils import parsedate_to_datetime
12
from urllib.parse import urljoin
23

34
import pytest
@@ -35,6 +36,20 @@ def synced_distro(
3536
return python_distribution_factory(repository=repo)
3637

3738

39+
@pytest.fixture
40+
def synced_distro_no_cache(
41+
python_remote_factory,
42+
python_repo_with_sync,
43+
python_distribution_factory,
44+
):
45+
"""
46+
Sync a repo and create a distribution (no cache requirement).
47+
"""
48+
remote = python_remote_factory(includes=PYTHON_SM_PROJECT_SPECIFIER)
49+
repo = python_repo_with_sync(remote)
50+
return python_distribution_factory(repository=repo)
51+
52+
3853
@pytest.mark.parallel
3954
def test_simple_cache_hit_miss_and_headers(synced_distro):
4055
"""
@@ -139,3 +154,159 @@ def test_simple_cache_etag_conditional_request(synced_distro):
139154
assert r3.headers["Cache-Control"] == cache_control
140155
assert r3.headers["X-PULP-CACHE"] == "HIT"
141156
assert len(r3.content) > 0
157+
158+
159+
@pytest.mark.parallel
160+
def test_simple_last_modified_header(synced_distro_no_cache):
161+
"""Simple API responses include Last-Modified header."""
162+
index_url = urljoin(synced_distro_no_cache.base_url, "simple/")
163+
detail_url = f"{index_url}aiohttp"
164+
165+
for url in [index_url, detail_url]:
166+
r = requests.get(url)
167+
assert r.status_code == 200
168+
assert "Last-Modified" in r.headers
169+
parsedate_to_datetime(r.headers["Last-Modified"])
170+
171+
172+
@pytest.mark.parallel
173+
def test_simple_if_modified_since_304(synced_distro_no_cache):
174+
"""If-Modified-Since with matching timestamp returns 304."""
175+
url = urljoin(synced_distro_no_cache.base_url, "simple/")
176+
177+
r1 = requests.get(url)
178+
assert r1.status_code == 200
179+
last_modified = r1.headers["Last-Modified"]
180+
181+
r2 = requests.get(url, headers={"If-Modified-Since": last_modified})
182+
assert r2.status_code == 304
183+
assert len(r2.content) == 0
184+
185+
186+
@pytest.mark.parallel
187+
def test_simple_if_modified_since_old_timestamp_200(synced_distro_no_cache):
188+
"""If-Modified-Since with old timestamp returns 200 with content."""
189+
url = urljoin(synced_distro_no_cache.base_url, "simple/")
190+
191+
r1 = requests.get(url)
192+
assert r1.status_code == 200
193+
194+
r2 = requests.get(url, headers={"If-Modified-Since": "Thu, 01 Jan 2009 00:00:00 GMT"})
195+
assert r2.status_code == 200
196+
assert len(r2.content) > 0
197+
198+
199+
@pytest.mark.parallel
200+
def test_metadata_conditional_request_headers(synced_distro_no_cache):
201+
"""JSON metadata responses include ETag, Last-Modified, and Cache-Control headers."""
202+
url = urljoin(synced_distro_no_cache.base_url, "pypi/aiohttp/json/")
203+
204+
r = requests.get(url)
205+
assert r.status_code == 200
206+
assert r.headers["Cache-Control"] == "max-age=900, public"
207+
assert "ETag" in r.headers
208+
assert r.headers["ETag"].startswith('"') and r.headers["ETag"].endswith('"')
209+
assert "Last-Modified" in r.headers
210+
parsedate_to_datetime(r.headers["Last-Modified"])
211+
212+
213+
@pytest.mark.parallel
214+
def test_metadata_etag_conditional_request(synced_distro_no_cache):
215+
"""JSON metadata: matching If-None-Match returns 304, non-matching returns 200."""
216+
url = urljoin(synced_distro_no_cache.base_url, "pypi/aiohttp/json/")
217+
218+
r1 = requests.get(url)
219+
assert r1.status_code == 200
220+
etag = r1.headers["ETag"]
221+
222+
r2 = requests.get(url, headers={"If-None-Match": etag})
223+
assert r2.status_code == 304
224+
assert len(r2.content) == 0
225+
226+
r3 = requests.get(url, headers={"If-None-Match": '"old"'})
227+
assert r3.status_code == 200
228+
assert r3.headers["ETag"] == etag
229+
230+
231+
@pytest.mark.parallel
232+
def test_metadata_if_modified_since_304(synced_distro_no_cache):
233+
"""JSON metadata: If-Modified-Since with matching timestamp returns 304."""
234+
url = urljoin(synced_distro_no_cache.base_url, "pypi/aiohttp/json/")
235+
236+
r1 = requests.get(url)
237+
assert r1.status_code == 200
238+
last_modified = r1.headers["Last-Modified"]
239+
240+
r2 = requests.get(url, headers={"If-Modified-Since": last_modified})
241+
assert r2.status_code == 304
242+
assert len(r2.content) == 0
243+
244+
245+
@pytest.mark.parallel
246+
def test_metadata_if_modified_since_old_timestamp_200(synced_distro_no_cache):
247+
"""JSON metadata: If-Modified-Since with old timestamp returns 200."""
248+
url = urljoin(synced_distro_no_cache.base_url, "pypi/aiohttp/json/")
249+
250+
r1 = requests.get(url)
251+
assert r1.status_code == 200
252+
253+
r2 = requests.get(url, headers={"If-Modified-Since": "Thu, 01 Jan 2009 00:00:00 GMT"})
254+
assert r2.status_code == 200
255+
assert len(r2.content) > 0
256+
257+
258+
def test_unauthorized_gets_403_not_304(synced_distro_no_cache, pulpcore_bindings, bindings_cfg):
259+
"""Unauthorized client gets 403, not 304, even with conditional request headers."""
260+
admin_auth = (bindings_cfg.username, bindings_cfg.password)
261+
simple_url = urljoin(synced_distro_no_cache.base_url, "simple/")
262+
263+
r1 = requests.get(simple_url, auth=admin_auth)
264+
assert r1.status_code == 200
265+
last_modified = r1.headers["Last-Modified"]
266+
etag = r1.headers["ETag"]
267+
268+
ap_response = pulpcore_bindings.AccessPoliciesApi.list(viewset_name="pypi/simple")
269+
assert ap_response.count == 1
270+
ap_href = ap_response.results[0].pulp_href
271+
272+
anon = requests.Session()
273+
anon.trust_env = False
274+
anon.verify = False
275+
276+
277+
try:
278+
pulpcore_bindings.AccessPoliciesApi.partial_update(
279+
ap_href,
280+
{
281+
"statements": [
282+
{
283+
"action": ["list", "retrieve"],
284+
"principal": "authenticated",
285+
"effect": "allow",
286+
},
287+
{
288+
"action": ["create"],
289+
"principal": "authenticated",
290+
"effect": "allow",
291+
"condition": "index_has_repo_perm:python.modify_pythonrepository",
292+
},
293+
],
294+
},
295+
)
296+
297+
r_ims = anon.get(simple_url, headers={"If-Modified-Since": last_modified})
298+
assert r_ims.status_code == 403, (
299+
f"Expected 403 for unauthorized If-Modified-Since, got {r_ims.status_code}"
300+
)
301+
302+
r_inm = anon.get(simple_url, headers={"If-None-Match": etag})
303+
assert r_inm.status_code == 403, (
304+
f"Expected 403 for unauthorized If-None-Match, got {r_inm.status_code}"
305+
)
306+
307+
r_authed = requests.get(
308+
simple_url, auth=admin_auth, headers={"If-Modified-Since": last_modified}
309+
)
310+
assert r_authed.status_code == 304
311+
finally:
312+
pulpcore_bindings.AccessPoliciesApi.reset(ap_href)

0 commit comments

Comments
 (0)