pulp-python 3.19.1__tar.gz → 3.20.1__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.1}/CHANGES.md +25 -0
  2. {pulp_python-3.19.1 → pulp_python-3.20.1}/PKG-INFO +2 -2
  3. {pulp_python-3.19.1 → pulp_python-3.20.1}/pulp_python/app/__init__.py +1 -1
  4. pulp_python-3.20.1/pulp_python/app/migrations/0016_pythonpackagecontent_metadata_sha256.py +18 -0
  5. {pulp_python-3.19.1 → pulp_python-3.20.1}/pulp_python/app/models.py +2 -0
  6. {pulp_python-3.19.1 → pulp_python-3.20.1}/pulp_python/app/pypi/views.py +92 -28
  7. {pulp_python-3.19.1 → pulp_python-3.20.1}/pulp_python/app/serializers.py +13 -0
  8. {pulp_python-3.19.1 → pulp_python-3.20.1}/pulp_python/app/tasks/publish.py +3 -1
  9. {pulp_python-3.19.1 → pulp_python-3.20.1}/pulp_python/app/utils.py +87 -16
  10. {pulp_python-3.19.1 → pulp_python-3.20.1}/pulp_python/tests/functional/api/test_consume_content.py +1 -0
  11. {pulp_python-3.19.1 → pulp_python-3.20.1}/pulp_python/tests/functional/api/test_domains.py +1 -0
  12. {pulp_python-3.19.1 → pulp_python-3.20.1}/pulp_python/tests/functional/api/test_full_mirror.py +48 -4
  13. pulp_python-3.20.1/pulp_python/tests/functional/api/test_pypi_simple_json_api.py +126 -0
  14. {pulp_python-3.19.1 → pulp_python-3.20.1}/pulp_python.egg-info/PKG-INFO +2 -2
  15. {pulp_python-3.19.1 → pulp_python-3.20.1}/pulp_python.egg-info/SOURCES.txt +2 -0
  16. {pulp_python-3.19.1 → pulp_python-3.20.1}/pulp_python.egg-info/requires.txt +1 -1
  17. {pulp_python-3.19.1 → pulp_python-3.20.1}/pyproject.toml +3 -3
  18. {pulp_python-3.19.1 → pulp_python-3.20.1}/COMMITMENT +0 -0
  19. {pulp_python-3.19.1 → pulp_python-3.20.1}/COPYRIGHT +0 -0
  20. {pulp_python-3.19.1 → pulp_python-3.20.1}/LICENSE +0 -0
  21. {pulp_python-3.19.1 → pulp_python-3.20.1}/MANIFEST.in +0 -0
  22. {pulp_python-3.19.1 → pulp_python-3.20.1}/README.md +0 -0
  23. {pulp_python-3.19.1 → pulp_python-3.20.1}/functest_requirements.txt +0 -0
  24. {pulp_python-3.19.1 → pulp_python-3.20.1}/pulp_python/__init__.py +0 -0
  25. {pulp_python-3.19.1 → pulp_python-3.20.1}/pulp_python/app/global_access_conditions.py +0 -0
  26. {pulp_python-3.19.1 → pulp_python-3.20.1}/pulp_python/app/management/__init__.py +0 -0
  27. {pulp_python-3.19.1 → pulp_python-3.20.1}/pulp_python/app/management/commands/__init__.py +0 -0
  28. {pulp_python-3.19.1 → pulp_python-3.20.1}/pulp_python/app/management/commands/repair-python-metadata.py +0 -0
  29. {pulp_python-3.19.1 → pulp_python-3.20.1}/pulp_python/app/migrations/0001_initial.py +0 -0
  30. {pulp_python-3.19.1 → pulp_python-3.20.1}/pulp_python/app/migrations/0001_squashed_0010_update_json_field.py +0 -0
  31. {pulp_python-3.19.1 → pulp_python-3.20.1}/pulp_python/app/migrations/0002_pythonpackagecontent_python_version.py +0 -0
  32. {pulp_python-3.19.1 → pulp_python-3.20.1}/pulp_python/app/migrations/0003_new_sync_filters.py +0 -0
  33. {pulp_python-3.19.1 → pulp_python-3.20.1}/pulp_python/app/migrations/0004_DATA_swap_distribution_model.py +0 -0
  34. {pulp_python-3.19.1 → pulp_python-3.20.1}/pulp_python/app/migrations/0005_pythonpackagecontent_sha256.py +0 -0
  35. {pulp_python-3.19.1 → pulp_python-3.20.1}/pulp_python/app/migrations/0006_pythonrepository_autopublish.py +0 -0
  36. {pulp_python-3.19.1 → pulp_python-3.20.1}/pulp_python/app/migrations/0007_pythonpackagecontent_mv-2-1.py +0 -0
  37. {pulp_python-3.19.1 → pulp_python-3.20.1}/pulp_python/app/migrations/0008_pythonpackagecontent_unique_sha256.py +0 -0
  38. {pulp_python-3.19.1 → pulp_python-3.20.1}/pulp_python/app/migrations/0009_pythondistribution_allow_uploads.py +0 -0
  39. {pulp_python-3.19.1 → pulp_python-3.20.1}/pulp_python/app/migrations/0010_update_json_field.py +0 -0
  40. {pulp_python-3.19.1 → pulp_python-3.20.1}/pulp_python/app/migrations/0011_alter_pythondistribution_distribution_ptr_and_more.py +0 -0
  41. {pulp_python-3.19.1 → pulp_python-3.20.1}/pulp_python/app/migrations/0012_add_domain.py +0 -0
  42. {pulp_python-3.19.1 → pulp_python-3.20.1}/pulp_python/app/migrations/0013_add_rbac_permissions.py +0 -0
  43. {pulp_python-3.19.1 → pulp_python-3.20.1}/pulp_python/app/migrations/0014_pythonpackagecontent_dynamic_and_more.py +0 -0
  44. {pulp_python-3.19.1 → pulp_python-3.20.1}/pulp_python/app/migrations/0015_alter_pythonpackagecontent_options.py +0 -0
  45. {pulp_python-3.19.1 → pulp_python-3.20.1}/pulp_python/app/migrations/__init__.py +0 -0
  46. {pulp_python-3.19.1 → pulp_python-3.20.1}/pulp_python/app/modelresource.py +0 -0
  47. {pulp_python-3.19.1 → pulp_python-3.20.1}/pulp_python/app/pypi/__init__.py +0 -0
  48. {pulp_python-3.19.1 → pulp_python-3.20.1}/pulp_python/app/pypi/serializers.py +0 -0
  49. {pulp_python-3.19.1 → pulp_python-3.20.1}/pulp_python/app/replica.py +0 -0
  50. {pulp_python-3.19.1 → pulp_python-3.20.1}/pulp_python/app/settings.py +0 -0
  51. {pulp_python-3.19.1 → pulp_python-3.20.1}/pulp_python/app/tasks/__init__.py +0 -0
  52. {pulp_python-3.19.1 → pulp_python-3.20.1}/pulp_python/app/tasks/repair.py +0 -0
  53. {pulp_python-3.19.1 → pulp_python-3.20.1}/pulp_python/app/tasks/sync.py +0 -0
  54. {pulp_python-3.19.1 → pulp_python-3.20.1}/pulp_python/app/tasks/upload.py +0 -0
  55. {pulp_python-3.19.1 → pulp_python-3.20.1}/pulp_python/app/urls.py +0 -0
  56. {pulp_python-3.19.1 → pulp_python-3.20.1}/pulp_python/app/viewsets.py +0 -0
  57. {pulp_python-3.19.1 → pulp_python-3.20.1}/pulp_python/app/webserver_snippets/__init__.py +0 -0
  58. {pulp_python-3.19.1 → pulp_python-3.20.1}/pulp_python/app/webserver_snippets/apache.conf +0 -0
  59. {pulp_python-3.19.1 → pulp_python-3.20.1}/pulp_python/app/webserver_snippets/nginx.conf +0 -0
  60. {pulp_python-3.19.1 → pulp_python-3.20.1}/pulp_python/pytest_plugin.py +0 -0
  61. {pulp_python-3.19.1 → pulp_python-3.20.1}/pulp_python/tests/__init__.py +0 -0
  62. {pulp_python-3.19.1 → pulp_python-3.20.1}/pulp_python/tests/functional/__init__.py +0 -0
  63. {pulp_python-3.19.1 → pulp_python-3.20.1}/pulp_python/tests/functional/api/__init__.py +0 -0
  64. {pulp_python-3.19.1 → pulp_python-3.20.1}/pulp_python/tests/functional/api/test_auto_publish.py +0 -0
  65. {pulp_python-3.19.1 → pulp_python-3.20.1}/pulp_python/tests/functional/api/test_crud_content_unit.py +0 -0
  66. {pulp_python-3.19.1 → pulp_python-3.20.1}/pulp_python/tests/functional/api/test_crud_publications.py +0 -0
  67. {pulp_python-3.19.1 → pulp_python-3.20.1}/pulp_python/tests/functional/api/test_crud_remotes.py +0 -0
  68. {pulp_python-3.19.1 → pulp_python-3.20.1}/pulp_python/tests/functional/api/test_download_content.py +0 -0
  69. {pulp_python-3.19.1 → pulp_python-3.20.1}/pulp_python/tests/functional/api/test_export_import.py +0 -0
  70. {pulp_python-3.19.1 → pulp_python-3.20.1}/pulp_python/tests/functional/api/test_pypi_apis.py +0 -0
  71. {pulp_python-3.19.1 → pulp_python-3.20.1}/pulp_python/tests/functional/api/test_rbac.py +0 -0
  72. {pulp_python-3.19.1 → pulp_python-3.20.1}/pulp_python/tests/functional/api/test_repair.py +0 -0
  73. {pulp_python-3.19.1 → pulp_python-3.20.1}/pulp_python/tests/functional/api/test_sync.py +0 -0
  74. {pulp_python-3.19.1 → pulp_python-3.20.1}/pulp_python/tests/functional/api/test_upload.py +0 -0
  75. {pulp_python-3.19.1 → pulp_python-3.20.1}/pulp_python/tests/functional/constants.py +0 -0
  76. {pulp_python-3.19.1 → pulp_python-3.20.1}/pulp_python/tests/functional/utils.py +0 -0
  77. {pulp_python-3.19.1 → pulp_python-3.20.1}/pulp_python/tests/unit/__init__.py +0 -0
  78. {pulp_python-3.19.1 → pulp_python-3.20.1}/pulp_python/tests/unit/test_models.py +0 -0
  79. {pulp_python-3.19.1 → pulp_python-3.20.1}/pulp_python.egg-info/dependency_links.txt +0 -0
  80. {pulp_python-3.19.1 → pulp_python-3.20.1}/pulp_python.egg-info/entry_points.txt +0 -0
  81. {pulp_python-3.19.1 → pulp_python-3.20.1}/pulp_python.egg-info/top_level.txt +0 -0
  82. {pulp_python-3.19.1 → pulp_python-3.20.1}/setup.cfg +0 -0
  83. {pulp_python-3.19.1 → pulp_python-3.20.1}/test_requirements.txt +0 -0
  84. {pulp_python-3.19.1 → pulp_python-3.20.1}/unittest_requirements.txt +0 -0
@@ -8,6 +8,31 @@
8
8
 
9
9
  [//]: # (towncrier release notes start)
10
10
 
11
+ ## 3.20.1 (2025-11-18) {: #3.20.1 }
12
+
13
+ #### Bugfixes {: #3.20.1-bugfix }
14
+
15
+ - Fixed pull-through caching not checking the repository if package was not present on remote.
16
+ [#1004](https://github.com/pulp/pulp_python/issues/1004)
17
+
18
+ ---
19
+
20
+ ## 3.20.0 (2025-11-07) {: #3.20.0 }
21
+
22
+ #### Features {: #3.20.0-feature }
23
+
24
+ - Added JSON-based Simple API (PEP 691).
25
+ [#625](https://github.com/pulp/pulp_python/issues/625)
26
+ - Updated tasks to always return JSON-serializable value.
27
+ [#972](https://github.com/pulp/pulp_python/issues/972)
28
+
29
+ #### Bugfixes {: #3.20.0-bugfix }
30
+
31
+ - Fixed publication error when package's dists contain differing versions of its name.
32
+ [#907](https://github.com/pulp/pulp_python/issues/907)
33
+
34
+ ---
35
+
11
36
  ## 3.19.1 (2025-09-14) {: #3.19.1 }
12
37
 
13
38
  #### 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.1
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,7 +20,7 @@ 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.0
24
24
  Requires-Dist: pkginfo<1.13.0,>=1.12.0
25
25
  Requires-Dist: bandersnatch<6.6,>=6.3.0
26
26
  Requires-Dist: pypi-simple<2.0,>=1.5.0
@@ -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.1"
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
@@ -13,6 +15,7 @@ from django.db import transaction
13
15
  from django.db.utils import DatabaseError
14
16
  from django.http.response import (
15
17
  Http404,
18
+ HttpResponseNotFound,
16
19
  HttpResponseForbidden,
17
20
  HttpResponseBadRequest,
18
21
  StreamingHttpResponse,
@@ -43,7 +46,9 @@ from pulp_python.app.pypi.serializers import (
43
46
  )
44
47
  from pulp_python.app.utils import (
45
48
  write_simple_index,
49
+ write_simple_index_json,
46
50
  write_simple_detail,
51
+ write_simple_detail_json,
47
52
  python_content_to_json,
48
53
  PYPI_LAST_SERIAL,
49
54
  PYPI_SERIAL_CONSTANT,
@@ -57,6 +62,17 @@ log = logging.getLogger(__name__)
57
62
  ORIGIN_HOST = settings.CONTENT_ORIGIN if settings.CONTENT_ORIGIN else settings.PYPI_API_HOSTNAME
58
63
  BASE_CONTENT_URL = urljoin(ORIGIN_HOST, settings.CONTENT_PATH_PREFIX)
59
64
 
65
+ PYPI_SIMPLE_V1_HTML = "application/vnd.pypi.simple.v1+html"
66
+ PYPI_SIMPLE_V1_JSON = "application/vnd.pypi.simple.v1+json"
67
+
68
+
69
+ class PyPISimpleHTMLRenderer(TemplateHTMLRenderer):
70
+ media_type = PYPI_SIMPLE_V1_HTML
71
+
72
+
73
+ class PyPISimpleJSONRenderer(JSONRenderer):
74
+ media_type = PYPI_SIMPLE_V1_JSON
75
+
60
76
 
61
77
  class PyPIMixin:
62
78
  """Mixin to get index specific info."""
@@ -235,6 +251,25 @@ class SimpleView(PackageUploadMixin, ViewSet):
235
251
  ],
236
252
  }
237
253
 
254
+ def perform_content_negotiation(self, request, force=False):
255
+ """
256
+ Uses standard content negotiation, defaulting to HTML if no acceptable renderer is found.
257
+ """
258
+ try:
259
+ return super().perform_content_negotiation(request, force)
260
+ except NotAcceptable:
261
+ return TemplateHTMLRenderer(), TemplateHTMLRenderer.media_type # text/html
262
+
263
+ def get_renderers(self):
264
+ """
265
+ Uses custom renderers for PyPI Simple API endpoints, defaulting to standard ones.
266
+ """
267
+ if self.action in ["list", "retrieve"]:
268
+ # Ordered by priority if multiple content types are present
269
+ return [TemplateHTMLRenderer(), PyPISimpleHTMLRenderer(), PyPISimpleJSONRenderer()]
270
+ else:
271
+ return [JSONRenderer(), BrowsableAPIRenderer()]
272
+
238
273
  @extend_schema(summary="Get index simple page")
239
274
  def list(self, request, path):
240
275
  """Gets the simple api html page for the index."""
@@ -242,7 +277,16 @@ class SimpleView(PackageUploadMixin, ViewSet):
242
277
  if self.should_redirect(repo_version=repo_version):
243
278
  return redirect(urljoin(self.base_content_url, f"{path}/simple/"))
244
279
  names = content.order_by("name").values_list("name", flat=True).distinct().iterator()
245
- return StreamingHttpResponse(write_simple_index(names, streamed=True))
280
+ media_type = request.accepted_renderer.media_type
281
+ headers = {"X-PyPI-Last-Serial": str(PYPI_SERIAL_CONSTANT)}
282
+
283
+ if media_type == PYPI_SIMPLE_V1_JSON:
284
+ index_data = write_simple_index_json(names)
285
+ return Response(index_data, headers=headers)
286
+ else:
287
+ index_data = write_simple_index(names, streamed=True)
288
+ kwargs = {"content_type": media_type, "headers": headers}
289
+ return StreamingHttpResponse(index_data, **kwargs)
246
290
 
247
291
  def pull_through_package_simple(self, package, path, remote):
248
292
  """Gets the package's simple page from remote."""
@@ -252,11 +296,17 @@ class SimpleView(PackageUploadMixin, ViewSet):
252
296
  stripped_url = urlunsplit(chain(parsed[:3], ("", "")))
253
297
  redirect_path = f"{path}/{release_package.filename}?redirect={stripped_url}"
254
298
  d_url = urljoin(self.base_content_url, redirect_path)
255
- return release_package.filename, d_url, release_package.digests.get("sha256", "")
299
+ return {
300
+ "filename": release_package.filename,
301
+ "url": d_url,
302
+ "sha256": release_package.digests.get("sha256", ""),
303
+ "requires_python": release_package.requires_python,
304
+ "metadata_sha256": (release_package.metadata_digests or {}).get("sha256"),
305
+ }
256
306
 
257
307
  rfilter = get_remote_package_filter(remote)
258
308
  if not rfilter.filter_project(package):
259
- raise Http404(f"{package} does not exist.")
309
+ return {}
260
310
 
261
311
  url = remote.get_remote_artifact_url(f"simple/{package}/")
262
312
  remote.headers = remote.headers or []
@@ -264,44 +314,58 @@ class SimpleView(PackageUploadMixin, ViewSet):
264
314
  downloader = remote.get_downloader(url=url, max_retries=1)
265
315
  try:
266
316
  d = downloader.fetch()
267
- except ClientError:
268
- return HttpResponse(f"Failed to fetch {package} from {remote.url}.", status=502)
269
- except TimeoutException:
270
- return HttpResponse(f"{remote.url} timed out while fetching {package}.", status=504)
317
+ except (ClientError, TimeoutException):
318
+ log.info(f"Failed to fetch {package} simple page from {remote.url}")
319
+ return {}
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
- packages = [
277
- parse_package(p) for p in page.packages if rfilter.filter_release(package, p.version)
278
- ]
279
- return HttpResponse(write_simple_detail(package, packages))
325
+ return {
326
+ p.filename: parse_package(p)
327
+ for p in page.packages
328
+ if rfilter.filter_release(package, p.version)
329
+ }
280
330
 
281
331
  @extend_schema(operation_id="pypi_simple_package_read", summary="Get package simple page")
282
332
  def retrieve(self, request, path, package):
283
- """Retrieves the simple api html page for a package."""
333
+ """Retrieves the simple api html/json page for a package."""
334
+ media_type = request.accepted_renderer.media_type
335
+
284
336
  repo_ver, content = self.get_rvc()
285
337
  # Should I redirect if the normalized name is different?
286
338
  normalized = canonicalize_name(package)
339
+ releases = {}
287
340
  if self.distribution.remote:
288
- return self.pull_through_package_simple(normalized, path, self.distribution.remote)
289
- if self.should_redirect(repo_version=repo_ver):
341
+ releases = self.pull_through_package_simple(normalized, path, self.distribution.remote)
342
+ elif self.should_redirect(repo_version=repo_ver):
290
343
  return redirect(urljoin(self.base_content_url, f"{path}/simple/{normalized}/"))
291
- packages = (
292
- content.filter(name__normalize=normalized)
293
- .values_list("filename", "sha256", "name")
294
- .iterator()
295
- )
296
- try:
297
- present = next(packages)
298
- except StopIteration:
299
- raise Http404(f"{normalized} does not exist.")
344
+ if content:
345
+ packages = content.filter(name__normalize=normalized).values(
346
+ "filename", "sha256", "metadata_sha256", "requires_python"
347
+ )
348
+ local_releases = {
349
+ p["filename"]: {
350
+ **p,
351
+ "url": urljoin(self.base_content_url, f"{path}/{p['filename']}"),
352
+ }
353
+ for p in packages
354
+ }
355
+ releases.update(local_releases)
356
+ if not releases:
357
+ return HttpResponseNotFound(f"{normalized} does not exist.")
358
+
359
+ media_type = request.accepted_renderer.media_type
360
+ headers = {"X-PyPI-Last-Serial": str(PYPI_SERIAL_CONSTANT)}
361
+
362
+ if media_type == PYPI_SIMPLE_V1_JSON:
363
+ detail_data = write_simple_detail_json(normalized, releases.values())
364
+ return Response(detail_data, headers=headers)
300
365
  else:
301
- packages = chain([present], packages)
302
- 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))
366
+ detail_data = write_simple_detail(normalized, releases.values())
367
+ kwargs = {"content_type": media_type, "headers": headers}
368
+ return HttpResponse(detail_data, **kwargs)
305
369
 
306
370
  @extend_schema(
307
371
  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 == "pulpcore does not exist."
70
88
 
71
89
  r = requests.get(f"{distro.base_url}simple/shelf-reader/")
72
90
  assert r.status_code == 200
@@ -86,11 +104,11 @@ 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 == "django does not exist."
90
108
 
91
109
  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}."
110
+ assert r.status_code == 404
111
+ assert r.text == "pulpcore does not exist."
94
112
 
95
113
  r = requests.get(f"{distro.base_url}simple/shelf-reader/")
96
114
  assert r.status_code == 200
@@ -138,3 +156,29 @@ def test_pull_through_with_repo(
138
156
  assert r.status_code == 200
139
157
  tasks = pulpcore_bindings.TasksApi.list(reserved_resources=repo.prn)
140
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."
@@ -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.1
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,7 +20,7 @@ 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.0
24
24
  Requires-Dist: pkginfo<1.13.0,>=1.12.0
25
25
  Requires-Dist: bandersnatch<6.6,>=6.3.0
26
26
  Requires-Dist: pypi-simple<2.0,>=1.5.0
@@ -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
@@ -1,4 +1,4 @@
1
- pulpcore<3.100,>=3.81.0
1
+ pulpcore<3.100,>=3.85.0
2
2
  pkginfo<1.13.0,>=1.12.0
3
3
  bandersnatch<6.6,>=6.3.0
4
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.19.1"
10
+ version = "3.20.1"
11
11
  description = "pulp-python plugin for the Pulp Project"
12
12
  readme = "README.md"
13
13
  authors = [
@@ -26,7 +26,7 @@ classifiers=[
26
26
  ]
27
27
  requires-python = ">=3.11"
28
28
  dependencies = [
29
- "pulpcore>=3.81.0,<3.100",
29
+ "pulpcore>=3.85.0,<3.100",
30
30
  "pkginfo>=1.12.0,<1.13.0",
31
31
  "bandersnatch>=6.3.0,<6.6", # 6.6 has breaking changes
32
32
  "pypi-simple>=1.5.0,<2.0",
@@ -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.1"
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