Repository navigation
Add a paginated Python repository package catalog #1451
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
795a381
d96e96c
1f36c54
3737d4b
aeb1dc0
12d265d
db4d778
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. |
| 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. |
| 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", | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should this be when the file was first created in Pulp, or when it was added to the repo? |
||
| **{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.""" | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we want to take |
||
| if not package_rows: | ||
| return [] | ||
|
|
||
| for row in package_rows: | ||
| row["versions"] = sorted( | ||
| ( | ||
| {"version": version, "last_updated": updated} | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Use |
||
| 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 | ||
| ] | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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") | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why do we need this? It does not seem to be related to the catalog. |
||
| .values_list("name", flat=True) | ||
| .distinct("name_normalized") | ||
| .iterator() | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Redundant parentheses