Skip to content

Commit f3ed92e

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 f3ed92e

13 files changed

Lines changed: 796 additions & 66 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: 1 addition & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,60 +1 @@
1-
# CLAUDE.md
2-
3-
The role of this file is to describe common mistakes and confusion points that agents might encounter as they work in this project.
4-
If you ever encounter something in the project that surprises you, please alert the developer working with you and indicate that this is the case in the CLAUDE.md file to help prevent future agents from having the same issue.
5-
6-
## Interacting with the developer environment
7-
8-
Use the `pulp-cli` to interact with the Pulp API. Fallback on `httpie/curl` when the CLI doesn't support the endpoint/options needed.
9-
10-
```bash
11-
pulp --help
12-
pulp --refresh-api status
13-
pulp python content -t package list --limit 5
14-
pulp python repository create --name foo
15-
pulp -v python repository sync --name foo --remote foo
16-
pulp task show --wait --href prn:core.task:019c8cae-cc5f-7148-a3de-456d0a9f39a1
17-
pulp show --href /pulp/api/v3/tasks/019c8cae-cc5f-7148-a3de-456d0a9f39a1/
18-
```
19-
20-
Use the `oci-env` cli to interact with the developer's Pulp instance. It has commands for managing state, running tests, and executing commands against a running Pulp.
21-
22-
```bash
23-
oci-env --help
24-
oci-env compose ps # check status of the Pulp dev container
25-
oci-env compose up/down/restart # start/stop/restart the Pulp dev container
26-
oci-env poll --attempts 10 --wait 10 # wait till Pulp container finishes booting up
27-
oci-env pstart/pstop/prestart # start/stop/restart the services inside the Pulp container
28-
oci-env generate-client --help # create the client bindings needed for the functional tests!
29-
oci-env test --help # run the functional/unit tests
30-
oci-env pulpcore-manager # run any pulpcore or Django commands
31-
```
32-
33-
## Running/Writing tests
34-
35-
Prefer writing functional tests for new changes/bugfixes and only fallback on unit tests when the change is not easily testable through the API.
36-
37-
pulp-python functional tests require pulp-python, pulpcore & pulp-file client bindings to be installed. The bindings must be regenerated for any changes to the API spec.
38-
39-
**Always** use the `oci-env` to run the functional and unit tests.
40-
41-
## Modifying template_config.yml
42-
43-
Use the `plugin-template` tool after any changes made to `template_config.yml`.
44-
45-
```bash
46-
# typically located in the parent directory of pulpcore/plugin
47-
../plugin_template/plugin-template --github
48-
```
49-
50-
## Fixing failed backports
51-
52-
When patchback fails to cherry-pick a PR into an older branch, you need to manually apply the equivalent change. Key things to know:
53-
54-
- Older branches (e.g. 3.11, 3.12) may use `requirements.txt` for dependencies, while newer branches use `pyproject.toml`. Always check which file the target branch uses before applying changes.
55-
- When creating a PR include `[<version>]` in the PR title (e.g. `[3.12] Raise upperbound for pkginfo requirement`).
56-
- Use `git cherry-pick -x`.
57-
58-
## Contributing
59-
60-
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.
1+
CLAUDE.md

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)