pulp-python 3.19.1__tar.gz → 3.20.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 (84) hide show
  1. {pulp_python-3.19.1 → pulp_python-3.20.0}/CHANGES.md +16 -0
  2. {pulp_python-3.19.1 → pulp_python-3.20.0}/PKG-INFO +1 -1
  3. {pulp_python-3.19.1 → pulp_python-3.20.0}/pulp_python/app/__init__.py +1 -1
  4. pulp_python-3.20.0/pulp_python/app/migrations/0016_pythonpackagecontent_metadata_sha256.py +18 -0
  5. {pulp_python-3.19.1 → pulp_python-3.20.0}/pulp_python/app/models.py +2 -0
  6. {pulp_python-3.19.1 → pulp_python-3.20.0}/pulp_python/app/pypi/views.py +89 -10
  7. {pulp_python-3.19.1 → pulp_python-3.20.0}/pulp_python/app/serializers.py +13 -0
  8. {pulp_python-3.19.1 → pulp_python-3.20.0}/pulp_python/app/tasks/publish.py +3 -1
  9. {pulp_python-3.19.1 → pulp_python-3.20.0}/pulp_python/app/utils.py +87 -16
  10. {pulp_python-3.19.1 → pulp_python-3.20.0}/pulp_python/tests/functional/api/test_consume_content.py +1 -0
  11. {pulp_python-3.19.1 → pulp_python-3.20.0}/pulp_python/tests/functional/api/test_domains.py +1 -0
  12. {pulp_python-3.19.1 → pulp_python-3.20.0}/pulp_python/tests/functional/api/test_full_mirror.py +20 -2
  13. pulp_python-3.20.0/pulp_python/tests/functional/api/test_pypi_simple_json_api.py +126 -0
  14. {pulp_python-3.19.1 → pulp_python-3.20.0}/pulp_python.egg-info/PKG-INFO +1 -1
  15. {pulp_python-3.19.1 → pulp_python-3.20.0}/pulp_python.egg-info/SOURCES.txt +2 -0
  16. {pulp_python-3.19.1 → pulp_python-3.20.0}/pyproject.toml +2 -2
  17. {pulp_python-3.19.1 → pulp_python-3.20.0}/COMMITMENT +0 -0
  18. {pulp_python-3.19.1 → pulp_python-3.20.0}/COPYRIGHT +0 -0
  19. {pulp_python-3.19.1 → pulp_python-3.20.0}/LICENSE +0 -0
  20. {pulp_python-3.19.1 → pulp_python-3.20.0}/MANIFEST.in +0 -0
  21. {pulp_python-3.19.1 → pulp_python-3.20.0}/README.md +0 -0
  22. {pulp_python-3.19.1 → pulp_python-3.20.0}/functest_requirements.txt +0 -0
  23. {pulp_python-3.19.1 → pulp_python-3.20.0}/pulp_python/__init__.py +0 -0
  24. {pulp_python-3.19.1 → pulp_python-3.20.0}/pulp_python/app/global_access_conditions.py +0 -0
  25. {pulp_python-3.19.1 → pulp_python-3.20.0}/pulp_python/app/management/__init__.py +0 -0
  26. {pulp_python-3.19.1 → pulp_python-3.20.0}/pulp_python/app/management/commands/__init__.py +0 -0
  27. {pulp_python-3.19.1 → pulp_python-3.20.0}/pulp_python/app/management/commands/repair-python-metadata.py +0 -0
  28. {pulp_python-3.19.1 → pulp_python-3.20.0}/pulp_python/app/migrations/0001_initial.py +0 -0
  29. {pulp_python-3.19.1 → pulp_python-3.20.0}/pulp_python/app/migrations/0001_squashed_0010_update_json_field.py +0 -0
  30. {pulp_python-3.19.1 → pulp_python-3.20.0}/pulp_python/app/migrations/0002_pythonpackagecontent_python_version.py +0 -0
  31. {pulp_python-3.19.1 → pulp_python-3.20.0}/pulp_python/app/migrations/0003_new_sync_filters.py +0 -0
  32. {pulp_python-3.19.1 → pulp_python-3.20.0}/pulp_python/app/migrations/0004_DATA_swap_distribution_model.py +0 -0
  33. {pulp_python-3.19.1 → pulp_python-3.20.0}/pulp_python/app/migrations/0005_pythonpackagecontent_sha256.py +0 -0
  34. {pulp_python-3.19.1 → pulp_python-3.20.0}/pulp_python/app/migrations/0006_pythonrepository_autopublish.py +0 -0
  35. {pulp_python-3.19.1 → pulp_python-3.20.0}/pulp_python/app/migrations/0007_pythonpackagecontent_mv-2-1.py +0 -0
  36. {pulp_python-3.19.1 → pulp_python-3.20.0}/pulp_python/app/migrations/0008_pythonpackagecontent_unique_sha256.py +0 -0
  37. {pulp_python-3.19.1 → pulp_python-3.20.0}/pulp_python/app/migrations/0009_pythondistribution_allow_uploads.py +0 -0
  38. {pulp_python-3.19.1 → pulp_python-3.20.0}/pulp_python/app/migrations/0010_update_json_field.py +0 -0
  39. {pulp_python-3.19.1 → pulp_python-3.20.0}/pulp_python/app/migrations/0011_alter_pythondistribution_distribution_ptr_and_more.py +0 -0
  40. {pulp_python-3.19.1 → pulp_python-3.20.0}/pulp_python/app/migrations/0012_add_domain.py +0 -0
  41. {pulp_python-3.19.1 → pulp_python-3.20.0}/pulp_python/app/migrations/0013_add_rbac_permissions.py +0 -0
  42. {pulp_python-3.19.1 → pulp_python-3.20.0}/pulp_python/app/migrations/0014_pythonpackagecontent_dynamic_and_more.py +0 -0
  43. {pulp_python-3.19.1 → pulp_python-3.20.0}/pulp_python/app/migrations/0015_alter_pythonpackagecontent_options.py +0 -0
  44. {pulp_python-3.19.1 → pulp_python-3.20.0}/pulp_python/app/migrations/__init__.py +0 -0
  45. {pulp_python-3.19.1 → pulp_python-3.20.0}/pulp_python/app/modelresource.py +0 -0
  46. {pulp_python-3.19.1 → pulp_python-3.20.0}/pulp_python/app/pypi/__init__.py +0 -0
  47. {pulp_python-3.19.1 → pulp_python-3.20.0}/pulp_python/app/pypi/serializers.py +0 -0
  48. {pulp_python-3.19.1 → pulp_python-3.20.0}/pulp_python/app/replica.py +0 -0
  49. {pulp_python-3.19.1 → pulp_python-3.20.0}/pulp_python/app/settings.py +0 -0
  50. {pulp_python-3.19.1 → pulp_python-3.20.0}/pulp_python/app/tasks/__init__.py +0 -0
  51. {pulp_python-3.19.1 → pulp_python-3.20.0}/pulp_python/app/tasks/repair.py +0 -0
  52. {pulp_python-3.19.1 → pulp_python-3.20.0}/pulp_python/app/tasks/sync.py +0 -0
  53. {pulp_python-3.19.1 → pulp_python-3.20.0}/pulp_python/app/tasks/upload.py +0 -0
  54. {pulp_python-3.19.1 → pulp_python-3.20.0}/pulp_python/app/urls.py +0 -0
  55. {pulp_python-3.19.1 → pulp_python-3.20.0}/pulp_python/app/viewsets.py +0 -0
  56. {pulp_python-3.19.1 → pulp_python-3.20.0}/pulp_python/app/webserver_snippets/__init__.py +0 -0
  57. {pulp_python-3.19.1 → pulp_python-3.20.0}/pulp_python/app/webserver_snippets/apache.conf +0 -0
  58. {pulp_python-3.19.1 → pulp_python-3.20.0}/pulp_python/app/webserver_snippets/nginx.conf +0 -0
  59. {pulp_python-3.19.1 → pulp_python-3.20.0}/pulp_python/pytest_plugin.py +0 -0
  60. {pulp_python-3.19.1 → pulp_python-3.20.0}/pulp_python/tests/__init__.py +0 -0
  61. {pulp_python-3.19.1 → pulp_python-3.20.0}/pulp_python/tests/functional/__init__.py +0 -0
  62. {pulp_python-3.19.1 → pulp_python-3.20.0}/pulp_python/tests/functional/api/__init__.py +0 -0
  63. {pulp_python-3.19.1 → pulp_python-3.20.0}/pulp_python/tests/functional/api/test_auto_publish.py +0 -0
  64. {pulp_python-3.19.1 → pulp_python-3.20.0}/pulp_python/tests/functional/api/test_crud_content_unit.py +0 -0
  65. {pulp_python-3.19.1 → pulp_python-3.20.0}/pulp_python/tests/functional/api/test_crud_publications.py +0 -0
  66. {pulp_python-3.19.1 → pulp_python-3.20.0}/pulp_python/tests/functional/api/test_crud_remotes.py +0 -0
  67. {pulp_python-3.19.1 → pulp_python-3.20.0}/pulp_python/tests/functional/api/test_download_content.py +0 -0
  68. {pulp_python-3.19.1 → pulp_python-3.20.0}/pulp_python/tests/functional/api/test_export_import.py +0 -0
  69. {pulp_python-3.19.1 → pulp_python-3.20.0}/pulp_python/tests/functional/api/test_pypi_apis.py +0 -0
  70. {pulp_python-3.19.1 → pulp_python-3.20.0}/pulp_python/tests/functional/api/test_rbac.py +0 -0
  71. {pulp_python-3.19.1 → pulp_python-3.20.0}/pulp_python/tests/functional/api/test_repair.py +0 -0
  72. {pulp_python-3.19.1 → pulp_python-3.20.0}/pulp_python/tests/functional/api/test_sync.py +0 -0
  73. {pulp_python-3.19.1 → pulp_python-3.20.0}/pulp_python/tests/functional/api/test_upload.py +0 -0
  74. {pulp_python-3.19.1 → pulp_python-3.20.0}/pulp_python/tests/functional/constants.py +0 -0
  75. {pulp_python-3.19.1 → pulp_python-3.20.0}/pulp_python/tests/functional/utils.py +0 -0
  76. {pulp_python-3.19.1 → pulp_python-3.20.0}/pulp_python/tests/unit/__init__.py +0 -0
  77. {pulp_python-3.19.1 → pulp_python-3.20.0}/pulp_python/tests/unit/test_models.py +0 -0
  78. {pulp_python-3.19.1 → pulp_python-3.20.0}/pulp_python.egg-info/dependency_links.txt +0 -0
  79. {pulp_python-3.19.1 → pulp_python-3.20.0}/pulp_python.egg-info/entry_points.txt +0 -0
  80. {pulp_python-3.19.1 → pulp_python-3.20.0}/pulp_python.egg-info/requires.txt +0 -0
  81. {pulp_python-3.19.1 → pulp_python-3.20.0}/pulp_python.egg-info/top_level.txt +0 -0
  82. {pulp_python-3.19.1 → pulp_python-3.20.0}/setup.cfg +0 -0
  83. {pulp_python-3.19.1 → pulp_python-3.20.0}/test_requirements.txt +0 -0
  84. {pulp_python-3.19.1 → pulp_python-3.20.0}/unittest_requirements.txt +0 -0
@@ -8,6 +8,22 @@
8
8
 
9
9
  [//]: # (towncrier release notes start)
10
10
 
11
+ ## 3.20.0 (2025-11-07) {: #3.20.0 }
12
+
13
+ #### Features {: #3.20.0-feature }
14
+
15
+ - Added JSON-based Simple API (PEP 691).
16
+ [#625](https://github.com/pulp/pulp_python/issues/625)
17
+ - Updated tasks to always return JSON-serializable value.
18
+ [#972](https://github.com/pulp/pulp_python/issues/972)
19
+
20
+ #### Bugfixes {: #3.20.0-bugfix }
21
+
22
+ - Fixed publication error when package's dists contain differing versions of its name.
23
+ [#907](https://github.com/pulp/pulp_python/issues/907)
24
+
25
+ ---
26
+
11
27
  ## 3.19.1 (2025-09-14) {: #3.19.1 }
12
28
 
13
29
  #### Bugfixes {: #3.19.1-bugfix }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pulp-python
3
- Version: 3.19.1
3
+ Version: 3.20.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.19.1"
13
+ version = "3.20.0"
14
14
  python_package_name = "pulp-python"
15
15
  domain_compatible = True
16
16
 
@@ -0,0 +1,18 @@
1
+ # Generated by Django 4.2.25 on 2025-11-04 07:34
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ("python", "0015_alter_pythonpackagecontent_options"),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.AddField(
14
+ model_name="pythonpackagecontent",
15
+ name="metadata_sha256",
16
+ field=models.CharField(max_length=64, null=True),
17
+ ),
18
+ ]
@@ -192,6 +192,8 @@ class PythonPackageContent(Content):
192
192
  packagetype = models.TextField(choices=PACKAGE_TYPES)
193
193
  python_version = models.TextField()
194
194
  sha256 = models.CharField(db_index=True, max_length=64)
195
+ metadata_sha256 = models.CharField(max_length=64, null=True)
196
+ # yanked and yanked_reason are not implemented because they are mutable
195
197
 
196
198
  # From pulpcore
197
199
  PROTECTED_FROM_RECLAIM = False
@@ -3,7 +3,9 @@ import logging
3
3
 
4
4
  from aiohttp.client_exceptions import ClientError
5
5
  from rest_framework.viewsets import ViewSet
6
+ from rest_framework.renderers import BrowsableAPIRenderer, JSONRenderer, TemplateHTMLRenderer
6
7
  from rest_framework.response import Response
8
+ from rest_framework.exceptions import NotAcceptable
7
9
  from django.core.exceptions import ObjectDoesNotExist
8
10
  from django.shortcuts import redirect
9
11
  from datetime import datetime, timezone, timedelta
@@ -43,7 +45,9 @@ from pulp_python.app.pypi.serializers import (
43
45
  )
44
46
  from pulp_python.app.utils import (
45
47
  write_simple_index,
48
+ write_simple_index_json,
46
49
  write_simple_detail,
50
+ write_simple_detail_json,
47
51
  python_content_to_json,
48
52
  PYPI_LAST_SERIAL,
49
53
  PYPI_SERIAL_CONSTANT,
@@ -57,6 +61,17 @@ log = logging.getLogger(__name__)
57
61
  ORIGIN_HOST = settings.CONTENT_ORIGIN if settings.CONTENT_ORIGIN else settings.PYPI_API_HOSTNAME
58
62
  BASE_CONTENT_URL = urljoin(ORIGIN_HOST, settings.CONTENT_PATH_PREFIX)
59
63
 
64
+ PYPI_SIMPLE_V1_HTML = "application/vnd.pypi.simple.v1+html"
65
+ PYPI_SIMPLE_V1_JSON = "application/vnd.pypi.simple.v1+json"
66
+
67
+
68
+ class PyPISimpleHTMLRenderer(TemplateHTMLRenderer):
69
+ media_type = PYPI_SIMPLE_V1_HTML
70
+
71
+
72
+ class PyPISimpleJSONRenderer(JSONRenderer):
73
+ media_type = PYPI_SIMPLE_V1_JSON
74
+
60
75
 
61
76
  class PyPIMixin:
62
77
  """Mixin to get index specific info."""
@@ -235,6 +250,25 @@ class SimpleView(PackageUploadMixin, ViewSet):
235
250
  ],
236
251
  }
237
252
 
253
+ def perform_content_negotiation(self, request, force=False):
254
+ """
255
+ Uses standard content negotiation, defaulting to HTML if no acceptable renderer is found.
256
+ """
257
+ try:
258
+ return super().perform_content_negotiation(request, force)
259
+ except NotAcceptable:
260
+ return TemplateHTMLRenderer(), TemplateHTMLRenderer.media_type # text/html
261
+
262
+ def get_renderers(self):
263
+ """
264
+ Uses custom renderers for PyPI Simple API endpoints, defaulting to standard ones.
265
+ """
266
+ if self.action in ["list", "retrieve"]:
267
+ # Ordered by priority if multiple content types are present
268
+ return [TemplateHTMLRenderer(), PyPISimpleHTMLRenderer(), PyPISimpleJSONRenderer()]
269
+ else:
270
+ return [JSONRenderer(), BrowsableAPIRenderer()]
271
+
238
272
  @extend_schema(summary="Get index simple page")
239
273
  def list(self, request, path):
240
274
  """Gets the simple api html page for the index."""
@@ -242,9 +276,18 @@ class SimpleView(PackageUploadMixin, ViewSet):
242
276
  if self.should_redirect(repo_version=repo_version):
243
277
  return redirect(urljoin(self.base_content_url, f"{path}/simple/"))
244
278
  names = content.order_by("name").values_list("name", flat=True).distinct().iterator()
245
- return StreamingHttpResponse(write_simple_index(names, streamed=True))
279
+ media_type = request.accepted_renderer.media_type
280
+ headers = {"X-PyPI-Last-Serial": str(PYPI_SERIAL_CONSTANT)}
281
+
282
+ if media_type == PYPI_SIMPLE_V1_JSON:
283
+ index_data = write_simple_index_json(names)
284
+ return Response(index_data, headers=headers)
285
+ else:
286
+ index_data = write_simple_index(names, streamed=True)
287
+ kwargs = {"content_type": media_type, "headers": headers}
288
+ return StreamingHttpResponse(index_data, **kwargs)
246
289
 
247
- def pull_through_package_simple(self, package, path, remote):
290
+ def pull_through_package_simple(self, package, path, remote, media_type):
248
291
  """Gets the package's simple page from remote."""
249
292
 
250
293
  def parse_package(release_package):
@@ -252,7 +295,13 @@ class SimpleView(PackageUploadMixin, ViewSet):
252
295
  stripped_url = urlunsplit(chain(parsed[:3], ("", "")))
253
296
  redirect_path = f"{path}/{release_package.filename}?redirect={stripped_url}"
254
297
  d_url = urljoin(self.base_content_url, redirect_path)
255
- return release_package.filename, d_url, release_package.digests.get("sha256", "")
298
+ return {
299
+ "filename": release_package.filename,
300
+ "url": d_url,
301
+ "sha256": release_package.digests.get("sha256", ""),
302
+ "requires_python": release_package.requires_python,
303
+ "metadata_sha256": (release_package.metadata_digests or {}).get("sha256"),
304
+ }
256
305
 
257
306
  rfilter = get_remote_package_filter(remote)
258
307
  if not rfilter.filter_project(package):
@@ -269,28 +318,40 @@ class SimpleView(PackageUploadMixin, ViewSet):
269
318
  except TimeoutException:
270
319
  return HttpResponse(f"{remote.url} timed out while fetching {package}.", status=504)
271
320
 
272
- if d.headers["content-type"] == "application/vnd.pypi.simple.v1+json":
321
+ if d.headers["content-type"] == PYPI_SIMPLE_V1_JSON:
273
322
  page = ProjectPage.from_json_data(json.load(open(d.path, "rb")), base_url=url)
274
323
  else:
275
324
  page = ProjectPage.from_html(package, open(d.path, "rb").read(), base_url=url)
276
325
  packages = [
277
326
  parse_package(p) for p in page.packages if rfilter.filter_release(package, p.version)
278
327
  ]
279
- return HttpResponse(write_simple_detail(package, packages))
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)
280
337
 
281
338
  @extend_schema(operation_id="pypi_simple_package_read", summary="Get package simple page")
282
339
  def retrieve(self, request, path, package):
283
- """Retrieves the simple api html page for a package."""
340
+ """Retrieves the simple api html/json page for a package."""
341
+ media_type = request.accepted_renderer.media_type
342
+
284
343
  repo_ver, content = self.get_rvc()
285
344
  # Should I redirect if the normalized name is different?
286
345
  normalized = canonicalize_name(package)
287
346
  if self.distribution.remote:
288
- return self.pull_through_package_simple(normalized, path, self.distribution.remote)
347
+ return self.pull_through_package_simple(
348
+ normalized, path, self.distribution.remote, media_type
349
+ )
289
350
  if self.should_redirect(repo_version=repo_ver):
290
351
  return redirect(urljoin(self.base_content_url, f"{path}/simple/{normalized}/"))
291
352
  packages = (
292
353
  content.filter(name__normalize=normalized)
293
- .values_list("filename", "sha256", "name")
354
+ .values_list("filename", "sha256", "name", "metadata_sha256", "requires_python")
294
355
  .iterator()
295
356
  )
296
357
  try:
@@ -300,8 +361,26 @@ class SimpleView(PackageUploadMixin, ViewSet):
300
361
  else:
301
362
  packages = chain([present], packages)
302
363
  name = present[2]
303
- releases = ((f, urljoin(self.base_content_url, f"{path}/{f}"), d) for f, d, _ in packages)
304
- return StreamingHttpResponse(write_simple_detail(name, releases, streamed=True))
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,
371
+ }
372
+ for filename, sha256, _, metadata_sha256, requires_python in packages
373
+ )
374
+ media_type = request.accepted_renderer.media_type
375
+ headers = {"X-PyPI-Last-Serial": str(PYPI_SERIAL_CONSTANT)}
376
+
377
+ if media_type == PYPI_SIMPLE_V1_JSON:
378
+ detail_data = write_simple_detail_json(name, releases)
379
+ return Response(detail_data, headers=headers)
380
+ else:
381
+ detail_data = write_simple_detail(name, releases, streamed=True)
382
+ kwargs = {"content_type": media_type, "headers": headers}
383
+ return StreamingHttpResponse(detail_data, **kwargs)
305
384
 
306
385
  @extend_schema(
307
386
  request=PackageUploadSerializer,
@@ -281,6 +281,11 @@ class PythonPackageContentSerializer(core_serializers.SingleArtifactContentUploa
281
281
  default="",
282
282
  help_text=_("The SHA256 digest of this package."),
283
283
  )
284
+ metadata_sha256 = serializers.CharField(
285
+ required=False,
286
+ allow_null=True,
287
+ help_text=_("The SHA256 digest of the package's METADATA file."),
288
+ )
284
289
 
285
290
  def deferred_validate(self, data):
286
291
  """
@@ -364,6 +369,7 @@ class PythonPackageContentSerializer(core_serializers.SingleArtifactContentUploa
364
369
  "packagetype",
365
370
  "python_version",
366
371
  "sha256",
372
+ "metadata_sha256",
367
373
  )
368
374
  model = python_models.PythonPackageContent
369
375
 
@@ -457,6 +463,13 @@ class MultipleChoiceArrayField(serializers.MultipleChoiceField):
457
463
  """Converts set to list."""
458
464
  return list(super().to_internal_value(data))
459
465
 
466
+ def to_representation(self, value):
467
+ """Converts set to list for JSON serialization."""
468
+ result = super().to_representation(value)
469
+ if isinstance(result, set):
470
+ result = list(result)
471
+ return result
472
+
460
473
 
461
474
  class PythonRemoteSerializer(core_serializers.RemoteSerializer):
462
475
  """
@@ -9,6 +9,7 @@ from pulpcore.plugin import models
9
9
  from pulpcore.plugin.util import get_domain
10
10
 
11
11
  from pulp_python.app import models as python_models
12
+ from pulp_python.app.serializers import PythonPublicationSerializer
12
13
  from pulp_python.app.utils import write_simple_index, write_simple_detail
13
14
 
14
15
 
@@ -36,6 +37,7 @@ def publish(repository_version_pk):
36
37
  write_simple_api(pub)
37
38
 
38
39
  log.info(_("Publication: {pk} created").format(pk=pub.pk))
40
+ pub = PythonPublicationSerializer(instance=pub, context={"request": None}).data
39
41
  return pub
40
42
 
41
43
 
@@ -99,7 +101,7 @@ def write_simple_api(publication):
99
101
  relative_path = release["filename"]
100
102
  path = f"../../{relative_path}"
101
103
  checksum = release["sha256"]
102
- package_releases.append((relative_path, path, checksum))
104
+ package_releases.append({"filename": relative_path, "url": path, "sha256": checksum})
103
105
  # Write the final project's page
104
106
  write_project_page(
105
107
  name=canonicalize_name(current_name),
@@ -1,7 +1,9 @@
1
+ import hashlib
1
2
  import pkginfo
2
3
  import re
3
4
  import shutil
4
5
  import tempfile
6
+ import zipfile
5
7
  import json
6
8
  from collections import defaultdict
7
9
  from django.conf import settings
@@ -16,15 +18,17 @@ PYPI_LAST_SERIAL = "X-PYPI-LAST-SERIAL"
16
18
  """TODO This serial constant is temporary until Python repositories implements serials"""
17
19
  PYPI_SERIAL_CONSTANT = 1000000000
18
20
 
21
+ SIMPLE_API_VERSION = "1.0"
22
+
19
23
  simple_index_template = """<!DOCTYPE html>
20
24
  <html>
21
25
  <head>
22
26
  <title>Simple Index</title>
23
- <meta name="api-version" value="2" />
27
+ <meta name="pypi:repository-version" content="{{ SIMPLE_API_VERSION }}">
24
28
  </head>
25
29
  <body>
26
30
  {% for name, canonical_name in projects %}
27
- <a href="{{ canonical_name }}/">{{ name }}</a><br/>
31
+ <a href="{{ canonical_name }}/">{{ name }}</a><br/>
28
32
  {% endfor %}
29
33
  </body>
30
34
  </html>
@@ -32,16 +36,16 @@ simple_index_template = """<!DOCTYPE html>
32
36
 
33
37
  simple_detail_template = """<!DOCTYPE html>
34
38
  <html>
35
- <head>
36
- <title>Links for {{ project_name }}</title>
37
- <meta name="api-version" value="2" />
38
- </head>
39
- <body>
39
+ <head>
40
+ <title>Links for {{ project_name }}</title>
41
+ <meta name="pypi:repository-version" content="{{ SIMPLE_API_VERSION }}">
42
+ </head>
43
+ <body>
40
44
  <h1>Links for {{ project_name }}</h1>
41
- {% for name, path, sha256 in project_packages %}
42
- <a href="{{ path }}#sha256={{ sha256 }}" rel="internal">{{ name }}</a><br/>
45
+ {% for pkg in project_packages %}
46
+ <a href="{{ pkg.url }}#sha256={{ pkg.sha256 }}" rel="internal">{{ pkg.filename }}</a><br/>
43
47
  {% endfor %}
44
- </body>
48
+ </body>
45
49
  </html>
46
50
  """
47
51
 
@@ -128,6 +132,7 @@ def parse_project_metadata(project):
128
132
  # Release metadata
129
133
  "packagetype": project.get("packagetype") or "",
130
134
  "python_version": project.get("python_version") or "",
135
+ "metadata_sha256": project.get("metadata_sha256"),
131
136
  }
132
137
 
133
138
 
@@ -154,10 +159,8 @@ def parse_metadata(project, version, distribution):
154
159
  package["version"] = version
155
160
  package["url"] = distribution.get("url") or ""
156
161
  package["sha256"] = distribution.get("digests", {}).get("sha256") or ""
157
- package["python_version"] = distribution.get("python_version") or package.get("python_version")
158
- package["requires_python"] = distribution.get("requires_python") or package.get(
159
- "requires_python"
160
- ) # noqa: E501
162
+ package["python_version"] = distribution.get("python_version") or ""
163
+ package["requires_python"] = distribution.get("requires_python") or ""
161
164
 
162
165
  return package
163
166
 
@@ -175,6 +178,7 @@ def get_project_metadata_from_file(filename):
175
178
  packagetype = DIST_EXTENSIONS[extensions[pkg_type_index]]
176
179
 
177
180
  metadata = DIST_TYPES[packagetype](filename)
181
+ metadata.metadata_sha256 = compute_metadata_sha256(filename)
178
182
  metadata.packagetype = packagetype
179
183
  if packagetype == "sdist":
180
184
  metadata.python_version = "source"
@@ -187,6 +191,25 @@ def get_project_metadata_from_file(filename):
187
191
  return metadata
188
192
 
189
193
 
194
+ def compute_metadata_sha256(filename: str) -> str | None:
195
+ """
196
+ Compute SHA256 hash of the metadata file from a Python package.
197
+
198
+ Returns SHA256 hash or None if metadata cannot be extracted.
199
+ """
200
+ if not filename.endswith(".whl"):
201
+ return None
202
+ try:
203
+ with zipfile.ZipFile(filename, "r") as f:
204
+ for file_path in f.namelist():
205
+ if file_path.endswith(".dist-info/METADATA"):
206
+ metadata_content = f.read(file_path)
207
+ return hashlib.sha256(metadata_content).hexdigest()
208
+ except (zipfile.BadZipFile, KeyError, OSError):
209
+ pass
210
+ return None
211
+
212
+
190
213
  def artifact_to_python_content_data(filename, artifact, domain=None):
191
214
  """
192
215
  Takes the artifact/filename and returns the metadata needed to create a PythonPackageContent.
@@ -403,17 +426,65 @@ def python_content_to_download_info(content, base_path, domain=None):
403
426
  def write_simple_index(project_names, streamed=False):
404
427
  """Writes the simple index."""
405
428
  simple = Template(simple_index_template)
406
- context = {"projects": ((x, canonicalize_name(x)) for x in project_names)}
429
+ context = {
430
+ "SIMPLE_API_VERSION": SIMPLE_API_VERSION,
431
+ "projects": ((x, canonicalize_name(x)) for x in project_names),
432
+ }
407
433
  return simple.stream(**context) if streamed else simple.render(**context)
408
434
 
409
435
 
410
436
  def write_simple_detail(project_name, project_packages, streamed=False):
411
437
  """Writes the simple detail page of a package."""
412
438
  detail = Template(simple_detail_template)
413
- context = {"project_name": project_name, "project_packages": project_packages}
439
+ context = {
440
+ "SIMPLE_API_VERSION": SIMPLE_API_VERSION,
441
+ "project_name": project_name,
442
+ "project_packages": project_packages,
443
+ }
414
444
  return detail.stream(**context) if streamed else detail.render(**context)
415
445
 
416
446
 
447
+ def write_simple_index_json(project_names):
448
+ """Writes the simple index in JSON format."""
449
+ return {
450
+ "meta": {"api-version": SIMPLE_API_VERSION, "_last-serial": PYPI_SERIAL_CONSTANT},
451
+ "projects": [
452
+ {"name": name, "_last-serial": PYPI_SERIAL_CONSTANT} for name in project_names
453
+ ],
454
+ }
455
+
456
+
457
+ def write_simple_detail_json(project_name, project_packages):
458
+ """Writes the simple detail page in JSON format."""
459
+ return {
460
+ "meta": {"api-version": SIMPLE_API_VERSION, "_last-serial": PYPI_SERIAL_CONSTANT},
461
+ "name": canonicalize_name(project_name),
462
+ "files": [
463
+ {
464
+ # v1.0, PEP 691
465
+ "filename": package["filename"],
466
+ "url": package["url"],
467
+ "hashes": {"sha256": package["sha256"]},
468
+ "requires-python": package["requires_python"] or None,
469
+ # data-dist-info-metadata is deprecated alias for core-metadata
470
+ "data-dist-info-metadata": (
471
+ {"sha256": package["metadata_sha256"]} if package["metadata_sha256"] else False
472
+ ),
473
+ # yanked and yanked_reason are not implemented because they are mutable
474
+ # TODO in the future:
475
+ # size, upload-time (v1.1, PEP 700)
476
+ # core-metadata (PEP 7.14)
477
+ # provenance (v1.3, PEP 740)
478
+ }
479
+ for package in project_packages
480
+ ],
481
+ # TODO in the future:
482
+ # versions (v1.1, PEP 700)
483
+ # alternate-locations (v1.2, PEP 708)
484
+ # project-status (v1.4, PEP 792 - pypi and docs differ)
485
+ }
486
+
487
+
417
488
  class PackageIncludeFilter:
418
489
  """A special class to help filter Package's based on a remote's include/exclude"""
419
490
 
@@ -21,6 +21,7 @@ def test_pip_consume_content(
21
21
  "install",
22
22
  "--no-deps",
23
23
  "--no-cache-dir",
24
+ "--no-build-isolation",
24
25
  "--force-reinstall",
25
26
  "--trusted-host",
26
27
  urlsplit(distro.base_url).hostname,
@@ -270,6 +270,7 @@ def test_domain_pypi_apis(
270
270
  "pip",
271
271
  "install",
272
272
  "--no-deps",
273
+ "--no-build-isolation",
273
274
  "--trusted-host",
274
275
  urlsplit(distro.base_url).hostname,
275
276
  "-i",
@@ -58,6 +58,24 @@ def test_pull_through_simple(python_remote_factory, python_distribution_factory,
58
58
  assert PYTHON_XS_FIXTURE_CHECKSUMS[package.filename] == package.digests["sha256"]
59
59
 
60
60
 
61
+ @pytest.mark.parallel
62
+ @pytest.mark.parametrize("media_type", ["application/vnd.pypi.simple.v1+json", "text/html"])
63
+ def test_pull_through_simple_media_types(
64
+ media_type, python_remote_factory, python_distribution_factory
65
+ ):
66
+ """Tests pull-through with different media types (JSON and HTML)."""
67
+ remote = python_remote_factory(url=PYPI_URL, includes=["shelf-reader"])
68
+ distro = python_distribution_factory(remote=remote.pulp_href)
69
+
70
+ url = f"{distro.base_url}simple/shelf-reader/"
71
+ headers = {"Accept": media_type}
72
+ response = requests.get(url, headers=headers)
73
+
74
+ assert response.status_code == 200
75
+ assert media_type in response.headers["Content-Type"]
76
+ assert "X-PyPI-Last-Serial" in response.headers
77
+
78
+
61
79
  @pytest.mark.parallel
62
80
  def test_pull_through_filter(python_remote_factory, python_distribution_factory):
63
81
  """Tests that pull-through respects the includes/excludes filter on the remote."""
@@ -66,7 +84,7 @@ def test_pull_through_filter(python_remote_factory, python_distribution_factory)
66
84
 
67
85
  r = requests.get(f"{distro.base_url}simple/pulpcore/")
68
86
  assert r.status_code == 404
69
- assert r.json() == {"detail": "pulpcore does not exist."}
87
+ assert r.text == "404 Not Found"
70
88
 
71
89
  r = requests.get(f"{distro.base_url}simple/shelf-reader/")
72
90
  assert r.status_code == 200
@@ -86,7 +104,7 @@ def test_pull_through_filter(python_remote_factory, python_distribution_factory)
86
104
 
87
105
  r = requests.get(f"{distro.base_url}simple/django/")
88
106
  assert r.status_code == 404
89
- assert r.json() == {"detail": "django does not exist."}
107
+ assert r.text == "404 Not Found"
90
108
 
91
109
  r = requests.get(f"{distro.base_url}simple/pulpcore/")
92
110
  assert r.status_code == 502
@@ -0,0 +1,126 @@
1
+ from urllib.parse import urljoin
2
+
3
+ import pytest
4
+ import requests
5
+
6
+ from pulp_python.tests.functional.constants import (
7
+ PYTHON_EGG_FILENAME,
8
+ PYTHON_EGG_URL,
9
+ PYTHON_SM_PROJECT_SPECIFIER,
10
+ PYTHON_WHEEL_FILENAME,
11
+ PYTHON_WHEEL_URL,
12
+ )
13
+
14
+ API_VERSION = "1.0"
15
+ PYPI_SERIAL_CONSTANT = 1000000000
16
+
17
+ PYPI_TEXT_HTML = "text/html"
18
+ PYPI_SIMPLE_V1_HTML = "application/vnd.pypi.simple.v1+html"
19
+ PYPI_SIMPLE_V1_JSON = "application/vnd.pypi.simple.v1+json"
20
+
21
+
22
+ @pytest.mark.parallel
23
+ def test_simple_json_index_api(
24
+ python_remote_factory, python_repo_with_sync, python_distribution_factory
25
+ ):
26
+ remote = python_remote_factory(includes=PYTHON_SM_PROJECT_SPECIFIER)
27
+ repo = python_repo_with_sync(remote)
28
+ distro = python_distribution_factory(repository=repo)
29
+
30
+ url = urljoin(distro.base_url, "simple/")
31
+ headers = {"Accept": PYPI_SIMPLE_V1_JSON}
32
+
33
+ response = requests.get(url, headers=headers)
34
+ assert response.headers["Content-Type"] == PYPI_SIMPLE_V1_JSON
35
+ assert response.headers["X-PyPI-Last-Serial"] == str(PYPI_SERIAL_CONSTANT)
36
+
37
+ data = response.json()
38
+ assert data["meta"] == {"api-version": API_VERSION, "_last-serial": PYPI_SERIAL_CONSTANT}
39
+ assert data["projects"]
40
+ for project in data["projects"]:
41
+ for i in ["_last-serial", "name"]:
42
+ assert i in project
43
+
44
+
45
+ def test_simple_json_detail_api(
46
+ delete_orphans_pre,
47
+ monitor_task,
48
+ python_bindings,
49
+ python_content_factory,
50
+ python_distribution_factory,
51
+ python_repo_factory,
52
+ ):
53
+ content_1 = python_content_factory(PYTHON_WHEEL_FILENAME, url=PYTHON_WHEEL_URL)
54
+ content_2 = python_content_factory(PYTHON_EGG_FILENAME, url=PYTHON_EGG_URL)
55
+ body = {"add_content_units": [content_1.pulp_href, content_2.pulp_href]}
56
+
57
+ repo = python_repo_factory()
58
+ monitor_task(python_bindings.RepositoriesPythonApi.modify(repo.pulp_href, body).task)
59
+ distro = python_distribution_factory(repository=repo)
60
+
61
+ url = f'{urljoin(distro.base_url, "simple/")}shelf-reader'
62
+ headers = {"Accept": PYPI_SIMPLE_V1_JSON}
63
+
64
+ response = requests.get(url, headers=headers)
65
+ assert response.headers["Content-Type"] == PYPI_SIMPLE_V1_JSON
66
+ assert response.headers["X-PyPI-Last-Serial"] == str(PYPI_SERIAL_CONSTANT)
67
+
68
+ data = response.json()
69
+ assert data["meta"] == {"api-version": API_VERSION, "_last-serial": PYPI_SERIAL_CONSTANT}
70
+ assert data["name"] == "shelf-reader"
71
+ assert data["files"]
72
+
73
+ # Check data of a wheel
74
+ file_whl = next(
75
+ (i for i in data["files"] if i["filename"] == "shelf_reader-0.1-py2-none-any.whl"), None
76
+ )
77
+ assert file_whl is not None, "wheel file not found"
78
+ assert file_whl["url"]
79
+ assert file_whl["hashes"] == {
80
+ "sha256": "2eceb1643c10c5e4a65970baf63bde43b79cbdac7de81dae853ce47ab05197e9"
81
+ }
82
+ assert file_whl["requires-python"] is None
83
+ assert file_whl["data-dist-info-metadata"] == {
84
+ "sha256": "ed333f0db05d77e933a157b7225b403ada9a2f93318d77b41b662eba78bac350"
85
+ }
86
+
87
+ # Check data of a tarball
88
+ file_tar = next((i for i in data["files"] if i["filename"] == "shelf-reader-0.1.tar.gz"), None)
89
+ assert file_tar is not None, "tar file not found"
90
+ assert file_tar["url"]
91
+ assert file_tar["hashes"] == {
92
+ "sha256": "04cfd8bb4f843e35d51bfdef2035109bdea831b55a57c3e6a154d14be116398c"
93
+ }
94
+ assert file_tar["requires-python"] is None
95
+ assert file_tar["data-dist-info-metadata"] is False
96
+
97
+
98
+ @pytest.mark.parallel
99
+ @pytest.mark.parametrize(
100
+ "header, result",
101
+ [
102
+ (PYPI_TEXT_HTML, PYPI_TEXT_HTML),
103
+ (PYPI_SIMPLE_V1_HTML, PYPI_SIMPLE_V1_HTML),
104
+ (PYPI_SIMPLE_V1_JSON, PYPI_SIMPLE_V1_JSON),
105
+ # Follows defined ordering (html, pypi html, pypi json)
106
+ (f"{PYPI_SIMPLE_V1_JSON}, {PYPI_SIMPLE_V1_HTML}", PYPI_SIMPLE_V1_HTML),
107
+ # Everything else should be html
108
+ ("", PYPI_TEXT_HTML),
109
+ ("application/json", PYPI_TEXT_HTML),
110
+ ("sth/else", PYPI_TEXT_HTML),
111
+ ],
112
+ )
113
+ def test_simple_api_content_headers(
114
+ python_remote_factory, python_repo_with_sync, python_distribution_factory, header, result
115
+ ):
116
+ remote = python_remote_factory(includes=PYTHON_SM_PROJECT_SPECIFIER)
117
+ repo = python_repo_with_sync(remote)
118
+ distro = python_distribution_factory(repository=repo)
119
+
120
+ index_url = urljoin(distro.base_url, "simple/")
121
+ detail_url = f"{index_url}aiohttp"
122
+
123
+ for url in [index_url, detail_url]:
124
+ response = requests.get(url, headers={"Accept": header})
125
+ assert response.status_code == 200
126
+ assert result in response.headers["Content-Type"]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pulp-python
3
- Version: 3.19.1
3
+ Version: 3.20.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
@@ -45,6 +45,7 @@ pulp_python/app/migrations/0012_add_domain.py
45
45
  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
+ pulp_python/app/migrations/0016_pythonpackagecontent_metadata_sha256.py
48
49
  pulp_python/app/migrations/__init__.py
49
50
  pulp_python/app/pypi/__init__.py
50
51
  pulp_python/app/pypi/serializers.py
@@ -72,6 +73,7 @@ pulp_python/tests/functional/api/test_download_content.py
72
73
  pulp_python/tests/functional/api/test_export_import.py
73
74
  pulp_python/tests/functional/api/test_full_mirror.py
74
75
  pulp_python/tests/functional/api/test_pypi_apis.py
76
+ pulp_python/tests/functional/api/test_pypi_simple_json_api.py
75
77
  pulp_python/tests/functional/api/test_rbac.py
76
78
  pulp_python/tests/functional/api/test_repair.py
77
79
  pulp_python/tests/functional/api/test_sync.py
@@ -7,7 +7,7 @@ build-backend = 'setuptools.build_meta'
7
7
 
8
8
  [project]
9
9
  name = "pulp-python"
10
- version = "3.19.1"
10
+ version = "3.20.0"
11
11
  description = "pulp-python plugin for the Pulp Project"
12
12
  readme = "README.md"
13
13
  authors = [
@@ -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.19.1"
79
+ current_version = "3.20.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]+))?"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes