Skip to content

Commit 9847c99

Browse files
committed
Fix upload_time to use repo addition time
closes #1232 Assisted By: Claude Opus 4.6
1 parent 793505e commit 9847c99

6 files changed

Lines changed: 84 additions & 12 deletions

File tree

CHANGES/1232.bugfix

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fixed `upload_time` in Simple and JSON APIs to reflect repository addition time instead of content creation time.

pulp_python/app/pypi/views.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from django.contrib.sessions.models import Session
88
from django.core.exceptions import ObjectDoesNotExist
99
from django.db import transaction
10+
from django.db.models import OuterRef, Subquery
1011
from django.db.utils import DatabaseError
1112
from django.http.response import (
1213
Http404,
@@ -25,6 +26,7 @@
2526
from rest_framework.response import Response
2627
from rest_framework.viewsets import ViewSet
2728

29+
from pulpcore.plugin.models import RepositoryContent
2830
from pulpcore.plugin.tasking import dispatch
2931
from pulpcore.plugin.util import get_domain, get_url
3032
from pulpcore.plugin.viewsets import OperationPostponedResponse
@@ -366,13 +368,20 @@ def retrieve(self, request, path, package):
366368
return redirect(urljoin(self.base_content_url, f"{path}/simple/{normalized}/"))
367369
if content is not None:
368370
local_packages = content.filter(name_normalized=normalized)
369-
packages = local_packages.values(
371+
repo_added_subquery = RepositoryContent.objects.filter(
372+
content_id=OuterRef("pk"),
373+
repository=repo_ver.repository,
374+
version_removed=None,
375+
).values("pulp_created")[:1]
376+
packages = local_packages.annotate(
377+
repo_added_time=Subquery(repo_added_subquery)
378+
).values(
370379
"filename",
371380
"sha256",
372381
"metadata_sha256",
373382
"requires_python",
374383
"size",
375-
"pulp_created",
384+
"repo_added_time",
376385
"version",
377386
)
378387
provenances = PackageProvenance.objects.filter(package__in=local_packages).values_list(
@@ -382,7 +391,7 @@ def retrieve(self, request, path, package):
382391
p["filename"]: {
383392
**p,
384393
"url": urljoin(self.base_content_url, f"{path}/{p['filename']}"),
385-
"upload_time": p["pulp_created"],
394+
"upload_time": p["repo_added_time"],
386395
"provenance": (
387396
self.get_provenance_url(normalized, p["version"], p["filename"])
388397
if p["filename"] in provenances
@@ -464,7 +473,11 @@ def retrieve(self, request, path, meta):
464473
if settings.DOMAIN_ENABLED:
465474
domain = get_domain()
466475
json_body = python_content_to_json(
467-
path, package_content, version=version, domain=domain
476+
path,
477+
package_content,
478+
version=version,
479+
domain=domain,
480+
repository_version=repo_ver,
468481
)
469482
if json_body:
470483
return Response(data=json_body, headers=headers)

pulp_python/app/utils.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import pkginfo
1212
from aiohttp.client_exceptions import ClientError
1313
from django.conf import settings
14+
from django.db.models import OuterRef, Subquery
1415
from django.db.utils import IntegrityError
1516
from jinja2 import Template
1617
from packaging.requirements import Requirement
@@ -19,7 +20,7 @@
1920
from pypi_simple import ACCEPT_JSON_PREFERRED, ProjectPage
2021

2122
from pulpcore.plugin.exceptions import TimeoutException
22-
from pulpcore.plugin.models import Artifact, Remote
23+
from pulpcore.plugin.models import Artifact, Remote, RepositoryContent
2324
from pulpcore.plugin.util import get_domain
2425

2526
log = logging.getLogger(__name__)
@@ -359,7 +360,9 @@ def fetch_json_release_metadata(name: str, version: str, remotes: set[Remote]) -
359360
raise Exception(f"Failed to fetch {url} from any remote.")
360361

361362

362-
def python_content_to_json(base_path, content_query, version=None, domain=None):
363+
def python_content_to_json(
364+
base_path, content_query, version=None, domain=None, repository_version=None
365+
):
363366
"""
364367
Converts a QuerySet of PythonPackageContent into the PyPi JSON format
365368
https://www.python.org/dev/peps/pep-0566/
@@ -371,6 +374,13 @@ def python_content_to_json(base_path, content_query, version=None, domain=None):
371374
372375
Returns None if version is specified but not found within content_query
373376
"""
377+
if repository_version:
378+
repo_added_subquery = RepositoryContent.objects.filter(
379+
content_id=OuterRef("pk"),
380+
repository=repository_version.repository,
381+
version_removed=None,
382+
).values("pulp_created")[:1]
383+
content_query = content_query.annotate(repo_added_time=Subquery(repo_added_subquery))
374384
full_metadata = {"last_serial": 0} # For now the serial field isn't supported by Pulp
375385
latest_content = latest_content_version(content_query, version)
376386
if not latest_content:
@@ -515,8 +525,10 @@ def find_artifact():
515525
"python_version": content.python_version,
516526
"requires_python": content.requires_python or None,
517527
"size": content.size,
518-
"upload_time": str(content.pulp_created),
519-
"upload_time_iso_8601": str(content.pulp_created.isoformat()),
528+
"upload_time": str(getattr(content, "repo_added_time", None) or content.pulp_created),
529+
"upload_time_iso_8601": str(
530+
(getattr(content, "repo_added_time", None) or content.pulp_created).isoformat()
531+
),
520532
"url": url,
521533
"yanked": False,
522534
"yanked_reason": None,

pulp_python/tests/functional/api/test_pypi_apis.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,23 @@
11
import subprocess
2+
import time
3+
from datetime import datetime
24
from urllib.parse import urljoin
35

46
import pytest
57
import requests
68

79
from pulp_python.tests.functional.constants import (
810
PYPI_SERIAL_CONSTANT,
11+
PYPI_SIMPLE_V1_JSON,
912
PYTHON_EGG_FILENAME,
1013
PYTHON_EGG_SHA256,
1114
PYTHON_MD_PROJECT_SPECIFIER,
1215
PYTHON_MD_PYPI_SUMMARY,
1316
PYTHON_WHEEL_FILENAME,
1417
PYTHON_WHEEL_SHA256,
1518
SHELF_PYTHON_JSON,
19+
TWINE_WHEEL_FILENAME,
20+
TWINE_WHEEL_URL,
1621
)
1722
from pulp_python.tests.functional.utils import ensure_metadata
1823

@@ -328,3 +333,41 @@ def assert_download_info(expected, received, message="Failed to match"):
328333
matched = True
329334
break
330335
assert matched is True, message
336+
337+
338+
@pytest.mark.parallel
339+
def test_upload_time_reflects_repo_addition(
340+
monitor_task,
341+
python_bindings,
342+
python_content_factory,
343+
python_distribution_factory,
344+
python_repo_factory,
345+
):
346+
"""
347+
Test that upload_time reflects repository addition time instead of content creation time.
348+
Checks both Simple and JSON APIs.
349+
"""
350+
content = python_content_factory(TWINE_WHEEL_FILENAME, url=TWINE_WHEEL_URL)
351+
content_created = datetime.fromisoformat(
352+
str(python_bindings.ContentPackagesApi.read(content.pulp_href).pulp_created)
353+
)
354+
time.sleep(2)
355+
356+
repo = python_repo_factory()
357+
body = {"add_content_units": [content.pulp_href]}
358+
monitor_task(python_bindings.RepositoriesPythonApi.modify(repo.pulp_href, body).task)
359+
distro = python_distribution_factory(repository=repo)
360+
361+
# Simple API
362+
headers = {"Accept": PYPI_SIMPLE_V1_JSON}
363+
resp = requests.get(f"{urljoin(distro.base_url, 'simple/')}twine", headers=headers)
364+
assert resp.status_code == 200
365+
simple_time = datetime.fromisoformat(resp.json()["files"][0]["upload-time"])
366+
assert simple_time > content_created
367+
368+
# JSON API
369+
json_resp = requests.get(urljoin(distro.base_url, "pypi/twine/json"))
370+
assert json_resp.status_code == 200
371+
json_time = datetime.fromisoformat(json_resp.json()["urls"][0]["upload_time"])
372+
assert json_time > content_created
373+
assert json_time == simple_time

pulp_python/tests/functional/api/test_pypi_simple_api.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55

66
from pulp_python.tests.functional.constants import (
77
PYPI_SERIAL_CONSTANT,
8+
PYPI_SIMPLE_V1_HTML,
9+
PYPI_SIMPLE_V1_JSON,
10+
PYPI_TEXT_HTML,
811
PYTHON_SM_FIXTURE_CHECKSUMS,
912
PYTHON_SM_FIXTURE_RELEASES,
1013
PYTHON_SM_PROJECT_SPECIFIER,
@@ -27,10 +30,6 @@
2730

2831
API_VERSION = "1.1"
2932

30-
PYPI_TEXT_HTML = "text/html"
31-
PYPI_SIMPLE_V1_HTML = "application/vnd.pypi.simple.v1+html"
32-
PYPI_SIMPLE_V1_JSON = "application/vnd.pypi.simple.v1+json"
33-
3433

3534
@pytest.mark.parallel
3635
def test_simple_html_index_api(

pulp_python/tests/functional/constants.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,3 +382,7 @@
382382
]
383383

384384
PYPI_SERIAL_CONSTANT = 1000000000
385+
386+
PYPI_TEXT_HTML = "text/html"
387+
PYPI_SIMPLE_V1_HTML = "application/vnd.pypi.simple.v1+html"
388+
PYPI_SIMPLE_V1_JSON = "application/vnd.pypi.simple.v1+json"

0 commit comments

Comments
 (0)