Skip to content

Commit ba7cd1d

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 ba7cd1d

15 files changed

Lines changed: 1340 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``). 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 ``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: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
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+
`created_at` is when that logical version entered the repository: the earliest `RepositoryContent.pulp_created` among its files, falling back to the content unit's `pulp_created`. `release` is empty until Python rebuilds are stored.
38+
39+
`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.
40+
41+
### Ordering
42+
43+
Default order is `name`. Pass `ordering` to change it:
44+
45+
```bash
46+
http GET "${BASE_ADDR}/pulp/api/v3/repositories/python/python/${REPO_PK}/packages/" \
47+
ordering==name
48+
http GET "${BASE_ADDR}/pulp/api/v3/repositories/python/python/${REPO_PK}/packages/" \
49+
ordering==-last_updated
50+
```
51+
52+
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.
53+
54+
### Name search
55+
56+
```bash
57+
http GET "${BASE_ADDR}/pulp/api/v3/repositories/python/python/${REPO_PK}/packages/" \
58+
name_normalized__istartswith==shelf
59+
http GET "${BASE_ADDR}/pulp/api/v3/repositories/python/python/${REPO_PK}/packages/" \
60+
name_normalized__icontains==http
61+
```
62+
63+
`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.
64+
65+
## Repository metrics
66+
67+
```bash
68+
http GET "${BASE_ADDR}/pulp/api/v3/repositories/python/python/${REPO_PK}/metrics/"
69+
```
70+
71+
```json
72+
{
73+
"package_count": 3,
74+
"version_count": 9,
75+
"build_count": 9
76+
}
77+
```
78+
79+
Counts use Python package content units in that repository version (not filtered by `packagetype`):
80+
81+
| Field | Identity |
82+
|-------|----------|
83+
| `package_count` | distinct `name_normalized` |
84+
| `version_count` | distinct `(name_normalized, base_version)` after rebuild-suffix strip |
85+
| `build_count` | distinct `(name_normalized, full version)` |
86+
87+
Until rebuild suffixes exist, `version_count` equals `build_count`.
88+
89+
## List versions of a package
90+
91+
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).
92+
93+
`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.
94+
95+
```bash
96+
http GET "${BASE_ADDR}/pulp/api/v3/content/python/packages/" \
97+
name==shelf-reader \
98+
packagetype==sdist \
99+
collapse_builds==true \
100+
repository_version=="${LATEST_VERSION_HREF}"
101+
```
102+
103+
Every content row includes `base_version` (stripped version; equal to `version` when there is no suffix).
104+
105+
## Get one version
106+
107+
Omit `collapse_builds`. Filter with `name`, `version`, and `packagetype=sdist`:
108+
109+
```bash
110+
http GET "${BASE_ADDR}/pulp/api/v3/content/python/packages/" \
111+
name==shelf-reader \
112+
version==0.1 \
113+
packagetype==sdist
114+
```

‎pulp_python/app/catalog.py‎

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

0 commit comments

Comments
 (0)