pulp-python 3.27.0__tar.gz → 3.28.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 (95) hide show
  1. {pulp_python-3.27.0 → pulp_python-3.28.0}/CHANGES.md +17 -0
  2. {pulp_python-3.27.0 → pulp_python-3.28.0}/PKG-INFO +2 -2
  3. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/app/__init__.py +1 -1
  4. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/app/global_access_conditions.py +0 -1
  5. pulp_python-3.28.0/pulp_python/app/migrations/0020_pythonpackagecontent_name_normalized.py +37 -0
  6. pulp_python-3.28.0/pulp_python/app/migrations/0021_pythonrepository_upload_duplicate_filenames.py +16 -0
  7. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/app/models.py +35 -11
  8. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/app/pypi/views.py +11 -6
  9. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/app/serializers.py +20 -5
  10. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/app/settings.py +1 -0
  11. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/app/tasks/publish.py +3 -4
  12. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/app/urls.py +3 -2
  13. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/app/utils.py +0 -1
  14. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/app/viewsets.py +63 -1
  15. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/pytest_plugin.py +0 -1
  16. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/tests/functional/api/test_crud_content_unit.py +125 -23
  17. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/tests/functional/api/test_domains.py +0 -1
  18. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/tests/functional/api/test_export_import.py +0 -1
  19. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/tests/functional/api/test_pypi_apis.py +0 -1
  20. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/tests/functional/constants.py +0 -1
  21. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python.egg-info/PKG-INFO +2 -2
  22. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python.egg-info/SOURCES.txt +2 -0
  23. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python.egg-info/requires.txt +1 -1
  24. {pulp_python-3.27.0 → pulp_python-3.28.0}/pyproject.toml +3 -3
  25. {pulp_python-3.27.0 → pulp_python-3.28.0}/COMMITMENT +0 -0
  26. {pulp_python-3.27.0 → pulp_python-3.28.0}/COPYRIGHT +0 -0
  27. {pulp_python-3.27.0 → pulp_python-3.28.0}/LICENSE +0 -0
  28. {pulp_python-3.27.0 → pulp_python-3.28.0}/MANIFEST.in +0 -0
  29. {pulp_python-3.27.0 → pulp_python-3.28.0}/README.md +0 -0
  30. {pulp_python-3.27.0 → pulp_python-3.28.0}/functest_requirements.txt +0 -0
  31. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/__init__.py +0 -0
  32. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/app/management/__init__.py +0 -0
  33. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/app/management/commands/__init__.py +0 -0
  34. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/app/management/commands/repair-python-metadata.py +0 -0
  35. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/app/migrations/0001_initial.py +0 -0
  36. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/app/migrations/0001_squashed_0010_update_json_field.py +0 -0
  37. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/app/migrations/0002_pythonpackagecontent_python_version.py +0 -0
  38. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/app/migrations/0003_new_sync_filters.py +0 -0
  39. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/app/migrations/0004_DATA_swap_distribution_model.py +0 -0
  40. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/app/migrations/0005_pythonpackagecontent_sha256.py +0 -0
  41. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/app/migrations/0006_pythonrepository_autopublish.py +0 -0
  42. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/app/migrations/0007_pythonpackagecontent_mv-2-1.py +0 -0
  43. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/app/migrations/0008_pythonpackagecontent_unique_sha256.py +0 -0
  44. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/app/migrations/0009_pythondistribution_allow_uploads.py +0 -0
  45. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/app/migrations/0010_update_json_field.py +0 -0
  46. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/app/migrations/0011_alter_pythondistribution_distribution_ptr_and_more.py +0 -0
  47. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/app/migrations/0012_add_domain.py +0 -0
  48. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/app/migrations/0013_add_rbac_permissions.py +0 -0
  49. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/app/migrations/0014_pythonpackagecontent_dynamic_and_more.py +0 -0
  50. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/app/migrations/0015_alter_pythonpackagecontent_options.py +0 -0
  51. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/app/migrations/0016_pythonpackagecontent_metadata_sha256.py +0 -0
  52. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/app/migrations/0017_pythonpackagecontent_size.py +0 -0
  53. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/app/migrations/0018_packageprovenance.py +0 -0
  54. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/app/migrations/0019_create_missing_metadata_artifacts.py +0 -0
  55. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/app/migrations/__init__.py +0 -0
  56. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/app/modelresource.py +0 -0
  57. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/app/provenance.py +0 -0
  58. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/app/pypi/__init__.py +0 -0
  59. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/app/pypi/serializers.py +0 -0
  60. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/app/replica.py +0 -0
  61. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/app/tasks/__init__.py +0 -0
  62. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/app/tasks/repair.py +0 -0
  63. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/app/tasks/sync.py +0 -0
  64. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/app/tasks/upload.py +0 -0
  65. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/app/tasks/vulnerability_report.py +0 -0
  66. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/app/webserver_snippets/__init__.py +0 -0
  67. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/app/webserver_snippets/apache.conf +0 -0
  68. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/app/webserver_snippets/nginx.conf +0 -0
  69. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/tests/__init__.py +0 -0
  70. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/tests/functional/__init__.py +0 -0
  71. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/tests/functional/api/__init__.py +0 -0
  72. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/tests/functional/api/test_attestations.py +0 -0
  73. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/tests/functional/api/test_auto_publish.py +0 -0
  74. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/tests/functional/api/test_consume_content.py +0 -0
  75. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/tests/functional/api/test_crud_publications.py +0 -0
  76. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/tests/functional/api/test_crud_remotes.py +0 -0
  77. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/tests/functional/api/test_download_content.py +0 -0
  78. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/tests/functional/api/test_full_mirror.py +0 -0
  79. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/tests/functional/api/test_pypi_simple_api.py +0 -0
  80. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/tests/functional/api/test_rbac.py +0 -0
  81. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/tests/functional/api/test_repair.py +0 -0
  82. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/tests/functional/api/test_sync.py +0 -0
  83. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/tests/functional/api/test_upload.py +0 -0
  84. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/tests/functional/api/test_vulnerability_report.py +0 -0
  85. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/tests/functional/assets/shelf-reader-0.1.tar.gz.publish.attestation +0 -0
  86. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/tests/functional/assets/shelf_reader-0.1-py2-none-any.whl.publish.attestation +0 -0
  87. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/tests/functional/utils.py +0 -0
  88. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/tests/unit/__init__.py +0 -0
  89. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python/tests/unit/test_models.py +0 -0
  90. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python.egg-info/dependency_links.txt +0 -0
  91. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python.egg-info/entry_points.txt +0 -0
  92. {pulp_python-3.27.0 → pulp_python-3.28.0}/pulp_python.egg-info/top_level.txt +0 -0
  93. {pulp_python-3.27.0 → pulp_python-3.28.0}/setup.cfg +0 -0
  94. {pulp_python-3.27.0 → pulp_python-3.28.0}/test_requirements.txt +0 -0
  95. {pulp_python-3.27.0 → pulp_python-3.28.0}/unittest_requirements.txt +0 -0
@@ -8,6 +8,23 @@
8
8
 
9
9
  [//]: # (towncrier release notes start)
10
10
 
11
+ ## 3.28.0 (2026-03-27) {: #3.28.0 }
12
+
13
+ #### Features {: #3.28.0-feature }
14
+
15
+ - Added the name_normalized field to PythonPackageContent model with a database index to replace
16
+ runtime regex normalization, reducing database load for package name lookups.
17
+ [#1159](https://github.com/pulp/pulp_python/issues/1159)
18
+ - Added a new `allow_package_substitution` boolean field to PythonRepository (default: True).
19
+ When set to False, any new repository version that would implicitly replace existing content
20
+ with content that has the same filename but a different sha256 checksum is rejected. This
21
+ applies to all repository version creation paths including uploads, modify, and sync. Content
22
+ with a matching checksum is allowed through idempotently.
23
+ [#1162](https://github.com/pulp/pulp_python/issues/1162)
24
+ - Added new setting PYPI_PATH_PREFIX to allow for customizing the path prefix for the PyPI API.
25
+
26
+ ---
27
+
11
28
  ## 3.27.0 (2026-03-17) {: #3.27.0 }
12
29
 
13
30
  #### Bugfixes {: #3.27.0-bugfix }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pulp-python
3
- Version: 3.27.0
3
+ Version: 3.28.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,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.115,>=3.85.3
23
+ Requires-Dist: pulpcore<3.115,>=3.105.0
24
24
  Requires-Dist: pkginfo<1.13.0,>=1.12.0
25
25
  Requires-Dist: bandersnatch<6.7,>=6.6.0
26
26
  Requires-Dist: pypi-simple<2.0,>=1.8.0
@@ -10,7 +10,7 @@ class PulpPythonPluginAppConfig(PulpPluginAppConfig):
10
10
 
11
11
  name = "pulp_python.app"
12
12
  label = "python"
13
- version = "3.27.0"
13
+ version = "3.28.0"
14
14
  python_package_name = "pulp-python"
15
15
  domain_compatible = True
16
16
 
@@ -1,6 +1,5 @@
1
1
  from django.conf import settings
2
2
 
3
-
4
3
  # Access Condition methods that can be used with PyPI access policies
5
4
 
6
5
 
@@ -0,0 +1,37 @@
1
+ import re
2
+
3
+ from django.db import migrations, models, transaction
4
+
5
+
6
+ def populate_name_normalized(apps, schema_editor):
7
+ """Populate name_normalized for existing PythonPackageContent rows."""
8
+ PythonPackageContent = apps.get_model("python", "PythonPackageContent")
9
+ package_bulk = []
10
+ normalize_re = re.compile(r"[-_.]+")
11
+
12
+ for package in PythonPackageContent.objects.only("pk", "name").iterator():
13
+ package.name_normalized = normalize_re.sub("-", package.name).lower()
14
+ package_bulk.append(package)
15
+ if len(package_bulk) == 100000:
16
+ with transaction.atomic():
17
+ PythonPackageContent.objects.bulk_update(package_bulk, ["name_normalized"])
18
+ package_bulk = []
19
+ if package_bulk:
20
+ with transaction.atomic():
21
+ PythonPackageContent.objects.bulk_update(package_bulk, ["name_normalized"])
22
+
23
+
24
+ class Migration(migrations.Migration):
25
+
26
+ dependencies = [
27
+ ("python", "0019_create_missing_metadata_artifacts"),
28
+ ]
29
+
30
+ operations = [
31
+ migrations.AddField(
32
+ model_name="pythonpackagecontent",
33
+ name="name_normalized",
34
+ field=models.TextField(db_index=True, default=""),
35
+ ),
36
+ migrations.RunPython(populate_name_normalized, migrations.RunPython.noop, elidable=True),
37
+ ]
@@ -0,0 +1,16 @@
1
+ from django.db import migrations, models
2
+
3
+
4
+ class Migration(migrations.Migration):
5
+
6
+ dependencies = [
7
+ ("python", "0020_pythonpackagecontent_name_normalized"),
8
+ ]
9
+
10
+ operations = [
11
+ migrations.AddField(
12
+ model_name="pythonrepository",
13
+ name="allow_package_substitution",
14
+ field=models.BooleanField(default=True),
15
+ ),
16
+ ]
@@ -11,6 +11,7 @@ from django_lifecycle import (
11
11
  BEFORE_SAVE,
12
12
  hook,
13
13
  )
14
+ from rest_framework.serializers import ValidationError
14
15
  from pulpcore.plugin.models import (
15
16
  AutoAddObjPermsMixin,
16
17
  Content,
@@ -31,7 +32,11 @@ from .utils import (
31
32
  PYPI_LAST_SERIAL,
32
33
  PYPI_SERIAL_CONSTANT,
33
34
  )
34
- from pulpcore.plugin.repo_version_utils import remove_duplicates, validate_repo_version
35
+ from pulpcore.plugin.repo_version_utils import (
36
+ collect_duplicates,
37
+ remove_duplicates,
38
+ validate_repo_version,
39
+ )
35
40
  from pulpcore.plugin.util import get_domain_pk, get_domain
36
41
 
37
42
  log = getLogger(__name__)
@@ -115,7 +120,7 @@ class PythonDistribution(Distribution, AutoAddObjPermsMixin):
115
120
  if name:
116
121
  normalized = canonicalize_name(name)
117
122
  package_content = PythonPackageContent.objects.filter(
118
- pk__in=self.publication.repository_version.content, name__normalize=normalized
123
+ pk__in=self.publication.repository_version.content, name_normalized=normalized
119
124
  )
120
125
  # TODO Change this value to the Repo's serial value when implemented
121
126
  headers = {PYPI_LAST_SERIAL: str(PYPI_SERIAL_CONSTANT)}
@@ -136,14 +141,6 @@ class PythonDistribution(Distribution, AutoAddObjPermsMixin):
136
141
  ]
137
142
 
138
143
 
139
- class NormalizeName(models.Transform):
140
- """A transform field to normalize package names according to PEP426."""
141
-
142
- function = "REGEXP_REPLACE"
143
- template = "LOWER(%(function)s(%(expressions)s, '(\.|_|-)', '-', 'ig'))" # noqa:W605
144
- lookup_name = "normalize"
145
-
146
-
147
144
  class PythonPackageContent(Content):
148
145
  """
149
146
  A Content Type representing Python's Distribution Package.
@@ -195,6 +192,9 @@ class PythonPackageContent(Content):
195
192
  license_expression = models.TextField()
196
193
  license_file = models.JSONField(default=list)
197
194
 
195
+ # Stored normalized name for indexed lookups
196
+ name_normalized = models.TextField(db_index=True, default="")
197
+
198
198
  # Release metadata
199
199
  filename = models.TextField(db_index=True)
200
200
  packagetype = models.TextField(choices=PACKAGE_TYPES)
@@ -208,9 +208,13 @@ class PythonPackageContent(Content):
208
208
  PROTECTED_FROM_RECLAIM = False
209
209
  TYPE = "python"
210
210
  _pulp_domain = models.ForeignKey("core.Domain", default=get_domain_pk, on_delete=models.PROTECT)
211
- name.register_lookup(NormalizeName)
212
211
  repo_key_fields = ("filename",)
213
212
 
213
+ @hook(BEFORE_SAVE)
214
+ def set_name_normalized(self):
215
+ """Pre-compute the normalized package name for indexed lookups."""
216
+ self.name_normalized = canonicalize_name(self.name)
217
+
214
218
  @staticmethod
215
219
  def init_from_artifact_and_relative_path(artifact, relative_path):
216
220
  """Used when downloading package from pull-through cache."""
@@ -363,6 +367,7 @@ class PythonRepository(Repository, AutoAddObjPermsMixin):
363
367
  PULL_THROUGH_SUPPORTED = True
364
368
 
365
369
  autopublish = models.BooleanField(default=False)
370
+ allow_package_substitution = models.BooleanField(default=True)
366
371
 
367
372
  class Meta:
368
373
  default_related_name = "%(app_label)s_%(model_name)s"
@@ -391,6 +396,25 @@ class PythonRepository(Repository, AutoAddObjPermsMixin):
391
396
  def finalize_new_version(self, new_version):
392
397
  """
393
398
  Remove duplicate packages that have the same filename.
399
+
400
+ When allow_package_substitution is False, reject any new version that would implicitly
401
+ replace existing content with different checksums (content substitution).
394
402
  """
403
+ if not self.allow_package_substitution:
404
+ self._check_for_package_substitution(new_version)
395
405
  remove_duplicates(new_version)
396
406
  validate_repo_version(new_version)
407
+
408
+ def _check_for_package_substitution(self, new_version):
409
+ """
410
+ Raise a ValidationError if newly added packages would replace existing packages that have
411
+ the same filename but a different sha256 checksum.
412
+ """
413
+ qs = PythonPackageContent.objects.filter(pk__in=new_version.content)
414
+ duplicates = collect_duplicates(qs, ("filename",))
415
+ if duplicates:
416
+ raise ValidationError(
417
+ "Found duplicate packages being added with the same filename but different checksums. " # noqa: E501
418
+ "To allow this, set 'allow_package_substitution' to True on the repository. "
419
+ f"Conflicting packages: {duplicates}"
420
+ )
@@ -59,7 +59,7 @@ log = logging.getLogger(__name__)
59
59
 
60
60
  ORIGIN_HOST = settings.CONTENT_ORIGIN if settings.CONTENT_ORIGIN else settings.PYPI_API_HOSTNAME
61
61
  BASE_CONTENT_URL = urljoin(ORIGIN_HOST, settings.CONTENT_PATH_PREFIX)
62
- BASE_API_URL = urljoin(settings.PYPI_API_HOSTNAME, "pypi/")
62
+ BASE_API_URL = urljoin(settings.PYPI_API_HOSTNAME, settings.PYPI_PATH_PREFIX)
63
63
 
64
64
  PYPI_SIMPLE_V1_HTML = "application/vnd.pypi.simple.v1+html"
65
65
  PYPI_SIMPLE_V1_JSON = "application/vnd.pypi.simple.v1+json"
@@ -303,7 +303,12 @@ class SimpleView(PackageUploadMixin, ViewSet):
303
303
  repo_version, content = self.get_rvc()
304
304
  if self.should_redirect(repo_version=repo_version):
305
305
  return redirect(urljoin(self.base_content_url, f"{path}/simple/"))
306
- names = content.order_by("name").values_list("name", flat=True).distinct().iterator()
306
+ names = (
307
+ content.order_by("name_normalized")
308
+ .values_list("name", flat=True)
309
+ .distinct("name_normalized")
310
+ .iterator()
311
+ )
307
312
  media_type = request.accepted_renderer.media_type
308
313
  headers = {"X-PyPI-Last-Serial": str(PYPI_SERIAL_CONSTANT)}
309
314
 
@@ -360,8 +365,8 @@ class SimpleView(PackageUploadMixin, ViewSet):
360
365
  releases = self.pull_through_package_simple(normalized, path, self.distribution.remote)
361
366
  elif self.should_redirect(repo_version=repo_ver):
362
367
  return redirect(urljoin(self.base_content_url, f"{path}/simple/{normalized}/"))
363
- if content:
364
- local_packages = content.filter(name__normalize=normalized)
368
+ if content is not None:
369
+ local_packages = content.filter(name_normalized=normalized)
365
370
  packages = local_packages.values(
366
371
  "filename",
367
372
  "sha256",
@@ -454,7 +459,7 @@ class MetadataView(PyPIMixin, ViewSet):
454
459
  name = meta_path.parts[0]
455
460
  if name:
456
461
  normalized = canonicalize_name(name)
457
- package_content = content.filter(name__normalize=normalized)
462
+ package_content = content.filter(name_normalized=normalized)
458
463
  # TODO Change this value to the Repo's serial value when implemented
459
464
  headers = {PYPI_LAST_SERIAL: str(PYPI_SERIAL_CONSTANT)}
460
465
  if settings.DOMAIN_ENABLED:
@@ -541,7 +546,7 @@ class ProvenanceView(PyPIMixin, ViewSet):
541
546
  repo_ver, content = self.get_rvc()
542
547
  if content:
543
548
  package_content = content.filter(
544
- name__normalize=package, version=version, filename=filename
549
+ name_normalized=package, version=version, filename=filename
545
550
  ).first()
546
551
  if package_content:
547
552
  provenance = self.get_provenances(repo_ver).filter(package=package_content).first()
@@ -9,6 +9,7 @@ from packaging.requirements import Requirement
9
9
  from rest_framework import serializers
10
10
  from pypi_attestations import AttestationError
11
11
  from pydantic import TypeAdapter, ValidationError
12
+ from urllib.parse import urljoin
12
13
 
13
14
  from pulpcore.plugin import models as core_models
14
15
  from pulpcore.plugin import serializers as core_serializers
@@ -30,8 +31,8 @@ from pulp_python.app.utils import (
30
31
  parse_project_metadata,
31
32
  )
32
33
 
33
-
34
34
  log = logging.getLogger(__name__)
35
+ PYPI_BASE_URL = urljoin(settings.PYPI_API_HOSTNAME, settings.PYPI_PATH_PREFIX)
35
36
 
36
37
 
37
38
  @extend_schema_serializer(
@@ -52,9 +53,23 @@ class PythonRepositorySerializer(core_serializers.RepositorySerializer):
52
53
  default=False,
53
54
  required=False,
54
55
  )
56
+ allow_package_substitution = serializers.BooleanField(
57
+ help_text=_(
58
+ "Whether to allow package substitution (replacing existing packages with packages "
59
+ "that have the same filename but a different checksum). When False, any new "
60
+ "repository version that would cause such a substitution will be rejected. This "
61
+ "applies to all repository version creation paths including uploads, modify, and "
62
+ "sync. When True (the default), package substitution is allowed."
63
+ ),
64
+ default=True,
65
+ required=False,
66
+ )
55
67
 
56
68
  class Meta:
57
- fields = core_serializers.RepositorySerializer.Meta.fields + ("autopublish",)
69
+ fields = core_serializers.RepositorySerializer.Meta.fields + (
70
+ "autopublish",
71
+ "allow_package_substitution",
72
+ )
58
73
  model = python_models.PythonRepository
59
74
 
60
75
 
@@ -93,8 +108,8 @@ class PythonDistributionSerializer(core_serializers.DistributionSerializer):
93
108
  def get_base_url(self, obj):
94
109
  """Gets the base url."""
95
110
  if settings.DOMAIN_ENABLED:
96
- return f"{settings.PYPI_API_HOSTNAME}/pypi/{get_domain().name}/{obj.base_path}/"
97
- return f"{settings.PYPI_API_HOSTNAME}/pypi/{obj.base_path}/"
111
+ return urljoin(PYPI_BASE_URL, f"{get_domain().name}/{obj.base_path}/")
112
+ return urljoin(PYPI_BASE_URL, f"{obj.base_path}/")
98
113
 
99
114
  class Meta:
100
115
  fields = core_serializers.DistributionSerializer.Meta.fields + (
@@ -447,7 +462,7 @@ class PythonPackageContentSerializer(core_serializers.SingleArtifactContentUploa
447
462
  content = super().create(validated_data)
448
463
  if provenance:
449
464
  prov_sha256 = python_models.PackageProvenance.calculate_sha256(provenance)
450
- prov_model, _ = python_models.PackageProvenance.objects.get_or_create(
465
+ prov_model, _created = python_models.PackageProvenance.objects.get_or_create(
451
466
  sha256=prov_sha256,
452
467
  _pulp_domain=get_domain(),
453
468
  defaults={"package": content, "provenance": provenance},
@@ -2,6 +2,7 @@ import socket
2
2
 
3
3
  PYTHON_GROUP_UPLOADS = False
4
4
  PYPI_API_HOSTNAME = "https://" + socket.getfqdn()
5
+ PYPI_PATH_PREFIX = "/pypi/"
5
6
 
6
7
  DRF_ACCESS_POLICY = {
7
8
  "dynaconf_merge_unique": True,
@@ -12,7 +12,6 @@ from pulp_python.app import models as python_models
12
12
  from pulp_python.app.serializers import PythonPublicationSerializer
13
13
  from pulp_python.app.utils import write_simple_index, write_simple_detail
14
14
 
15
-
16
15
  log = logging.getLogger(__name__)
17
16
 
18
17
 
@@ -61,9 +60,9 @@ def write_simple_api(publication):
61
60
  python_models.PythonPackageContent.objects.filter(
62
61
  pk__in=publication.repository_version.content, _pulp_domain=domain
63
62
  )
64
- .order_by("name__normalize")
63
+ .order_by("name_normalized")
65
64
  .values_list("name", flat=True)
66
- .distinct("name__normalize")
65
+ .distinct("name_normalized")
67
66
  )
68
67
 
69
68
  # write the root index, which lists all of the projects for which there is a package available
@@ -82,7 +81,7 @@ def write_simple_api(publication):
82
81
  packages = python_models.PythonPackageContent.objects.filter(
83
82
  pk__in=publication.repository_version.content, _pulp_domain=domain
84
83
  )
85
- releases = packages.order_by("name__normalize").values("name", "filename", "sha256")
84
+ releases = packages.order_by("name_normalized").values("name", "filename", "sha256")
86
85
 
87
86
  ind = 0
88
87
  current_name = canonicalize_name(project_names[ind])
@@ -10,9 +10,10 @@ from pulp_python.app.pypi.views import (
10
10
  )
11
11
 
12
12
  if settings.DOMAIN_ENABLED:
13
- PYPI_API_URL = "pypi/<slug:pulp_domain>/<path:path>/"
13
+ PYPI_API_URL = "/<slug:pulp_domain>/<path:path>/"
14
14
  else:
15
- PYPI_API_URL = "pypi/<path:path>/"
15
+ PYPI_API_URL = "/<path:path>/"
16
+ PYPI_API_URL = settings.PYPI_PATH_PREFIX.strip("/") + PYPI_API_URL
16
17
  # TODO: Implement remaining PyPI endpoints
17
18
  # path("project/", PackageProject.as_view()), # Endpoints to nicely see contents of index
18
19
  # path("search/", PackageSearch.as_view()),
@@ -20,7 +20,6 @@ from pulpcore.plugin.models import Artifact, Remote
20
20
  from pulpcore.plugin.exceptions import TimeoutException
21
21
  from pulpcore.plugin.util import get_domain
22
22
 
23
-
24
23
  log = logging.getLogger(__name__)
25
24
 
26
25
 
@@ -1,19 +1,25 @@
1
1
  from bandersnatch.configuration import BandersnatchConfig
2
2
  from django.db import transaction
3
+ from django_filters import CharFilter
4
+ from django_filters.rest_framework import filters as drf_filters
3
5
  from drf_spectacular.utils import extend_schema, extend_schema_view
6
+ from packaging.utils import canonicalize_name
4
7
  from pathlib import Path
5
8
  from rest_framework import status
6
9
  from rest_framework.decorators import action
7
10
  from rest_framework.response import Response
11
+ from rest_framework.serializers import ValidationError
8
12
 
9
13
  from pulpcore.plugin import viewsets as core_viewsets
10
14
  from pulpcore.plugin.actions import ModifyRepositoryActionMixin
11
15
  from pulpcore.plugin.models import RepositoryVersion
12
16
  from pulpcore.plugin.serializers import (
13
17
  AsyncOperationResponseSerializer,
18
+ RepositoryAddRemoveContentSerializer,
14
19
  RepositorySyncURLSerializer,
15
20
  )
16
21
  from pulpcore.plugin.tasking import check_content, dispatch
22
+ from pulpcore.plugin.util import extract_pk
17
23
 
18
24
  from pulp_python.app import models as python_models
19
25
  from pulp_python.app import serializers as python_serializers
@@ -124,6 +130,43 @@ class PythonRepositoryViewSet(
124
130
  "python.pythonrepository_viewer": ["python.view_pythonrepository"],
125
131
  }
126
132
 
133
+ @extend_schema(
134
+ description="Trigger an asynchronous task to create a new repository version.",
135
+ summary="Modify Repository Content",
136
+ responses={202: AsyncOperationResponseSerializer},
137
+ )
138
+ @action(detail=True, methods=["post"], serializer_class=RepositoryAddRemoveContentSerializer)
139
+ def modify(self, request, pk):
140
+ """
141
+ Queues a task that creates a new RepositoryVersion by adding and removing content units.
142
+
143
+ If allow_package_substitution is False and the request is **only** adding packages, then a
144
+ package substitution check is performed to provide a quicker error response. Otherwise, the
145
+ check is delegated to the task.
146
+ """
147
+ repository = self.get_object()
148
+ if not repository.allow_package_substitution:
149
+ remove_content_units = request.data.get("remove_content_units", [])
150
+ if remove_content_units or "base_version" in request.data:
151
+ return super().modify(request, pk)
152
+ rvc = repository.latest_version().content
153
+ add_content_units = request.data.get("add_content_units", [])
154
+ content_ids = [extract_pk(x) for x in add_content_units]
155
+ packages = (
156
+ python_models.PythonPackageContent.objects.filter(pk__in=content_ids)
157
+ .exclude(pk__in=rvc)
158
+ .values("filename")
159
+ )
160
+ conflicting_packages = python_models.PythonPackageContent.objects.filter(
161
+ filename__in=packages, pk__in=rvc
162
+ )
163
+ if conflicting_packages.exists():
164
+ raise ValidationError(
165
+ "Found duplicate packages being added with the same filename but different checksums. " # noqa: E501
166
+ f"Existing conflicting packages: {conflicting_packages.values('filename', 'sha256', 'pk')}" # noqa: E501
167
+ )
168
+ return super().modify(request, pk)
169
+
127
170
  @extend_schema(
128
171
  summary="Repair metadata",
129
172
  responses={202: AsyncOperationResponseSerializer},
@@ -329,15 +372,34 @@ class PythonDistributionViewSet(core_viewsets.DistributionViewSet, core_viewsets
329
372
  }
330
373
 
331
374
 
375
+ class NormalizedNameFilter(CharFilter):
376
+ """Filter that normalizes the input and queries name_normalized."""
377
+
378
+ def filter(self, qs, value):
379
+ if value:
380
+ if isinstance(value, list):
381
+ value = [canonicalize_name(v) for v in value]
382
+ else:
383
+ value = canonicalize_name(value)
384
+ return super().filter(qs, value)
385
+
386
+
387
+ class NormalizedNameInFilter(drf_filters.BaseInFilter, NormalizedNameFilter):
388
+ """In-filter that normalizes each input value and queries name_normalized."""
389
+
390
+
332
391
  class PythonPackageContentFilter(core_viewsets.ContentFilter):
333
392
  """
334
393
  FilterSet for PythonPackageContent.
335
394
  """
336
395
 
396
+ name = NormalizedNameFilter(field_name="name_normalized", lookup_expr="exact")
397
+ name__in = NormalizedNameInFilter(field_name="name_normalized", lookup_expr="in")
398
+ name__contains = CharFilter(field_name="name", lookup_expr="contains")
399
+
337
400
  class Meta:
338
401
  model = python_models.PythonPackageContent
339
402
  fields = {
340
- "name": ["exact", "in", "contains"],
341
403
  "author": ["exact", "in", "contains"],
342
404
  "packagetype": ["exact", "in"],
343
405
  "requires_python": ["exact", "in", "contains"],
@@ -13,7 +13,6 @@ from pulp_python.tests.functional.constants import (
13
13
  PYTHON_WHEEL_FILENAME,
14
14
  )
15
15
 
16
-
17
16
  # Bindings API Fixtures
18
17
 
19
18
 
@@ -144,44 +144,41 @@ def test_content_create_new_metadata(
144
144
  assert getattr(content, k) == v
145
145
 
146
146
 
147
+ def get_package_url(package, filename):
148
+ with PyPISimple() as client:
149
+ page = client.get_project_page(package)
150
+ for package in page.packages:
151
+ if package.filename == filename:
152
+ return package.url
153
+ raise ValueError(f"Package {filename} not found")
154
+
155
+
147
156
  @pytest.mark.parallel
148
157
  def test_upload_metadata_23_spec(python_content_factory):
149
158
  """Test that packages using metadata spec 2.3 can be uploaded to pulp."""
150
159
  filename = "urllib3-2.2.2-py3-none-any.whl"
151
- with PyPISimple() as client:
152
- page = client.get_project_page("urllib3")
153
- for package in page.packages:
154
- if package.filename == filename:
155
- content = python_content_factory(filename, url=package.url)
156
- assert content.metadata_version == "2.3"
157
- break
160
+ url = get_package_url("urllib3", filename)
161
+ content = python_content_factory(filename, url=url)
162
+ assert content.metadata_version == "2.3"
158
163
 
159
164
 
160
165
  @pytest.mark.parallel
161
166
  def test_upload_requires_python(python_content_factory):
162
167
  filename = "pip-24.3.1-py3-none-any.whl"
163
- with PyPISimple() as client:
164
- page = client.get_project_page("pip")
165
- for package in page.packages:
166
- if package.filename == filename:
167
- content = python_content_factory(filename, url=package.url)
168
- assert content.requires_python == ">=3.8"
169
- break
168
+ url = get_package_url("pip", filename)
169
+ content = python_content_factory(filename, url=url)
170
+ assert content.requires_python == ">=3.8"
170
171
 
171
172
 
172
173
  @pytest.mark.parallel
173
174
  def test_upload_metadata_24_spec(python_content_factory):
174
175
  """Test that packages using metadata spec 2.4 can be uploaded to pulp."""
175
176
  filename = "setuptools-80.9.0.tar.gz"
176
- with PyPISimple() as client:
177
- page = client.get_project_page("setuptools")
178
- for package in page.packages:
179
- if package.filename == filename:
180
- content = python_content_factory(filename, url=package.url)
181
- assert content.metadata_version == "2.4"
182
- assert content.license_expression == "MIT"
183
- assert content.license_file == '["LICENSE"]'
184
- break
177
+ url = get_package_url("setuptools", filename)
178
+ content = python_content_factory(filename, url=url)
179
+ assert content.metadata_version == "2.4"
180
+ assert content.license_expression == "MIT"
181
+ assert content.license_file == '["LICENSE"]'
185
182
 
186
183
 
187
184
  @pytest.mark.parallel
@@ -203,3 +200,108 @@ def test_package_creation_with_metadata(
203
200
  ensure_metadata(
204
201
  pulp_content_url, distro.base_path, PYTHON_WHEEL_FILENAME, "shelf-reader", "0.1"
205
202
  )
203
+
204
+
205
+ @pytest.mark.parallel
206
+ def test_disallow_package_substitution(
207
+ monitor_task,
208
+ python_bindings,
209
+ python_repo_factory,
210
+ ):
211
+ """
212
+ When allow_package_substitution=False, any new repository version that would substitute
213
+ existing content (same filename, different sha256) is rejected. This applies to both
214
+ content uploads and repository modify operations. Re-adding content with a matching
215
+ sha256 succeeds idempotently.
216
+ """
217
+ repo = python_repo_factory(allow_package_substitution=False)
218
+ msg1 = "Found duplicate packages being added with the same filename but different checksums."
219
+ msg2 = "To allow this, set 'allow_package_substitution' to True on the repository."
220
+
221
+ # First upload succeeds
222
+ content_body = {"relative_path": PYTHON_EGG_FILENAME, "file_url": PYTHON_EGG_URL}
223
+ response = python_bindings.ContentPackagesApi.create(repository=repo.pulp_href, **content_body)
224
+ task = monitor_task(response.task)
225
+ content = python_bindings.ContentPackagesApi.read(task.created_resources[-1])
226
+ assert content.filename == PYTHON_EGG_FILENAME
227
+
228
+ # Re-upload same artifact with same filename — should succeed (idempotent)
229
+ response = python_bindings.ContentPackagesApi.create(repository=repo.pulp_href, **content_body)
230
+ task = monitor_task(response.task)
231
+ assert content.pulp_href in task.created_resources
232
+ repo = python_bindings.RepositoriesPythonApi.read(repo.pulp_href)
233
+ assert repo.latest_version_href.endswith("/1/")
234
+
235
+ # Upload a different artifact with the same filename — should be rejected
236
+ second_filename = "pip-26.0.1.tar.gz"
237
+ second_url = get_package_url("pip", second_filename)
238
+ content_body2 = {"relative_path": PYTHON_EGG_FILENAME, "file_url": second_url}
239
+ with pytest.raises(PulpTaskError) as exc:
240
+ response = python_bindings.ContentPackagesApi.create(
241
+ repository=repo.pulp_href, **content_body2
242
+ )
243
+ monitor_task(response.task)
244
+ assert msg1 in exc.value.task.error["description"]
245
+ assert msg2 in exc.value.task.error["description"]
246
+
247
+ # Also create the conflicting content without a repo, then try to add via modify
248
+ response = python_bindings.ContentPackagesApi.create(**content_body2)
249
+ task = monitor_task(response.task)
250
+ content2 = python_bindings.ContentPackagesApi.read(task.created_resources[0])
251
+ # When body only contains add_content_units, the request will be rejected
252
+ body = {"add_content_units": [content2.pulp_href]}
253
+ with pytest.raises(python_bindings.ApiException) as exc:
254
+ python_bindings.RepositoriesPythonApi.modify(repo.pulp_href, body)
255
+ assert msg1 in exc.value.body
256
+ # Else when body contains other fields, the check will be delegated to the task
257
+ body = {"add_content_units": [content2.pulp_href], "base_version": repo.latest_version_href}
258
+ with pytest.raises(PulpTaskError) as exc:
259
+ monitor_task(python_bindings.RepositoriesPythonApi.modify(repo.pulp_href, body).task)
260
+ assert msg1 in exc.value.task.error["description"]
261
+ assert msg2 in exc.value.task.error["description"]
262
+
263
+ # Verify the repository still has only the original content
264
+ repo = python_bindings.RepositoriesPythonApi.read(repo.pulp_href)
265
+ assert repo.latest_version_href.endswith("/1/")
266
+ # Check that you can remove the conflicting content and add the new content
267
+ body = {"remove_content_units": [content.pulp_href], "add_content_units": [content2.pulp_href]}
268
+ response = python_bindings.RepositoriesPythonApi.modify(repo.pulp_href, body)
269
+ task = monitor_task(response.task)
270
+ repo = python_bindings.RepositoriesPythonApi.read(repo.pulp_href)
271
+ assert repo.latest_version_href.endswith("/2/")
272
+
273
+
274
+ @pytest.mark.parallel
275
+ def test_package_substitution_allowed_by_default(
276
+ monitor_task,
277
+ python_bindings,
278
+ python_repo_factory,
279
+ ):
280
+ """
281
+ By default (allow_package_substitution=True), uploading a file with the same filename
282
+ but different sha256 replaces the existing content in the repository.
283
+ """
284
+ repo = python_repo_factory()
285
+ assert repo.allow_package_substitution is True
286
+
287
+ content_body = {"relative_path": PYTHON_EGG_FILENAME, "file_url": PYTHON_EGG_URL}
288
+ response = python_bindings.ContentPackagesApi.create(repository=repo.pulp_href, **content_body)
289
+ task = monitor_task(response.task)
290
+ content1 = python_bindings.ContentPackagesApi.read(task.created_resources[-1])
291
+
292
+ # Upload a different artifact with the same filename — should succeed and replace
293
+ second_filename = "pip-26.0.tar.gz"
294
+ second_url = get_package_url("pip", second_filename)
295
+ content_body = {"relative_path": PYTHON_EGG_FILENAME, "file_url": second_url}
296
+ response = python_bindings.ContentPackagesApi.create(repository=repo.pulp_href, **content_body)
297
+ task = monitor_task(response.task)
298
+ content2 = python_bindings.ContentPackagesApi.read(task.created_resources[-1])
299
+ assert content2.pulp_href != content1.pulp_href
300
+
301
+ # Verify the repo has only the new content
302
+ repo = python_bindings.RepositoriesPythonApi.read(repo.pulp_href)
303
+ content_list = python_bindings.ContentPackagesApi.list(
304
+ repository_version=repo.latest_version_href
305
+ )
306
+ assert content_list.count == 1
307
+ assert content_list.results[0].sha256 == content2.sha256
@@ -12,7 +12,6 @@ from pulp_python.tests.functional.constants import (
12
12
  )
13
13
  from urllib.parse import urlsplit
14
14
 
15
-
16
15
  pytestmark = pytest.mark.skipif(not settings.DOMAIN_ENABLED, reason="Domain not enabled")
17
16
 
18
17
 
@@ -14,7 +14,6 @@ from pulp_python.tests.functional.constants import (
14
14
  PYTHON_SM_PROJECT_SPECIFIER,
15
15
  )
16
16
 
17
-
18
17
  pytestmark = [
19
18
  pytest.mark.skipif(
20
19
  "/tmp" not in settings.ALLOWED_EXPORT_PATHS,
@@ -16,7 +16,6 @@ from pulp_python.tests.functional.constants import (
16
16
  )
17
17
  from pulp_python.tests.functional.utils import ensure_metadata
18
18
 
19
-
20
19
  PYPI_LAST_SERIAL = "X-PYPI-LAST-SERIAL"
21
20
 
22
21
 
@@ -1,7 +1,6 @@
1
1
  import os
2
2
  from urllib.parse import urljoin
3
3
 
4
-
5
4
  PULP_FIXTURES_BASE_URL = os.environ.get(
6
5
  "REMOTE_FIXTURES_ORIGIN", "https://fixtures.pulpproject.org/"
7
6
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pulp-python
3
- Version: 3.27.0
3
+ Version: 3.28.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,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.115,>=3.85.3
23
+ Requires-Dist: pulpcore<3.115,>=3.105.0
24
24
  Requires-Dist: pkginfo<1.13.0,>=1.12.0
25
25
  Requires-Dist: bandersnatch<6.7,>=6.6.0
26
26
  Requires-Dist: pypi-simple<2.0,>=1.8.0
@@ -50,6 +50,8 @@ pulp_python/app/migrations/0016_pythonpackagecontent_metadata_sha256.py
50
50
  pulp_python/app/migrations/0017_pythonpackagecontent_size.py
51
51
  pulp_python/app/migrations/0018_packageprovenance.py
52
52
  pulp_python/app/migrations/0019_create_missing_metadata_artifacts.py
53
+ pulp_python/app/migrations/0020_pythonpackagecontent_name_normalized.py
54
+ pulp_python/app/migrations/0021_pythonrepository_upload_duplicate_filenames.py
53
55
  pulp_python/app/migrations/__init__.py
54
56
  pulp_python/app/pypi/__init__.py
55
57
  pulp_python/app/pypi/serializers.py
@@ -1,4 +1,4 @@
1
- pulpcore<3.115,>=3.85.3
1
+ pulpcore<3.115,>=3.105.0
2
2
  pkginfo<1.13.0,>=1.12.0
3
3
  bandersnatch<6.7,>=6.6.0
4
4
  pypi-simple<2.0,>=1.8.0
@@ -7,7 +7,7 @@ build-backend = 'setuptools.build_meta'
7
7
 
8
8
  [project]
9
9
  name = "pulp-python"
10
- version = "3.27.0"
10
+ version = "3.28.0"
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.85.3,<3.115",
29
+ "pulpcore>=3.105.0,<3.115",
30
30
  "pkginfo>=1.12.0,<1.13.0",
31
31
  "bandersnatch>=6.6.0,<6.7",
32
32
  "pypi-simple>=1.8.0,<2.0",
@@ -79,7 +79,7 @@ ignore = [
79
79
  [tool.bumpversion]
80
80
  # This section is managed by the plugin template. Do not edit manually.
81
81
 
82
- current_version = "3.27.0"
82
+ current_version = "3.28.0"
83
83
  commit = false
84
84
  tag = false
85
85
  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