Skip to content

Commit 28a4710

Browse files
ryanpetrelloclaude
andcommitted
fix: stabilize RSS feed GUIDs for multi-arch wheel arrivals
Base GUIDs on PEP 427 build tags instead of timestamps so that additional platform wheels for the same build do not cause feed readers to show duplicate entries. Closes #1381 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Ryan Petrello <ryan@ryanpetrello.com>
1 parent a47e20f commit 28a4710

4 files changed

Lines changed: 166 additions & 8 deletions

File tree

CHANGES/1381.bugfix

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
RSS feed GUIDs are now stable when additional platform wheels arrive for the same release. GUIDs only change when a new PEP 427 build tag is introduced, preventing feed readers from showing duplicate entries for multi-architecture builds.

pulp_python/app/pypi/feeds.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from email.utils import getaddresses
33
from urllib.parse import urljoin
44

5+
from django.contrib.postgres.aggregates import ArrayAgg
56
from django.db.models import F, FilteredRelation, Max, Min, Q
67
from django.http.response import HttpResponse, HttpResponseNotFound
78
from django.utils.decorators import method_decorator
@@ -15,6 +16,8 @@
1516
from pulp_python.app.cache import PythonApiCache, find_base_path_cached
1617
from pulp_python.app.pypi.views import PyPIMixin, _etag_func
1718

19+
_WHEEL_BUILD_TAG_RE = re.compile(r"^.+?-.+?-(?P<build>\d[^-]*?)-[^-]+-[^-]+-[^-]+\.whl$")
20+
1821
UPDATES_LIMIT = 500
1922
PACKAGES_LIMIT = 40
2023
PROJECT_RELEASES_LIMIT = 40
@@ -70,6 +73,7 @@ def iter_releases(content, repo_ver, name_normalized=None, limit=UPDATES_LIMIT):
7073
name=Min("name"),
7174
summary=Min("summary"),
7275
author_email=Min("author_email"),
76+
filenames=ArrayAgg("filename", distinct=True, ordering="filename"),
7377
)
7478
.order_by("-added_at", "name_normalized", "version")[:limit]
7579
)
@@ -91,14 +95,30 @@ def iter_projects(content, repo_ver, limit=PACKAGES_LIMIT):
9195
)
9296

9397

94-
def _item_dict(title, link, description, author_email, pubdate):
98+
def _build_tag_fragment(filenames):
99+
"""Extract sorted distinct build tags from wheel filenames for GUID stability.
100+
101+
Returns a fragment like ``#builds=1,2`` when build tags are present,
102+
or an empty string for sdists and wheels without build tags.
103+
"""
104+
tags = set()
105+
for fn in filenames or ():
106+
m = _WHEEL_BUILD_TAG_RE.match(fn)
107+
if m:
108+
tags.add(m.group("build"))
109+
if not tags:
110+
return ""
111+
return "#builds=" + ",".join(sorted(tags))
112+
113+
114+
def _item_dict(title, link, description, author_email, pubdate, filenames=()):
95115
return {
96116
"title": sanitize_xml_text(title),
97117
"link": link,
98118
"description": sanitize_xml_text(description),
99119
"author_email": format_author(author_email),
100120
"pubdate": pubdate,
101-
"unique_id": f"{link}#{pubdate.isoformat()}",
121+
"unique_id": f"{link}{_build_tag_fragment(filenames)}",
102122
}
103123

104124

@@ -139,6 +159,7 @@ def render_updates_feed(index_url, releases):
139159
description=release["summary"],
140160
author_email=release["author_email"],
141161
pubdate=release["added_at"],
162+
filenames=release.get("filenames", ()),
142163
)
143164
for release in releases
144165
]
@@ -178,6 +199,7 @@ def render_project_releases_feed(index_url, project_name, releases):
178199
description=release["summary"],
179200
author_email=release["author_email"],
180201
pubdate=release["added_at"],
202+
filenames=release.get("filenames", ()),
181203
)
182204
for release in releases
183205
]

pulp_python/tests/functional/api/test_pypi_feeds.py

Lines changed: 74 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import io
2+
import zipfile
13
from urllib.parse import urljoin, urlsplit
24
from xml.etree import ElementTree as ET
35

@@ -20,6 +22,23 @@
2022
TWINE_500_WHEEL_URL = urljoin(urljoin(PYTHON_FIXTURES_URL, "packages/"), TWINE_500_WHEEL_FILENAME)
2123

2224

25+
def _make_build_tagged_wheel(tmp_path, base_wheel_bytes, build_tag, platform="linux_x86_64"):
26+
"""Repackage a wheel with a PEP 427 build tag and platform in the filename."""
27+
filename = f"shelf_reader-0.1-{build_tag}-py2-none-{platform}.whl"
28+
path = tmp_path / filename
29+
with zipfile.ZipFile(io.BytesIO(base_wheel_bytes)) as src, zipfile.ZipFile(path, "w") as dst:
30+
for item in src.infolist():
31+
dst.writestr(item, src.read(item.filename))
32+
# Pulp deduplicates content by sha256, so identical zip bytes with
33+
# different filenames map to the same content unit. Inject a unique
34+
# marker so each (build_tag, platform) combination gets its own sha256.
35+
dst.writestr(
36+
"shelf_reader/.build_marker",
37+
f"build={build_tag} platform={platform}\n",
38+
)
39+
return str(path), filename
40+
41+
2342
def _index_url(distro, bindings_cfg):
2443
"""Build the index URL using the same origin the API client uses."""
2544
path = urlsplit(distro.base_url).path
@@ -125,18 +144,18 @@ def test_pinned_version_feeds(
125144

126145
item = _parse_items(_get_feed(distro, "rss/updates.xml", bindings_cfg))[0]
127146
assert item.findtext("link").endswith("pypi/shelf-reader/0.1/json")
128-
assert "pypi/shelf-reader/0.1/json#" in item.findtext("guid")
147+
assert item.findtext("guid").endswith("pypi/shelf-reader/0.1/json")
129148

130149
python_content_factory(TWINE_WHEEL_FILENAME, url=TWINE_WHEEL_URL, repository=repo)
131150
update_titles = _titles(_parse_items(_get_feed(distro, "rss/updates.xml", bindings_cfg)))
132151
assert update_titles == ["shelf-reader 0.1"]
133152

134153

135154
@pytest.mark.parallel
136-
def test_new_file_for_existing_version_updates_guid(
155+
def test_guid_stable_when_adding_file_without_new_build_tag(
137156
bindings_cfg, python_content_factory, python_empty_repo_distro
138157
):
139-
"""Adding a new file for an existing (name, version) produces a new guid and updated date."""
158+
"""Adding a file without a new build tag keeps the GUID stable but updates pubDate."""
140159
repo, distro = python_empty_repo_distro()
141160

142161
python_content_factory(PYTHON_EGG_FILENAME, url=PYTHON_EGG_URL, repository=repo)
@@ -145,7 +164,7 @@ def test_new_file_for_existing_version_updates_guid(
145164
assert len(items) == 1
146165
first_guid = items[0].findtext("guid")
147166
first_date = items[0].findtext("pubDate")
148-
assert "pypi/shelf-reader/0.1/json" in first_guid
167+
assert first_guid.endswith("pypi/shelf-reader/0.1/json")
149168

150169
python_content_factory(PYTHON_WHEEL_FILENAME, url=PYTHON_WHEEL_URL, repository=repo)
151170

@@ -154,11 +173,60 @@ def test_new_file_for_existing_version_updates_guid(
154173
second_guid = items[0].findtext("guid")
155174
second_date = items[0].findtext("pubDate")
156175

157-
assert second_guid != first_guid
176+
assert second_guid == first_guid
158177
assert second_date >= first_date
159178

160179
release_items = _parse_items(
161180
_get_feed(distro, "rss/project/shelf-reader/releases.xml", bindings_cfg)
162181
)
163182
assert len(release_items) == 1
164-
assert release_items[0].findtext("guid") == second_guid
183+
assert release_items[0].findtext("guid") == first_guid
184+
185+
186+
@pytest.mark.parallel
187+
def test_new_build_tag_changes_guid(
188+
tmp_path, bindings_cfg, python_bindings, monitor_task, python_empty_repo_distro
189+
):
190+
"""A new build tag produces a new GUID; a new arch for the same tag does not."""
191+
repo, distro = python_empty_repo_distro()
192+
193+
base_wheel = requests.get(PYTHON_WHEEL_URL, timeout=30).content
194+
195+
# Upload build tag 1 (x86_64)
196+
path_1, name_1 = _make_build_tagged_wheel(tmp_path, base_wheel, "1")
197+
task = python_bindings.ContentPackagesApi.create(
198+
relative_path=name_1, file=path_1, repository=repo.pulp_href
199+
).task
200+
monitor_task(task)
201+
202+
items = _parse_items(_get_feed(distro, "rss/updates.xml", bindings_cfg))
203+
assert len(items) == 1
204+
guid_after_build1 = items[0].findtext("guid")
205+
assert "#builds=1" in guid_after_build1
206+
207+
# Upload build tag 2 (x86_64) -- different build, GUID must change
208+
path_2, name_2 = _make_build_tagged_wheel(tmp_path, base_wheel, "2")
209+
task = python_bindings.ContentPackagesApi.create(
210+
relative_path=name_2, file=path_2, repository=repo.pulp_href
211+
).task
212+
monitor_task(task)
213+
214+
items = _parse_items(_get_feed(distro, "rss/updates.xml", bindings_cfg))
215+
assert len(items) == 1
216+
guid_after_build2 = items[0].findtext("guid")
217+
assert guid_after_build2 != guid_after_build1
218+
assert "#builds=1,2" in guid_after_build2
219+
220+
# Upload build tag 2 again with a different arch -- same build tag, GUID must stay
221+
path_2_arm, name_2_arm = _make_build_tagged_wheel(
222+
tmp_path, base_wheel, "2", platform="linux_aarch64"
223+
)
224+
task = python_bindings.ContentPackagesApi.create(
225+
relative_path=name_2_arm, file=path_2_arm, repository=repo.pulp_href
226+
).task
227+
monitor_task(task)
228+
229+
items = _parse_items(_get_feed(distro, "rss/updates.xml", bindings_cfg))
230+
assert len(items) == 1
231+
guid_after_build2_arm = items[0].findtext("guid")
232+
assert guid_after_build2_arm == guid_after_build2
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import re
2+
3+
import pytest
4+
5+
# Duplicated here to avoid importing feeds.py, which pulls in Django/DRF and
6+
# requires a configured Django settings module that the unit test runner lacks.
7+
_WHEEL_BUILD_TAG_RE = re.compile(r"^.+?-.+?-(?P<build>\d[^-]*?)-[^-]+-[^-]+-[^-]+\.whl$")
8+
9+
10+
def _build_tag_fragment(filenames):
11+
tags = set()
12+
for fn in filenames or ():
13+
m = _WHEEL_BUILD_TAG_RE.match(fn)
14+
if m:
15+
tags.add(m.group("build"))
16+
if not tags:
17+
return ""
18+
return "#builds=" + ",".join(sorted(tags))
19+
20+
21+
@pytest.mark.parametrize(
22+
"filenames, expected",
23+
[
24+
([], ""),
25+
(["shelf-reader-0.1.tar.gz"], ""),
26+
(["shelf_reader-0.1-py2-none-any.whl"], ""),
27+
(
28+
["docling_parse-7.19.1-1-cp312-cp312-linux_x86_64.whl"],
29+
"#builds=1",
30+
),
31+
(
32+
[
33+
"docling_parse-7.19.1-1-cp312-cp312-linux_x86_64.whl",
34+
"docling_parse-7.19.1-1-cp312-cp312-linux_aarch64.whl",
35+
"docling_parse-7.19.1-1-cp312-cp312-linux_ppc64le.whl",
36+
],
37+
"#builds=1",
38+
),
39+
(
40+
[
41+
"ctranslate2-4.5.0-1-cp312-cp312-linux_x86_64.whl",
42+
"ctranslate2-4.5.0-2-cp312-cp312-linux_x86_64.whl",
43+
],
44+
"#builds=1,2",
45+
),
46+
(
47+
[
48+
"foo-1.0-1-cp312-cp312-linux_x86_64.whl",
49+
"foo-1.0.tar.gz",
50+
],
51+
"#builds=1",
52+
),
53+
(None, ""),
54+
],
55+
ids=[
56+
"empty",
57+
"sdist-only",
58+
"wheel-no-build-tag",
59+
"single-build-tag",
60+
"same-build-tag-multi-arch",
61+
"two-build-tags",
62+
"mixed-sdist-and-tagged-wheel",
63+
"none",
64+
],
65+
)
66+
def test_build_tag_fragment(filenames, expected):
67+
assert _build_tag_fragment(filenames) == expected

0 commit comments

Comments
 (0)