Skip to content

Commit 9511cb1

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 9511cb1

13 files changed

Lines changed: 799 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. 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``, models, viewsets). Keep ``strip_build_suffix`` in ``pulp_python/app/versions.py``. The suffix is ``.[a-zA-Z]+-digits`` at the end of ``version`` (SQL uses ``[0-9]`` because Postgres POSIX ``\\d`` is not digits).

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: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
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+
"versions": ["0.1"],
24+
"latest_releases": [
25+
{
26+
"version": "0.1",
27+
"release": "",
28+
"created_at": "2026-08-10T10:45:08.099362Z"
29+
}
30+
]
31+
}
32+
```
33+
34+
`set(versions)` is always the same as `set(latest_releases[].version)`. There is one `latest_releases` entry per **logical version** (after stripping a trailing rebuild suffix `\.[a-zA-Z]+-\d+$`), not per wheel or sdist.
35+
36+
`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.
37+
38+
### Prefix search
39+
40+
```bash
41+
http GET "${BASE_ADDR}/pulp/api/v3/repositories/python/python/${REPO_PK}/packages/" \
42+
name_normalized__istartswith==shelf
43+
```
44+
45+
`name_normalized__istartswith` and `name__istartswith` are case-insensitive (`ILIKE`). Prefix search belongs on this index, not on the flat content list.
46+
47+
## Repository metrics
48+
49+
```bash
50+
http GET "${BASE_ADDR}/pulp/api/v3/repositories/python/python/${REPO_PK}/metrics/"
51+
```
52+
53+
```json
54+
{
55+
"package_count": 3,
56+
"version_count": 9,
57+
"build_count": 9
58+
}
59+
```
60+
61+
Counts use Python package content units in that repository version (not filtered by `packagetype`):
62+
63+
| Field | Identity |
64+
|-------|----------|
65+
| `package_count` | distinct `name_normalized` |
66+
| `version_count` | distinct `(name_normalized, base_version)` after rebuild-suffix strip |
67+
| `build_count` | distinct `(name_normalized, full version)` |
68+
69+
Until rebuild suffixes exist, `version_count` equals `build_count`.
70+
71+
## List versions of a package
72+
73+
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).
74+
75+
`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.
76+
77+
```bash
78+
http GET "${BASE_ADDR}/pulp/api/v3/content/python/packages/" \
79+
name==shelf-reader \
80+
packagetype==sdist \
81+
collapse_builds==true \
82+
repository_version=="${LATEST_VERSION_HREF}"
83+
```
84+
85+
Every content row includes `base_version` (stripped version; equal to `version` when there is no suffix).
86+
87+
## Get one version
88+
89+
Omit `collapse_builds`. Filter with `name`, `version`, and `packagetype=sdist`:
90+
91+
```bash
92+
http GET "${BASE_ADDR}/pulp/api/v3/content/python/packages/" \
93+
name==shelf-reader \
94+
version==0.1 \
95+
packagetype==sdist
96+
```

pulp_python/app/catalog.py

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
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 packaging.version import InvalidVersion, Version
7+
8+
from pulp_python.app.models import PythonPackageContent
9+
10+
# POSIX regex for REGEXP_REPLACE. PostgreSQL does not treat ``\d`` as digits.
11+
BUILD_SUFFIX_PG_REGEX = r"\.[a-zA-Z]+-[0-9]+$"
12+
13+
14+
def base_version_annotation(field_name="version"):
15+
"""SQL expression that strips a trailing rebuild suffix from ``version``.
16+
17+
PostgreSQL POSIX regex does not treat ``\\d`` as digits, so the SQL pattern
18+
uses ``[0-9]`` while the Python pattern in ``versions.strip_build_suffix`` uses ``\\d``.
19+
Implemented with ``REGEXP_REPLACE`` so it does not depend on Django's
20+
``RegexpReplace`` (not present in every Django 4.2/5.2 packaging Pulp uses).
21+
"""
22+
return Func(
23+
field_name,
24+
Value(BUILD_SUFFIX_PG_REGEX),
25+
Value(""),
26+
function="REGEXP_REPLACE",
27+
output_field=CharField(),
28+
)
29+
30+
31+
def collapse_python_builds(queryset):
32+
"""Keep one content unit per ``(name_normalized, base_version)``.
33+
34+
``base_version`` is ``version`` with a trailing rebuild suffix stripped.
35+
The unit with the latest ``pulp_created`` is kept. Callers that want one
36+
row per logical version (not per wheel/sdist) should also filter
37+
``packagetype``.
38+
"""
39+
return (
40+
queryset.prefetch_related(None)
41+
.annotate(_collapse_base_version=base_version_annotation())
42+
.order_by("name_normalized", "_collapse_base_version", "-pulp_created")
43+
.distinct("name_normalized", "_collapse_base_version")
44+
)
45+
46+
47+
def python_packages_in_version(repository_version):
48+
"""Python package content contained in ``repository_version``."""
49+
if repository_version is None:
50+
return PythonPackageContent.objects.none()
51+
return PythonPackageContent.objects.filter(pk__in=repository_version.content)
52+
53+
54+
def apply_package_prefix_filters(queryset, name_normalized_prefix=None, name_prefix=None):
55+
"""Apply case-insensitive prefix filters used by the package index."""
56+
if name_normalized_prefix:
57+
queryset = queryset.filter(name_normalized__istartswith=name_normalized_prefix)
58+
if name_prefix:
59+
queryset = queryset.filter(name__istartswith=name_prefix)
60+
return queryset
61+
62+
63+
def distinct_package_names_qs(content_qs):
64+
"""One row per distinct ``name_normalized``, ordered for stable pagination."""
65+
return (
66+
content_qs.order_by()
67+
.values("name_normalized")
68+
.annotate(name=Max("name"))
69+
.order_by("name_normalized")
70+
)
71+
72+
73+
def _version_sort_key(version):
74+
try:
75+
return (0, Version(version))
76+
except InvalidVersion:
77+
return (1, version)
78+
79+
80+
def assemble_package_index(content_qs, name_rows, repository, repository_version):
81+
"""Build package-index dicts for ``name_rows``.
82+
83+
``created_at`` is the earliest repository-membership time
84+
(``RepositoryContent.pulp_created``) of any file of that logical version
85+
in ``repository_version``, falling back to the content unit's ``pulp_created``.
86+
"""
87+
if not name_rows or repository_version is None:
88+
return []
89+
90+
names = [row["name_normalized"] for row in name_rows]
91+
name_by_normalized = {row["name_normalized"]: row["name"] for row in name_rows}
92+
93+
in_this_version = Q(
94+
version_memberships__repository=repository,
95+
version_memberships__version_added__number__lte=repository_version.number,
96+
) & (
97+
Q(version_memberships__version_removed__isnull=True)
98+
| Q(version_memberships__version_removed__number__gt=repository_version.number)
99+
)
100+
101+
release_rows = (
102+
content_qs.filter(name_normalized__in=names)
103+
.annotate(_base_version=base_version_annotation())
104+
.values("name_normalized", "_base_version")
105+
.annotate(
106+
membership_created=Min(
107+
"version_memberships__pulp_created",
108+
filter=in_this_version,
109+
),
110+
unit_created=Min("pulp_created"),
111+
)
112+
)
113+
114+
releases_by_name = defaultdict(list)
115+
for rel in release_rows:
116+
releases_by_name[rel["name_normalized"]].append(rel)
117+
118+
result = []
119+
for row in name_rows:
120+
normalized = row["name_normalized"]
121+
rels = sorted(
122+
releases_by_name.get(normalized, []),
123+
key=lambda item: _version_sort_key(item["_base_version"]),
124+
)
125+
versions = [item["_base_version"] for item in rels]
126+
latest_releases = [
127+
{
128+
"version": item["_base_version"],
129+
"release": "",
130+
"created_at": item["membership_created"] or item["unit_created"],
131+
}
132+
for item in rels
133+
]
134+
result.append(
135+
{
136+
"name": name_by_normalized[normalized],
137+
"name_normalized": normalized,
138+
"versions": versions,
139+
"latest_releases": latest_releases,
140+
}
141+
)
142+
return result
143+
144+
145+
def repository_metrics(content_qs):
146+
"""Distinct package / logical-version / build counts for package content.
147+
148+
Identity is always ``PythonPackageContent`` (not filtered by packagetype):
149+
150+
* ``package_count``: distinct ``name_normalized``
151+
* ``version_count``: distinct ``(name_normalized, base_version)``
152+
* ``build_count``: distinct ``(name_normalized, version)``
153+
154+
Until rebuild suffixes exist, ``version_count`` equals ``build_count``.
155+
"""
156+
content_qs = content_qs.order_by()
157+
return {
158+
"package_count": content_qs.values("name_normalized").distinct().count(),
159+
"version_count": (
160+
content_qs.annotate(_base_version=base_version_annotation())
161+
.values("name_normalized", "_base_version")
162+
.distinct()
163+
.count()
164+
),
165+
"build_count": content_qs.values("name_normalized", "version").distinct().count(),
166+
}

0 commit comments

Comments
 (0)