Skip to content

Commit aa4be40

Browse files
committed
feat: add HTTP catalog for Python packages and repository metrics
Clients can list distinct packages and repository counts over the REST API instead of querying the database. The content list also supports collapsing rebuilds and returns base_version. Closes #1358. Assisted-By: Cursor
1 parent 71f42e8 commit aa4be40

15 files changed

Lines changed: 1369 additions & 6 deletions

File tree

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ __pycache__/
88
# Distribution / packaging
99
.Python
1010
env/
11+
.venv/
1112
build/
1213
develop-eggs/
1314
dist/
@@ -61,3 +62,6 @@ target/
6162

6263
# PyCharm
6364
.idea
65+
66+
# VS Code / Cursor
67+
.vscode/

CHANGES/1358.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added repository package catalog and metrics endpoints, plus ``collapse_builds`` and ``base_version`` on the Python package content API. The catalog includes ``last_updated``, ``ordering``, newest-first PEP 440 ``versions``/``latest_releases``, and ``name_normalized`` prefix/substring search (at least 3 characters). A trailing rebuild suffix is ``\.[a-zA-Z]+-[^.]+$`` (for example ``5.3.17.rhlw-00001-n0001`` groups with ``5.3.17``). ``latest_releases[].release`` is that suffix on the newest unit in the group, or empty when the stored version has none. Existing installs pick up access policy for the new actions on migrate unless the policy was customized.

CLAUDE.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,3 +58,7 @@ When patchback fails to cherry-pick a PR into an older branch, you need to manua
5858
## Contributing
5959

6060
When preparing to commit and create a PR you **must** follow our [PR checklist](https://pulpproject.org/pulpcore/docs/dev/guides/pull-request-walkthrough/) Important to note is the AI attribution requirement in our commit messages. Also, note that our changelog entries are markdown.
61+
62+
## Catalog `strip_build_suffix` and CI unit tests
63+
64+
CI runs unit tests with ``pytest -p no:pulpcore``. Collection must not import Django-backed modules (``pulp_python.app.utils``, ``catalog``, models, viewsets). Keep ``strip_build_suffix``, ``BUILD_SUFFIX_PATTERN``, ``version_sort_key``, ``normalize_package_index_ordering``, and ``normalize_name_normalized_search`` in ``pulp_python/app/versions.py``. The rebuild suffix is the last dot-segment matching POSIX ``\.[a-zA-Z]+-[^.]+$`` (letters, dash, rest of that segment; not hard-coded to ``rhlw``). Python ``re`` and SQL ``REGEXP_REPLACE`` share ``BUILD_SUFFIX_PATTERN``; ``catalog.py`` may import it. Catalog ``latest_releases`` keeps the newest ``pulp_created`` unit per logical version; ``release`` is ``rebuild_release`` of that stored ``version`` (empty when there is no suffix). Catalog ``name_normalized`` prefix/substring filters lowercase the input, use ``LIKE`` (not ``ILIKE``) against the trigram GIN index, and reject values shorter than 3 characters.

docs/index.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ The REST API documentation for `pulp_python` is available [here](site:pulp_pytho
1212

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

3637
1. `How-to Guides` shows the **major features** of the Python plugin, with links to reference docs.
37-
2. The [REST API Docs](site:pulp_python/restapi/) are automatically generated and provide more detailed information for each
38-
minor feature, including all fields and options.
38+
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.

docs/user/guides/_SUMMARY.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
* [Set up your own PyPI](pypi.md)
22
* [Sync from Remote Repositories](sync.md)
33
* [Upload and Manage Content](upload.md)
4+
* [Browse the package catalog](catalog.md)
45
* [Host Python Content](host.md)
56
* [Vulnerability Report](vulnerability_report.md)
67
* [Attestation Hosting](attestation.md)

docs/user/guides/catalog.md

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
# Browse the package catalog
2+
3+
Pulp CLI commands for these endpoints are generated from the OpenAPI spec in a separate package; until that is updated, use HTTP.
4+
5+
The content list (`/pulp/api/v3/content/python/packages/`) returns **one row per distribution file** (wheel, sdist, …). For catalog UIs and automation that need **one row per package name**, plus repository metrics, use the repository package index.
6+
7+
These endpoints default to the **latest complete repository version**. `{pulp_id}` is the repository UUID. Pass `repository_version` (HREF or PRN) to read a specific version of that repository.
8+
9+
## List packages
10+
11+
```bash
12+
http GET "${BASE_ADDR}/pulp/api/v3/repositories/python/python/${REPO_PK}/packages/?limit=10"
13+
```
14+
15+
Pagination `count` is the number of **distinct packages** (`name_normalized`), not files.
16+
17+
Each row includes both a simple version list and per-version metadata:
18+
19+
```json
20+
{
21+
"name": "shelf-reader",
22+
"name_normalized": "shelf-reader",
23+
"last_updated": "2026-08-10T10:45:08.099362Z",
24+
"versions": ["0.1"],
25+
"latest_releases": [
26+
{
27+
"version": "0.1",
28+
"release": "",
29+
"created_at": "2026-08-10T10:45:08.099362Z"
30+
}
31+
]
32+
}
33+
```
34+
35+
`set(versions)` is always the same as `set(latest_releases[].version)`. Both lists are newest-first using PEP 440 version order (`1.10` before `1.9` before `1.2`). There is one `latest_releases` entry per **logical version** (after stripping a trailing rebuild suffix `\.[a-zA-Z]+-[^.]+$`), not per wheel or sdist. A rebuild is the last dot-segment that is letters, a dash, then the rest of that segment (for example `5.3.17.rhlw-00001-n0001``5.3.17`). Public and predisclosure files of the same `name_normalized` and logical version collapse to that one row.
36+
37+
`version` is that base. `release` is the stripped suffix without the leading dot (`rhlw-00001` or `rhlw-00001-n0001`) of the newest unit (`pulp_created`) in that group, otherwise empty.
38+
39+
`created_at` is when that logical version entered the repository: `RepositoryContent.pulp_created` of the selected newest rebuild, falling back to the content unit's `pulp_created`.
40+
41+
`last_updated` is when the **package** was last updated in this repository version: the latest `RepositoryContent.pulp_created` among **all** Python package units for that `name_normalized` (any rebuild), falling back to the content unit's `pulp_created`. A rebuild of an older version uploaded yesterday updates `last_updated` even if a newer version number already exists.
42+
43+
### Ordering
44+
45+
Default order is `name`. Pass `ordering` to change it:
46+
47+
```bash
48+
http GET "${BASE_ADDR}/pulp/api/v3/repositories/python/python/${REPO_PK}/packages/" \
49+
ordering==name
50+
http GET "${BASE_ADDR}/pulp/api/v3/repositories/python/python/${REPO_PK}/packages/" \
51+
ordering==-last_updated
52+
```
53+
54+
Allowed fields: `name`, `name_normalized`, `last_updated`. Prefix with `-` for descending. `last_updated` uses `name` then `name_normalized` as a stable pagination tiebreaker. Unknown fields return 400.
55+
56+
### Name search
57+
58+
```bash
59+
http GET "${BASE_ADDR}/pulp/api/v3/repositories/python/python/${REPO_PK}/packages/" \
60+
name_normalized__istartswith==shelf
61+
http GET "${BASE_ADDR}/pulp/api/v3/repositories/python/python/${REPO_PK}/packages/" \
62+
name_normalized__icontains==http
63+
```
64+
65+
`name_normalized__istartswith` and `name_normalized__icontains` are case-insensitive: the value is lowercased and matched with `LIKE` against already-canonical `name_normalized`. Each requires **at least 3 characters** (shorter values return 400). `name__istartswith` is still `ILIKE` on the original package name and has no minimum length. Name search belongs on this index, not on the flat content list.
66+
67+
## Repository metrics
68+
69+
```bash
70+
http GET "${BASE_ADDR}/pulp/api/v3/repositories/python/python/${REPO_PK}/metrics/"
71+
```
72+
73+
```json
74+
{
75+
"package_count": 3,
76+
"version_count": 9,
77+
"build_count": 9
78+
}
79+
```
80+
81+
Counts use Python package content units in that repository version (not filtered by `packagetype`):
82+
83+
| Field | Identity |
84+
|-------|----------|
85+
| `package_count` | distinct `name_normalized` |
86+
| `version_count` | distinct `(name_normalized, base_version)` after rebuild-suffix strip |
87+
| `build_count` | distinct `(name_normalized, full version)` |
88+
89+
Until rebuild suffixes exist, `version_count` equals `build_count`.
90+
91+
## List versions of a package
92+
93+
Use the existing content API. Pass `packagetype=sdist` for one representative file per PEP version (retry with `packagetype=bdist_wheel` if a release is wheel-only).
94+
95+
`collapse_builds=true` keeps one unit per logical version (`name_normalized` + `base_version`), the one with the latest `pulp_created`. Do not nest rebuilds on this list. Clients can drain Pulp `next` if the page is full.
96+
97+
```bash
98+
http GET "${BASE_ADDR}/pulp/api/v3/content/python/packages/" \
99+
name==shelf-reader \
100+
packagetype==sdist \
101+
collapse_builds==true \
102+
repository_version=="${LATEST_VERSION_HREF}"
103+
```
104+
105+
Every content row includes `base_version` (stripped version; equal to `version` when there is no suffix).
106+
107+
## Get one version
108+
109+
Omit `collapse_builds`. Filter with `name`, `version`, and `packagetype=sdist`:
110+
111+
```bash
112+
http GET "${BASE_ADDR}/pulp/api/v3/content/python/packages/" \
113+
name==shelf-reader \
114+
version==0.1 \
115+
packagetype==sdist
116+
```

pulp_python/app/catalog.py

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
"""Helpers for repository package catalog, metrics, and rebuild collapse."""
2+
3+
from collections import defaultdict
4+
5+
from django.db.models import CharField, Func, Max, Min, Q, Value
6+
from django.db.models.functions import Coalesce
7+
8+
from pulp_python.app.models import PythonPackageContent
9+
from pulp_python.app.versions import (
10+
BUILD_SUFFIX_PATTERN,
11+
normalize_package_index_ordering,
12+
rebuild_release,
13+
version_sort_key,
14+
)
15+
16+
17+
def base_version_annotation(field_name="version"):
18+
"""SQL expression that strips a trailing rebuild suffix from ``version``.
19+
20+
Uses ``versions.BUILD_SUFFIX_PATTERN`` (POSIX) so Python ``strip_build_suffix``
21+
and this ``REGEXP_REPLACE`` stay aligned. Implemented with ``REGEXP_REPLACE``
22+
so it does not depend on Django's ``RegexpReplace`` (not present in every
23+
Django 4.2/5.2 packaging Pulp uses).
24+
"""
25+
return Func(
26+
field_name,
27+
Value(BUILD_SUFFIX_PATTERN),
28+
Value(""),
29+
function="REGEXP_REPLACE",
30+
output_field=CharField(),
31+
)
32+
33+
34+
def collapse_python_builds(queryset):
35+
"""Keep one content unit per ``(name_normalized, base_version)``.
36+
37+
``base_version`` is ``version`` with a trailing rebuild suffix stripped.
38+
The unit with the latest ``pulp_created`` is kept. Callers that want one
39+
row per logical version (not per wheel/sdist) should also filter
40+
``packagetype``.
41+
"""
42+
return (
43+
queryset.prefetch_related(None)
44+
.annotate(_collapse_base_version=base_version_annotation())
45+
.order_by("name_normalized", "_collapse_base_version", "-pulp_created")
46+
.distinct("name_normalized", "_collapse_base_version")
47+
)
48+
49+
50+
def python_packages_in_version(repository_version):
51+
"""Python package content contained in ``repository_version``."""
52+
if repository_version is None:
53+
return PythonPackageContent.objects.none()
54+
return PythonPackageContent.objects.filter(pk__in=repository_version.content)
55+
56+
57+
def apply_package_prefix_filters(
58+
queryset,
59+
name_normalized_prefix=None,
60+
name_prefix=None,
61+
name_normalized_contains=None,
62+
):
63+
"""Apply case-insensitive name filters used by the package index."""
64+
if name_normalized_prefix:
65+
queryset = queryset.filter(name_normalized__startswith=name_normalized_prefix)
66+
if name_normalized_contains:
67+
queryset = queryset.filter(name_normalized__contains=name_normalized_contains)
68+
if name_prefix:
69+
queryset = queryset.filter(name__istartswith=name_prefix)
70+
return queryset
71+
72+
73+
def membership_in_version_q(repository, repository_version):
74+
"""Q-object matching RepositoryContent rows present in ``repository_version``."""
75+
return Q(
76+
version_memberships__repository=repository,
77+
version_memberships__version_added__number__lte=repository_version.number,
78+
) & (
79+
Q(version_memberships__version_removed__isnull=True)
80+
| Q(version_memberships__version_removed__number__gt=repository_version.number)
81+
)
82+
83+
84+
def last_updated_annotation(repository, repository_version):
85+
"""Newest repository-membership time among all package units for a name.
86+
87+
Uses ``RepositoryContent.pulp_created`` (any rebuild/build), falling back to
88+
the content unit's ``pulp_created``.
89+
"""
90+
return Coalesce(
91+
Max(
92+
"version_memberships__pulp_created",
93+
filter=membership_in_version_q(repository, repository_version),
94+
),
95+
Max("pulp_created"),
96+
)
97+
98+
99+
def distinct_package_names_qs(content_qs, repository, repository_version, ordering=None):
100+
"""One row per distinct ``name_normalized``, ordered for stable pagination."""
101+
if ordering is None:
102+
ordering = normalize_package_index_ordering([])
103+
qs = content_qs.order_by().values("name_normalized").annotate(name=Max("name"))
104+
if repository_version is None:
105+
qs = qs.annotate(last_updated=Max("pulp_created"))
106+
else:
107+
qs = qs.annotate(last_updated=last_updated_annotation(repository, repository_version))
108+
return qs.order_by(*ordering)
109+
110+
111+
def assemble_package_index(content_qs, name_rows, repository, repository_version):
112+
"""Build package-index dicts for ``name_rows``.
113+
114+
``versions`` are distinct logical versions, newest first (PEP 440).
115+
``latest_releases`` keeps the newest rebuild (latest ``pulp_created``)
116+
per base version in the same order. ``created_at`` is that unit's
117+
repository-membership time (``RepositoryContent.pulp_created``), falling
118+
back to the content unit's ``pulp_created``. ``last_updated`` is the newest
119+
membership among all units for the package (any rebuild), taken from
120+
``name_rows`` when annotated.
121+
"""
122+
if not name_rows or repository_version is None:
123+
return []
124+
125+
names = [row["name_normalized"] for row in name_rows]
126+
name_by_normalized = {row["name_normalized"]: row["name"] for row in name_rows}
127+
128+
in_this_version = membership_in_version_q(repository, repository_version)
129+
130+
newest_units = list(
131+
content_qs.filter(name_normalized__in=names)
132+
.prefetch_related(None)
133+
.annotate(_base_version=base_version_annotation())
134+
.order_by("name_normalized", "_base_version", "-pulp_created")
135+
.distinct("name_normalized", "_base_version")
136+
)
137+
newest = [
138+
{
139+
"pk": unit.pk,
140+
"name_normalized": unit.name_normalized,
141+
"version": unit.version,
142+
"_base_version": unit._base_version,
143+
"pulp_created": unit.pulp_created,
144+
}
145+
for unit in newest_units
146+
]
147+
148+
memberships = {}
149+
if newest:
150+
memberships = dict(
151+
PythonPackageContent.objects.filter(pk__in=[row["pk"] for row in newest])
152+
.annotate(
153+
membership_created=Min(
154+
"version_memberships__pulp_created",
155+
filter=in_this_version,
156+
)
157+
)
158+
.values_list("pk", "membership_created")
159+
)
160+
161+
releases_by_name = defaultdict(list)
162+
for rel in newest:
163+
releases_by_name[rel["name_normalized"]].append(rel)
164+
165+
result = []
166+
for row in name_rows:
167+
normalized = row["name_normalized"]
168+
rels = sorted(
169+
releases_by_name.get(normalized, []),
170+
key=lambda item: version_sort_key(item["_base_version"]),
171+
reverse=True,
172+
)
173+
versions = [item["_base_version"] for item in rels]
174+
latest_releases = [
175+
{
176+
"version": item["_base_version"],
177+
"release": rebuild_release(item["version"]),
178+
"created_at": memberships.get(item["pk"]) or item["pulp_created"],
179+
}
180+
for item in rels
181+
]
182+
result.append(
183+
{
184+
"name": name_by_normalized[normalized],
185+
"name_normalized": normalized,
186+
"last_updated": row.get("last_updated"),
187+
"versions": versions,
188+
"latest_releases": latest_releases,
189+
}
190+
)
191+
return result
192+
193+
194+
def repository_metrics(content_qs):
195+
"""Distinct package / logical-version / build counts for package content.
196+
197+
Identity is always ``PythonPackageContent`` (not filtered by packagetype):
198+
199+
* ``package_count``: distinct ``name_normalized``
200+
* ``version_count``: distinct ``(name_normalized, base_version)``
201+
* ``build_count``: distinct ``(name_normalized, version)``
202+
203+
Until rebuild suffixes exist, ``version_count`` equals ``build_count``.
204+
"""
205+
content_qs = content_qs.order_by()
206+
return {
207+
"package_count": content_qs.values("name_normalized").distinct().count(),
208+
"version_count": (
209+
content_qs.annotate(_base_version=base_version_annotation())
210+
.values("name_normalized", "_base_version")
211+
.distinct()
212+
.count()
213+
),
214+
"build_count": content_qs.values("name_normalized", "version").distinct().count(),
215+
}

0 commit comments

Comments
 (0)