pulp-python 3.32.1__py3-none-any.whl → 3.33.0__py3-none-any.whl

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.
@@ -12,7 +12,7 @@ class PulpPythonPluginAppConfig(PulpPluginAppConfig):
12
12
 
13
13
  name = "pulp_python.app"
14
14
  label = "python"
15
- version = "3.32.1"
15
+ version = "3.33.0"
16
16
  python_package_name = "pulp-python"
17
17
  domain_compatible = True
18
18
 
@@ -0,0 +1,50 @@
1
+ # Generated by Django 5.2.16 on 2026-07-30 10:44
2
+
3
+ import django.db.models.deletion
4
+ from django.db import migrations, models
5
+
6
+ import pulpcore.app.util
7
+
8
+
9
+ class Migration(migrations.Migration):
10
+
11
+ dependencies = [
12
+ ("python", "0022_pythonblocklistentry"),
13
+ ]
14
+
15
+ operations = [
16
+ migrations.CreateModel(
17
+ name="PackageYank",
18
+ fields=[
19
+ (
20
+ "content_ptr",
21
+ models.OneToOneField(
22
+ auto_created=True,
23
+ on_delete=django.db.models.deletion.CASCADE,
24
+ parent_link=True,
25
+ primary_key=True,
26
+ serialize=False,
27
+ to="core.content",
28
+ ),
29
+ ),
30
+ ("name_normalized", models.TextField()),
31
+ ("version", models.TextField()),
32
+ ("yanked_reason", models.TextField(default="")),
33
+ (
34
+ "_pulp_domain",
35
+ models.ForeignKey(
36
+ default=pulpcore.app.util.get_domain_pk,
37
+ on_delete=django.db.models.deletion.PROTECT,
38
+ to="core.domain",
39
+ ),
40
+ ),
41
+ ],
42
+ options={
43
+ "default_related_name": "%(app_label)s_%(model_name)s",
44
+ "unique_together": {
45
+ ("name_normalized", "version", "yanked_reason", "_pulp_domain")
46
+ },
47
+ },
48
+ bases=("core.content",),
49
+ ),
50
+ ]
pulp_python/app/models.py CHANGED
@@ -204,8 +204,6 @@ class PythonPackageContent(Content):
204
204
  sha256 = models.CharField(db_index=True, max_length=64)
205
205
  metadata_sha256 = models.CharField(max_length=64, null=True)
206
206
  size = models.BigIntegerField(default=0)
207
- # yanked and yanked_reason are not implemented because they are mutable
208
-
209
207
  # From pulpcore
210
208
  PROTECTED_FROM_RECLAIM = False
211
209
  TYPE = "python"
@@ -289,6 +287,32 @@ class PackageProvenance(Content):
289
287
  unique_together = ("sha256", "_pulp_domain")
290
288
 
291
289
 
290
+ class PackageYank(Content):
291
+ """
292
+ A marker content type indicating a package version is yanked in a repository (PEP 592).
293
+
294
+ Its presence in a repository version means all files for the matching
295
+ (name_normalized, version) pair are yanked. Yank/unyank operations
296
+ add/remove this marker, creating new repository versions.
297
+ """
298
+
299
+ TYPE = "python_yank"
300
+ repo_key_fields = ("name_normalized", "version")
301
+
302
+ name_normalized = models.TextField()
303
+ version = models.TextField()
304
+ yanked_reason = models.TextField(default="")
305
+
306
+ _pulp_domain = models.ForeignKey("core.Domain", default=get_domain_pk, on_delete=models.PROTECT)
307
+
308
+ def __str__(self):
309
+ return f"<{self._meta.object_name}: {self.name_normalized} [{self.version}]>"
310
+
311
+ class Meta:
312
+ default_related_name = "%(app_label)s_%(model_name)s"
313
+ unique_together = ("name_normalized", "version", "yanked_reason", "_pulp_domain")
314
+
315
+
292
316
  class PythonPublication(Publication, AutoAddObjPermsMixin):
293
317
  """
294
318
  A Publication for PythonContent.
@@ -364,7 +388,7 @@ class PythonRepository(Repository, AutoAddObjPermsMixin):
364
388
  """
365
389
 
366
390
  TYPE = "python"
367
- CONTENT_TYPES = [PythonPackageContent, PackageProvenance]
391
+ CONTENT_TYPES = [PythonPackageContent, PackageProvenance, PackageYank]
368
392
  REMOTE_TYPES = [PythonRemote]
369
393
  PULL_THROUGH_SUPPORTED = True
370
394
 
@@ -136,3 +136,24 @@ class PackageUploadTaskSerializer(serializers.Serializer):
136
136
  session = serializers.CharField(allow_null=True)
137
137
  task = serializers.CharField()
138
138
  task_start_time = serializers.DateTimeField(allow_null=True)
139
+
140
+
141
+ class YankSerializer(serializers.Serializer):
142
+ """
143
+ A Serializer for yank/unyank requests (PEP 592).
144
+ """
145
+
146
+ name = serializers.CharField(
147
+ help_text=_("The name of the package to yank or unyank."),
148
+ required=True,
149
+ )
150
+ version = serializers.CharField(
151
+ help_text=_("The version of the package to yank or unyank."),
152
+ required=True,
153
+ )
154
+ yanked_reason = serializers.CharField(
155
+ help_text=_("The reason for yanking the package version."),
156
+ required=False,
157
+ allow_blank=True,
158
+ default="",
159
+ )
@@ -29,6 +29,7 @@ from rest_framework.renderers import BrowsableAPIRenderer, JSONRenderer, Templat
29
29
  from rest_framework.response import Response
30
30
  from rest_framework.viewsets import ViewSet
31
31
 
32
+ from pulpcore.plugin.serializers import AsyncOperationResponseSerializer
32
33
  from pulpcore.plugin.tasking import dispatch
33
34
  from pulpcore.plugin.util import get_domain, get_url
34
35
  from pulpcore.plugin.viewsets import OperationPostponedResponse
@@ -37,6 +38,7 @@ from pulp_python.app import tasks
37
38
  from pulp_python.app.cache import PythonApiCache, find_base_path_cached
38
39
  from pulp_python.app.models import (
39
40
  PackageProvenance,
41
+ PackageYank,
40
42
  PythonDistribution,
41
43
  PythonPackageContent,
42
44
  PythonPublication,
@@ -46,6 +48,7 @@ from pulp_python.app.pypi.serializers import (
46
48
  PackageUploadSerializer,
47
49
  PackageUploadTaskSerializer,
48
50
  SummarySerializer,
51
+ YankSerializer,
49
52
  )
50
53
  from pulp_python.app.utils import (
51
54
  PYPI_LAST_SERIAL,
@@ -356,6 +359,8 @@ class SimpleView(PackageUploadMixin, ViewSet):
356
359
  "upload_time": release_package.upload_time,
357
360
  "version": release_package.version,
358
361
  "provenance": release_package.provenance_url,
362
+ "yanked": release_package.is_yanked,
363
+ "yanked_reason": release_package.yanked_reason or "",
359
364
  }
360
365
 
361
366
  rfilter = get_remote_package_filter(remote)
@@ -408,6 +413,11 @@ class SimpleView(PackageUploadMixin, ViewSet):
408
413
  "version",
409
414
  "has_provenance",
410
415
  )
416
+ yank_markers = dict(
417
+ PackageYank.objects.filter(
418
+ pk__in=repo_ver.content, name_normalized=normalized
419
+ ).values_list("version", "yanked_reason")
420
+ )
411
421
  local_releases = {
412
422
  p["filename"]: {
413
423
  **p,
@@ -418,6 +428,8 @@ class SimpleView(PackageUploadMixin, ViewSet):
418
428
  if p["has_provenance"]
419
429
  else None
420
430
  ),
431
+ "yanked": p["version"] in yank_markers,
432
+ "yanked_reason": yank_markers.get(p["version"], ""),
421
433
  }
422
434
  for p in packages
423
435
  }
@@ -493,12 +505,18 @@ class MetadataView(PyPIMixin, ViewSet):
493
505
  headers = {PYPI_LAST_SERIAL: str(PYPI_SERIAL_CONSTANT)}
494
506
  if settings.DOMAIN_ENABLED:
495
507
  domain = get_domain()
508
+ yank_markers = dict(
509
+ PackageYank.objects.filter(
510
+ pk__in=repo_ver.content, name_normalized=normalized
511
+ ).values_list("version", "yanked_reason")
512
+ )
496
513
  json_body = python_content_to_json(
497
514
  path,
498
515
  package_content,
499
516
  version=version,
500
517
  domain=domain,
501
518
  repository_version=repo_ver,
519
+ yank_markers=yank_markers,
502
520
  )
503
521
  if json_body:
504
522
  return Response(data=json_body, headers=headers)
@@ -586,3 +604,78 @@ class ProvenanceView(PyPIMixin, ViewSet):
586
604
  if provenance:
587
605
  return Response(data=provenance.provenance)
588
606
  return HttpResponseNotFound(f"{package} {version} {filename} provenance does not exist.")
607
+
608
+
609
+ class YankView(PyPIMixin, ViewSet):
610
+ """View for yank/unyank requests (PEP 592)."""
611
+
612
+ endpoint_name = "yank"
613
+ DEFAULT_ACCESS_POLICY = {
614
+ "statements": [
615
+ {
616
+ "action": ["yank", "unyank"],
617
+ "principal": "authenticated",
618
+ "effect": "allow",
619
+ "condition": "index_has_repo_perm:python.modify_pythonrepository",
620
+ },
621
+ ],
622
+ }
623
+
624
+ @extend_schema(
625
+ request=YankSerializer,
626
+ responses={202: AsyncOperationResponseSerializer},
627
+ summary="Yank a package version",
628
+ )
629
+ def yank(self, request, path):
630
+ """Yank a package version, marking all its files with data-yanked."""
631
+ repo = self.distribution.repository
632
+ if not repo:
633
+ return HttpResponseBadRequest(reason="Index is not pointing to a repository")
634
+
635
+ serializer = YankSerializer(data=request.data)
636
+ serializer.is_valid(raise_exception=True)
637
+
638
+ normalized = canonicalize_name(serializer.validated_data["name"])
639
+ version = serializer.validated_data["version"]
640
+ repo_ver = self.get_repository_version(self.distribution)
641
+ if not PythonPackageContent.objects.filter(
642
+ pk__in=repo_ver.content, name_normalized=normalized, version=version
643
+ ).exists():
644
+ return HttpResponseNotFound(f"{normalized}=={version} not found in repository")
645
+
646
+ result = dispatch(
647
+ tasks.ayank_package,
648
+ exclusive_resources=[repo],
649
+ kwargs={
650
+ "repository_pk": str(repo.pk),
651
+ "name": serializer.validated_data["name"],
652
+ "version": serializer.validated_data["version"],
653
+ "yanked_reason": serializer.validated_data.get("yanked_reason", ""),
654
+ },
655
+ )
656
+ return OperationPostponedResponse(result, request)
657
+
658
+ @extend_schema(
659
+ request=YankSerializer,
660
+ responses={202: AsyncOperationResponseSerializer},
661
+ summary="Unyank a package version",
662
+ )
663
+ def unyank(self, request, path):
664
+ """Unyank a package version, unmarking all its files with data-yanked."""
665
+ repo = self.distribution.repository
666
+ if not repo:
667
+ return HttpResponseBadRequest(reason="Index is not pointing to a repository")
668
+
669
+ serializer = YankSerializer(data=request.data)
670
+ serializer.is_valid(raise_exception=True)
671
+
672
+ result = dispatch(
673
+ tasks.aunyank_package,
674
+ exclusive_resources=[repo],
675
+ kwargs={
676
+ "repository_pk": str(repo.pk),
677
+ "name": serializer.validated_data["name"],
678
+ "version": serializer.validated_data["version"],
679
+ },
680
+ )
681
+ return OperationPostponedResponse(result, request)
@@ -683,6 +683,25 @@ class PackageProvenanceSerializer(core_serializers.NoArtifactContentUploadSerial
683
683
  model = python_models.PackageProvenance
684
684
 
685
685
 
686
+ class PackageYankSerializer(core_serializers.NoArtifactContentSerializer):
687
+ """
688
+ Read-only serializer for PackageYank content units (PEP 592).
689
+ Used by PackageYankViewSet to expose yank markers via the Pulp REST API.
690
+ """
691
+
692
+ name_normalized = serializers.CharField(read_only=True)
693
+ version = serializers.CharField(read_only=True)
694
+ yanked_reason = serializers.CharField(read_only=True)
695
+
696
+ class Meta:
697
+ fields = core_serializers.NoArtifactContentSerializer.Meta.fields + (
698
+ "name_normalized",
699
+ "version",
700
+ "yanked_reason",
701
+ )
702
+ model = python_models.PackageYank
703
+
704
+
686
705
  class MultipleChoiceArrayField(serializers.MultipleChoiceField):
687
706
  """
688
707
  A wrapper to make sure this DRF serializer works properly with ArrayFields.
@@ -7,3 +7,4 @@ from .repair import repair # noqa:F401
7
7
  from .sync import sync # noqa:F401
8
8
  from .upload import upload, upload_group # noqa:F401
9
9
  from .vulnerability_report import get_repo_version_content # noqa:F401
10
+ from .yank import aunyank_package, ayank_package # noqa:F401
@@ -24,6 +24,7 @@ from pulpcore.plugin.stages import (
24
24
  from pulp_python.app.exceptions import UnsupportedProtocolError
25
25
  from pulp_python.app.models import (
26
26
  PackageProvenance,
27
+ PackageYank,
27
28
  PythonPackageContent,
28
29
  PythonRemote,
29
30
  )
@@ -265,6 +266,15 @@ class PulpMirror(Mirror):
265
266
  )
266
267
  d_artifacts.append(metadata_artifact)
267
268
 
269
+ if upstream_pkg.is_yanked:
270
+ yank_marker = PackageYank(
271
+ name_normalized=pkg.name,
272
+ version=version,
273
+ yanked_reason=upstream_pkg.yanked_reason or "",
274
+ )
275
+ yank_dc = DeclarativeContent(content=yank_marker, d_artifacts=[])
276
+ await self.python_stage.put(yank_dc)
277
+
268
278
  dc = DeclarativeContent(content=package, d_artifacts=d_artifacts)
269
279
  declared_contents[entry["filename"]] = dc
270
280
  await self.python_stage.put(dc)
@@ -0,0 +1,64 @@
1
+ from packaging.utils import canonicalize_name
2
+
3
+ from pulpcore.plugin.exceptions import ValidationError
4
+ from pulpcore.plugin.tasking import aadd_and_remove
5
+
6
+ from pulp_python.app.models import PackageYank, PythonPackageContent, PythonRepository
7
+
8
+
9
+ async def ayank_package(repository_pk, name, version, yanked_reason=""):
10
+ """
11
+ Yank a package version in a repository by adding a PackageYank marker.
12
+ Creates a new repository version with the yank marker added.
13
+ """
14
+ normalized = canonicalize_name(name)
15
+ repository = await PythonRepository.objects.aget(pk=repository_pk)
16
+ latest = await repository.alatest_version()
17
+
18
+ exists = await PythonPackageContent.objects.filter(
19
+ pk__in=latest.content, name_normalized=normalized, version=version
20
+ ).aexists()
21
+ if not exists:
22
+ raise ValidationError(f"Package {name}=={version} not found in repository")
23
+
24
+ existing_yank = await PackageYank.objects.filter(
25
+ pk__in=latest.content, name_normalized=normalized, version=version
26
+ ).afirst()
27
+ if existing_yank and existing_yank.yanked_reason == yanked_reason:
28
+ return
29
+
30
+ yank_marker, _ = await PackageYank.objects.aget_or_create(
31
+ name_normalized=normalized,
32
+ version=version,
33
+ yanked_reason=yanked_reason,
34
+ _pulp_domain_id=repository.pulp_domain_id,
35
+ )
36
+
37
+ await aadd_and_remove(
38
+ repository_pk=repository.pk,
39
+ add_content_units=[yank_marker.pk],
40
+ remove_content_units=[],
41
+ )
42
+
43
+
44
+ async def aunyank_package(repository_pk, name, version):
45
+ """
46
+ Unyank a package version in a repository by removing its PackageYank marker.
47
+ Creates a new repository version with the yank marker removed.
48
+ """
49
+ normalized = canonicalize_name(name)
50
+ repository = await PythonRepository.objects.aget(pk=repository_pk)
51
+ latest = await repository.alatest_version()
52
+
53
+ yank_marker = await PackageYank.objects.filter(
54
+ pk__in=latest.content, name_normalized=normalized, version=version
55
+ ).afirst()
56
+
57
+ if yank_marker is None:
58
+ return
59
+
60
+ await aadd_and_remove(
61
+ repository_pk=repository.pk,
62
+ add_content_units=[],
63
+ remove_content_units=[yank_marker.pk],
64
+ )
pulp_python/app/urls.py CHANGED
@@ -7,6 +7,7 @@ from pulp_python.app.pypi.views import (
7
7
  PyPIView,
8
8
  SimpleView,
9
9
  UploadView,
10
+ YankView,
10
11
  )
11
12
 
12
13
  if settings.DOMAIN_ENABLED:
@@ -40,5 +41,7 @@ urlpatterns = [
40
41
  SimpleView.as_view({"get": "list", "post": "create"}),
41
42
  name="simple-detail",
42
43
  ),
44
+ path(PYPI_API_URL + "yank/", YankView.as_view({"post": "yank"}), name="yank"),
45
+ path(PYPI_API_URL + "unyank/", YankView.as_view({"post": "unyank"}), name="unyank"),
43
46
  path(PYPI_API_URL, PyPIView.as_view({"get": "retrieve"}), name="pypi-detail"),
44
47
  ]
pulp_python/app/utils.py CHANGED
@@ -30,7 +30,7 @@ log = logging.getLogger(__name__)
30
30
  PYPI_LAST_SERIAL = "X-PYPI-LAST-SERIAL"
31
31
  """TODO This serial constant is temporary until Python repositories implements serials"""
32
32
  PYPI_SERIAL_CONSTANT = 1000000000
33
- SUPPORTED_METADATA_VERSIONS = ("1.0", "1.1", "1.2", "2.0", "2.1", "2.2", "2.3", "2.4")
33
+ SUPPORTED_METADATA_VERSIONS = ("1.0", "1.1", "1.2", "2.0", "2.1", "2.2", "2.3", "2.4", "2.5")
34
34
 
35
35
  SIMPLE_API_VERSION = "1.1"
36
36
  PYPI_SIMPLE_V1_HTML = "application/vnd.pypi.simple.v1+html"
@@ -50,7 +50,6 @@ simple_index_template = """<!DOCTYPE html>
50
50
  </html>
51
51
  """
52
52
 
53
- # TODO in the future: data-yanked (not implemented yet because it is mutable)
54
53
  simple_detail_template = """<!DOCTYPE html>
55
54
  <html>
56
55
  <head>
@@ -62,6 +61,7 @@ simple_detail_template = """<!DOCTYPE html>
62
61
  {%- for pkg in project_packages %}
63
62
  <a href="{{ pkg.url }}#sha256={{ pkg.sha256 }}" rel="internal"
64
63
  {%- if pkg.requires_python %} data-requires-python="{{ pkg.requires_python }}" {%- endif %}
64
+ {%- if pkg.yanked %} data-yanked="{{ pkg.yanked_reason }}" {%- endif %}
65
65
  {%- if pkg.metadata_sha256 %} data-dist-info-metadata="sha256={{ pkg.metadata_sha256 }}" data-core-metadata="sha256={{ pkg.metadata_sha256 }}"
66
66
  {%- endif %} {% if pkg.provenance -%}
67
67
  data-provenance="{{ pkg.provenance }}"{% endif %}>{{ pkg.filename }}</a><br/>
@@ -362,7 +362,12 @@ def fetch_json_release_metadata(name: str, version: str, remotes: set[Remote]) -
362
362
 
363
363
 
364
364
  def python_content_to_json(
365
- base_path, content_query, version=None, domain=None, repository_version=None
365
+ base_path,
366
+ content_query,
367
+ version=None,
368
+ domain=None,
369
+ repository_version=None,
370
+ yank_markers=None,
366
371
  ):
367
372
  """
368
373
  Converts a QuerySet of PythonPackageContent into the PyPi JSON format
@@ -375,6 +380,8 @@ def python_content_to_json(
375
380
 
376
381
  Returns None if version is specified but not found within content_query
377
382
  """
383
+ if yank_markers is None:
384
+ yank_markers = {}
378
385
  if repository_version:
379
386
  content_query = content_query.annotate(
380
387
  active_membership=FilteredRelation(
@@ -386,25 +393,32 @@ def python_content_to_json(
386
393
  ),
387
394
  repo_added_time=F("active_membership__pulp_created"),
388
395
  )
389
- full_metadata = {"last_serial": 0} # For now the serial field isn't supported by Pulp
390
- latest_content = latest_content_version(content_query, version)
396
+
397
+ all_content = list(content_query)
398
+ for content in all_content:
399
+ content.yanked = content.version in yank_markers
400
+ content.yanked_reason = yank_markers.get(content.version)
401
+
402
+ latest_content = latest_content_version(all_content, version)
391
403
  if not latest_content:
392
404
  return None
393
- full_metadata.update({"info": python_content_to_info(latest_content[0])})
394
- full_metadata.update({"releases": python_content_to_releases(content_query, base_path, domain)})
395
- full_metadata.update({"urls": python_content_to_urls(latest_content, base_path, domain)})
405
+
406
+ full_metadata = {"last_serial": 0} # For now the serial field isn't supported by Pulp
407
+ full_metadata["info"] = python_content_to_info(latest_content[0])
408
+ full_metadata["releases"] = python_content_to_releases(all_content, base_path, domain)
409
+ full_metadata["urls"] = python_content_to_urls(latest_content, base_path, domain)
396
410
  return full_metadata
397
411
 
398
412
 
399
- def latest_content_version(content_query, version):
413
+ def latest_content_version(all_content, version):
400
414
  """
401
- Walks through the content QuerySet and finds the instances that is the latest version.
415
+ Walks through the content list and finds the instances that are the latest version.
402
416
  If 'version' is specified, the function instead tries to find content instances
403
417
  with that version and will return an empty list if nothing is found
404
418
  """
405
419
  latest_version = version
406
420
  latest_content = []
407
- for content in content_query:
421
+ for content in all_content:
408
422
  if version and parse(version) == parse(content.version):
409
423
  latest_content.append(content)
410
424
  elif not latest_version or parse(content.version) > parse(latest_version):
@@ -462,8 +476,8 @@ def python_content_to_info(content):
462
476
  "platform": content.platform or "",
463
477
  "requires_dist": json_to_dict(content.requires_dist) or None,
464
478
  "classifiers": json_to_dict(content.classifiers) or None,
465
- "yanked": False, # These are no longer used on PyPI, but are still present
466
- "yanked_reason": None,
479
+ "yanked": getattr(content, "yanked", False),
480
+ "yanked_reason": getattr(content, "yanked_reason", None),
467
481
  # New core metadata (Version 2.1, 2.2, 2.4)
468
482
  "provides_extras": json_to_dict(content.provides_extras) or None,
469
483
  "dynamic": json_to_dict(content.dynamic) or None,
@@ -472,13 +486,13 @@ def python_content_to_info(content):
472
486
  }
473
487
 
474
488
 
475
- def python_content_to_releases(content_query, base_path, domain=None):
489
+ def python_content_to_releases(all_content, base_path, domain=None):
476
490
  """
477
- Takes a QuerySet of PythonPackageContent and returns a dictionary of releases
491
+ Takes a list of PythonPackageContent and returns a dictionary of releases
478
492
  with each key being a version and value being a list of content for that version of the package
479
493
  """
480
494
  releases = defaultdict(lambda: [])
481
- for content in content_query:
495
+ for content in all_content:
482
496
  releases[content.version].append(
483
497
  python_content_to_download_info(content, base_path, domain)
484
498
  )
@@ -535,22 +549,22 @@ def python_content_to_download_info(content, base_path, domain=None):
535
549
  (getattr(content, "repo_added_time", None) or content.pulp_created).isoformat()
536
550
  ),
537
551
  "url": url,
538
- "yanked": False,
539
- "yanked_reason": None,
552
+ "yanked": getattr(content, "yanked", False),
553
+ "yanked_reason": getattr(content, "yanked_reason", None),
540
554
  }
541
555
 
542
556
 
543
- def write_simple_index(project_names, streamed=False):
557
+ def write_simple_index(project_names):
544
558
  """Writes the simple index."""
545
559
  simple = Template(simple_index_template)
546
560
  context = {
547
561
  "SIMPLE_API_VERSION": SIMPLE_API_VERSION,
548
562
  "projects": ((x, canonicalize_name(x)) for x in project_names),
549
563
  }
550
- return simple.stream(**context) if streamed else simple.render(**context)
564
+ return simple.render(**context)
551
565
 
552
566
 
553
- def write_simple_detail(project_name, project_packages, streamed=False):
567
+ def write_simple_detail(project_name, project_packages):
554
568
  """Writes the simple detail page of a package."""
555
569
  detail = Template(simple_detail_template, autoescape=True)
556
570
  context = {
@@ -558,7 +572,7 @@ def write_simple_detail(project_name, project_packages, streamed=False):
558
572
  "project_name": project_name,
559
573
  "project_packages": project_packages,
560
574
  }
561
- return detail.stream(**context) if streamed else detail.render(**context)
575
+ return detail.render(**context)
562
576
 
563
577
 
564
578
  def write_simple_index_json(project_names):
@@ -591,7 +605,12 @@ def write_simple_detail_json(project_name, project_packages):
591
605
  "core-metadata": (
592
606
  {"sha256": package["metadata_sha256"]} if package["metadata_sha256"] else False
593
607
  ),
594
- # yanked and yanked_reason are not implemented because they are mutable
608
+ # PEP 592
609
+ "yanked": (
610
+ package.get("yanked_reason")
611
+ if package.get("yanked") and package.get("yanked_reason")
612
+ else package.get("yanked", False)
613
+ ),
595
614
  # (v1.1, PEP 700)
596
615
  "size": package["size"],
597
616
  "upload-time": format_upload_time(package["upload_time"]),
@@ -5,6 +5,7 @@ from django.db import transaction
5
5
  from django_filters import CharFilter
6
6
  from django_filters.rest_framework import filters as drf_filters
7
7
  from drf_spectacular.utils import extend_schema, extend_schema_view
8
+ from packaging.specifiers import InvalidSpecifier, SpecifierSet
8
9
  from packaging.utils import canonicalize_name
9
10
  from rest_framework import status
10
11
  from rest_framework.decorators import action
@@ -466,6 +467,25 @@ class NormalizedNameInFilter(drf_filters.BaseInFilter, NormalizedNameFilter):
466
467
  """In-filter that normalizes each input value and queries name_normalized."""
467
468
 
468
469
 
470
+ class VersionSpecifierFilter(CharFilter):
471
+ """Filter that matches versions against a PEP 440 specifier string."""
472
+
473
+ def filter(self, qs, value):
474
+ if not value:
475
+ return qs
476
+ try:
477
+ spec = SpecifierSet(value, prereleases=True)
478
+ except InvalidSpecifier:
479
+ raise ValidationError(
480
+ {"version_specifier": f"Invalid PEP 440 version specifier: {value}"}
481
+ )
482
+ matching_pks = []
483
+ for pk, version in qs.values_list("pk", self.field_name):
484
+ if spec.contains(version):
485
+ matching_pks.append(pk)
486
+ return qs.filter(pk__in=matching_pks)
487
+
488
+
469
489
  class PythonPackageContentFilter(core_viewsets.ContentFilter):
470
490
  """
471
491
  FilterSet for PythonPackageContent.
@@ -474,6 +494,10 @@ class PythonPackageContentFilter(core_viewsets.ContentFilter):
474
494
  name = NormalizedNameFilter(field_name="name_normalized", lookup_expr="exact")
475
495
  name__in = NormalizedNameInFilter(field_name="name_normalized", lookup_expr="in")
476
496
  name__contains = CharFilter(field_name="name", lookup_expr="contains")
497
+ version_specifier = VersionSpecifierFilter(
498
+ field_name="version",
499
+ help_text="Filter by PEP 440 version specifier (e.g., >=2.4,<3.0 or ~=1.26)",
500
+ )
477
501
 
478
502
  class Meta:
479
503
  model = python_models.PythonPackageContent
@@ -615,6 +639,29 @@ class PackageProvenanceViewSet(core_viewsets.NoArtifactContentUploadViewSet):
615
639
  }
616
640
 
617
641
 
642
+ class PackageYankViewSet(core_viewsets.ReadOnlyContentViewSet):
643
+ """
644
+ Read-only viewset for PackageYank content units (PEP 592).
645
+ PackageYank markers indicate that a package version has been yanked in a repository.
646
+ Use the /yank/ and /unyank/ PyPI endpoints to create or remove these markers.
647
+ """
648
+
649
+ endpoint_name = "yanks"
650
+ queryset = python_models.PackageYank.objects.all()
651
+ serializer_class = python_serializers.PackageYankSerializer
652
+
653
+ DEFAULT_ACCESS_POLICY = {
654
+ "statements": [
655
+ {
656
+ "action": ["list", "retrieve"],
657
+ "principal": "authenticated",
658
+ "effect": "allow",
659
+ },
660
+ ],
661
+ "queryset_scoping": {"function": "scope_queryset"},
662
+ }
663
+
664
+
618
665
  class PythonRemoteViewSet(core_viewsets.RemoteViewSet, core_viewsets.RolesMixin):
619
666
  """
620
667
  <!-- User-facing documentation, rendered as html-->
@@ -1,6 +1,7 @@
1
1
  from urllib.parse import urljoin
2
2
 
3
3
  import pytest
4
+ import requests
4
5
  from pypi_simple import PyPISimple
5
6
 
6
7
  from pulpcore.tests.functional.utils import PulpTaskError
@@ -174,14 +175,32 @@ def test_upload_requires_python(python_content_factory):
174
175
  @pytest.mark.parallel
175
176
  def test_upload_metadata_24_spec(python_content_factory):
176
177
  """Test that packages using metadata spec 2.4 can be uploaded to pulp."""
177
- filename = "setuptools-80.9.0.tar.gz"
178
- url = get_package_url("setuptools", filename)
178
+ filename = "attrs-26.1.0.tar.gz"
179
+ url = get_package_url("attrs", filename)
179
180
  content = python_content_factory(filename, url=url)
180
181
  assert content.metadata_version == "2.4"
181
182
  assert content.license_expression == "MIT"
182
183
  assert content.license_file == '["LICENSE"]'
183
184
 
184
185
 
186
+ @pytest.mark.parallel
187
+ def test_legacy_upload_metadata_25(python_empty_repo_distro, python_package_dist_directory):
188
+ """Test that metadata_version 2.5 (PEP 794) is accepted via the legacy upload endpoint."""
189
+ flit_url = get_package_url("flit", "flit-4.0.2-py3-none-any.whl")
190
+ _, flit_file = python_package_dist_directory(flit_url)
191
+ _, distro = python_empty_repo_distro()
192
+ url = urljoin(distro.base_url, "legacy/")
193
+ sha256 = "7b0b0038cd91a7f04e2e287d958dc5f25e68ffeed0b93e613bd50aec99a0d9ca"
194
+ with open(flit_file, "rb") as f:
195
+ response = requests.post(
196
+ url,
197
+ data={"sha256_digest": sha256, "metadata_version": "2.5"},
198
+ files={"content": f},
199
+ auth=("admin", "password"),
200
+ )
201
+ assert response.status_code == 202
202
+
203
+
185
204
  @pytest.mark.parallel
186
205
  def test_package_creation_with_metadata(
187
206
  pulp_content_url,
@@ -32,7 +32,7 @@ def test_pull_through_install(
32
32
  # Perform pull-through install
33
33
  host = urlsplit(distro.base_url).hostname
34
34
  url = f"{distro.base_url}simple/"
35
- cmd = ("pip", "install", "--trusted-host", host, "-i", url, PACKAGE)
35
+ cmd = ("pip", "install", "--no-deps", "--trusted-host", host, "-i", url, PACKAGE)
36
36
  subprocess.run(cmd, check=True)
37
37
 
38
38
  stdout = subprocess.run(("pip", "list"), capture_output=True).stdout.decode("utf-8")
@@ -0,0 +1,28 @@
1
+ import pytest
2
+
3
+ from pulp_python.tests.functional.constants import PYTHON_SM_PROJECT_SPECIFIER
4
+
5
+
6
+ @pytest.mark.parallel
7
+ def test_version_specifier_filter(python_bindings, python_repo_with_sync, python_remote_factory):
8
+ """Test filtering content by PEP 440 version specifier."""
9
+ remote = python_remote_factory(includes=PYTHON_SM_PROJECT_SPECIFIER)
10
+ repo = python_repo_with_sync(remote=remote)
11
+
12
+ result = python_bindings.ContentPackagesApi.list(
13
+ repository_version=repo.latest_version_href,
14
+ name="Django",
15
+ version_specifier=">=1.10.2,<1.10.4",
16
+ )
17
+ versions = {c.version for c in result.results}
18
+ assert "1.10.2" in versions
19
+ assert "1.10.3" in versions
20
+ assert "1.10.4" not in versions
21
+
22
+
23
+ @pytest.mark.parallel
24
+ def test_version_specifier_filter_invalid(python_bindings):
25
+ """Test that an invalid specifier returns a 400 error."""
26
+ with pytest.raises(python_bindings.ApiException) as exc:
27
+ python_bindings.ContentPackagesApi.list(version_specifier=">=invalid!version")
28
+ assert exc.value.status == 400
@@ -0,0 +1,306 @@
1
+ from urllib.parse import urljoin
2
+
3
+ import pytest
4
+ import requests
5
+
6
+ from pulp_python.tests.functional.constants import (
7
+ PYPI_SIMPLE_V1_JSON,
8
+ PYTHON_FIXTURES_URL,
9
+ TWINE_EGG_FILENAME,
10
+ TWINE_EGG_URL,
11
+ TWINE_WHEEL_FILENAME,
12
+ TWINE_WHEEL_URL,
13
+ )
14
+
15
+ TWINE_NAME = "twine"
16
+ TWINE_VERSION = "5.1.0"
17
+
18
+ TWINE_500_WHEEL_FILENAME = "twine-5.0.0-py3-none-any.whl"
19
+ TWINE_500_WHEEL_URL = urljoin(urljoin(PYTHON_FIXTURES_URL, "packages/"), TWINE_500_WHEEL_FILENAME)
20
+
21
+
22
+ def test_yank_and_unyank(
23
+ delete_orphans_pre,
24
+ monitor_task,
25
+ python_bindings,
26
+ python_content_factory,
27
+ python_content_summary,
28
+ python_distribution_factory,
29
+ python_repo_factory,
30
+ ):
31
+ """
32
+ Yank and unyank lifecycle including idempotency and reason update checks.
33
+
34
+ Every yank/unyank that changes state creates a new repo version.
35
+ Repeating the same operation with the same reason is a no-op (no new version).
36
+ Re-yanking with a different reason updates the reason and creates a new version.
37
+ """
38
+ content_sdist = python_content_factory(TWINE_EGG_FILENAME, url=TWINE_EGG_URL)
39
+ content_whl = python_content_factory(TWINE_WHEEL_FILENAME, url=TWINE_WHEEL_URL)
40
+ repo = python_repo_factory()
41
+ body = {"add_content_units": [content_sdist.pulp_href, content_whl.pulp_href]}
42
+ monitor_task(python_bindings.RepositoriesPythonApi.modify(repo.pulp_href, body).task)
43
+ repo = python_bindings.RepositoriesPythonApi.read(repo.pulp_href)
44
+ distro = python_distribution_factory(repository=repo)
45
+ version_1 = repo.latest_version_href
46
+
47
+ # 1. Yank
48
+ response = python_bindings.PypiYankApi.yank(
49
+ path=distro.base_path,
50
+ yank={"name": TWINE_NAME, "version": TWINE_VERSION, "yanked_reason": "broken"},
51
+ )
52
+ monitor_task(response.task)
53
+
54
+ repo = python_bindings.RepositoriesPythonApi.read(repo.pulp_href)
55
+ version_2 = repo.latest_version_href
56
+ assert version_2 != version_1
57
+ summary = python_content_summary(repository=repo, version=2)
58
+ assert summary.added["python.python_yank"]["count"] == 1
59
+
60
+ # Check Simple API JSON - yanked files should have yanked reason
61
+ simple_url = urljoin(distro.base_url, f"simple/{TWINE_NAME}")
62
+ response = requests.get(simple_url, headers={"Accept": PYPI_SIMPLE_V1_JSON})
63
+ assert response.json()["files"][0]["yanked"] == "broken"
64
+ assert response.json()["files"][1]["yanked"] == "broken"
65
+
66
+ # Check Simple API HTML - data-yanked attribute should be present
67
+ response = requests.get(simple_url)
68
+ assert response.text.count('data-yanked="broken"') == 2
69
+
70
+ # Check PyPI Metadata API - yanked info in metadata
71
+ pypi_url = urljoin(distro.base_url, f"pypi/{TWINE_NAME}/json")
72
+ response = requests.get(pypi_url)
73
+ data = response.json()
74
+ assert data["info"]["yanked"] is True
75
+ assert data["info"]["yanked_reason"] == "broken"
76
+ for f in data["releases"][TWINE_VERSION]:
77
+ assert f["yanked"] is True
78
+ assert f["yanked_reason"] == "broken"
79
+ for f in data["urls"]:
80
+ assert f["yanked"] is True
81
+ assert f["yanked_reason"] == "broken"
82
+
83
+ # Yank again with same reason - idempotent, no new repo version
84
+ response = python_bindings.PypiYankApi.yank(
85
+ path=distro.base_path,
86
+ yank={"name": TWINE_NAME, "version": TWINE_VERSION, "yanked_reason": "broken"},
87
+ )
88
+ monitor_task(response.task)
89
+
90
+ repo = python_bindings.RepositoriesPythonApi.read(repo.pulp_href)
91
+ assert repo.latest_version_href == version_2
92
+
93
+ # Yank again with different reason - updates the reason
94
+ response = python_bindings.PypiYankApi.yank(
95
+ path=distro.base_path,
96
+ yank={"name": TWINE_NAME, "version": TWINE_VERSION, "yanked_reason": "security fix"},
97
+ )
98
+ monitor_task(response.task)
99
+
100
+ repo = python_bindings.RepositoriesPythonApi.read(repo.pulp_href)
101
+ version_3 = repo.latest_version_href
102
+ assert version_3 != version_2
103
+
104
+ # Simple API should show updated reason
105
+ response = requests.get(simple_url, headers={"Accept": PYPI_SIMPLE_V1_JSON})
106
+ assert response.json()["files"][0]["yanked"] == "security fix"
107
+ assert response.json()["files"][1]["yanked"] == "security fix"
108
+
109
+ response = requests.get(simple_url)
110
+ assert response.text.count('data-yanked="security fix"') == 2
111
+
112
+ # 2. Unyank
113
+ response = python_bindings.PypiUnyankApi.unyank(
114
+ path=distro.base_path, yank={"name": TWINE_NAME, "version": TWINE_VERSION}
115
+ )
116
+ monitor_task(response.task)
117
+
118
+ repo = python_bindings.RepositoriesPythonApi.read(repo.pulp_href)
119
+ version_4 = repo.latest_version_href
120
+ assert version_4 != version_3
121
+ summary = python_content_summary(repository=repo, version=4)
122
+ assert summary.removed["python.python_yank"]["count"] == 1
123
+
124
+ # Check Simple API JSON - yanked should be False after unyank
125
+ response = requests.get(simple_url, headers={"Accept": PYPI_SIMPLE_V1_JSON})
126
+ assert response.json()["files"][0]["yanked"] is False
127
+ assert response.json()["files"][1]["yanked"] is False
128
+
129
+ # Check Simple API HTML - data-yanked attribute should not be present
130
+ response = requests.get(simple_url)
131
+ assert "data-yanked" not in response.text
132
+
133
+ # Check PyPI Metadata API - yanked should be False after unyank
134
+ response = requests.get(pypi_url)
135
+ data = response.json()
136
+ assert data["info"]["yanked"] is False
137
+ assert data["info"]["yanked_reason"] is None
138
+ for f in data["releases"][TWINE_VERSION]:
139
+ assert f["yanked"] is False
140
+ assert f["yanked_reason"] is None
141
+ for f in data["urls"]:
142
+ assert f["yanked"] is False
143
+ assert f["yanked_reason"] is None
144
+
145
+ # Unyank again - idempotent, no new repo version
146
+ response = python_bindings.PypiUnyankApi.unyank(
147
+ path=distro.base_path, yank={"name": TWINE_NAME, "version": TWINE_VERSION}
148
+ )
149
+ monitor_task(response.task)
150
+
151
+ repo = python_bindings.RepositoriesPythonApi.read(repo.pulp_href)
152
+ assert repo.latest_version_href == version_4
153
+
154
+
155
+ def test_yank_sync(
156
+ delete_orphans_pre,
157
+ python_remote_factory,
158
+ python_repo_with_sync,
159
+ python_content_summary,
160
+ python_distribution_factory,
161
+ ):
162
+ """
163
+ Syncing a yanked package from upstream creates a PackageYank marker.
164
+ """
165
+ remote = python_remote_factory(includes=[f"{TWINE_NAME}=={TWINE_VERSION}"])
166
+ repo = python_repo_with_sync(remote)
167
+ distro = python_distribution_factory(repository=repo)
168
+
169
+ # Sync should have created a yank marker for twine 5.1.0
170
+ summary = python_content_summary(repository=repo, version=1)
171
+ assert summary.added["python.python"]["count"] == 2
172
+ assert summary.added["python.python_yank"]["count"] == 1
173
+
174
+ # Check Simple API JSON - yanked files should have yanked reason
175
+ simple_url = urljoin(distro.base_url, f"simple/{TWINE_NAME}")
176
+ response = requests.get(simple_url, headers={"Accept": PYPI_SIMPLE_V1_JSON})
177
+ assert response.json()["files"][0]["yanked"] == "https://github.com/pypa/twine/issues/1125"
178
+ assert response.json()["files"][1]["yanked"] == "https://github.com/pypa/twine/issues/1125"
179
+
180
+ # Check Simple API HTML - data-yanked attribute should be present
181
+ response = requests.get(simple_url)
182
+ assert response.text.count("data-yanked=") == 2
183
+
184
+
185
+ @pytest.mark.parallel
186
+ def test_partial_yank(
187
+ monitor_task,
188
+ python_bindings,
189
+ python_content_factory,
190
+ python_content_summary,
191
+ python_distribution_factory,
192
+ python_repo_factory,
193
+ ):
194
+ """
195
+ Yanking one version does not affect other versions of the same package.
196
+ """
197
+ content_510 = python_content_factory(TWINE_WHEEL_FILENAME, url=TWINE_WHEEL_URL)
198
+ content_500 = python_content_factory(TWINE_500_WHEEL_FILENAME, url=TWINE_500_WHEEL_URL)
199
+
200
+ repo = python_repo_factory()
201
+ body = {"add_content_units": [content_510.pulp_href, content_500.pulp_href]}
202
+ monitor_task(python_bindings.RepositoriesPythonApi.modify(repo.pulp_href, body).task)
203
+ distro = python_distribution_factory(repository=repo)
204
+
205
+ response = python_bindings.PypiYankApi.yank(
206
+ path=distro.base_path,
207
+ yank={"name": TWINE_NAME, "version": TWINE_VERSION, "yanked_reason": "broken 5.1.0"},
208
+ )
209
+ monitor_task(response.task)
210
+
211
+ summary = python_content_summary(repository=repo, version=2)
212
+ assert summary.added["python.python_yank"]["count"] == 1
213
+ assert summary.present["python.python_yank"]["count"] == 1
214
+
215
+ # Check Simple API JSON - yanked files should have yanked reason
216
+ simple_url = urljoin(distro.base_url, f"simple/{TWINE_NAME}")
217
+ response = requests.get(simple_url, headers={"Accept": PYPI_SIMPLE_V1_JSON})
218
+ data = response.json()
219
+ file_510 = next(f for f in data["files"] if f["filename"] == TWINE_WHEEL_FILENAME)
220
+ file_500 = next(f for f in data["files"] if f["filename"] == TWINE_500_WHEEL_FILENAME)
221
+ assert file_510["yanked"] == "broken 5.1.0"
222
+ assert file_500["yanked"] is False
223
+
224
+
225
+ @pytest.mark.parallel
226
+ def test_yank_isolation_across_repositories(
227
+ monitor_task,
228
+ python_bindings,
229
+ python_content_factory,
230
+ python_content_summary,
231
+ python_distribution_factory,
232
+ python_repo_factory,
233
+ ):
234
+ """
235
+ Yanking in one repo does not affect another repo with the same content.
236
+ """
237
+ content = python_content_factory(TWINE_WHEEL_FILENAME, url=TWINE_WHEEL_URL)
238
+
239
+ repo_a = python_repo_factory()
240
+ repo_b = python_repo_factory()
241
+ body = {"add_content_units": [content.pulp_href]}
242
+ monitor_task(python_bindings.RepositoriesPythonApi.modify(repo_a.pulp_href, body).task)
243
+ monitor_task(python_bindings.RepositoriesPythonApi.modify(repo_b.pulp_href, body).task)
244
+
245
+ distro_a = python_distribution_factory(repository=repo_a)
246
+ distro_b = python_distribution_factory(repository=repo_b)
247
+
248
+ # Yank in repo A only
249
+ response = python_bindings.PypiYankApi.yank(
250
+ path=distro_a.base_path, yank={"name": TWINE_NAME, "version": TWINE_VERSION}
251
+ )
252
+ monitor_task(response.task)
253
+
254
+ # Repo A should have a yank marker, repo B should not
255
+ summary_a = python_content_summary(repository=repo_a, version=2)
256
+ assert summary_a.present["python.python_yank"]["count"] == 1
257
+ summary_b = python_content_summary(repository=repo_b, version=1)
258
+ assert "python.python_yank" not in summary_b.present
259
+
260
+ # Yank in repo B too, then unyank only in repo A
261
+ response = python_bindings.PypiYankApi.yank(
262
+ path=distro_b.base_path, yank={"name": TWINE_NAME, "version": TWINE_VERSION}
263
+ )
264
+ monitor_task(response.task)
265
+
266
+ response = python_bindings.PypiUnyankApi.unyank(
267
+ path=distro_a.base_path, yank={"name": TWINE_NAME, "version": TWINE_VERSION}
268
+ )
269
+ monitor_task(response.task)
270
+
271
+ # Repo A should be not-yanked, repo B should remain yanked
272
+ summary_a = python_content_summary(repository=repo_a, version=3)
273
+ assert "python.python_yank" not in summary_a.present
274
+ summary_b = python_content_summary(repository=repo_b, version=2)
275
+ assert summary_b.present["python.python_yank"]["count"] == 1
276
+
277
+
278
+ @pytest.mark.parallel
279
+ def test_yank_nonexistent_package(
280
+ python_bindings, python_repo_factory, python_distribution_factory
281
+ ):
282
+ """
283
+ Yanking a package not in the repo should return 404.
284
+ """
285
+ repo = python_repo_factory()
286
+ distro = python_distribution_factory(repository=repo)
287
+
288
+ with pytest.raises(python_bindings.ApiException) as exc:
289
+ python_bindings.PypiYankApi.yank(
290
+ path=distro.base_path, yank={"name": "nonexistent-package", "version": "99.99.99"}
291
+ )
292
+ assert exc.value.status == 404
293
+
294
+
295
+ @pytest.mark.parallel
296
+ def test_yank_no_repository(python_bindings, python_distribution_factory):
297
+ """
298
+ Yanking on a distribution with no repository should return 400.
299
+ """
300
+ distro = python_distribution_factory()
301
+
302
+ with pytest.raises(python_bindings.ApiException) as exc:
303
+ python_bindings.PypiYankApi.yank(
304
+ path=distro.base_path, yank={"name": TWINE_NAME, "version": TWINE_VERSION}
305
+ )
306
+ assert exc.value.status == 400
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pulp-python
3
- Version: 3.32.1
3
+ Version: 3.33.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
@@ -1,18 +1,18 @@
1
1
  pulp_python/__init__.py,sha256=GIuTLoBTc-07dSLJUh8xrZPRz8x-jJ61pfR0J1IjnzI,65
2
2
  pulp_python/pytest_plugin.py,sha256=8Ko-O87JGPC79h8-Ecw2gJLxa6q1Ng8wD6WxbinOnVs,8573
3
- pulp_python/app/__init__.py,sha256=iwdj2oF4-hKr7-1-FR2-qyjZQjEL5ZTRgv-cKfDFsjs,2490
3
+ pulp_python/app/__init__.py,sha256=bjr3cUnummu5g439G8dE0t3bIvggT7lgQOXuhuMM_IM,2490
4
4
  pulp_python/app/cache.py,sha256=Zl2nb4uKyPYp5a3FBrdRZ_-cXfofNMQmIRaQ6dUZZ1g,1037
5
5
  pulp_python/app/exceptions.py,sha256=mPNcWyuzF0XeOPybm-G6oDKtRqvfj4jyp41iaYqcqkA,1314
6
6
  pulp_python/app/global_access_conditions.py,sha256=MZJtyoVsr-4hRaty6mKDqh3caOHd5UKJjEWLV2crOLs,1080
7
7
  pulp_python/app/modelresource.py,sha256=4SFAdqk6lozi_cZz4uqDIqhqPAZF-7l5jJwPn-xGZFs,1249
8
- pulp_python/app/models.py,sha256=oEZ05nKkYzqkMOq2dUJ4QzZlA0-rvwWyxrEHwax91YI,17691
8
+ pulp_python/app/models.py,sha256=jTt3Ulk8KeX2QEzQdquYNszwjjeRWYdwJ-OoMIFi3rU,18557
9
9
  pulp_python/app/provenance.py,sha256=iyhkuNahHiTDK0Djrd4-PlgErA5SJVI0uQOIPj46tEI,2352
10
10
  pulp_python/app/replica.py,sha256=qiWRP7tM_v4yP_XLIQfumfGolru-Jt6ZA0KVb-9g2cA,1882
11
- pulp_python/app/serializers.py,sha256=y4WJTMPVKcGlbj98cKTeqWQLCB2kfjkX2hON90_iGDo,33882
11
+ pulp_python/app/serializers.py,sha256=F_MrhMaxkA1dNnQhRAmefrsbOkHoWu8i3DqHa2ndmIw,34519
12
12
  pulp_python/app/settings.py,sha256=Cyc_p6U0HQjKpyrRL6JFrK3R7RMQJ9MAgNMJCfzPEiA,255
13
- pulp_python/app/urls.py,sha256=M2xjQ0j47BwQVpi75QCa5eUnQDcroKv3Cee7UrQ3QcA,1387
14
- pulp_python/app/utils.py,sha256=oIFX4RaDcxPvlFdkTilyW_66MOdlKMz7Ke42lLlyNNs,27414
15
- pulp_python/app/viewsets.py,sha256=yN1gHaT9vosqTbEP6emyMzoitliTzeHZTEqBVyn_2vA,34219
13
+ pulp_python/app/urls.py,sha256=p784I54JxOFXOg9dcXXimbnXQWvYfSLwjw3Qopib9Pw,1573
14
+ pulp_python/app/utils.py,sha256=13jUDe8BwfuN1m3-pAJ8Kip0vixQTHAiZZIn7xl0YUo,27786
15
+ pulp_python/app/viewsets.py,sha256=WUn9iOneAF61tHT8r4iTDGjIBY6Vis71fczVX78czW4,35858
16
16
  pulp_python/app/management/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
17
17
  pulp_python/app/management/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
18
18
  pulp_python/app/management/commands/repair-python-metadata.py,sha256=knvTPcPwyfgCH0-KNNCK1Mj0mQKzXUdYEwo-v6F94XU,4548
@@ -39,16 +39,18 @@ pulp_python/app/migrations/0019_create_missing_metadata_artifacts.py,sha256=glHe
39
39
  pulp_python/app/migrations/0020_pythonpackagecontent_name_normalized.py,sha256=n-gtLKmhDiydgkM5Fv8OisoFZYPPOhOa8-9nExCCq14,1317
40
40
  pulp_python/app/migrations/0021_pythonrepository_upload_duplicate_filenames.py,sha256=Yx-Cgk8DR9J9e9ht00xy0s6ogh2Rh5JS7AbdzSngKLY,384
41
41
  pulp_python/app/migrations/0022_pythonblocklistentry.py,sha256=EbtjZuN65myTAHVWrJYbt4mWgWL7EElEYs1XulHB6Bo,1732
42
+ pulp_python/app/migrations/0023_packageyank.py,sha256=357fqSNK-D1OvI9RynnyMMrXJtdcjoEg5j4GteqiESU,1590
42
43
  pulp_python/app/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
43
44
  pulp_python/app/pypi/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
44
- pulp_python/app/pypi/serializers.py,sha256=rE0Cci2IMg4NbNWlVdo-8zPaJCAUq2Re57q_T3jMt0Y,5030
45
- pulp_python/app/pypi/views.py,sha256=guGJeH9D4HZ5cG4YaByjwIr-1CrC2ziD7YkM_gPgH_U,22616
46
- pulp_python/app/tasks/__init__.py,sha256=lTFpVvpDKbqv9RC0b2RYU8Bo6svDjrA-djt16pADFr8,284
45
+ pulp_python/app/pypi/serializers.py,sha256=VDV4REMltZr98bxulgOGoGLnBxUtDod0t1fg_lDFpcs,5601
46
+ pulp_python/app/pypi/views.py,sha256=m5_b_MLoSNhwZ64UpQKOzmlaAA7nCfKU_4r1RFY3jnA,26252
47
+ pulp_python/app/tasks/__init__.py,sha256=JplOj2JtE_i4sQYqV7JnDY7zkraU_KlsCaNnYYaZ1x4,346
47
48
  pulp_python/app/tasks/publish.py,sha256=bjsJzqJbLu7TF5rLb-UsZMmlNnc_LKw-sdHX9Gcatbw,4334
48
49
  pulp_python/app/tasks/repair.py,sha256=5InzdbjW8y3AC4Vj2PsNLm3wGGTr8D3LcfPw_WA2Fks,12257
49
- pulp_python/app/tasks/sync.py,sha256=kD1E_Y8lzsUP2T6gZ58lESkV5dkjj8uOPoqsDYh6jcs,12895
50
+ pulp_python/app/tasks/sync.py,sha256=pgiw6R142ynN-lVocyOYDmokklMJFWeH9xAgwXan9a8,13363
50
51
  pulp_python/app/tasks/upload.py,sha256=HBOknlsAb0mlE-2dJsalH_8JUZwrKk4AsVibmVJf2aQ,6102
51
52
  pulp_python/app/tasks/vulnerability_report.py,sha256=0cyxNb4048HFUdUlGBA6wYsg-hEMjSfE8mtw05Ct9BQ,1126
53
+ pulp_python/app/tasks/yank.py,sha256=VU16uLkA2-CUJe7q_u87AdkDH6gqWCwsHyc36wH35rc,2200
52
54
  pulp_python/app/webserver_snippets/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
53
55
  pulp_python/app/webserver_snippets/apache.conf,sha256=3frHSl2YV_8pJPscaFxMVo7HmxGJdb8XVmfdLtCxzoA,97
54
56
  pulp_python/app/webserver_snippets/nginx.conf,sha256=gMqZGFefsTJVVx9YRxpHVS7NMEll9CzOseYdtLr3Avc,344
@@ -61,13 +63,13 @@ pulp_python/tests/functional/api/test_attestations.py,sha256=7tXJY4b48ww8Sdc37Vo
61
63
  pulp_python/tests/functional/api/test_auto_publish.py,sha256=uCIt4LsO61oMk3bDs3LMQDJI8zkKqvY0b1uX16bTxzM,1747
62
64
  pulp_python/tests/functional/api/test_blocklist.py,sha256=IWzlazoQbrfyHNq9bNAa9FcbShWqzC2Zhq8qrN7HBwg,5994
63
65
  pulp_python/tests/functional/api/test_consume_content.py,sha256=-7b6KOrIT3NLSUafeu1tzXEXRPddDbS5VeMHxMIy2n8,999
64
- pulp_python/tests/functional/api/test_crud_content_unit.py,sha256=S14yzx0HrRtSbs1uhvfQRPge6gB--ZOEqSescDtOtD8,13750
66
+ pulp_python/tests/functional/api/test_crud_content_unit.py,sha256=g2G_Rxd9e8-m5n_Tv3J527nDQ4UgsDNKJzIdn2hshfI,14548
65
67
  pulp_python/tests/functional/api/test_crud_publications.py,sha256=3cuSDC9C9M1opt0lh5ObGj8t7gq5jUW-RWj6MrA4Teo,5647
66
68
  pulp_python/tests/functional/api/test_crud_remotes.py,sha256=clFFhgG5udyKzWOLwxpmwjhiVz8r5JEPAQHkztcAf9w,5812
67
69
  pulp_python/tests/functional/api/test_domains.py,sha256=uEA7dIBXaXah3WQ6g6xSIjdXOR_hrsQnSFrfbdtz8bg,10421
68
70
  pulp_python/tests/functional/api/test_download_content.py,sha256=5IuaHXyLakPkjm5sLTxtTl7Dq0yl7N36HpObnIa0Sks,4950
69
71
  pulp_python/tests/functional/api/test_export_import.py,sha256=rHns9wdaeP-vtfW_qoGHU9EVuJ4YuWE0-rjl_8otAgU,4530
70
- pulp_python/tests/functional/api/test_full_mirror.py,sha256=eU5HzgBBFOnX1q4m8ELx-t-I3U4TWXL-LNrlc2BcWVc,12045
72
+ pulp_python/tests/functional/api/test_full_mirror.py,sha256=v0nSlGKGSycFqZM4opCTHVtbmCfo_Y5y3k_6Rm7eBF8,12058
71
73
  pulp_python/tests/functional/api/test_pypi_apis.py,sha256=iZnoSN2wCczXh14KJDH8pSDTygjs0kgZsYIcIYS5TP8,13647
72
74
  pulp_python/tests/functional/api/test_pypi_simple_api.py,sha256=tOUl8WXgJkMoxGyLbA-WorCxIRKfGWM-27_dK_Jc0Q4,7171
73
75
  pulp_python/tests/functional/api/test_rbac.py,sha256=-xNWqvKKU-v1uC6tCRGBK0aWaff25K9C-AfzQkdHhhI,10513
@@ -75,12 +77,14 @@ pulp_python/tests/functional/api/test_repair.py,sha256=4FR7jx_LA2K-pnRJXKkhq-1uU
75
77
  pulp_python/tests/functional/api/test_simple_cache.py,sha256=kJ9mwJKf9A-RthLzPZi0rjRQlygEkWsV6jAaAR8aYgc,4944
76
78
  pulp_python/tests/functional/api/test_sync.py,sha256=TTHR1CpZeRoD97RKxj2pZPsP0OS9ibYKGPOh1WfKISg,13801
77
79
  pulp_python/tests/functional/api/test_upload.py,sha256=f21Li9agJ5fdBqErVg1j6v5FDVdazB5mNClmGOjLUq8,5981
80
+ pulp_python/tests/functional/api/test_version_specifier_filter.py,sha256=nmnBbhwxCEmcHOyFbS1uvemar67sONBGM9SsNn79LCc,1063
78
81
  pulp_python/tests/functional/api/test_vulnerability_report.py,sha256=Rv492Wrvu1FY7O_moo9DTB6OkI-OZURj_fuTKbENLh8,1730
82
+ pulp_python/tests/functional/api/test_yank.py,sha256=qyZbAJ6bJO-4LPkVfVxKuWFjypJ8BmBrCnLo0luAhVk,11913
79
83
  pulp_python/tests/unit/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
80
84
  pulp_python/tests/unit/test_models.py,sha256=TBI0yKsrdbnJSPeBFfxSqhXK7zaNvR6qg5JehGH3Pds,229
81
- pulp_python-3.32.1.dist-info/licenses/LICENSE,sha256=2ylvL381vKOhdO-w6zkrOxe9lLNBhRQpo9_0EbHC_HM,18046
82
- pulp_python-3.32.1.dist-info/METADATA,sha256=L3MSixYuk-rR8Xw7sm3-iXNiVPPx__1IQ_-7mWTBN2o,1744
83
- pulp_python-3.32.1.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
84
- pulp_python-3.32.1.dist-info/entry_points.txt,sha256=HvqLEXjw_dS5jqAwnE5JiRZFE6f-y5SRtitKLPml2To,115
85
- pulp_python-3.32.1.dist-info/top_level.txt,sha256=X0hXgXc_bpbiKqVrkt8jD5_QEiQviKbHDwveQcOcJjo,12
86
- pulp_python-3.32.1.dist-info/RECORD,,
85
+ pulp_python-3.33.0.dist-info/licenses/LICENSE,sha256=2ylvL381vKOhdO-w6zkrOxe9lLNBhRQpo9_0EbHC_HM,18046
86
+ pulp_python-3.33.0.dist-info/METADATA,sha256=7sbTkC494WCjyKIVjtdp9drQ29M8gzlsgiiyg5fZfYg,1744
87
+ pulp_python-3.33.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
88
+ pulp_python-3.33.0.dist-info/entry_points.txt,sha256=HvqLEXjw_dS5jqAwnE5JiRZFE6f-y5SRtitKLPml2To,115
89
+ pulp_python-3.33.0.dist-info/top_level.txt,sha256=X0hXgXc_bpbiKqVrkt8jD5_QEiQviKbHDwveQcOcJjo,12
90
+ pulp_python-3.33.0.dist-info/RECORD,,