pulp-python 3.13.5__tar.gz → 3.15.0__tar.gz

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.
Files changed (79) hide show
  1. {pulp_python-3.13.5 → pulp_python-3.15.0}/CHANGES.md +58 -5
  2. {pulp_python-3.13.5 → pulp_python-3.15.0}/PKG-INFO +1 -1
  3. {pulp_python-3.13.5 → pulp_python-3.15.0}/pulp_python/app/__init__.py +1 -1
  4. {pulp_python-3.13.5 → pulp_python-3.15.0}/pulp_python/app/models.py +1 -0
  5. {pulp_python-3.13.5 → pulp_python-3.15.0}/pulp_python/app/pypi/views.py +36 -24
  6. {pulp_python-3.13.5 → pulp_python-3.15.0}/pulp_python/app/tasks/__init__.py +1 -0
  7. pulp_python-3.15.0/pulp_python/app/tasks/repair.py +89 -0
  8. {pulp_python-3.13.5 → pulp_python-3.15.0}/pulp_python/app/utils.py +79 -1
  9. {pulp_python-3.13.5 → pulp_python-3.15.0}/pulp_python/app/viewsets.py +20 -1
  10. pulp_python-3.15.0/pulp_python/tests/functional/api/test_full_mirror.py +140 -0
  11. {pulp_python-3.13.5 → pulp_python-3.15.0}/pulp_python/tests/functional/api/test_repair.py +49 -1
  12. {pulp_python-3.13.5 → pulp_python-3.15.0}/pulp_python.egg-info/PKG-INFO +1 -1
  13. {pulp_python-3.13.5 → pulp_python-3.15.0}/pulp_python.egg-info/SOURCES.txt +1 -0
  14. {pulp_python-3.13.5 → pulp_python-3.15.0}/pyproject.toml +2 -2
  15. pulp_python-3.13.5/pulp_python/tests/functional/api/test_full_mirror.py +0 -72
  16. {pulp_python-3.13.5 → pulp_python-3.15.0}/COMMITMENT +0 -0
  17. {pulp_python-3.13.5 → pulp_python-3.15.0}/COPYRIGHT +0 -0
  18. {pulp_python-3.13.5 → pulp_python-3.15.0}/LICENSE +0 -0
  19. {pulp_python-3.13.5 → pulp_python-3.15.0}/MANIFEST.in +0 -0
  20. {pulp_python-3.13.5 → pulp_python-3.15.0}/README.md +0 -0
  21. {pulp_python-3.13.5 → pulp_python-3.15.0}/functest_requirements.txt +0 -0
  22. {pulp_python-3.13.5 → pulp_python-3.15.0}/pulp_python/__init__.py +0 -0
  23. {pulp_python-3.13.5 → pulp_python-3.15.0}/pulp_python/app/global_access_conditions.py +0 -0
  24. {pulp_python-3.13.5 → pulp_python-3.15.0}/pulp_python/app/management/__init__.py +0 -0
  25. {pulp_python-3.13.5 → pulp_python-3.15.0}/pulp_python/app/management/commands/__init__.py +0 -0
  26. {pulp_python-3.13.5 → pulp_python-3.15.0}/pulp_python/app/management/commands/repair-python-metadata.py +0 -0
  27. {pulp_python-3.13.5 → pulp_python-3.15.0}/pulp_python/app/migrations/0001_initial.py +0 -0
  28. {pulp_python-3.13.5 → pulp_python-3.15.0}/pulp_python/app/migrations/0002_pythonpackagecontent_python_version.py +0 -0
  29. {pulp_python-3.13.5 → pulp_python-3.15.0}/pulp_python/app/migrations/0003_new_sync_filters.py +0 -0
  30. {pulp_python-3.13.5 → pulp_python-3.15.0}/pulp_python/app/migrations/0004_DATA_swap_distribution_model.py +0 -0
  31. {pulp_python-3.13.5 → pulp_python-3.15.0}/pulp_python/app/migrations/0005_pythonpackagecontent_sha256.py +0 -0
  32. {pulp_python-3.13.5 → pulp_python-3.15.0}/pulp_python/app/migrations/0006_pythonrepository_autopublish.py +0 -0
  33. {pulp_python-3.13.5 → pulp_python-3.15.0}/pulp_python/app/migrations/0007_pythonpackagecontent_mv-2-1.py +0 -0
  34. {pulp_python-3.13.5 → pulp_python-3.15.0}/pulp_python/app/migrations/0008_pythonpackagecontent_unique_sha256.py +0 -0
  35. {pulp_python-3.13.5 → pulp_python-3.15.0}/pulp_python/app/migrations/0009_pythondistribution_allow_uploads.py +0 -0
  36. {pulp_python-3.13.5 → pulp_python-3.15.0}/pulp_python/app/migrations/0010_update_json_field.py +0 -0
  37. {pulp_python-3.13.5 → pulp_python-3.15.0}/pulp_python/app/migrations/0011_alter_pythondistribution_distribution_ptr_and_more.py +0 -0
  38. {pulp_python-3.13.5 → pulp_python-3.15.0}/pulp_python/app/migrations/0012_add_domain.py +0 -0
  39. {pulp_python-3.13.5 → pulp_python-3.15.0}/pulp_python/app/migrations/0013_add_rbac_permissions.py +0 -0
  40. {pulp_python-3.13.5 → pulp_python-3.15.0}/pulp_python/app/migrations/__init__.py +0 -0
  41. {pulp_python-3.13.5 → pulp_python-3.15.0}/pulp_python/app/modelresource.py +0 -0
  42. {pulp_python-3.13.5 → pulp_python-3.15.0}/pulp_python/app/pypi/__init__.py +0 -0
  43. {pulp_python-3.13.5 → pulp_python-3.15.0}/pulp_python/app/pypi/serializers.py +0 -0
  44. {pulp_python-3.13.5 → pulp_python-3.15.0}/pulp_python/app/replica.py +0 -0
  45. {pulp_python-3.13.5 → pulp_python-3.15.0}/pulp_python/app/serializers.py +0 -0
  46. {pulp_python-3.13.5 → pulp_python-3.15.0}/pulp_python/app/settings.py +0 -0
  47. {pulp_python-3.13.5 → pulp_python-3.15.0}/pulp_python/app/tasks/publish.py +0 -0
  48. {pulp_python-3.13.5 → pulp_python-3.15.0}/pulp_python/app/tasks/sync.py +0 -0
  49. {pulp_python-3.13.5 → pulp_python-3.15.0}/pulp_python/app/tasks/upload.py +0 -0
  50. {pulp_python-3.13.5 → pulp_python-3.15.0}/pulp_python/app/urls.py +0 -0
  51. {pulp_python-3.13.5 → pulp_python-3.15.0}/pulp_python/app/webserver_snippets/__init__.py +0 -0
  52. {pulp_python-3.13.5 → pulp_python-3.15.0}/pulp_python/app/webserver_snippets/apache.conf +0 -0
  53. {pulp_python-3.13.5 → pulp_python-3.15.0}/pulp_python/app/webserver_snippets/nginx.conf +0 -0
  54. {pulp_python-3.13.5 → pulp_python-3.15.0}/pulp_python/pytest_plugin.py +0 -0
  55. {pulp_python-3.13.5 → pulp_python-3.15.0}/pulp_python/tests/__init__.py +0 -0
  56. {pulp_python-3.13.5 → pulp_python-3.15.0}/pulp_python/tests/functional/__init__.py +0 -0
  57. {pulp_python-3.13.5 → pulp_python-3.15.0}/pulp_python/tests/functional/api/__init__.py +0 -0
  58. {pulp_python-3.13.5 → pulp_python-3.15.0}/pulp_python/tests/functional/api/test_auto_publish.py +0 -0
  59. {pulp_python-3.13.5 → pulp_python-3.15.0}/pulp_python/tests/functional/api/test_consume_content.py +0 -0
  60. {pulp_python-3.13.5 → pulp_python-3.15.0}/pulp_python/tests/functional/api/test_crud_content_unit.py +0 -0
  61. {pulp_python-3.13.5 → pulp_python-3.15.0}/pulp_python/tests/functional/api/test_crud_publications.py +0 -0
  62. {pulp_python-3.13.5 → pulp_python-3.15.0}/pulp_python/tests/functional/api/test_crud_remotes.py +0 -0
  63. {pulp_python-3.13.5 → pulp_python-3.15.0}/pulp_python/tests/functional/api/test_domains.py +0 -0
  64. {pulp_python-3.13.5 → pulp_python-3.15.0}/pulp_python/tests/functional/api/test_download_content.py +0 -0
  65. {pulp_python-3.13.5 → pulp_python-3.15.0}/pulp_python/tests/functional/api/test_export_import.py +0 -0
  66. {pulp_python-3.13.5 → pulp_python-3.15.0}/pulp_python/tests/functional/api/test_pypi_apis.py +0 -0
  67. {pulp_python-3.13.5 → pulp_python-3.15.0}/pulp_python/tests/functional/api/test_rbac.py +0 -0
  68. {pulp_python-3.13.5 → pulp_python-3.15.0}/pulp_python/tests/functional/api/test_sync.py +0 -0
  69. {pulp_python-3.13.5 → pulp_python-3.15.0}/pulp_python/tests/functional/constants.py +0 -0
  70. {pulp_python-3.13.5 → pulp_python-3.15.0}/pulp_python/tests/functional/utils.py +0 -0
  71. {pulp_python-3.13.5 → pulp_python-3.15.0}/pulp_python/tests/unit/__init__.py +0 -0
  72. {pulp_python-3.13.5 → pulp_python-3.15.0}/pulp_python/tests/unit/test_models.py +0 -0
  73. {pulp_python-3.13.5 → pulp_python-3.15.0}/pulp_python.egg-info/dependency_links.txt +0 -0
  74. {pulp_python-3.13.5 → pulp_python-3.15.0}/pulp_python.egg-info/entry_points.txt +0 -0
  75. {pulp_python-3.13.5 → pulp_python-3.15.0}/pulp_python.egg-info/requires.txt +0 -0
  76. {pulp_python-3.13.5 → pulp_python-3.15.0}/pulp_python.egg-info/top_level.txt +0 -0
  77. {pulp_python-3.13.5 → pulp_python-3.15.0}/setup.cfg +0 -0
  78. {pulp_python-3.13.5 → pulp_python-3.15.0}/test_requirements.txt +0 -0
  79. {pulp_python-3.13.5 → pulp_python-3.15.0}/unittest_requirements.txt +0 -0
@@ -8,6 +8,35 @@
8
8
 
9
9
  [//]: # (towncrier release notes start)
10
10
 
11
+ ## 3.15.0 (2025-05-13) {: #3.15.0 }
12
+
13
+ #### Features {: #3.15.0-feature }
14
+
15
+ - Added new `repair_metadata` endpoint to `Repository` for fixing packages' metadata.
16
+ [#805](https://github.com/pulp/pulp_python/issues/805)
17
+
18
+ ---
19
+
20
+ ## 3.14.0 (2025-04-10) {: #3.14.0 }
21
+
22
+ #### Features {: #3.14.0-feature }
23
+
24
+ - Pull-through caching now respects the include/exclude filters on the upstream remote.
25
+ [#706](https://github.com/pulp/pulp_python/issues/706)
26
+ - Added support for automatically saving pull-through content to a repository.
27
+ Requires pulpcore 3.74 or later.
28
+ [#815](https://github.com/pulp/pulp_python/issues/815)
29
+
30
+ #### Bugfixes {: #3.14.0-bugfix }
31
+
32
+ - Fixed a proxy sync regression introduced in 3.13.0.
33
+
34
+ #### Misc {: #3.14.0-misc }
35
+
36
+ - [#809](https://github.com/pulp/pulp_python/issues/809)
37
+
38
+ ---
39
+
11
40
  ## 3.13.5 (2025-04-23) {: #3.13.5 }
12
41
 
13
42
  No significant changes.
@@ -74,6 +103,14 @@ No significant changes.
74
103
 
75
104
  ---
76
105
 
106
+ ## 3.12.6 (2025-02-26) {: #3.12.6 }
107
+
108
+ #### Misc {: #3.12.6-misc }
109
+
110
+ -
111
+
112
+ ---
113
+
77
114
  ## 3.12.5 (2024-10-25) {: #3.12.5 }
78
115
 
79
116
  #### Bugfixes {: #3.12.5-bugfix }
@@ -101,7 +138,7 @@ No significant changes.
101
138
 
102
139
  ---
103
140
 
104
- # ## 3.12.2 (2024-08-21) {: #3.12.2 }
141
+ ## 3.12.2 (2024-08-21) {: #3.12.2 }
105
142
 
106
143
  #### Bugfixes {: #3.12.2-bugfix }
107
144
 
@@ -112,7 +149,6 @@ No significant changes.
112
149
 
113
150
  ## 3.12.1 (2024-06-27) {: #3.12.1 }
114
151
 
115
-
116
152
  #### Bugfixes {: #3.12.1-bugfix }
117
153
 
118
154
  - Fixed the `package_types` filter breaking other remote filters.
@@ -122,7 +158,6 @@ No significant changes.
122
158
 
123
159
  ## 3.12.0 (2024-06-25) {: #3.12.0 }
124
160
 
125
-
126
161
  #### Features {: #3.12.0-feature }
127
162
 
128
163
  - Added RBAC support.
@@ -148,6 +183,25 @@ No significant changes.
148
183
 
149
184
  ---
150
185
 
186
+ ## 3.11.5 (2025-04-15) {: #3.11.5 }
187
+
188
+ No significant changes.
189
+
190
+ ---
191
+
192
+ ## 3.11.4 (2025-02-20) {: #3.11.4 }
193
+
194
+ #### Bugfixes {: #3.11.4-bugfix }
195
+
196
+ - Fixed the JSONField specification so it doesn't break ruby bindings.
197
+ See context [here](https://github.com/pulp/pulp_rpm/issues/3639).
198
+
199
+ #### Misc {: #3.11.4-misc }
200
+
201
+ -
202
+
203
+ ---
204
+
151
205
  ## 3.11.3 (2024-08-21) {: #3.11.3 }
152
206
 
153
207
  #### Bugfixes {: #3.11.3-bugfix }
@@ -161,7 +215,6 @@ No significant changes.
161
215
 
162
216
  ## 3.11.2 (2024-06-27) {: #3.11.2 }
163
217
 
164
-
165
218
  #### Bugfixes {: #3.11.2-bugfix }
166
219
 
167
220
  - Fixed the `package_types` filter breaking other remote filters.
@@ -379,7 +432,7 @@ No significant changes.
379
432
 
380
433
  ---
381
434
 
382
- 3.4.0 (2021-06-17)
435
+ ## 3.4.0 (2021-06-17) {: #3.4.0 }
383
436
 
384
437
  ### Features
385
438
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pulp-python
3
- Version: 3.13.5
3
+ Version: 3.15.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
@@ -10,7 +10,7 @@ class PulpPythonPluginAppConfig(PulpPluginAppConfig):
10
10
 
11
11
  name = "pulp_python.app"
12
12
  label = "python"
13
- version = "3.13.5"
13
+ version = "3.15.0"
14
14
  python_package_name = "pulp-python"
15
15
  domain_compatible = True
16
16
 
@@ -275,6 +275,7 @@ class PythonRepository(Repository, AutoAddObjPermsMixin):
275
275
  TYPE = "python"
276
276
  CONTENT_TYPES = [PythonPackageContent]
277
277
  REMOTE_TYPES = [PythonRemote]
278
+ PULL_THROUGH_SUPPORTED = True
278
279
 
279
280
  autopublish = models.BooleanField(default=False)
280
281
 
@@ -1,6 +1,7 @@
1
+ import json
1
2
  import logging
2
- import requests
3
3
 
4
+ from aiohttp.client_exceptions import ClientError
4
5
  from rest_framework.viewsets import ViewSet
5
6
  from rest_framework.response import Response
6
7
  from django.core.exceptions import ObjectDoesNotExist
@@ -15,7 +16,8 @@ from django.http.response import (
15
16
  Http404,
16
17
  HttpResponseForbidden,
17
18
  HttpResponseBadRequest,
18
- StreamingHttpResponse
19
+ StreamingHttpResponse,
20
+ HttpResponse,
19
21
  )
20
22
  from drf_spectacular.utils import extend_schema
21
23
  from dynaconf import settings
@@ -23,11 +25,12 @@ from itertools import chain
23
25
  from packaging.utils import canonicalize_name
24
26
  from urllib.parse import urljoin, urlparse, urlunsplit
25
27
  from pathlib import PurePath
26
- from pypi_simple import parse_links_stream_response
28
+ from pypi_simple import ACCEPT_JSON_PREFERRED, ProjectPage
27
29
 
28
30
  from pulpcore.plugin.viewsets import OperationPostponedResponse
29
31
  from pulpcore.plugin.tasking import dispatch
30
32
  from pulpcore.plugin.util import get_domain
33
+ from pulpcore.plugin.exceptions import TimeoutException
31
34
  from pulp_python.app.models import (
32
35
  PythonDistribution,
33
36
  PythonPackageContent,
@@ -37,7 +40,7 @@ from pulp_python.app.pypi.serializers import (
37
40
  SummarySerializer,
38
41
  PackageMetadataSerializer,
39
42
  PackageUploadSerializer,
40
- PackageUploadTaskSerializer
43
+ PackageUploadTaskSerializer,
41
44
  )
42
45
  from pulp_python.app.utils import (
43
46
  write_simple_index,
@@ -45,6 +48,7 @@ from pulp_python.app.utils import (
45
48
  python_content_to_json,
46
49
  PYPI_LAST_SERIAL,
47
50
  PYPI_SERIAL_CONSTANT,
51
+ get_remote_package_filter,
48
52
  )
49
53
 
50
54
  from pulp_python.app import tasks
@@ -233,27 +237,36 @@ class SimpleView(PackageUploadMixin, ViewSet):
233
237
 
234
238
  def pull_through_package_simple(self, package, path, remote):
235
239
  """Gets the package's simple page from remote."""
236
- def parse_url(link):
237
- parsed = urlparse(link.url)
238
- digest, _, value = parsed.fragment.partition('=')
240
+ def parse_package(release_package):
241
+ parsed = urlparse(release_package.url)
239
242
  stripped_url = urlunsplit(chain(parsed[:3], ("", "")))
240
- redirect = f'{path}/{link.text}?redirect={stripped_url}'
241
- d_url = urljoin(self.base_content_url, redirect)
242
- return link.text, d_url, value if digest == 'sha256' else ''
243
+ redirect_path = f'{path}/{release_package.filename}?redirect={stripped_url}'
244
+ d_url = urljoin(self.base_content_url, redirect_path)
245
+ return release_package.filename, d_url, release_package.digests.get("sha256", "")
246
+
247
+ rfilter = get_remote_package_filter(remote)
248
+ if not rfilter.filter_project(package):
249
+ raise Http404(f"{package} does not exist.")
243
250
 
244
251
  url = remote.get_remote_artifact_url(f'simple/{package}/')
245
- kwargs = {}
246
- if proxy_url := remote.proxy_url:
247
- if remote.proxy_username or remote.proxy_password:
248
- parsed_proxy = urlparse(proxy_url)
249
- netloc = f"{remote.proxy_username}:{remote.proxy_password}@{parsed_proxy.netloc}"
250
- proxy_url = urlunsplit((parsed_proxy.scheme, netloc, "", "", ""))
251
- kwargs["proxies"] = {"http": proxy_url, "https": proxy_url}
252
-
253
- response = requests.get(url, stream=True, **kwargs)
254
- links = parse_links_stream_response(response)
255
- packages = (parse_url(link) for link in links)
256
- return StreamingHttpResponse(write_simple_detail(package, packages, streamed=True))
252
+ remote.headers = remote.headers or []
253
+ remote.headers.append({"Accept": ACCEPT_JSON_PREFERRED})
254
+ downloader = remote.get_downloader(url=url, max_retries=1)
255
+ try:
256
+ d = downloader.fetch()
257
+ except ClientError:
258
+ return HttpResponse(f"Failed to fetch {package} from {remote.url}.", status=502)
259
+ except TimeoutException:
260
+ return HttpResponse(f"{remote.url} timed out while fetching {package}.", status=504)
261
+
262
+ if d.headers["content-type"] == "application/vnd.pypi.simple.v1+json":
263
+ page = ProjectPage.from_json_data(json.load(open(d.path, "rb")), base_url=remote.url)
264
+ else:
265
+ page = ProjectPage.from_html(package, open(d.path, "rb").read(), base_url=remote.url)
266
+ packages = [
267
+ parse_package(p) for p in page.packages if rfilter.filter_release(package, p.version)
268
+ ]
269
+ return HttpResponse(write_simple_detail(package, packages))
257
270
 
258
271
  @extend_schema(operation_id="pypi_simple_package_read", summary="Get package simple page")
259
272
  def retrieve(self, request, path, package):
@@ -262,8 +275,7 @@ class SimpleView(PackageUploadMixin, ViewSet):
262
275
  # Should I redirect if the normalized name is different?
263
276
  normalized = canonicalize_name(package)
264
277
  if self.distribution.remote:
265
- if not repo_ver or not content.filter(name__normalize=normalized).exists():
266
- return self.pull_through_package_simple(normalized, path, self.distribution.remote)
278
+ return self.pull_through_package_simple(normalized, path, self.distribution.remote)
267
279
  if self.should_redirect(repo_version=repo_ver):
268
280
  return redirect(urljoin(self.base_content_url, f'{path}/simple/{normalized}/'))
269
281
  packages = (
@@ -3,5 +3,6 @@ Asynchronous task definitions.
3
3
  """
4
4
 
5
5
  from .publish import publish # noqa:F401
6
+ from .repair import repair # noqa:F401
6
7
  from .sync import sync # noqa:F401
7
8
  from .upload import upload, upload_group # noqa:F401
@@ -0,0 +1,89 @@
1
+ import logging
2
+ import uuid
3
+ from gettext import gettext as _
4
+
5
+ from django.db.models.query import QuerySet
6
+ from pulpcore.plugin.models import ProgressReport
7
+ from pulpcore.plugin.util import get_domain
8
+
9
+ from pulp_python.app.models import PythonPackageContent, PythonRepository
10
+ from pulp_python.app.utils import artifact_to_python_content_data
11
+
12
+ log = logging.getLogger(__name__)
13
+
14
+
15
+ def repair(repository_pk: uuid.UUID) -> None:
16
+ """
17
+ Repairs metadata of all packages for the specified repository.
18
+
19
+ Args:
20
+ repository_pk (uuid.UUID): The primary key of the repository to repair.
21
+
22
+ Returns:
23
+ None
24
+ """
25
+ repository = PythonRepository.objects.get(pk=repository_pk)
26
+
27
+ log.info(
28
+ _(
29
+ "Repairing packages' metadata for the latest version of repository {}."
30
+ ).format(repository.name)
31
+ )
32
+ content_set = repository.latest_version().content.values_list("pk", flat=True)
33
+ content = PythonPackageContent.objects.filter(pk__in=content_set)
34
+
35
+ num_repaired = repair_metadata(content)
36
+ log.info(_("{} packages' metadata repaired.").format(num_repaired))
37
+
38
+
39
+ def repair_metadata(content: QuerySet[PythonPackageContent]) -> int:
40
+ """
41
+ Repairs metadata for a queryset of PythonPackageContent objects
42
+ and updates the progress report.
43
+
44
+ Args:
45
+ content (QuerySet[PythonPackageContent]): The queryset of items to repair.
46
+
47
+ Returns:
48
+ int: The number of packages that were repaired.
49
+ """
50
+ # TODO: Add on_demand content repair
51
+ immediate_content = content.filter(contentartifact__artifact__isnull=False)
52
+ domain = get_domain()
53
+
54
+ batch = []
55
+ set_of_update_fields = set()
56
+ total_repaired = 0
57
+
58
+ progress_report = ProgressReport(
59
+ message="Repairing packages' metadata",
60
+ code="repair.metadata",
61
+ total=immediate_content.count(),
62
+ )
63
+ progress_report.save()
64
+ with progress_report:
65
+ for package in progress_report.iter(
66
+ immediate_content.prefetch_related("_artifacts").iterator(chunk_size=1000)
67
+ ):
68
+ new_data = artifact_to_python_content_data(
69
+ package.filename, package._artifacts.get(), domain
70
+ )
71
+ changed = False
72
+ for field, value in new_data.items():
73
+ if getattr(package, field) != value:
74
+ setattr(package, field, value)
75
+ set_of_update_fields.add(field)
76
+ changed = True
77
+ if changed:
78
+ batch.append(package)
79
+ if len(batch) == 1000:
80
+ total_repaired += len(batch)
81
+ PythonPackageContent.objects.bulk_update(batch, set_of_update_fields)
82
+ batch = []
83
+ set_of_update_fields.clear()
84
+
85
+ if batch:
86
+ total_repaired += len(batch)
87
+ PythonPackageContent.objects.bulk_update(batch, set_of_update_fields)
88
+
89
+ return total_repaired
@@ -7,7 +7,8 @@ from collections import defaultdict
7
7
  from django.conf import settings
8
8
  from jinja2 import Template
9
9
  from packaging.utils import canonicalize_name
10
- from packaging.version import parse
10
+ from packaging.requirements import Requirement
11
+ from packaging.version import parse, InvalidVersion
11
12
 
12
13
 
13
14
  PYPI_LAST_SERIAL = "X-PYPI-LAST-SERIAL"
@@ -356,3 +357,80 @@ def write_simple_detail(project_name, project_packages, streamed=False):
356
357
  detail = Template(simple_detail_template)
357
358
  context = {"project_name": project_name, "project_packages": project_packages}
358
359
  return detail.stream(**context) if streamed else detail.render(**context)
360
+
361
+
362
+ class PackageIncludeFilter:
363
+ """A special class to help filter Package's based on a remote's include/exclude"""
364
+
365
+ def __init__(self, remote):
366
+ self.remote = remote.cast()
367
+ self._filter_includes = self._parse_packages(self.remote.includes)
368
+ self._filter_excludes = self._parse_packages(self.remote.excludes)
369
+
370
+ def _parse_packages(self, packages):
371
+ config = defaultdict(lambda: defaultdict(list))
372
+ for value in packages:
373
+ requirement = Requirement(value)
374
+ requirement.name = canonicalize_name(requirement.name)
375
+ if requirement.specifier:
376
+ requirement.specifier.prereleases = True
377
+ config["range"][requirement.name].append(requirement)
378
+ else:
379
+ config["full"][requirement.name].append(requirement)
380
+ return config
381
+
382
+ def filter_project(self, project_name):
383
+ """Return true/false if project_name would be allowed through remote's filters."""
384
+ project_name = canonicalize_name(project_name)
385
+ include_full = self._filter_includes.get("full", {})
386
+ include_range = self._filter_includes.get("range", {})
387
+ include = set(include_range.keys()).union(include_full.keys())
388
+ if include and project_name not in include:
389
+ return False
390
+
391
+ exclude_full = self._filter_excludes.get("full", {})
392
+ if project_name in exclude_full:
393
+ return False
394
+
395
+ return True
396
+
397
+ def filter_release(self, project_name, version):
398
+ """Returns true/false if release would be allowed through remote's filters."""
399
+ project_name = canonicalize_name(project_name)
400
+ if not self.filter_project(project_name):
401
+ return False
402
+
403
+ try:
404
+ version = parse(version)
405
+ except InvalidVersion:
406
+ return False
407
+
408
+ include_range = self._filter_includes.get("range", {})
409
+ if project_name in include_range:
410
+ for req in include_range[project_name]:
411
+ if version in req.specifier:
412
+ break
413
+ else:
414
+ return False
415
+
416
+ exclude_range = self._filter_excludes.get("range", {})
417
+ if project_name in exclude_range:
418
+ for req in exclude_range[project_name]:
419
+ if version in req.specifier:
420
+ return False
421
+
422
+ return True
423
+
424
+
425
+ _remote_filters = {}
426
+
427
+
428
+ def get_remote_package_filter(remote):
429
+ if date_filter_tuple := _remote_filters.get(remote.pulp_id):
430
+ last_update, rfilter = date_filter_tuple
431
+ if last_update == remote.pulp_last_updated:
432
+ return rfilter
433
+
434
+ rfilter = PackageIncludeFilter(remote)
435
+ _remote_filters[remote.pulp_id] = (remote.pulp_last_updated, rfilter)
436
+ return rfilter
@@ -83,7 +83,7 @@ class PythonRepositoryViewSet(
83
83
  ],
84
84
  },
85
85
  {
86
- "action": ["modify"],
86
+ "action": ["modify", "repair_metadata"],
87
87
  "principal": "authenticated",
88
88
  "effect": "allow",
89
89
  "condition": [
@@ -122,6 +122,25 @@ class PythonRepositoryViewSet(
122
122
  "python.pythonrepository_viewer": ["python.view_pythonrepository"],
123
123
  }
124
124
 
125
+ @extend_schema(
126
+ summary="Repair metadata",
127
+ responses={202: AsyncOperationResponseSerializer},
128
+ )
129
+ @action(detail=True, methods=["post"], serializer_class=None)
130
+ def repair_metadata(self, request, pk):
131
+ """
132
+ Trigger an asynchronous task to repair Python metadata. This task will repair metadata
133
+ of all packages for the specified `Repository`, without creating a new `RepositoryVersion`.
134
+ """
135
+ repository = self.get_object()
136
+
137
+ result = dispatch(
138
+ tasks.repair,
139
+ exclusive_resources=[repository],
140
+ kwargs={"repository_pk": str(repository.pk)},
141
+ )
142
+ return core_viewsets.OperationPostponedResponse(result, request)
143
+
125
144
  @extend_schema(
126
145
  summary="Sync from remote",
127
146
  responses={202: AsyncOperationResponseSerializer}
@@ -0,0 +1,140 @@
1
+ import pytest
2
+ import requests
3
+ import subprocess
4
+
5
+ from pulp_python.tests.functional.constants import (
6
+ PYPI_URL,
7
+ PYTHON_XS_FIXTURE_CHECKSUMS,
8
+ PYTHON_SM_PROJECT_SPECIFIER,
9
+ PYTHON_SM_FIXTURE_RELEASES,
10
+ )
11
+
12
+ from pypi_simple import ProjectPage
13
+ from packaging.version import parse
14
+ from urllib.parse import urljoin, urlsplit
15
+ from random import sample
16
+
17
+
18
+ def test_pull_through_install(
19
+ python_bindings, python_remote_factory, python_distribution_factory, delete_orphans_pre
20
+ ):
21
+ """Tests that a pull-through distro can be installed from."""
22
+ remote = python_remote_factory(url=PYPI_URL, includes=[])
23
+ distro = python_distribution_factory(remote=remote.pulp_href)
24
+ PACKAGE = "pulpcore-releases"
25
+
26
+ # Check if already installed
27
+ stdout = subprocess.run(("pip", "list"), capture_output=True).stdout.decode("utf-8")
28
+ if stdout.find(PACKAGE) != -1:
29
+ subprocess.run(("pip", "uninstall", PACKAGE, "-y"))
30
+
31
+ # Perform pull-through install
32
+ host = urlsplit(distro.base_url).hostname
33
+ url = f"{distro.base_url}simple/"
34
+ cmd = ("pip", "install", "--trusted-host", host, "-i", url, PACKAGE)
35
+ subprocess.run(cmd, check=True)
36
+
37
+ stdout = subprocess.run(("pip", "list"), capture_output=True).stdout.decode("utf-8")
38
+ assert stdout.find(PACKAGE) != -1
39
+ subprocess.run(("pip", "uninstall", PACKAGE, "-y"))
40
+ content = python_bindings.ContentPackagesApi.list(name=PACKAGE)
41
+ assert content.count == 1
42
+
43
+
44
+ @pytest.mark.parallel
45
+ def test_pull_through_simple(python_remote_factory, python_distribution_factory, pulp_content_url):
46
+ """Tests that the simple page is properly modified when requesting a pull-through."""
47
+ remote = python_remote_factory(url=PYPI_URL, includes=["shelf-reader"])
48
+ distro = python_distribution_factory(remote=remote.pulp_href)
49
+
50
+ url = f"{distro.base_url}simple/shelf-reader/"
51
+ project_page = ProjectPage.from_response(requests.get(url), "shelf-reader")
52
+
53
+ assert len(project_page.packages) == 2
54
+ for package in project_page.packages:
55
+ assert package.filename in PYTHON_XS_FIXTURE_CHECKSUMS
56
+ relative_path = f"{distro.base_path}/{package.filename}?redirect="
57
+ assert urljoin(pulp_content_url, relative_path) in package.url
58
+ assert PYTHON_XS_FIXTURE_CHECKSUMS[package.filename] == package.digests["sha256"]
59
+
60
+
61
+ @pytest.mark.parallel
62
+ def test_pull_through_filter(python_remote_factory, python_distribution_factory):
63
+ """Tests that pull-through respects the includes/excludes filter on the remote."""
64
+ remote = python_remote_factory(url=PYPI_URL, includes=["shelf-reader"])
65
+ distro = python_distribution_factory(remote=remote.pulp_href)
66
+
67
+ r = requests.get(f"{distro.base_url}simple/pulpcore/")
68
+ assert r.status_code == 404
69
+ assert r.json() == {"detail": "pulpcore does not exist."}
70
+
71
+ r = requests.get(f"{distro.base_url}simple/shelf-reader/")
72
+ assert r.status_code == 200
73
+
74
+ # Test complex include specifiers
75
+ remote = python_remote_factory(includes=PYTHON_SM_PROJECT_SPECIFIER)
76
+ distro = python_distribution_factory(remote=remote.pulp_href)
77
+ for package, releases in PYTHON_SM_FIXTURE_RELEASES.items():
78
+ url = f"{distro.base_url}simple/{package}/"
79
+ project_page = ProjectPage.from_response(requests.get(url), package)
80
+ packages = {p.filename for p in project_page.packages if not parse(p.version).is_prerelease}
81
+ assert packages == set(releases)
82
+
83
+ # Test exclude logic
84
+ remote = python_remote_factory(includes=[], excludes=["django"])
85
+ distro = python_distribution_factory(remote=remote.pulp_href)
86
+
87
+ r = requests.get(f"{distro.base_url}simple/django/")
88
+ assert r.status_code == 404
89
+ assert r.json() == {"detail": "django does not exist."}
90
+
91
+ r = requests.get(f"{distro.base_url}simple/pulpcore/")
92
+ assert r.status_code == 502
93
+ assert r.text == f"Failed to fetch pulpcore from {remote.url}."
94
+
95
+ r = requests.get(f"{distro.base_url}simple/shelf-reader/")
96
+ assert r.status_code == 200
97
+
98
+
99
+ @pytest.mark.parallel
100
+ def test_pull_through_with_repo(
101
+ python_repo_factory,
102
+ python_remote_factory,
103
+ python_distribution_factory,
104
+ python_bindings,
105
+ pulpcore_bindings,
106
+ monitor_task,
107
+ has_pulp_plugin,
108
+ ):
109
+ """Tests that content is saved to repository."""
110
+ remote = python_remote_factory(includes=[])
111
+ repo = python_repo_factory()
112
+ distro = python_distribution_factory(repository=repo.pulp_href, remote=remote.pulp_href)
113
+
114
+ # Perform a download of aiohttp to ensure it's saved to the repo
115
+ url = urljoin(distro.base_url, "simple/aiohttp/")
116
+ project_page = ProjectPage.from_response(requests.get(url), "aiohttp")
117
+ for package in sample(project_page.packages, 3):
118
+ assert "?redirect=" in package.url
119
+ r = requests.get(package.url)
120
+ assert r.status_code == 200
121
+
122
+ if has_pulp_plugin("core", max="3.73"):
123
+ pytest.skip("Pull-through repository save added in 3.74")
124
+
125
+ tasks = pulpcore_bindings.TasksApi.list(reserved_resources=repo.prn)
126
+ assert tasks.count == 3
127
+
128
+ for task in tasks.results:
129
+ monitor_task(task.pulp_href)
130
+
131
+ repo = python_bindings.RepositoriesPythonApi.read(repo.pulp_href)
132
+ assert repo.latest_version_href[-2] == "3"
133
+ content = python_bindings.ContentPackagesApi.list(repository_version=repo.latest_version_href)
134
+ assert content.count == 3
135
+
136
+ # Check that getting content that is already present doesn't trigger a task
137
+ r = requests.get(package.url)
138
+ assert r.status_code == 200
139
+ tasks = pulpcore_bindings.TasksApi.list(reserved_resources=repo.prn)
140
+ assert tasks.count == 3
@@ -1,7 +1,11 @@
1
1
  import pytest
2
2
  import subprocess
3
+ from urllib.parse import urljoin
3
4
 
4
- from pulp_python.tests.functional.constants import PYTHON_EGG_FILENAME
5
+ from pulp_python.tests.functional.constants import (
6
+ PYTHON_EGG_FILENAME,
7
+ PYTHON_FIXTURES_URL,
8
+ )
5
9
 
6
10
 
7
11
  @pytest.fixture
@@ -76,3 +80,47 @@ def test_metadata_repair_command(
76
80
  assert content.packagetype == "sdist"
77
81
  assert content.requires_python == "" # technically null
78
82
  assert content.author == "Austin Macdonald"
83
+
84
+
85
+ def test_metadata_repair_endpoint(
86
+ create_content_direct,
87
+ download_python_file,
88
+ monitor_task,
89
+ move_to_repository,
90
+ python_bindings,
91
+ python_repo,
92
+ ):
93
+ """
94
+ Test repairing of package metadata via `Repositories.repair_metadata` endpoint.
95
+ """
96
+ python_egg_filename = "scipy-1.1.0.tar.gz"
97
+ python_egg_url = urljoin(
98
+ urljoin(PYTHON_FIXTURES_URL, "packages/"), python_egg_filename
99
+ )
100
+ python_file = download_python_file(python_egg_filename, python_egg_url)
101
+
102
+ data = {
103
+ "name": "scipy",
104
+ # Wrong metadata
105
+ "author": "ME",
106
+ "packagetype": "bdist",
107
+ "requires_python": ">=3.8",
108
+ "version": "0.2",
109
+ }
110
+ content = create_content_direct(python_file, python_egg_filename, data)
111
+ for field, wrong_value in data.items():
112
+ if field == "python_version":
113
+ continue
114
+ assert getattr(content, field) == wrong_value
115
+ move_to_repository(python_repo.pulp_href, [content.pulp_href])
116
+
117
+ response = python_bindings.RepositoriesPythonApi.repair_metadata(
118
+ python_repo.pulp_href
119
+ )
120
+ monitor_task(response.task)
121
+
122
+ content = python_bindings.ContentPackagesApi.read(content.pulp_href)
123
+ assert content.version == "1.1.0"
124
+ assert content.packagetype == "sdist"
125
+ assert content.requires_python == ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*"
126
+ assert content.author == ""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pulp-python
3
- Version: 3.13.5
3
+ Version: 3.15.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
@@ -48,6 +48,7 @@ pulp_python/app/pypi/serializers.py
48
48
  pulp_python/app/pypi/views.py
49
49
  pulp_python/app/tasks/__init__.py
50
50
  pulp_python/app/tasks/publish.py
51
+ pulp_python/app/tasks/repair.py
51
52
  pulp_python/app/tasks/sync.py
52
53
  pulp_python/app/tasks/upload.py
53
54
  pulp_python/app/webserver_snippets/__init__.py
@@ -7,7 +7,7 @@ build-backend = 'setuptools.build_meta'
7
7
 
8
8
  [project]
9
9
  name = "pulp-python"
10
- version = "3.13.5"
10
+ version = "3.15.0"
11
11
  description = "pulp-python plugin for the Pulp Project"
12
12
  readme = "README.md"
13
13
  authors = [
@@ -77,7 +77,7 @@ ignore = [
77
77
  [tool.bumpversion]
78
78
  # This section is managed by the plugin template. Do not edit manually.
79
79
 
80
- current_version = "3.13.5"
80
+ current_version = "3.15.0"
81
81
  commit = false
82
82
  tag = false
83
83
  parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<alpha>0a)?(?P<patch>\\d+)(\\.(?P<release>[a-z]+))?"
@@ -1,72 +0,0 @@
1
- import pytest
2
- import requests
3
- import subprocess
4
-
5
- from pulp_python.tests.functional.constants import (
6
- PYPI_URL,
7
- PYTHON_XS_FIXTURE_CHECKSUMS,
8
- )
9
-
10
- from pypi_simple import ProjectPage
11
- from urllib.parse import urljoin, urlsplit
12
-
13
-
14
- def test_pull_through_install(
15
- python_bindings, python_remote_factory, python_distribution_factory, delete_orphans_pre
16
- ):
17
- """Tests that a pull-through distro can be installed from."""
18
- remote = python_remote_factory(url=PYPI_URL)
19
- distro = python_distribution_factory(remote=remote.pulp_href)
20
- PACKAGE = "pulpcore-releases"
21
-
22
- # Check if already installed
23
- stdout = subprocess.run(("pip", "list"), capture_output=True).stdout.decode("utf-8")
24
- if stdout.find(PACKAGE) != -1:
25
- subprocess.run(("pip", "uninstall", PACKAGE, "-y"))
26
-
27
- # Perform pull-through install
28
- host = urlsplit(distro.base_url).hostname
29
- url = f"{distro.base_url}simple/"
30
- cmd = ("pip", "install", "--trusted-host", host, "-i", url, PACKAGE)
31
- subprocess.run(cmd, check=True)
32
-
33
- stdout = subprocess.run(("pip", "list"), capture_output=True).stdout.decode("utf-8")
34
- assert stdout.find(PACKAGE) != -1
35
- subprocess.run(("pip", "uninstall", PACKAGE, "-y"))
36
- content = python_bindings.ContentPackagesApi.list(name=PACKAGE)
37
- assert content.count == 1
38
-
39
-
40
- @pytest.mark.parallel
41
- def test_pull_through_simple(python_remote_factory, python_distribution_factory, pulp_content_url):
42
- """Tests that the simple page is properly modified when requesting a pull-through."""
43
- remote = python_remote_factory(url=PYPI_URL)
44
- distro = python_distribution_factory(remote=remote.pulp_href)
45
-
46
- url = f"{distro.base_url}simple/shelf-reader/"
47
- project_page = ProjectPage.from_response(requests.get(url), "shelf-reader")
48
-
49
- assert len(project_page.packages) == 2
50
- for package in project_page.packages:
51
- assert package.filename in PYTHON_XS_FIXTURE_CHECKSUMS
52
- relative_path = f"{distro.base_path}/{package.filename}?redirect="
53
- assert urljoin(pulp_content_url, relative_path) in package.url
54
- assert PYTHON_XS_FIXTURE_CHECKSUMS[package.filename] == package.digests["sha256"]
55
-
56
-
57
- @pytest.mark.parallel
58
- def test_pull_through_with_repo(
59
- python_repo_with_sync, python_remote_factory, python_distribution_factory
60
- ):
61
- """Tests that if content is already in repository, pull-through isn't used."""
62
- remote = python_remote_factory()
63
- repo = python_repo_with_sync(remote)
64
- distro = python_distribution_factory(repository=repo.pulp_href, remote=remote.pulp_href)
65
-
66
- url = urljoin(distro.base_url, "simple/shelf-reader/")
67
- project_page = ProjectPage.from_response(requests.get(url), "shelf-reader")
68
-
69
- assert len(project_page.packages) == 2
70
- for package in project_page.packages:
71
- assert package.filename in PYTHON_XS_FIXTURE_CHECKSUMS
72
- assert "?redirect=" not in package.url
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes