Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGES/1358.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added repository and repository-version endpoints that list Python packages and version update times with limit/offset pagination.
4 changes: 2 additions & 2 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ The REST API documentation for `pulp_python` is available [here](site:pulp_pytho

- [Create local mirrors of PyPI](site:pulp_python/docs/user/guides/sync/) that you have full control over
- [Upload your own Python packages](site:pulp_python/docs/user/guides/upload/)
- [Browse the package catalog](site:pulp_python/docs/user/guides/catalog/) over the REST API
- [Perform pip install](site:pulp_python/docs/user/guides/host/) from your Pulp Python repositories
- Download packages on-demand to reduce disk usage
- Every operation creates a restorable snapshot with Versioned Repositories
Expand All @@ -34,5 +35,4 @@ Users may also find pulpcore’s conceptual docs useful.
This documentation falls into two main categories:

1. `How-to Guides` shows the **major features** of the Python plugin, with links to reference docs.
2. The [REST API Docs](site:pulp_python/restapi/) are automatically generated and provide more detailed information for each
minor feature, including all fields and options.
2. The [REST API Docs](site:pulp_python/restapi/) are automatically generated and provide more detailed information for each minor feature, including all fields and options.
1 change: 1 addition & 0 deletions docs/user/guides/_SUMMARY.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
* [Set up your own PyPI](pypi.md)
* [Sync from Remote Repositories](sync.md)
* [Upload and Manage Content](upload.md)
* [Browse the package catalog](catalog.md)
* [Host Python Content](host.md)
* [Vulnerability Report](vulnerability_report.md)
* [Attestation Hosting](attestation.md)
Expand Down
60 changes: 60 additions & 0 deletions docs/user/guides/catalog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Browse the package catalog

The package catalog returns one row per normalized package name. Use the
repository URL to read its latest complete repository version, or a repository
version URL to read a specific complete version. `${REPO_PK}` is the repository UUID.

## List packages

```bash
http GET "${BASE_ADDR}/pulp/api/v3/repositories/python/python/${REPO_PK}/packages/?limit=10"
```

To read a specific repository version, set `${VERSION_NUMBER}` to its number:

```bash
http GET "${BASE_ADDR}/pulp/api/v3/repositories/python/python/${REPO_PK}/versions/${VERSION_NUMBER}/packages/?limit=10"
```

`count` is the number of distinct packages, not files. `summary`, `description`,
`author`, `author_email`, and `license` come from the highest package version in
PEP 440 order. Metadata comes from an sdist of that version when available.
Otherwise, it comes from that version's newest file.

`versions` has one entry per stored version string, in descending PEP 440 order.
A wheel and an sdist of the same version share one entry. Different version
strings, such as `1.0.0` and `1.0.0+test.1`, produce separate entries.

Each version's `last_updated` is the latest `pulp_created` timestamp among its
files in the selected repository version. This records when Pulp created the
content, not the upstream release date.

```json
{
"count": 1,
"next": null,
"previous": null,
"results": [
{
"name": "shelf-reader",
"name_normalized": "shelf-reader",
"summary": "A small example package",
"description": "",
"author": "",
"author_email": "",
"license": "",
"versions": [
{"version": "0.1", "last_updated": "2026-11-11T12:00:00Z"}
]
}
]
}
```

Results are ordered by `name_normalized`. Use `limit` and `offset` to page
through the catalog. Use a repository version URL to keep every page on the
same repository version. Missing or incomplete repository versions return HTTP 404.

Filter names with `name_normalized`, `name_normalized__icontains`, or
`name_normalized__istartswith`. Use `ordering=-name_normalized` to list names
in descending order.
102 changes: 102 additions & 0 deletions pulp_python/app/catalog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
"""Helpers for the repository package catalog."""

from django import VERSION as DJANGO_VERSION
from django.contrib.postgres.aggregates import JSONBAgg
from django.db.models import Case, IntegerField, Value, When
from packaging.version import InvalidVersion, Version

from pulp_python.app.models import PythonPackageContent


def _version_sort_key(version):
"""Sort invalid version strings after valid PEP 440 versions."""
try:
return (True, Version(version))
except InvalidVersion:
return (False, version)


class PackageVersions(JSONBAgg):
"""Aggregate values by version, keeping the last value for duplicate keys."""

function = "JSONB_OBJECT_AGG"
arity = 2


def package_catalog_queryset(content, ordering=("name_normalized",)):
"""Collect version timestamps and metadata IDs for each normalized package name.

Wheels and sdists with the same version share an aggregate entry.
Each timestamp is the newest file's `pulp_created`. Use sdist metadata when available.
"""
# Aggregate ordering selects the newest timestamp and prefers sdist metadata.
ordering_argument = "order_by" if DJANGO_VERSION >= (5, 2) else "ordering"
file_ordering = {
ordering_argument: (
"version",
Case(
When(packagetype="sdist", then=Value(1)),
default=Value(0),
output_field=IntegerField(),
),
"pulp_created",
"-pk",
)
}
return (
content.order_by(*ordering)
.values("name_normalized")
.annotate(
versions=PackageVersions(
"version",
"pulp_created",
**{ordering_argument: ("pulp_created",)},
),
metadata_ids=PackageVersions(
"version",
"pk",
**file_ordering,
),
)
)


def assemble_package_index(package_rows):
"""Sort each package's versions and fetch metadata for its highest version in one query."""
if not package_rows:
return []

for row in package_rows:
row["versions"] = sorted(
(
{"version": version, "last_updated": updated}
for version, updated in row["versions"].items()
),
key=lambda unit: (_version_sort_key(unit["version"]), unit["version"]),
reverse=True,
)

fields = (
"name",
"name_normalized",
"summary",
"description",
"author",
"author_email",
"license",
)
metadata = {
str(unit["pk"]): {field: unit[field] for field in fields}
for unit in PythonPackageContent.objects.filter(
pk__in=[row["metadata_ids"][row["versions"][0]["version"]] for row in package_rows]
)
.order_by()
.values("pk", *fields)
}
return [
{
**metadata[row["metadata_ids"][row["versions"][0]["version"]]],
"versions": row["versions"],
}
for row in package_rows
]
2 changes: 1 addition & 1 deletion pulp_python/app/pypi/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -349,7 +349,7 @@ def list(self, request, path):
if self.should_redirect(repo_version=repo_version):
return redirect(urljoin(self.base_content_url, f"{path}/simple/"))
names = (
content.order_by("name_normalized")
content.order_by("name_normalized", "name")
.values_list("name", flat=True)
.distinct("name_normalized")
.iterator()
Expand Down
61 changes: 61 additions & 0 deletions pulp_python/app/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from pulpcore.plugin.util import get_current_authenticated_user, get_domain, get_prn, reverse

from pulp_python.app import models as python_models
from pulp_python.app.catalog import assemble_package_index
from pulp_python.app.provenance import (
AnyPublisher,
Attestation,
Expand Down Expand Up @@ -641,6 +642,66 @@ class Meta:
model = python_models.PythonPackageContent


class PythonRepositoryPackageVersionSerializer(serializers.Serializer):
"""One stored version of a package."""

version = serializers.CharField(help_text=_("The package's version number."))
last_updated = serializers.DateTimeField(
help_text=_(
"The latest `pulp_created` timestamp for this version's files "
"in the selected repository version."
),
)


class PythonRepositoryPackageListSerializer(serializers.ListSerializer):
"""Load package metadata for the current page."""

def to_representation(self, data):
rows = assemble_package_index(list(data))
return super().to_representation(rows)


class PythonRepositoryPackageSerializer(serializers.Serializer):
"""One distinct package name and its stored versions."""

name = serializers.CharField(
help_text=_("The package name from its metadata."),
)
name_normalized = serializers.CharField(
help_text=_("The package name normalized according to PEP 503."),
)
summary = serializers.CharField(
allow_blank=True,
help_text=_("A one-line summary of what the package does."),
)
description = serializers.CharField(
allow_blank=True,
help_text=_("The package description."),
)
author = serializers.CharField(
allow_blank=True,
help_text=_("Author information from the package metadata."),
)
author_email = serializers.CharField(
allow_blank=True,
help_text=_("The author's e-mail address."),
)
license = serializers.CharField(
allow_blank=True,
help_text=_("The license declared by the package."),
)
versions = PythonRepositoryPackageVersionSerializer(
many=True,
help_text=_(
"Package versions in descending PEP 440 order, with an update time for each version."
),
)

class Meta:
list_serializer_class = PythonRepositoryPackageListSerializer


class PackageProvenanceSerializer(core_serializers.NoArtifactContentUploadSerializer):
"""
A Serializer for PackageProvenance.
Expand Down
Loading
Loading