pulp-python 3.34.0__py3-none-any.whl → 3.36.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,7 +12,7 @@ class PulpPythonPluginAppConfig(PulpPluginAppConfig):
12
12
 
13
13
  name = "pulp_python.app"
14
14
  label = "python"
15
- version = "3.34.0"
15
+ version = "3.36.0"
16
16
  python_package_name = "pulp-python"
17
17
  domain_compatible = True
18
18
 
@@ -28,6 +28,7 @@ class PulpPythonPluginAppConfig(PulpPluginAppConfig):
28
28
 
29
29
  # TODO: Remove this when https://github.com/pulp/pulpcore/issues/5500 is resolved
30
30
  def _populate_pypi_access_policies(sender, apps, verbosity, **kwargs):
31
+ from pulp_python.app.pypi.feeds import FeedView
31
32
  from pulp_python.app.pypi.views import MetadataView, PyPIView, SimpleView, UploadView
32
33
 
33
34
  try:
@@ -37,7 +38,7 @@ def _populate_pypi_access_policies(sender, apps, verbosity, **kwargs):
37
38
  print(_("AccessPolicy model does not exist. Skipping initialization."))
38
39
  return
39
40
 
40
- for viewset in (PyPIView, SimpleView, UploadView, MetadataView):
41
+ for viewset in (PyPIView, SimpleView, UploadView, MetadataView, FeedView):
41
42
  access_policy = getattr(viewset, "DEFAULT_ACCESS_POLICY", None)
42
43
  if access_policy is not None:
43
44
  viewset_name = viewset.urlpattern()
@@ -0,0 +1,252 @@
1
+ import re
2
+ from email.utils import getaddresses
3
+ from urllib.parse import urljoin
4
+
5
+ from django.db.models import F, FilteredRelation, Min, Q
6
+ from django.http.response import HttpResponse, HttpResponseNotFound
7
+ from django.utils.decorators import method_decorator
8
+ from django.utils.feedgenerator import Rss201rev2Feed
9
+ from django.views.decorators.cache import cache_control
10
+ from django.views.decorators.http import condition
11
+ from drf_spectacular.utils import extend_schema
12
+ from packaging.utils import canonicalize_name
13
+ from rest_framework.viewsets import ViewSet
14
+
15
+ from pulp_python.app.cache import PythonApiCache, find_base_path_cached
16
+ from pulp_python.app.pypi.views import PyPIMixin, _etag_func
17
+
18
+ UPDATES_LIMIT = 100
19
+ PACKAGES_LIMIT = 40
20
+ PROJECT_RELEASES_LIMIT = 40
21
+ RSS_CONTENT_TYPE = "application/rss+xml; charset=utf-8"
22
+
23
+ # XML 1.0 disallowed characters (Django still escapes <>&).
24
+ _INVALID_XML_CHARS = re.compile("[\x00-\x08\x0b\x0c\x0e-\x1f\ud800-\udfff\ufffe\uffff]")
25
+
26
+
27
+ def sanitize_xml_text(value):
28
+ """Return a string safe to place in an RSS text field."""
29
+ if not value:
30
+ return ""
31
+ return _INVALID_XML_CHARS.sub("", str(value))
32
+
33
+
34
+ def format_author(author_email):
35
+ """Return an RSS author email, or None if the value is not a usable address."""
36
+ if not author_email:
37
+ return None
38
+ emails = []
39
+ for _, email in getaddresses([author_email]):
40
+ if "@" not in email:
41
+ return None
42
+ emails.append(email)
43
+ return ", ".join(emails) or None
44
+
45
+
46
+ def annotate_added_at(content, repo_ver):
47
+ """Annotate each package file with when it was added to this repository."""
48
+ return content.annotate(
49
+ active_membership=FilteredRelation(
50
+ "version_memberships",
51
+ condition=Q(
52
+ version_memberships__repository=repo_ver.repository,
53
+ version_memberships__version_removed=None,
54
+ ),
55
+ ),
56
+ file_added_at=F("active_membership__pulp_created"),
57
+ )
58
+
59
+
60
+ def iter_releases(content, repo_ver, name_normalized=None, limit=UPDATES_LIMIT):
61
+ """Yield latest (name, version) releases in the served repository version."""
62
+ qs = annotate_added_at(content, repo_ver)
63
+ if name_normalized:
64
+ qs = qs.filter(name_normalized=name_normalized)
65
+ return (
66
+ qs.order_by()
67
+ .values("name_normalized", "version")
68
+ .annotate(
69
+ added_at=Min("file_added_at"),
70
+ name=Min("name"),
71
+ summary=Min("summary"),
72
+ author_email=Min("author_email"),
73
+ )
74
+ .order_by("-added_at", "name_normalized", "version")[:limit]
75
+ )
76
+
77
+
78
+ def iter_projects(content, repo_ver, limit=PACKAGES_LIMIT):
79
+ """Yield latest newly added projects in the served repository version."""
80
+ qs = annotate_added_at(content, repo_ver)
81
+ return (
82
+ qs.order_by()
83
+ .values("name_normalized")
84
+ .annotate(
85
+ added_at=Min("file_added_at"),
86
+ name=Min("name"),
87
+ summary=Min("summary"),
88
+ author_email=Min("author_email"),
89
+ )
90
+ .order_by("-added_at", "name_normalized")[:limit]
91
+ )
92
+
93
+
94
+ def _item_dict(title, link, description, author_email, pubdate):
95
+ return {
96
+ "title": sanitize_xml_text(title),
97
+ "link": link,
98
+ "description": sanitize_xml_text(description),
99
+ "author_email": format_author(author_email),
100
+ "pubdate": pubdate,
101
+ "unique_id": link,
102
+ }
103
+
104
+
105
+ def render_rss(title, link, description, items):
106
+ """Render an RSS 2.0 document from item dicts produced by `_item_dict`."""
107
+ feed = Rss201rev2Feed(
108
+ title=sanitize_xml_text(title),
109
+ link=link,
110
+ description=sanitize_xml_text(description),
111
+ language="en",
112
+ )
113
+ for item in items:
114
+ feed.add_item(
115
+ title=item["title"],
116
+ link=item["link"],
117
+ description=item["description"],
118
+ author_email=item["author_email"],
119
+ pubdate=item["pubdate"],
120
+ unique_id=item["unique_id"],
121
+ unique_id_is_permalink=True,
122
+ )
123
+ return feed.writeString("utf-8")
124
+
125
+
126
+ def _release_link(index_url, name_normalized, version):
127
+ return urljoin(index_url, f"pypi/{name_normalized}/{version}/json")
128
+
129
+
130
+ def _project_link(index_url, name_normalized):
131
+ return urljoin(index_url, f"pypi/{name_normalized}/json")
132
+
133
+
134
+ def render_updates_feed(index_url, releases):
135
+ items = [
136
+ _item_dict(
137
+ title=f"{release['name']} {release['version']}",
138
+ link=_release_link(index_url, release["name_normalized"], release["version"]),
139
+ description=release["summary"],
140
+ author_email=release["author_email"],
141
+ pubdate=release["added_at"],
142
+ )
143
+ for release in releases
144
+ ]
145
+ return render_rss(
146
+ title="Recent updates",
147
+ link=index_url,
148
+ description="Recent updates to this Python package index",
149
+ items=items,
150
+ )
151
+
152
+
153
+ def render_packages_feed(index_url, projects):
154
+ items = [
155
+ _item_dict(
156
+ title=f"{project['name']} added to index",
157
+ link=_project_link(index_url, project["name_normalized"]),
158
+ description=project["summary"],
159
+ author_email=project["author_email"],
160
+ pubdate=project["added_at"],
161
+ )
162
+ for project in projects
163
+ ]
164
+ return render_rss(
165
+ title="Newest packages",
166
+ link=index_url,
167
+ description="Newest packages registered on this Python package index",
168
+ items=items,
169
+ )
170
+
171
+
172
+ def render_project_releases_feed(index_url, project_name, releases):
173
+ project_link = _project_link(index_url, canonicalize_name(project_name))
174
+ items = [
175
+ _item_dict(
176
+ title=release["version"],
177
+ link=_release_link(index_url, release["name_normalized"], release["version"]),
178
+ description=release["summary"],
179
+ author_email=release["author_email"],
180
+ pubdate=release["added_at"],
181
+ )
182
+ for release in releases
183
+ ]
184
+ return render_rss(
185
+ title=f"Recent updates for {project_name}",
186
+ link=project_link,
187
+ description=f"Recent updates to {project_name} on this Python package index",
188
+ items=items,
189
+ )
190
+
191
+
192
+ class FeedView(PyPIMixin, ViewSet):
193
+ """View for PyPI-compatible RSS feeds on a distribution."""
194
+
195
+ endpoint_name = "rss"
196
+ DEFAULT_ACCESS_POLICY = {
197
+ "statements": [
198
+ {
199
+ "action": ["updates", "packages", "project_releases"],
200
+ "principal": "*",
201
+ "effect": "allow",
202
+ },
203
+ ],
204
+ }
205
+
206
+ def _index_url(self, path):
207
+ return urljoin(self.base_api_url, f"{path}/")
208
+
209
+ def _rss_response(self, xml):
210
+ return HttpResponse(xml, content_type=RSS_CONTENT_TYPE)
211
+
212
+ @extend_schema(summary="Get latest updates RSS feed")
213
+ @method_decorator(cache_control(max_age=600, public=True))
214
+ @method_decorator(condition(etag_func=_etag_func))
215
+ @PythonApiCache(base_key=find_base_path_cached)
216
+ def updates(self, request, path):
217
+ """Latest releases added to this index, analogous to PyPI `/rss/updates.xml`."""
218
+ repo_ver, content = self.get_rvc()
219
+ index_url = self._index_url(path)
220
+ releases = list(iter_releases(content, repo_ver)) if content is not None else []
221
+ return self._rss_response(render_updates_feed(index_url, releases))
222
+
223
+ @extend_schema(summary="Get newest packages RSS feed")
224
+ @method_decorator(cache_control(max_age=600, public=True))
225
+ @method_decorator(condition(etag_func=_etag_func))
226
+ @PythonApiCache(base_key=find_base_path_cached)
227
+ def packages(self, request, path):
228
+ """Latest newly added projects, analogous to PyPI `/rss/packages.xml`."""
229
+ repo_ver, content = self.get_rvc()
230
+ index_url = self._index_url(path)
231
+ projects = list(iter_projects(content, repo_ver)) if content is not None else []
232
+ return self._rss_response(render_packages_feed(index_url, projects))
233
+
234
+ @extend_schema(summary="Get project releases RSS feed")
235
+ @method_decorator(cache_control(max_age=600, public=True))
236
+ @method_decorator(condition(etag_func=_etag_func))
237
+ @PythonApiCache(base_key=find_base_path_cached)
238
+ def project_releases(self, request, path, package):
239
+ """Latest releases for one project, analogous to PyPI `/rss/project/<name>/releases.xml`."""
240
+ repo_ver, content = self.get_rvc()
241
+ normalized = canonicalize_name(package)
242
+ if content is None or not content.filter(name_normalized=normalized).exists():
243
+ return HttpResponseNotFound(f"{normalized} does not exist.")
244
+
245
+ index_url = self._index_url(path)
246
+ releases = list(
247
+ iter_releases(
248
+ content, repo_ver, name_normalized=normalized, limit=PROJECT_RELEASES_LIMIT
249
+ )
250
+ )
251
+ project_name = releases[0]["name"] if releases else package
252
+ return self._rss_response(render_project_releases_feed(index_url, project_name, releases))
@@ -72,17 +72,32 @@ PYPI_SIMPLE_V1_HTML = "application/vnd.pypi.simple.v1+html"
72
72
  PYPI_SIMPLE_V1_JSON = "application/vnd.pypi.simple.v1+json"
73
73
 
74
74
 
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."""
77
77
  try:
78
78
  distro = PyPIMixin.get_distribution(path)
79
- repo_ver = PyPIMixin.get_repository_version(distro)
79
+ return PyPIMixin.get_repository_version(distro)
80
80
  except Http404:
81
81
  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
82
89
  raw = f"{repo_ver.number}:{repo_ver.pulp_created.isoformat()}"
83
90
  return hashlib.sha256(raw.encode()).hexdigest()[:16]
84
91
 
85
92
 
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
+
86
101
  class PyPISimpleHTMLRenderer(TemplateHTMLRenderer):
87
102
  media_type = PYPI_SIMPLE_V1_HTML
88
103
 
@@ -317,7 +332,7 @@ class SimpleView(PackageUploadMixin, ViewSet):
317
332
 
318
333
  @extend_schema(summary="Get index simple page")
319
334
  @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))
321
336
  @PythonApiCache(base_key=find_base_path_cached)
322
337
  def list(self, request, path):
323
338
  """Gets the simple api html page for the index."""
@@ -379,7 +394,7 @@ class SimpleView(PackageUploadMixin, ViewSet):
379
394
 
380
395
  @extend_schema(operation_id="pypi_simple_package_read", summary="Get package simple page")
381
396
  @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))
383
398
  @PythonApiCache(base_key=find_base_path_cached)
384
399
  def retrieve(self, request, path, package):
385
400
  """Retrieves the simple api html/json page for a package."""
@@ -482,6 +497,8 @@ class MetadataView(PyPIMixin, ViewSet):
482
497
  responses={200: PackageMetadataSerializer},
483
498
  summary="Get package metadata",
484
499
  )
500
+ @method_decorator(cache_control(max_age=900, public=True))
501
+ @method_decorator(condition(etag_func=_etag_func, last_modified_func=_last_modified_func))
485
502
  def retrieve(self, request, path, meta):
486
503
  """
487
504
  Retrieves the package's core-metadata specified by
@@ -50,18 +50,29 @@ def repair(repository_pk: UUID) -> None:
50
50
  num_repaired, pkgs_not_repaired, num_metadata_repaired, pkgs_metadata_not_repaired = (
51
51
  repair_metadata(content)
52
52
  )
53
- # Convert set() to 0
54
- if not pkgs_not_repaired:
55
- pkgs_not_repaired = 0
56
- if not pkgs_metadata_not_repaired:
57
- pkgs_metadata_not_repaired = 0
53
+
54
+ def _format_pkg_set(pk_set):
55
+ """Resolve a set of package PKs to 'name-version (pk)' strings."""
56
+ if not pk_set:
57
+ return "none"
58
+ packages = PythonPackageContent.objects.filter(pk__in=pk_set).values_list(
59
+ "name", "version", "pk"
60
+ )
61
+ return ", ".join(f"{name}-{version} ({pk})" for name, version, pk in packages)
58
62
 
59
63
  log.info(
60
64
  _(
61
- "{} packages' metadata repaired. Not repaired packages due to either "
62
- "inaccessible URL or mismatched sha256: {}. "
63
- "{} metadata files repaired. Packages whose metadata files could not be repaired: {}."
64
- ).format(num_repaired, pkgs_not_repaired, num_metadata_repaired, pkgs_metadata_not_repaired)
65
+ "Repository: %s. "
66
+ "%d packages' metadata repaired. "
67
+ "Not repaired packages due to either inaccessible URL or mismatched sha256: [%s]. "
68
+ "%d metadata files repaired. "
69
+ "Packages whose metadata files could not be repaired: [%s]."
70
+ ),
71
+ repository.name,
72
+ num_repaired,
73
+ _format_pkg_set(pkgs_not_repaired),
74
+ num_metadata_repaired,
75
+ _format_pkg_set(pkgs_metadata_not_repaired),
65
76
  )
66
77
 
67
78
 
pulp_python/app/urls.py CHANGED
@@ -1,6 +1,7 @@
1
1
  from django.conf import settings
2
2
  from django.urls import path
3
3
 
4
+ from pulp_python.app.pypi.feeds import FeedView
4
5
  from pulp_python.app.pypi.views import (
5
6
  MetadataView,
6
7
  ProvenanceView,
@@ -43,5 +44,20 @@ urlpatterns = [
43
44
  ),
44
45
  path(PYPI_API_URL + "yank/", YankView.as_view({"post": "yank"}), name="yank"),
45
46
  path(PYPI_API_URL + "unyank/", YankView.as_view({"post": "unyank"}), name="unyank"),
47
+ path(
48
+ PYPI_API_URL + "rss/updates.xml",
49
+ FeedView.as_view({"get": "updates"}),
50
+ name="rss-updates",
51
+ ),
52
+ path(
53
+ PYPI_API_URL + "rss/packages.xml",
54
+ FeedView.as_view({"get": "packages"}),
55
+ name="rss-packages",
56
+ ),
57
+ path(
58
+ PYPI_API_URL + "rss/project/<str:package>/releases.xml",
59
+ FeedView.as_view({"get": "project_releases"}),
60
+ name="rss-project-releases",
61
+ ),
46
62
  path(PYPI_API_URL, PyPIView.as_view({"get": "retrieve"}), name="pypi-detail"),
47
63
  ]
@@ -0,0 +1,132 @@
1
+ from urllib.parse import urljoin, urlsplit
2
+ from xml.etree import ElementTree as ET
3
+
4
+ import pytest
5
+ import requests
6
+
7
+ from pulp_python.tests.functional.constants import (
8
+ PYTHON_EGG_FILENAME,
9
+ PYTHON_EGG_URL,
10
+ PYTHON_FIXTURES_URL,
11
+ PYTHON_WHEEL_FILENAME,
12
+ PYTHON_WHEEL_URL,
13
+ TWINE_EGG_FILENAME,
14
+ TWINE_EGG_URL,
15
+ TWINE_WHEEL_FILENAME,
16
+ TWINE_WHEEL_URL,
17
+ )
18
+
19
+ TWINE_500_WHEEL_FILENAME = "twine-5.0.0-py3-none-any.whl"
20
+ TWINE_500_WHEEL_URL = urljoin(urljoin(PYTHON_FIXTURES_URL, "packages/"), TWINE_500_WHEEL_FILENAME)
21
+
22
+
23
+ def _index_url(distro, bindings_cfg):
24
+ """Build the index URL using the same origin the API client uses."""
25
+ path = urlsplit(distro.base_url).path
26
+ if not path.endswith("/"):
27
+ path += "/"
28
+ return bindings_cfg.host.rstrip("/") + path
29
+
30
+
31
+ def _get_feed(distro, relative, bindings_cfg):
32
+ return requests.get(urljoin(_index_url(distro, bindings_cfg), relative))
33
+
34
+
35
+ def _parse_items(response):
36
+ assert response.status_code == 200, response.text
37
+ assert "application/rss+xml" in response.headers["Content-Type"]
38
+ root = ET.fromstring(response.content)
39
+ assert root.tag == "rss"
40
+ channel = root.find("channel")
41
+ assert channel is not None
42
+ return channel.findall("item")
43
+
44
+
45
+ def _titles(items):
46
+ return [item.findtext("title") for item in items]
47
+
48
+
49
+ @pytest.mark.parallel
50
+ def test_empty_feeds(bindings_cfg, python_empty_repo_distro):
51
+ """Empty indexes return valid RSS documents with no items."""
52
+ _, distro = python_empty_repo_distro()
53
+
54
+ for relative in ("rss/updates.xml", "rss/packages.xml"):
55
+ items = _parse_items(_get_feed(distro, relative, bindings_cfg))
56
+ assert items == []
57
+
58
+ response = _get_feed(distro, "rss/project/shelf-reader/releases.xml", bindings_cfg)
59
+ assert response.status_code == 404
60
+
61
+
62
+ @pytest.mark.parallel
63
+ def test_feeds_projects_releases_and_files(
64
+ bindings_cfg, python_content_factory, python_empty_repo_distro
65
+ ):
66
+ """Feeds collapse files to releases, track new projects vs new versions, and stay per-index."""
67
+ repo, distro = python_empty_repo_distro()
68
+ _, other_distro = python_empty_repo_distro()
69
+
70
+ python_content_factory(PYTHON_EGG_FILENAME, url=PYTHON_EGG_URL, repository=repo)
71
+ python_content_factory(PYTHON_WHEEL_FILENAME, url=PYTHON_WHEEL_URL, repository=repo)
72
+
73
+ update_titles = _titles(_parse_items(_get_feed(distro, "rss/updates.xml", bindings_cfg)))
74
+ package_titles = _titles(_parse_items(_get_feed(distro, "rss/packages.xml", bindings_cfg)))
75
+ release_titles = _titles(
76
+ _parse_items(_get_feed(distro, "rss/project/shelf-reader/releases.xml", bindings_cfg))
77
+ )
78
+
79
+ assert update_titles == ["shelf-reader 0.1"]
80
+ assert package_titles == ["shelf-reader added to index"]
81
+ assert release_titles == ["0.1"]
82
+ assert _parse_items(_get_feed(other_distro, "rss/updates.xml", bindings_cfg)) == []
83
+
84
+ python_content_factory(TWINE_500_WHEEL_FILENAME, url=TWINE_500_WHEEL_URL, repository=repo)
85
+
86
+ update_titles = _titles(_parse_items(_get_feed(distro, "rss/updates.xml", bindings_cfg)))
87
+ package_titles = _titles(_parse_items(_get_feed(distro, "rss/packages.xml", bindings_cfg)))
88
+ assert update_titles == ["twine 5.0.0", "shelf-reader 0.1"]
89
+ assert package_titles == ["twine added to index", "shelf-reader added to index"]
90
+
91
+ python_content_factory(TWINE_WHEEL_FILENAME, url=TWINE_WHEEL_URL, repository=repo)
92
+ python_content_factory(TWINE_EGG_FILENAME, url=TWINE_EGG_URL, repository=repo)
93
+
94
+ update_titles = _titles(_parse_items(_get_feed(distro, "rss/updates.xml", bindings_cfg)))
95
+ package_titles = _titles(_parse_items(_get_feed(distro, "rss/packages.xml", bindings_cfg)))
96
+ twine_releases = _titles(
97
+ _parse_items(_get_feed(distro, "rss/project/twine/releases.xml", bindings_cfg))
98
+ )
99
+
100
+ assert update_titles == ["twine 5.1.0", "twine 5.0.0", "shelf-reader 0.1"]
101
+ assert package_titles == ["twine added to index", "shelf-reader added to index"]
102
+ assert twine_releases == ["5.1.0", "5.0.0"]
103
+
104
+ response = _get_feed(distro, "rss/project/does-not-exist/releases.xml", bindings_cfg)
105
+ assert response.status_code == 404
106
+ assert _parse_items(_get_feed(other_distro, "rss/updates.xml", bindings_cfg)) == []
107
+
108
+
109
+ @pytest.mark.parallel
110
+ def test_pinned_version_feeds(
111
+ bindings_cfg,
112
+ python_content_factory,
113
+ python_distribution_factory,
114
+ python_repo_factory,
115
+ ):
116
+ """Feeds for a pinned repository version stay frozen, as publication-backed indexes do."""
117
+ repo = python_repo_factory()
118
+ python_content_factory(PYTHON_EGG_FILENAME, url=PYTHON_EGG_URL, repository=repo)
119
+ distro = python_distribution_factory(repository=repo, version="1")
120
+
121
+ update_titles = _titles(_parse_items(_get_feed(distro, "rss/updates.xml", bindings_cfg)))
122
+ package_titles = _titles(_parse_items(_get_feed(distro, "rss/packages.xml", bindings_cfg)))
123
+ assert update_titles == ["shelf-reader 0.1"]
124
+ assert package_titles == ["shelf-reader added to index"]
125
+
126
+ item = _parse_items(_get_feed(distro, "rss/updates.xml", bindings_cfg))[0]
127
+ assert item.findtext("link").endswith("pypi/shelf-reader/0.1/json")
128
+ assert item.findtext("guid").endswith("pypi/shelf-reader/0.1/json")
129
+
130
+ python_content_factory(TWINE_WHEEL_FILENAME, url=TWINE_WHEEL_URL, repository=repo)
131
+ update_titles = _titles(_parse_items(_get_feed(distro, "rss/updates.xml", bindings_cfg)))
132
+ assert update_titles == ["shelf-reader 0.1"]
@@ -1,3 +1,4 @@
1
+ from email.utils import parsedate_to_datetime
1
2
  from urllib.parse import urljoin
2
3
 
3
4
  import pytest
@@ -35,6 +36,20 @@ def synced_distro(
35
36
  return python_distribution_factory(repository=repo)
36
37
 
37
38
 
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
+
38
53
  @pytest.mark.parallel
39
54
  def test_simple_cache_hit_miss_and_headers(synced_distro):
40
55
  """
@@ -139,3 +154,161 @@ def test_simple_cache_etag_conditional_request(synced_distro):
139
154
  assert r3.headers["Cache-Control"] == cache_control
140
155
  assert r3.headers["X-PULP-CACHE"] == "HIT"
141
156
  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_unauthenticated_gets_401_not_304(synced_distro_no_cache, pulpcore_bindings, bindings_cfg):
259
+ """Unauthenticated client gets 401, 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
+ original_statements = pulpcore_bindings.AccessPoliciesApi.read(ap_href).statements
272
+
273
+ anon = requests.Session()
274
+ anon.trust_env = False
275
+ anon.verify = False
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 == 401, (
299
+ f"Expected 401 for unauthenticated 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 == 401, (
304
+ f"Expected 401 for unauthenticated 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.partial_update(
313
+ ap_href, {"statements": original_statements}
314
+ )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pulp-python
3
- Version: 3.34.0
3
+ Version: 3.36.0
4
4
  Summary: pulp-python plugin for the Pulp Project
5
5
  Author-email: Pulp Team <pulp-list@redhat.com>
6
6
  Project-URL: Homepage, https://pulpproject.org
@@ -1,6 +1,6 @@
1
1
  pulp_python/__init__.py,sha256=GIuTLoBTc-07dSLJUh8xrZPRz8x-jJ61pfR0J1IjnzI,65
2
2
  pulp_python/pytest_plugin.py,sha256=8Ko-O87JGPC79h8-Ecw2gJLxa6q1Ng8wD6WxbinOnVs,8573
3
- pulp_python/app/__init__.py,sha256=Xb-_6MfmJcerUvsQoyz0exptoi64okJEqZ0OcO_GtHo,2490
3
+ pulp_python/app/__init__.py,sha256=2kWKrbhWKqmAVK_xuWgc2S5ZzcpRVS0yPOFCjEVwY1M,2552
4
4
  pulp_python/app/cache.py,sha256=Zl2nb4uKyPYp5a3FBrdRZ_-cXfofNMQmIRaQ6dUZZ1g,1037
5
5
  pulp_python/app/exceptions.py,sha256=mPNcWyuzF0XeOPybm-G6oDKtRqvfj4jyp41iaYqcqkA,1314
6
6
  pulp_python/app/global_access_conditions.py,sha256=MZJtyoVsr-4hRaty6mKDqh3caOHd5UKJjEWLV2crOLs,1080
@@ -10,7 +10,7 @@ pulp_python/app/provenance.py,sha256=iyhkuNahHiTDK0Djrd4-PlgErA5SJVI0uQOIPj46tEI
10
10
  pulp_python/app/replica.py,sha256=qiWRP7tM_v4yP_XLIQfumfGolru-Jt6ZA0KVb-9g2cA,1882
11
11
  pulp_python/app/serializers.py,sha256=19viBO88VTKWfw9xaNpkPPQrxUmNnWiXAVJxigr0qNA,35110
12
12
  pulp_python/app/settings.py,sha256=Cyc_p6U0HQjKpyrRL6JFrK3R7RMQJ9MAgNMJCfzPEiA,255
13
- pulp_python/app/urls.py,sha256=p784I54JxOFXOg9dcXXimbnXQWvYfSLwjw3Qopib9Pw,1573
13
+ pulp_python/app/urls.py,sha256=szPHGWrxaWwMZ0QEsftAyFwisEMFfusfDS4lcYEt4nU,2064
14
14
  pulp_python/app/utils.py,sha256=13jUDe8BwfuN1m3-pAJ8Kip0vixQTHAiZZIn7xl0YUo,27786
15
15
  pulp_python/app/viewsets.py,sha256=9ySqQFtpheVVYTLQ9LRRmr99zVHIOfHwYhFBnsqry28,36385
16
16
  pulp_python/app/management/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -43,11 +43,12 @@ pulp_python/app/migrations/0023_packageyank.py,sha256=357fqSNK-D1OvI9RynnyMMrXJt
43
43
  pulp_python/app/migrations/0024_pythonrepository_error_on_reject.py,sha256=p10wp0OEVA7m-ZpuzlqsJg9dEbf4cF6kow9AfK37JU8,348
44
44
  pulp_python/app/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
45
45
  pulp_python/app/pypi/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
46
+ pulp_python/app/pypi/feeds.py,sha256=KReO4_HPzw1Pf_A2lv19Yfia-k2mfDzFMuo3-kvdH7g,8985
46
47
  pulp_python/app/pypi/serializers.py,sha256=VDV4REMltZr98bxulgOGoGLnBxUtDod0t1fg_lDFpcs,5601
47
- pulp_python/app/pypi/views.py,sha256=m5_b_MLoSNhwZ64UpQKOzmlaAA7nCfKU_4r1RFY3jnA,26252
48
+ pulp_python/app/pypi/views.py,sha256=9smlBKDtSNTDH5heuKTkAoW4qVGejIkZbdzhijYcVJs,26917
48
49
  pulp_python/app/tasks/__init__.py,sha256=JplOj2JtE_i4sQYqV7JnDY7zkraU_KlsCaNnYYaZ1x4,346
49
50
  pulp_python/app/tasks/publish.py,sha256=bjsJzqJbLu7TF5rLb-UsZMmlNnc_LKw-sdHX9Gcatbw,4334
50
- pulp_python/app/tasks/repair.py,sha256=5InzdbjW8y3AC4Vj2PsNLm3wGGTr8D3LcfPw_WA2Fks,12257
51
+ pulp_python/app/tasks/repair.py,sha256=suvpn7VLGx_B-mixjQWdiHPDy8FclLQ6IwjcL9PeIpU,12603
51
52
  pulp_python/app/tasks/sync.py,sha256=pgiw6R142ynN-lVocyOYDmokklMJFWeH9xAgwXan9a8,13363
52
53
  pulp_python/app/tasks/upload.py,sha256=HBOknlsAb0mlE-2dJsalH_8JUZwrKk4AsVibmVJf2aQ,6102
53
54
  pulp_python/app/tasks/vulnerability_report.py,sha256=0cyxNb4048HFUdUlGBA6wYsg-hEMjSfE8mtw05Ct9BQ,1126
@@ -72,10 +73,11 @@ pulp_python/tests/functional/api/test_download_content.py,sha256=5IuaHXyLakPkjm5
72
73
  pulp_python/tests/functional/api/test_export_import.py,sha256=rHns9wdaeP-vtfW_qoGHU9EVuJ4YuWE0-rjl_8otAgU,4530
73
74
  pulp_python/tests/functional/api/test_full_mirror.py,sha256=v0nSlGKGSycFqZM4opCTHVtbmCfo_Y5y3k_6Rm7eBF8,12058
74
75
  pulp_python/tests/functional/api/test_pypi_apis.py,sha256=iZnoSN2wCczXh14KJDH8pSDTygjs0kgZsYIcIYS5TP8,13647
76
+ pulp_python/tests/functional/api/test_pypi_feeds.py,sha256=RmeWEwH3-XOTkeYziClLZhF3CM8p2BXdY8MmQs7MIg4,5424
75
77
  pulp_python/tests/functional/api/test_pypi_simple_api.py,sha256=tOUl8WXgJkMoxGyLbA-WorCxIRKfGWM-27_dK_Jc0Q4,7171
76
78
  pulp_python/tests/functional/api/test_rbac.py,sha256=-xNWqvKKU-v1uC6tCRGBK0aWaff25K9C-AfzQkdHhhI,10513
77
79
  pulp_python/tests/functional/api/test_repair.py,sha256=4FR7jx_LA2K-pnRJXKkhq-1uUoWXST1leZ9XdyhGM2U,12909
78
- pulp_python/tests/functional/api/test_simple_cache.py,sha256=kJ9mwJKf9A-RthLzPZi0rjRQlygEkWsV6jAaAR8aYgc,4944
80
+ pulp_python/tests/functional/api/test_simple_cache.py,sha256=jTEvn1qUjtzbG0qi_57J0yk_CQGe_0L31_0giwz7hbY,11141
79
81
  pulp_python/tests/functional/api/test_sync.py,sha256=TTHR1CpZeRoD97RKxj2pZPsP0OS9ibYKGPOh1WfKISg,13801
80
82
  pulp_python/tests/functional/api/test_upload.py,sha256=f21Li9agJ5fdBqErVg1j6v5FDVdazB5mNClmGOjLUq8,5981
81
83
  pulp_python/tests/functional/api/test_version_specifier_filter.py,sha256=nmnBbhwxCEmcHOyFbS1uvemar67sONBGM9SsNn79LCc,1063
@@ -83,9 +85,9 @@ pulp_python/tests/functional/api/test_vulnerability_report.py,sha256=Rv492Wrvu1F
83
85
  pulp_python/tests/functional/api/test_yank.py,sha256=qyZbAJ6bJO-4LPkVfVxKuWFjypJ8BmBrCnLo0luAhVk,11913
84
86
  pulp_python/tests/unit/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
85
87
  pulp_python/tests/unit/test_models.py,sha256=TBI0yKsrdbnJSPeBFfxSqhXK7zaNvR6qg5JehGH3Pds,229
86
- pulp_python-3.34.0.dist-info/licenses/LICENSE,sha256=2ylvL381vKOhdO-w6zkrOxe9lLNBhRQpo9_0EbHC_HM,18046
87
- pulp_python-3.34.0.dist-info/METADATA,sha256=CmHI0GXc46uJorUum6Hfd4Em4doAL8o5iUZ4hbFBjWU,1744
88
- pulp_python-3.34.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
89
- pulp_python-3.34.0.dist-info/entry_points.txt,sha256=HvqLEXjw_dS5jqAwnE5JiRZFE6f-y5SRtitKLPml2To,115
90
- pulp_python-3.34.0.dist-info/top_level.txt,sha256=X0hXgXc_bpbiKqVrkt8jD5_QEiQviKbHDwveQcOcJjo,12
91
- pulp_python-3.34.0.dist-info/RECORD,,
88
+ pulp_python-3.36.0.dist-info/licenses/LICENSE,sha256=2ylvL381vKOhdO-w6zkrOxe9lLNBhRQpo9_0EbHC_HM,18046
89
+ pulp_python-3.36.0.dist-info/METADATA,sha256=0ZFTpj5vGEaHBSEpJnhAb0o8_bFwUZtjUVc2rknfhf4,1744
90
+ pulp_python-3.36.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
91
+ pulp_python-3.36.0.dist-info/entry_points.txt,sha256=HvqLEXjw_dS5jqAwnE5JiRZFE6f-y5SRtitKLPml2To,115
92
+ pulp_python-3.36.0.dist-info/top_level.txt,sha256=X0hXgXc_bpbiKqVrkt8jD5_QEiQviKbHDwveQcOcJjo,12
93
+ pulp_python-3.36.0.dist-info/RECORD,,