pulp-python 3.20.0__tar.gz → 3.21.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 (88) hide show
  1. {pulp_python-3.20.0 → pulp_python-3.21.0}/CHANGES.md +18 -0
  2. {pulp_python-3.20.0 → pulp_python-3.21.0}/PKG-INFO +3 -3
  3. {pulp_python-3.20.0 → pulp_python-3.21.0}/pulp_python/app/__init__.py +1 -1
  4. pulp_python-3.21.0/pulp_python/app/migrations/0017_pythonpackagecontent_size.py +50 -0
  5. {pulp_python-3.20.0 → pulp_python-3.21.0}/pulp_python/app/models.py +1 -0
  6. {pulp_python-3.20.0 → pulp_python-3.21.0}/pulp_python/app/pypi/views.py +44 -46
  7. {pulp_python-3.20.0 → pulp_python-3.21.0}/pulp_python/app/serializers.py +10 -0
  8. {pulp_python-3.20.0 → pulp_python-3.21.0}/pulp_python/app/tasks/__init__.py +1 -0
  9. {pulp_python-3.20.0 → pulp_python-3.21.0}/pulp_python/app/tasks/sync.py +2 -1
  10. pulp_python-3.21.0/pulp_python/app/tasks/vulnerability_report.py +30 -0
  11. {pulp_python-3.20.0 → pulp_python-3.21.0}/pulp_python/app/utils.py +19 -5
  12. {pulp_python-3.20.0 → pulp_python-3.21.0}/pulp_python/app/viewsets.py +30 -2
  13. {pulp_python-3.20.0 → pulp_python-3.21.0}/pulp_python/pytest_plugin.py +1 -1
  14. {pulp_python-3.20.0 → pulp_python-3.21.0}/pulp_python/tests/functional/api/test_full_mirror.py +30 -4
  15. {pulp_python-3.20.0 → pulp_python-3.21.0}/pulp_python/tests/functional/api/test_pypi_apis.py +5 -0
  16. {pulp_python-3.20.0 → pulp_python-3.21.0}/pulp_python/tests/functional/api/test_pypi_simple_json_api.py +6 -2
  17. pulp_python-3.21.0/pulp_python/tests/functional/api/test_vulnerability_report.py +48 -0
  18. {pulp_python-3.20.0 → pulp_python-3.21.0}/pulp_python/tests/functional/constants.py +5 -0
  19. {pulp_python-3.20.0 → pulp_python-3.21.0}/pulp_python.egg-info/PKG-INFO +3 -3
  20. {pulp_python-3.20.0 → pulp_python-3.21.0}/pulp_python.egg-info/SOURCES.txt +3 -0
  21. pulp_python-3.21.0/pulp_python.egg-info/requires.txt +4 -0
  22. {pulp_python-3.20.0 → pulp_python-3.21.0}/pyproject.toml +4 -4
  23. pulp_python-3.20.0/pulp_python.egg-info/requires.txt +0 -4
  24. {pulp_python-3.20.0 → pulp_python-3.21.0}/COMMITMENT +0 -0
  25. {pulp_python-3.20.0 → pulp_python-3.21.0}/COPYRIGHT +0 -0
  26. {pulp_python-3.20.0 → pulp_python-3.21.0}/LICENSE +0 -0
  27. {pulp_python-3.20.0 → pulp_python-3.21.0}/MANIFEST.in +0 -0
  28. {pulp_python-3.20.0 → pulp_python-3.21.0}/README.md +0 -0
  29. {pulp_python-3.20.0 → pulp_python-3.21.0}/functest_requirements.txt +0 -0
  30. {pulp_python-3.20.0 → pulp_python-3.21.0}/pulp_python/__init__.py +0 -0
  31. {pulp_python-3.20.0 → pulp_python-3.21.0}/pulp_python/app/global_access_conditions.py +0 -0
  32. {pulp_python-3.20.0 → pulp_python-3.21.0}/pulp_python/app/management/__init__.py +0 -0
  33. {pulp_python-3.20.0 → pulp_python-3.21.0}/pulp_python/app/management/commands/__init__.py +0 -0
  34. {pulp_python-3.20.0 → pulp_python-3.21.0}/pulp_python/app/management/commands/repair-python-metadata.py +0 -0
  35. {pulp_python-3.20.0 → pulp_python-3.21.0}/pulp_python/app/migrations/0001_initial.py +0 -0
  36. {pulp_python-3.20.0 → pulp_python-3.21.0}/pulp_python/app/migrations/0001_squashed_0010_update_json_field.py +0 -0
  37. {pulp_python-3.20.0 → pulp_python-3.21.0}/pulp_python/app/migrations/0002_pythonpackagecontent_python_version.py +0 -0
  38. {pulp_python-3.20.0 → pulp_python-3.21.0}/pulp_python/app/migrations/0003_new_sync_filters.py +0 -0
  39. {pulp_python-3.20.0 → pulp_python-3.21.0}/pulp_python/app/migrations/0004_DATA_swap_distribution_model.py +0 -0
  40. {pulp_python-3.20.0 → pulp_python-3.21.0}/pulp_python/app/migrations/0005_pythonpackagecontent_sha256.py +0 -0
  41. {pulp_python-3.20.0 → pulp_python-3.21.0}/pulp_python/app/migrations/0006_pythonrepository_autopublish.py +0 -0
  42. {pulp_python-3.20.0 → pulp_python-3.21.0}/pulp_python/app/migrations/0007_pythonpackagecontent_mv-2-1.py +0 -0
  43. {pulp_python-3.20.0 → pulp_python-3.21.0}/pulp_python/app/migrations/0008_pythonpackagecontent_unique_sha256.py +0 -0
  44. {pulp_python-3.20.0 → pulp_python-3.21.0}/pulp_python/app/migrations/0009_pythondistribution_allow_uploads.py +0 -0
  45. {pulp_python-3.20.0 → pulp_python-3.21.0}/pulp_python/app/migrations/0010_update_json_field.py +0 -0
  46. {pulp_python-3.20.0 → pulp_python-3.21.0}/pulp_python/app/migrations/0011_alter_pythondistribution_distribution_ptr_and_more.py +0 -0
  47. {pulp_python-3.20.0 → pulp_python-3.21.0}/pulp_python/app/migrations/0012_add_domain.py +0 -0
  48. {pulp_python-3.20.0 → pulp_python-3.21.0}/pulp_python/app/migrations/0013_add_rbac_permissions.py +0 -0
  49. {pulp_python-3.20.0 → pulp_python-3.21.0}/pulp_python/app/migrations/0014_pythonpackagecontent_dynamic_and_more.py +0 -0
  50. {pulp_python-3.20.0 → pulp_python-3.21.0}/pulp_python/app/migrations/0015_alter_pythonpackagecontent_options.py +0 -0
  51. {pulp_python-3.20.0 → pulp_python-3.21.0}/pulp_python/app/migrations/0016_pythonpackagecontent_metadata_sha256.py +0 -0
  52. {pulp_python-3.20.0 → pulp_python-3.21.0}/pulp_python/app/migrations/__init__.py +0 -0
  53. {pulp_python-3.20.0 → pulp_python-3.21.0}/pulp_python/app/modelresource.py +0 -0
  54. {pulp_python-3.20.0 → pulp_python-3.21.0}/pulp_python/app/pypi/__init__.py +0 -0
  55. {pulp_python-3.20.0 → pulp_python-3.21.0}/pulp_python/app/pypi/serializers.py +0 -0
  56. {pulp_python-3.20.0 → pulp_python-3.21.0}/pulp_python/app/replica.py +0 -0
  57. {pulp_python-3.20.0 → pulp_python-3.21.0}/pulp_python/app/settings.py +0 -0
  58. {pulp_python-3.20.0 → pulp_python-3.21.0}/pulp_python/app/tasks/publish.py +0 -0
  59. {pulp_python-3.20.0 → pulp_python-3.21.0}/pulp_python/app/tasks/repair.py +0 -0
  60. {pulp_python-3.20.0 → pulp_python-3.21.0}/pulp_python/app/tasks/upload.py +0 -0
  61. {pulp_python-3.20.0 → pulp_python-3.21.0}/pulp_python/app/urls.py +0 -0
  62. {pulp_python-3.20.0 → pulp_python-3.21.0}/pulp_python/app/webserver_snippets/__init__.py +0 -0
  63. {pulp_python-3.20.0 → pulp_python-3.21.0}/pulp_python/app/webserver_snippets/apache.conf +0 -0
  64. {pulp_python-3.20.0 → pulp_python-3.21.0}/pulp_python/app/webserver_snippets/nginx.conf +0 -0
  65. {pulp_python-3.20.0 → pulp_python-3.21.0}/pulp_python/tests/__init__.py +0 -0
  66. {pulp_python-3.20.0 → pulp_python-3.21.0}/pulp_python/tests/functional/__init__.py +0 -0
  67. {pulp_python-3.20.0 → pulp_python-3.21.0}/pulp_python/tests/functional/api/__init__.py +0 -0
  68. {pulp_python-3.20.0 → pulp_python-3.21.0}/pulp_python/tests/functional/api/test_auto_publish.py +0 -0
  69. {pulp_python-3.20.0 → pulp_python-3.21.0}/pulp_python/tests/functional/api/test_consume_content.py +0 -0
  70. {pulp_python-3.20.0 → pulp_python-3.21.0}/pulp_python/tests/functional/api/test_crud_content_unit.py +0 -0
  71. {pulp_python-3.20.0 → pulp_python-3.21.0}/pulp_python/tests/functional/api/test_crud_publications.py +0 -0
  72. {pulp_python-3.20.0 → pulp_python-3.21.0}/pulp_python/tests/functional/api/test_crud_remotes.py +0 -0
  73. {pulp_python-3.20.0 → pulp_python-3.21.0}/pulp_python/tests/functional/api/test_domains.py +0 -0
  74. {pulp_python-3.20.0 → pulp_python-3.21.0}/pulp_python/tests/functional/api/test_download_content.py +0 -0
  75. {pulp_python-3.20.0 → pulp_python-3.21.0}/pulp_python/tests/functional/api/test_export_import.py +0 -0
  76. {pulp_python-3.20.0 → pulp_python-3.21.0}/pulp_python/tests/functional/api/test_rbac.py +0 -0
  77. {pulp_python-3.20.0 → pulp_python-3.21.0}/pulp_python/tests/functional/api/test_repair.py +0 -0
  78. {pulp_python-3.20.0 → pulp_python-3.21.0}/pulp_python/tests/functional/api/test_sync.py +0 -0
  79. {pulp_python-3.20.0 → pulp_python-3.21.0}/pulp_python/tests/functional/api/test_upload.py +0 -0
  80. {pulp_python-3.20.0 → pulp_python-3.21.0}/pulp_python/tests/functional/utils.py +0 -0
  81. {pulp_python-3.20.0 → pulp_python-3.21.0}/pulp_python/tests/unit/__init__.py +0 -0
  82. {pulp_python-3.20.0 → pulp_python-3.21.0}/pulp_python/tests/unit/test_models.py +0 -0
  83. {pulp_python-3.20.0 → pulp_python-3.21.0}/pulp_python.egg-info/dependency_links.txt +0 -0
  84. {pulp_python-3.20.0 → pulp_python-3.21.0}/pulp_python.egg-info/entry_points.txt +0 -0
  85. {pulp_python-3.20.0 → pulp_python-3.21.0}/pulp_python.egg-info/top_level.txt +0 -0
  86. {pulp_python-3.20.0 → pulp_python-3.21.0}/setup.cfg +0 -0
  87. {pulp_python-3.20.0 → pulp_python-3.21.0}/test_requirements.txt +0 -0
  88. {pulp_python-3.20.0 → pulp_python-3.21.0}/unittest_requirements.txt +0 -0
@@ -8,6 +8,24 @@
8
8
 
9
9
  [//]: # (towncrier release notes start)
10
10
 
11
+ ## 3.21.0 (2025-11-18) {: #3.21.0 }
12
+
13
+ #### Features {: #3.21.0-feature }
14
+
15
+ - Added ability to serve a specific repository version of a PyPI index.
16
+ [#982](https://github.com/pulp/pulp_python/issues/982)
17
+ - Implemented PEP 700 support, adding `versions`, `size` and `upload-time` to the Simple JSON API.
18
+ [#996](https://github.com/pulp/pulp_python/issues/996)
19
+ - Added the new /scan endpoint to the RepositoryVersion viewset to generate vulnerability reports.
20
+ [#1012](https://github.com/pulp/pulp_python/issues/1012)
21
+
22
+ #### Bugfixes {: #3.21.0-bugfix }
23
+
24
+ - Fixed pull-through caching not checking the repository if package was not present on remote.
25
+ [#1004](https://github.com/pulp/pulp_python/issues/1004)
26
+
27
+ ---
28
+
11
29
  ## 3.20.0 (2025-11-07) {: #3.20.0 }
12
30
 
13
31
  #### Features {: #3.20.0-feature }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pulp-python
3
- Version: 3.20.0
3
+ Version: 3.21.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
@@ -20,9 +20,9 @@ Classifier: Programming Language :: Python :: 3.13
20
20
  Requires-Python: >=3.11
21
21
  Description-Content-Type: text/markdown
22
22
  License-File: LICENSE
23
- Requires-Dist: pulpcore<3.100,>=3.81.0
23
+ Requires-Dist: pulpcore<3.100,>=3.85.3
24
24
  Requires-Dist: pkginfo<1.13.0,>=1.12.0
25
- Requires-Dist: bandersnatch<6.6,>=6.3.0
25
+ Requires-Dist: bandersnatch<6.7,>=6.6.0
26
26
  Requires-Dist: pypi-simple<2.0,>=1.5.0
27
27
  Dynamic: license-file
28
28
 
@@ -10,7 +10,7 @@ class PulpPythonPluginAppConfig(PulpPluginAppConfig):
10
10
 
11
11
  name = "pulp_python.app"
12
12
  label = "python"
13
- version = "3.20.0"
13
+ version = "3.21.0"
14
14
  python_package_name = "pulp-python"
15
15
  domain_compatible = True
16
16
 
@@ -0,0 +1,50 @@
1
+ # Generated by Django 4.2.26 on 2025-11-11 21:43
2
+
3
+ from django.db import migrations, models, transaction
4
+
5
+
6
+ def add_size_to_current_models(apps, schema_editor):
7
+ """Adds the size to current PythonPackageContent models."""
8
+ PythonPackageContent = apps.get_model("python", "PythonPackageContent")
9
+ RemoteArtifact = apps.get_model("core", "RemoteArtifact")
10
+ package_bulk = []
11
+ for python_package in PythonPackageContent.objects.only("pk", "size").iterator():
12
+ content_artifact = python_package.contentartifact_set.first()
13
+ if content_artifact.artifact:
14
+ artifact = content_artifact.artifact
15
+ else:
16
+ artifact = RemoteArtifact.objects.filter(content_artifact=content_artifact).first()
17
+ python_package.size = artifact.size or 0
18
+ package_bulk.append(python_package)
19
+ if len(package_bulk) == 100000:
20
+ with transaction.atomic():
21
+ PythonPackageContent.objects.bulk_update(
22
+ package_bulk,
23
+ [
24
+ "size",
25
+ ],
26
+ )
27
+ package_bulk = []
28
+ with transaction.atomic():
29
+ PythonPackageContent.objects.bulk_update(
30
+ package_bulk,
31
+ [
32
+ "size",
33
+ ],
34
+ )
35
+
36
+
37
+ class Migration(migrations.Migration):
38
+
39
+ dependencies = [
40
+ ("python", "0016_pythonpackagecontent_metadata_sha256"),
41
+ ]
42
+
43
+ operations = [
44
+ migrations.AddField(
45
+ model_name="pythonpackagecontent",
46
+ name="size",
47
+ field=models.BigIntegerField(default=0),
48
+ ),
49
+ migrations.RunPython(add_size_to_current_models, migrations.RunPython.noop, elidable=True),
50
+ ]
@@ -193,6 +193,7 @@ class PythonPackageContent(Content):
193
193
  python_version = models.TextField()
194
194
  sha256 = models.CharField(db_index=True, max_length=64)
195
195
  metadata_sha256 = models.CharField(max_length=64, null=True)
196
+ size = models.BigIntegerField(default=0)
196
197
  # yanked and yanked_reason are not implemented because they are mutable
197
198
 
198
199
  # From pulpcore
@@ -15,6 +15,7 @@ from django.db import transaction
15
15
  from django.db.utils import DatabaseError
16
16
  from django.http.response import (
17
17
  Http404,
18
+ HttpResponseNotFound,
18
19
  HttpResponseForbidden,
19
20
  HttpResponseBadRequest,
20
21
  StreamingHttpResponse,
@@ -104,10 +105,13 @@ class PyPIMixin:
104
105
  """Finds the repository version this distribution is serving."""
105
106
  pub = distribution.publication
106
107
  rep = distribution.repository
108
+ rep_version = distribution.repository_version
107
109
  if pub:
108
110
  return pub.repository_version or pub.repository.latest_version()
109
111
  elif rep:
110
112
  return rep.latest_version()
113
+ elif rep_version:
114
+ return rep_version
111
115
  else:
112
116
  raise Http404("No repository associated with this index")
113
117
 
@@ -287,7 +291,7 @@ class SimpleView(PackageUploadMixin, ViewSet):
287
291
  kwargs = {"content_type": media_type, "headers": headers}
288
292
  return StreamingHttpResponse(index_data, **kwargs)
289
293
 
290
- def pull_through_package_simple(self, package, path, remote, media_type):
294
+ def pull_through_package_simple(self, package, path, remote):
291
295
  """Gets the package's simple page from remote."""
292
296
 
293
297
  def parse_package(release_package):
@@ -301,11 +305,14 @@ class SimpleView(PackageUploadMixin, ViewSet):
301
305
  "sha256": release_package.digests.get("sha256", ""),
302
306
  "requires_python": release_package.requires_python,
303
307
  "metadata_sha256": (release_package.metadata_digests or {}).get("sha256"),
308
+ "size": release_package.size,
309
+ "upload_time": release_package.upload_time,
310
+ "version": release_package.version,
304
311
  }
305
312
 
306
313
  rfilter = get_remote_package_filter(remote)
307
314
  if not rfilter.filter_project(package):
308
- raise Http404(f"{package} does not exist.")
315
+ return {}
309
316
 
310
317
  url = remote.get_remote_artifact_url(f"simple/{package}/")
311
318
  remote.headers = remote.headers or []
@@ -313,27 +320,19 @@ class SimpleView(PackageUploadMixin, ViewSet):
313
320
  downloader = remote.get_downloader(url=url, max_retries=1)
314
321
  try:
315
322
  d = downloader.fetch()
316
- except ClientError:
317
- return HttpResponse(f"Failed to fetch {package} from {remote.url}.", status=502)
318
- except TimeoutException:
319
- return HttpResponse(f"{remote.url} timed out while fetching {package}.", status=504)
323
+ except (ClientError, TimeoutException):
324
+ log.info(f"Failed to fetch {package} simple page from {remote.url}")
325
+ return {}
320
326
 
321
327
  if d.headers["content-type"] == PYPI_SIMPLE_V1_JSON:
322
328
  page = ProjectPage.from_json_data(json.load(open(d.path, "rb")), base_url=url)
323
329
  else:
324
330
  page = ProjectPage.from_html(package, open(d.path, "rb").read(), base_url=url)
325
- packages = [
326
- parse_package(p) for p in page.packages if rfilter.filter_release(package, p.version)
327
- ]
328
- headers = {"X-PyPI-Last-Serial": str(PYPI_SERIAL_CONSTANT)}
329
-
330
- if media_type == PYPI_SIMPLE_V1_JSON:
331
- detail_data = write_simple_detail_json(package, packages)
332
- return Response(detail_data, headers=headers)
333
- else:
334
- detail_data = write_simple_detail(package, packages)
335
- kwargs = {"content_type": media_type, "headers": headers}
336
- return HttpResponse(detail_data, **kwargs)
331
+ return {
332
+ p.filename: parse_package(p)
333
+ for p in page.packages
334
+ if rfilter.filter_release(package, p.version)
335
+ }
337
336
 
338
337
  @extend_schema(operation_id="pypi_simple_package_read", summary="Get package simple page")
339
338
  def retrieve(self, request, path, package):
@@ -343,44 +342,43 @@ class SimpleView(PackageUploadMixin, ViewSet):
343
342
  repo_ver, content = self.get_rvc()
344
343
  # Should I redirect if the normalized name is different?
345
344
  normalized = canonicalize_name(package)
345
+ releases = {}
346
346
  if self.distribution.remote:
347
- return self.pull_through_package_simple(
348
- normalized, path, self.distribution.remote, media_type
349
- )
350
- if self.should_redirect(repo_version=repo_ver):
347
+ releases = self.pull_through_package_simple(normalized, path, self.distribution.remote)
348
+ elif self.should_redirect(repo_version=repo_ver):
351
349
  return redirect(urljoin(self.base_content_url, f"{path}/simple/{normalized}/"))
352
- packages = (
353
- content.filter(name__normalize=normalized)
354
- .values_list("filename", "sha256", "name", "metadata_sha256", "requires_python")
355
- .iterator()
356
- )
357
- try:
358
- present = next(packages)
359
- except StopIteration:
360
- raise Http404(f"{normalized} does not exist.")
361
- else:
362
- packages = chain([present], packages)
363
- name = present[2]
364
- releases = (
365
- {
366
- "filename": filename,
367
- "url": urljoin(self.base_content_url, f"{path}/{filename}"),
368
- "sha256": sha256,
369
- "metadata_sha256": metadata_sha256,
370
- "requires_python": requires_python,
350
+ if content:
351
+ packages = content.filter(name__normalize=normalized).values(
352
+ "filename",
353
+ "sha256",
354
+ "metadata_sha256",
355
+ "requires_python",
356
+ "size",
357
+ "pulp_created",
358
+ "version",
359
+ )
360
+ local_releases = {
361
+ p["filename"]: {
362
+ **p,
363
+ "url": urljoin(self.base_content_url, f"{path}/{p['filename']}"),
364
+ "upload_time": p["pulp_created"],
365
+ }
366
+ for p in packages
371
367
  }
372
- for filename, sha256, _, metadata_sha256, requires_python in packages
373
- )
368
+ releases.update(local_releases)
369
+ if not releases:
370
+ return HttpResponseNotFound(f"{normalized} does not exist.")
371
+
374
372
  media_type = request.accepted_renderer.media_type
375
373
  headers = {"X-PyPI-Last-Serial": str(PYPI_SERIAL_CONSTANT)}
376
374
 
377
375
  if media_type == PYPI_SIMPLE_V1_JSON:
378
- detail_data = write_simple_detail_json(name, releases)
376
+ detail_data = write_simple_detail_json(normalized, releases.values())
379
377
  return Response(detail_data, headers=headers)
380
378
  else:
381
- detail_data = write_simple_detail(name, releases, streamed=True)
379
+ detail_data = write_simple_detail(normalized, releases.values())
382
380
  kwargs = {"content_type": media_type, "headers": headers}
383
- return StreamingHttpResponse(detail_data, **kwargs)
381
+ return HttpResponse(detail_data, **kwargs)
384
382
 
385
383
  @extend_schema(
386
384
  request=PackageUploadSerializer,
@@ -53,6 +53,9 @@ class PythonDistributionSerializer(core_serializers.DistributionSerializer):
53
53
  queryset=core_models.Publication.objects.exclude(complete=False),
54
54
  allow_null=True,
55
55
  )
56
+ repository_version = core_serializers.RepositoryVersionRelatedField(
57
+ required=False, help_text=_("RepositoryVersion to be served."), allow_null=True
58
+ )
56
59
  base_url = serializers.SerializerMethodField(read_only=True)
57
60
  allow_uploads = serializers.BooleanField(
58
61
  default=True, help_text=_("Allow packages to be uploaded to this index.")
@@ -74,6 +77,7 @@ class PythonDistributionSerializer(core_serializers.DistributionSerializer):
74
77
  class Meta:
75
78
  fields = core_serializers.DistributionSerializer.Meta.fields + (
76
79
  "publication",
80
+ "repository_version",
77
81
  "allow_uploads",
78
82
  "remote",
79
83
  )
@@ -277,6 +281,10 @@ class PythonPackageContentSerializer(core_serializers.SingleArtifactContentUploa
277
281
  ),
278
282
  read_only=True,
279
283
  )
284
+ size = serializers.IntegerField(
285
+ help_text=_("The size of the package in bytes."),
286
+ read_only=True,
287
+ )
280
288
  sha256 = serializers.CharField(
281
289
  default="",
282
290
  help_text=_("The SHA256 digest of this package."),
@@ -368,6 +376,7 @@ class PythonPackageContentSerializer(core_serializers.SingleArtifactContentUploa
368
376
  "filename",
369
377
  "packagetype",
370
378
  "python_version",
379
+ "size",
371
380
  "sha256",
372
381
  "metadata_sha256",
373
382
  )
@@ -421,6 +430,7 @@ class PythonPackageContentUploadSerializer(PythonPackageContentSerializer):
421
430
  data["artifact"] = artifact
422
431
  data["sha256"] = artifact.sha256
423
432
  data["relative_path"] = filename
433
+ data["size"] = artifact.size
424
434
  data.update(parse_project_metadata(vars(metadata)))
425
435
  # Overwrite filename from metadata
426
436
  data["filename"] = filename
@@ -6,3 +6,4 @@ from .publish import publish # noqa:F401
6
6
  from .repair import repair # noqa:F401
7
7
  from .sync import sync # noqa:F401
8
8
  from .upload import upload, upload_group # noqa:F401
9
+ from .vulnerability_report import get_repo_version_content # noqa:F401
@@ -59,9 +59,10 @@ def sync(remote_pk, repository_pk, mirror):
59
59
 
60
60
  def create_bandersnatch_config(remote):
61
61
  """Modifies the global Bandersnatch config state for this sync"""
62
- config = BandersnatchConfig().config
62
+ config = BandersnatchConfig()
63
63
  config["mirror"]["master"] = remote.url
64
64
  config["mirror"]["workers"] = str(remote.download_concurrency)
65
+ config["mirror"]["allow_non_https"] = "true"
65
66
  if not config.has_section("plugins"):
66
67
  config.add_section("plugins")
67
68
  config["plugins"]["enabled"] = "blocklist_release\n"
@@ -0,0 +1,30 @@
1
+ from pulpcore.plugin.models import RepositoryVersion
2
+ from pulpcore.plugin.sync import sync_to_async_iterable
3
+
4
+ from pulp_python.app.models import PythonPackageContent
5
+
6
+
7
+ async def get_repo_version_content(repo_version_pk: str):
8
+ """
9
+ Retrieve Python package content from a repository version for vulnerability scanning.
10
+ """
11
+ repo_version = await RepositoryVersion.objects.aget(pk=repo_version_pk)
12
+ content_units = PythonPackageContent.objects.filter(pk__in=repo_version.content).only(
13
+ "name", "version"
14
+ )
15
+ ecosystem = "PyPI"
16
+ async for content in sync_to_async_iterable(content_units):
17
+ repo_content_osv_data = _build_osv_data(content.name, ecosystem, content.version)
18
+ repo_content_osv_data["repo_version"] = repo_version
19
+ repo_content_osv_data["content"] = content
20
+ yield repo_content_osv_data
21
+
22
+
23
+ def _build_osv_data(name, ecosystem, version=None):
24
+ """
25
+ Build an OSV data structure for vulnerability queries.
26
+ """
27
+ osv_data = {"package": {"name": name, "ecosystem": ecosystem}}
28
+ if version:
29
+ osv_data["version"] = version
30
+ return osv_data
@@ -7,6 +7,7 @@ import zipfile
7
7
  import json
8
8
  from collections import defaultdict
9
9
  from django.conf import settings
10
+ from django.utils import timezone
10
11
  from jinja2 import Template
11
12
  from packaging.utils import canonicalize_name
12
13
  from packaging.requirements import Requirement
@@ -18,7 +19,7 @@ PYPI_LAST_SERIAL = "X-PYPI-LAST-SERIAL"
18
19
  """TODO This serial constant is temporary until Python repositories implements serials"""
19
20
  PYPI_SERIAL_CONSTANT = 1000000000
20
21
 
21
- SIMPLE_API_VERSION = "1.0"
22
+ SIMPLE_API_VERSION = "1.1"
22
23
 
23
24
  simple_index_template = """<!DOCTYPE html>
24
25
  <html>
@@ -161,6 +162,7 @@ def parse_metadata(project, version, distribution):
161
162
  package["sha256"] = distribution.get("digests", {}).get("sha256") or ""
162
163
  package["python_version"] = distribution.get("python_version") or ""
163
164
  package["requires_python"] = distribution.get("requires_python") or ""
165
+ package["size"] = distribution.get("size") or 0
164
166
 
165
167
  return package
166
168
 
@@ -223,6 +225,7 @@ def artifact_to_python_content_data(filename, artifact, domain=None):
223
225
  metadata = get_project_metadata_from_file(temp_file.name)
224
226
  data = parse_project_metadata(vars(metadata))
225
227
  data["sha256"] = artifact.sha256
228
+ data["size"] = artifact.size
226
229
  data["filename"] = filename
227
230
  data["pulp_domain"] = domain or artifact.pulp_domain
228
231
  data["_pulp_domain"] = data["pulp_domain"]
@@ -403,7 +406,6 @@ def python_content_to_download_info(content, base_path, domain=None):
403
406
  components.insert(2, domain.name)
404
407
  url = "/".join(components)
405
408
  md5 = artifact.md5 if artifact and artifact.md5 else ""
406
- size = artifact.size if artifact and artifact.size else 0
407
409
  return {
408
410
  "comment_text": "",
409
411
  "digests": {"md5": md5, "sha256": content.sha256},
@@ -414,7 +416,7 @@ def python_content_to_download_info(content, base_path, domain=None):
414
416
  "packagetype": content.packagetype,
415
417
  "python_version": content.python_version,
416
418
  "requires_python": content.requires_python or None,
417
- "size": size,
419
+ "size": content.size,
418
420
  "upload_time": str(content.pulp_created),
419
421
  "upload_time_iso_8601": str(content.pulp_created.isoformat()),
420
422
  "url": url,
@@ -471,20 +473,32 @@ def write_simple_detail_json(project_name, project_packages):
471
473
  {"sha256": package["metadata_sha256"]} if package["metadata_sha256"] else False
472
474
  ),
473
475
  # yanked and yanked_reason are not implemented because they are mutable
476
+ # (v1.1, PEP 700)
477
+ "size": package["size"],
478
+ "upload-time": format_upload_time(package["upload_time"]),
474
479
  # TODO in the future:
475
- # size, upload-time (v1.1, PEP 700)
476
480
  # core-metadata (PEP 7.14)
477
481
  # provenance (v1.3, PEP 740)
478
482
  }
479
483
  for package in project_packages
480
484
  ],
485
+ # (v1.1, PEP 700)
486
+ "versions": sorted(set(package["version"] for package in project_packages)),
481
487
  # TODO in the future:
482
- # versions (v1.1, PEP 700)
483
488
  # alternate-locations (v1.2, PEP 708)
484
489
  # project-status (v1.4, PEP 792 - pypi and docs differ)
485
490
  }
486
491
 
487
492
 
493
+ def format_upload_time(upload_time):
494
+ """Formats the upload time to be in Zulu time. UTC with Z suffix"""
495
+ if upload_time:
496
+ if upload_time.tzinfo:
497
+ dt = upload_time.astimezone(timezone.utc)
498
+ return dt.isoformat().replace("+00:00", "Z")
499
+ return None
500
+
501
+
488
502
  class PackageIncludeFilter:
489
503
  """A special class to help filter Package's based on a remote's include/exclude"""
490
504
 
@@ -1,6 +1,7 @@
1
1
  from bandersnatch.configuration import BandersnatchConfig
2
2
  from django.db import transaction
3
3
  from drf_spectacular.utils import extend_schema
4
+ from pathlib import Path
4
5
  from rest_framework import status
5
6
  from rest_framework.decorators import action
6
7
  from rest_framework.response import Response
@@ -12,7 +13,7 @@ from pulpcore.plugin.serializers import (
12
13
  AsyncOperationResponseSerializer,
13
14
  RepositorySyncURLSerializer,
14
15
  )
15
- from pulpcore.plugin.tasking import dispatch
16
+ from pulpcore.plugin.tasking import check_content, dispatch
16
17
 
17
18
  from pulp_python.app import models as python_models
18
19
  from pulp_python.app import serializers as python_serializers
@@ -205,9 +206,36 @@ class PythonRepositoryVersionViewSet(core_viewsets.RepositoryVersionViewSet):
205
206
  "has_repository_model_or_domain_or_obj_perms:python.view_pythonrepository",
206
207
  ],
207
208
  },
209
+ {
210
+ "action": ["scan"],
211
+ "principal": "authenticated",
212
+ "effect": "allow",
213
+ "condition": [
214
+ "has_repository_model_or_domain_or_obj_perms:python.view_pythonrepository",
215
+ ],
216
+ },
208
217
  ],
209
218
  }
210
219
 
220
+ @extend_schema(
221
+ summary="Generate vulnerability report", responses={202: AsyncOperationResponseSerializer}
222
+ )
223
+ @action(detail=True, methods=["post"], serializer_class=None)
224
+ def scan(self, request, repository_pk, **kwargs):
225
+ """
226
+ Scan a repository version for vulnerabilities.
227
+ """
228
+ repository_version = self.get_object()
229
+ func = (
230
+ f"{tasks.get_repo_version_content.__module__}.{tasks.get_repo_version_content.__name__}"
231
+ )
232
+ task = dispatch(
233
+ check_content,
234
+ shared_resources=[repository_version.repository],
235
+ args=[func, [repository_version.pk]],
236
+ )
237
+ return core_viewsets.OperationPostponedResponse(task, request)
238
+
211
239
 
212
240
  class PythonDistributionViewSet(core_viewsets.DistributionViewSet, core_viewsets.RolesMixin):
213
241
  """
@@ -496,7 +524,7 @@ class PythonRemoteViewSet(core_viewsets.RemoteViewSet, core_viewsets.RolesMixin)
496
524
  bander_config_file = serializer.validated_data.get("config")
497
525
  name = serializer.validated_data.get("name")
498
526
  policy = serializer.validated_data.get("policy")
499
- bander_config = BandersnatchConfig(bander_config_file.file.name).config
527
+ bander_config = BandersnatchConfig(Path(bander_config_file.file.name))
500
528
  data = {
501
529
  "name": name,
502
530
  "policy": policy,
@@ -73,7 +73,7 @@ def python_distribution_factory(python_bindings, gen_object_with_cleanup):
73
73
  ver_href = f"{repo_href}versions/{version}/"
74
74
  else:
75
75
  ver_href = get_href(version)
76
- body = {"repository_version": ver_href}
76
+ body["repository_version"] = ver_href
77
77
  else:
78
78
  body["repository"] = repo_href
79
79
  kwargs = {}
@@ -84,7 +84,7 @@ def test_pull_through_filter(python_remote_factory, python_distribution_factory)
84
84
 
85
85
  r = requests.get(f"{distro.base_url}simple/pulpcore/")
86
86
  assert r.status_code == 404
87
- assert r.text == "404 Not Found"
87
+ assert r.text == "pulpcore does not exist."
88
88
 
89
89
  r = requests.get(f"{distro.base_url}simple/shelf-reader/")
90
90
  assert r.status_code == 200
@@ -104,11 +104,11 @@ def test_pull_through_filter(python_remote_factory, python_distribution_factory)
104
104
 
105
105
  r = requests.get(f"{distro.base_url}simple/django/")
106
106
  assert r.status_code == 404
107
- assert r.text == "404 Not Found"
107
+ assert r.text == "django does not exist."
108
108
 
109
109
  r = requests.get(f"{distro.base_url}simple/pulpcore/")
110
- assert r.status_code == 502
111
- assert r.text == f"Failed to fetch pulpcore from {remote.url}."
110
+ assert r.status_code == 404
111
+ assert r.text == "pulpcore does not exist."
112
112
 
113
113
  r = requests.get(f"{distro.base_url}simple/shelf-reader/")
114
114
  assert r.status_code == 200
@@ -156,3 +156,29 @@ def test_pull_through_with_repo(
156
156
  assert r.status_code == 200
157
157
  tasks = pulpcore_bindings.TasksApi.list(reserved_resources=repo.prn)
158
158
  assert tasks.count == 3
159
+
160
+
161
+ @pytest.mark.parallel
162
+ def test_pull_through_local_only(
163
+ python_remote_factory, python_distribution_factory, python_repo_with_sync
164
+ ):
165
+ """Tests that pull-through checks the repository if the package is not present on the remote."""
166
+ remote = python_remote_factory(url=PYPI_URL, includes=["pulpcore"])
167
+ repo = python_repo_with_sync(remote=remote)
168
+ remote2 = python_remote_factory(includes=[]) # Fixtures does not have pulpcore
169
+ distro = python_distribution_factory(repository=repo.pulp_href, remote=remote2.pulp_href)
170
+
171
+ url = f"{distro.base_url}simple/pulpcore/"
172
+ r = requests.get(url)
173
+ assert r.status_code == 200
174
+ assert "?redirect=" not in r.text
175
+
176
+ url = f"{distro.base_url}simple/shelf-reader/"
177
+ r = requests.get(url)
178
+ assert r.status_code == 200
179
+ assert "?redirect=" in r.text
180
+
181
+ url = f"{distro.base_url}simple/pulp_python/"
182
+ r = requests.get(url)
183
+ assert r.status_code == 404
184
+ assert r.text == "pulp-python does not exist."
@@ -267,6 +267,11 @@ def test_pypi_json(python_remote_factory, python_repo_with_sync, python_distribu
267
267
  distro = python_distribution_factory(repository=repo)
268
268
  response = requests.get(urljoin(distro.base_url, "pypi/shelf-reader/json"))
269
269
  assert_pypi_json(response.json())
270
+ # Test serving a repository version
271
+ distro = python_distribution_factory(repository=repo, version="1")
272
+ assert distro.repository is None
273
+ response = requests.get(urljoin(distro.base_url, "pypi/shelf-reader/json"))
274
+ assert_pypi_json(response.json())
270
275
 
271
276
 
272
277
  @pytest.mark.parallel
@@ -11,7 +11,7 @@ from pulp_python.tests.functional.constants import (
11
11
  PYTHON_WHEEL_URL,
12
12
  )
13
13
 
14
- API_VERSION = "1.0"
14
+ API_VERSION = "1.1"
15
15
  PYPI_SERIAL_CONSTANT = 1000000000
16
16
 
17
17
  PYPI_TEXT_HTML = "text/html"
@@ -69,6 +69,7 @@ def test_simple_json_detail_api(
69
69
  assert data["meta"] == {"api-version": API_VERSION, "_last-serial": PYPI_SERIAL_CONSTANT}
70
70
  assert data["name"] == "shelf-reader"
71
71
  assert data["files"]
72
+ assert data["versions"] == ["0.1"]
72
73
 
73
74
  # Check data of a wheel
74
75
  file_whl = next(
@@ -83,7 +84,8 @@ def test_simple_json_detail_api(
83
84
  assert file_whl["data-dist-info-metadata"] == {
84
85
  "sha256": "ed333f0db05d77e933a157b7225b403ada9a2f93318d77b41b662eba78bac350"
85
86
  }
86
-
87
+ assert file_whl["size"] == 22455
88
+ assert file_whl["upload-time"] is not None
87
89
  # Check data of a tarball
88
90
  file_tar = next((i for i in data["files"] if i["filename"] == "shelf-reader-0.1.tar.gz"), None)
89
91
  assert file_tar is not None, "tar file not found"
@@ -93,6 +95,8 @@ def test_simple_json_detail_api(
93
95
  }
94
96
  assert file_tar["requires-python"] is None
95
97
  assert file_tar["data-dist-info-metadata"] is False
98
+ assert file_tar["size"] == 19097
99
+ assert file_tar["upload-time"] is not None
96
100
 
97
101
 
98
102
  @pytest.mark.parallel
@@ -0,0 +1,48 @@
1
+ import pytest
2
+
3
+ from pulp_python.tests.functional.constants import (
4
+ PYPI_URL,
5
+ VULNERABILITY_REPORT_TEST_PACKAGE_NAME,
6
+ VULNERABILITY_REPORT_TEST_PACKAGES,
7
+ )
8
+
9
+
10
+ @pytest.mark.parallel
11
+ def test_vulnerability_report(
12
+ pulpcore_bindings, python_bindings, python_repo, python_remote_factory, monitor_task
13
+ ):
14
+
15
+ # Sync the test repository.
16
+ remote = python_remote_factory(url=PYPI_URL, includes=VULNERABILITY_REPORT_TEST_PACKAGES)
17
+ sync_data = dict(remote=remote.pulp_href)
18
+ response = python_bindings.RepositoriesPythonApi.sync(python_repo.pulp_href, sync_data)
19
+ monitor_task(response.task)
20
+
21
+ # get repo latest version
22
+ repo = python_bindings.RepositoriesPythonApi.read(python_repo.pulp_href)
23
+ latest_version_href = repo.latest_version_href
24
+
25
+ # scan
26
+ response = python_bindings.RepositoriesPythonVersionsApi.scan(
27
+ python_python_repository_version_href=latest_version_href
28
+ )
29
+ monitor_task(response.task)
30
+
31
+ # checks
32
+ vulns_list = pulpcore_bindings.VulnReportApi.list()
33
+ assert len(vulns_list.results) > 0
34
+ for results in vulns_list.results:
35
+ assert len(results.vulns) > 0
36
+ for vuln in results.vulns:
37
+ assert VULNERABILITY_REPORT_TEST_PACKAGE_NAME.lower() in (
38
+ affected["package"]["name"] for affected in vuln["affected"]
39
+ )
40
+
41
+ repo_version = python_bindings.RepositoriesPythonVersionsApi.read(latest_version_href)
42
+ assert repo_version.vuln_report is not None
43
+
44
+ python_packages = python_bindings.ContentPackagesApi.list(
45
+ name=VULNERABILITY_REPORT_TEST_PACKAGE_NAME, repository_version=latest_version_href
46
+ )
47
+ for content in python_packages.results:
48
+ assert content.vuln_report is not None
@@ -345,3 +345,8 @@ SHELF_PYTHON_JSON = {
345
345
  "releases": {"0.1": SHELF_0DOT1_RELEASE},
346
346
  "urls": SHELF_0DOT1_RELEASE,
347
347
  }
348
+
349
+ VULNERABILITY_REPORT_TEST_PACKAGE_NAME = "Django"
350
+ VULNERABILITY_REPORT_TEST_PACKAGES = [
351
+ "django==5.2.1",
352
+ ]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pulp-python
3
- Version: 3.20.0
3
+ Version: 3.21.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
@@ -20,9 +20,9 @@ Classifier: Programming Language :: Python :: 3.13
20
20
  Requires-Python: >=3.11
21
21
  Description-Content-Type: text/markdown
22
22
  License-File: LICENSE
23
- Requires-Dist: pulpcore<3.100,>=3.81.0
23
+ Requires-Dist: pulpcore<3.100,>=3.85.3
24
24
  Requires-Dist: pkginfo<1.13.0,>=1.12.0
25
- Requires-Dist: bandersnatch<6.6,>=6.3.0
25
+ Requires-Dist: bandersnatch<6.7,>=6.6.0
26
26
  Requires-Dist: pypi-simple<2.0,>=1.5.0
27
27
  Dynamic: license-file
28
28
 
@@ -46,6 +46,7 @@ pulp_python/app/migrations/0013_add_rbac_permissions.py
46
46
  pulp_python/app/migrations/0014_pythonpackagecontent_dynamic_and_more.py
47
47
  pulp_python/app/migrations/0015_alter_pythonpackagecontent_options.py
48
48
  pulp_python/app/migrations/0016_pythonpackagecontent_metadata_sha256.py
49
+ pulp_python/app/migrations/0017_pythonpackagecontent_size.py
49
50
  pulp_python/app/migrations/__init__.py
50
51
  pulp_python/app/pypi/__init__.py
51
52
  pulp_python/app/pypi/serializers.py
@@ -55,6 +56,7 @@ pulp_python/app/tasks/publish.py
55
56
  pulp_python/app/tasks/repair.py
56
57
  pulp_python/app/tasks/sync.py
57
58
  pulp_python/app/tasks/upload.py
59
+ pulp_python/app/tasks/vulnerability_report.py
58
60
  pulp_python/app/webserver_snippets/__init__.py
59
61
  pulp_python/app/webserver_snippets/apache.conf
60
62
  pulp_python/app/webserver_snippets/nginx.conf
@@ -78,5 +80,6 @@ pulp_python/tests/functional/api/test_rbac.py
78
80
  pulp_python/tests/functional/api/test_repair.py
79
81
  pulp_python/tests/functional/api/test_sync.py
80
82
  pulp_python/tests/functional/api/test_upload.py
83
+ pulp_python/tests/functional/api/test_vulnerability_report.py
81
84
  pulp_python/tests/unit/__init__.py
82
85
  pulp_python/tests/unit/test_models.py
@@ -0,0 +1,4 @@
1
+ pulpcore<3.100,>=3.85.3
2
+ pkginfo<1.13.0,>=1.12.0
3
+ bandersnatch<6.7,>=6.6.0
4
+ pypi-simple<2.0,>=1.5.0
@@ -7,7 +7,7 @@ build-backend = 'setuptools.build_meta'
7
7
 
8
8
  [project]
9
9
  name = "pulp-python"
10
- version = "3.20.0"
10
+ version = "3.21.0"
11
11
  description = "pulp-python plugin for the Pulp Project"
12
12
  readme = "README.md"
13
13
  authors = [
@@ -26,9 +26,9 @@ classifiers=[
26
26
  ]
27
27
  requires-python = ">=3.11"
28
28
  dependencies = [
29
- "pulpcore>=3.81.0,<3.100",
29
+ "pulpcore>=3.85.3,<3.100",
30
30
  "pkginfo>=1.12.0,<1.13.0",
31
- "bandersnatch>=6.3.0,<6.6", # 6.6 has breaking changes
31
+ "bandersnatch>=6.6.0,<6.7",
32
32
  "pypi-simple>=1.5.0,<2.0",
33
33
  ]
34
34
 
@@ -76,7 +76,7 @@ ignore = [
76
76
  [tool.bumpversion]
77
77
  # This section is managed by the plugin template. Do not edit manually.
78
78
 
79
- current_version = "3.20.0"
79
+ current_version = "3.21.0"
80
80
  commit = false
81
81
  tag = false
82
82
  parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<alpha>0a)?(?P<patch>\\d+)(\\.(?P<release>[a-z]+))?"
@@ -1,4 +0,0 @@
1
- pulpcore<3.100,>=3.81.0
2
- pkginfo<1.13.0,>=1.12.0
3
- bandersnatch<6.6,>=6.3.0
4
- pypi-simple<2.0,>=1.5.0
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes