Simple API serving
peryx derives the advertised version from upstream data, retains signature markers only for reachable signatures, and redirects clients to canonical URLs. See Simple API serving for the design and HTTP endpoints for routes.
Advertised Simple API version
Every Simple page peryx serves carries a version: meta.api-version in the PEP 691
JSON, pypi:repository-version in the PEP 503 HTML <meta>. Hosted pages use
peryx's 1.4 ceiling. For proxied pages, peryx derives the version from what the upstream declared and does not promise a
field that the re-served payload can omit.
Version rule
peryx assigns hosted pages its ceiling and maps upstream declarations as follows:
| Page source | peryx serves | Why |
|---|---|---|
| hosted by peryx | 1.4 | peryx supplies every required field |
upstream JSON declares 1.1, 1.2, 1.3, 1.4, 1.5, … (minor ≥ 1) | 1.4 | PEP 700 makes versions and per-file size mandatory here |
upstream JSON declares 1.0, or omits api-version | 1.0 | PEP 691 mandates neither field |
| a PEP 503 HTML detail page, whatever version it declares | 1.0 | PEP 700 leaves the HTML form unchanged from 1.0 |
upstream declares a major other than 1 (2.0, …) | rejected | peryx does not support the major version |
upstream declares a version that does not parse (1.x, abc) | rejected | peryx rejects the invalid version |
upstream JSON declares 1.1+ and omits versions or a file size | rejected | the page contradicts the version it declared |
A hosted page and a versioned JSON upstream that declares 1.1 both serve 1.4, but for different reasons: peryx supplies the hosted fields, while the upstream promises the PEP 700 fields.
Incomplete PEP 700 payloads
A JSON page that declares 1.1 or newer and then omits the versions array, or leaves any file without a size,
breaks its own contract. peryx does not repair it and does not quietly lower its version: it rejects the response, so
the previously published generation stays serviceable and the client keeps reading the page it already had. versions
is a set, so a repeated version string is rejected the same way, at every version that carries the field.
1.4 is peryx's own ceiling: the highest version it implements. The threshold that decides between the ceiling and the
base is PEP 700's, minor version 1. Above it, every guarantee through 1.4 is
one peryx meets by passing the upstream's fields through, so it advertises the full ceiling rather than echoing the
exact minor the upstream sent.
Version guarantees
PEP 700 raised the Simple API to 1.1 and made two fields mandatory in the JSON serialization:
versions: a top-level array of every release version of the project.size: an integer byte count on every file entry.
A page that advertises 1.1 or higher promises both are present; 1.0 promises neither. peryx advertises 1.4 for
hosted content and when an upstream declared 1.1+, where the upstream guarantees those fields in the bytes peryx
re-serves. peryx falls back to 1.0 for an upstream that makes neither guarantee.
Virtual indexes take the weakest layer
A virtual index merges the project pages of its layers, and it is only as capable as its least capable layer. peryx
starts the merged page at its 1.4 ceiling and drops it to 1.0 the moment any layer that resolved the project serves
1.0. A single pre-PEP 700 layer therefore caps the merged page at 1.0, because the merged payload can no longer
guarantee versions and size for every file.
The cap is per project. A layer only lowers the version when it returns a page for the requested project; a layer that does not carry the project has no say in its version.
An HTML upstream cannot reach 1.1
PEP 700 changes the JSON serialization alone; it leaves the HTML form unchanged from 1.0. The HTML serialization
defines no versions array, so a page in that form carries none of PEP 700's guarantees however high its
pypi:repository-version reads. peryx re-serves an HTML upstream as JSON, where that promise would become peryx's own,
so an HTML detail page is held at 1.0 rather than promoted on the strength of a <meta> tag.
Version-derivation scope
- It does not synthesize
versionsorsizeto reach1.4. When the upstream promises neither, peryx lowers the version rather than inventing the fields. - It does not echo the upstream's exact minor. Any
1.1+maps to1.4, peryx's ceiling, not to the number the upstream sent. - It does not serve an unsupported major or an unparseable version. Those are errors, not a page.
gpg-sig marker
The Simple API can mark a file as having a detached OpenPGP signature next to it.
PEP 503 spells the marker data-gpg-sig on the HTML anchor,
PEP 691 spells it gpg-sig on the JSON file object, and the legacy PyPI JSON API
spells it has_sig. All three mean the same thing: a signature is served as an .asc sibling of the file URL, at
{file_url}.asc.
Retention
peryx keeps the marker when it serves a file at its upstream URL unchanged, a pass-through. That happens when peryx
has no sha256 to content-address the file by, so it does not rewrite the URL. The upstream .asc sits next to the
upstream file, which is still where the file URL points, so the marker stays true and peryx passes it through.
Removal
peryx drops the marker when it content-addresses the file, rewriting the file URL to its own
/{route}/files/{sha256}/{filename} route (see endpoints). At that route
peryx serves the blob and the PEP 658 .metadata sibling, and nothing else. There
is no .asc there, so peryx clears the marker rather than advertise a signature it will not serve. A file carries a
sha256 in almost every real index, so this is the common case.
The rule holds across all three surfaces, and both serving paths agree on it:
| Surface | Marker | Content-addressed file | Pass-through file |
|---|---|---|---|
| PEP 691 JSON | gpg-sig | omitted | passed through |
| PEP 503 HTML | data-gpg-sig | omitted | passed through |
| Legacy PyPI JSON | has_sig | false | reflects upstream |
The JSON simple API served to pip and uv streams through one transformer; the HTML page and the legacy JSON are
rendered from the buffered resolve path. Both clear the marker on the same condition, so a file reads the same way
whichever surface a client asks for.
Signature-serving scope
For a content-addressed file, peryx serves the artifact blob at /{route}/files/{sha256}/{filename}, its core metadata
at .../{filename}.metadata, and an advertised provenance object at .../{filename}.provenance. It does not serve
an .asc at .../{filename}.asc; that route returns 404. The detached signature lived at the upstream URL, which
peryx has replaced with its own for a content-addressed file, so dropping the marker keeps the page honest about what is
reachable.
Provenance and attestations
A file uploaded with PEP 740 attestations advertises a provenance object; a file
without them advertises none. {route} below is the index's route.
| Surface | Key / attribute | With attestations | Without |
|---|---|---|---|
| PEP 691 JSON | provenance | /{route}/files/{sha256}/{filename}.provenance | omitted |
| PEP 503 HTML | data-provenance | the same URL, as an escaped attribute | omitted |
Route
GET /{route}/files/{sha256}/{filename}.provenance returns the provenance object this route's publication carries. A
digest and filename this route publishes with no hosted or registered upstream provenance returns 404, even when
another index publishes the same bytes with a bundle.
Body
The response is {"version": 1, "attestation_bundles": [{"publisher": null, "attestations": [...]}]}. The publisher
is null because peryx does not resolve a Trusted Publisher identity. The uploaded attestations are served verbatim.
Upstream policy
upstream_attestations = "direct" preserves the upstream URL and makes no provenance request. "proxy" publishes
peryx's route and fetches the body for each request without retaining it. "cache" publishes the same route. It retains
a structurally accepted, unverified body and revalidates stale bodies with ETag or Last-Modified. The default is
direct.
Source state
A local response has X-Peryx-Provenance-Source: hosted|<configured-source> and
X-Peryx-Provenance-Availability: cached|remote-only. Hosted bodies are immutable. Upstream bodies use no-cache
because the provenance URL may keep pointing at a changed document.
Validation and failure
peryx accepts the PEP 740 media type or application/json; each version 1 document must contain a publisher with a
non-empty string kind and one or more typed attestations. claims is optional and accepts an object or null. The
limit is 2 MiB. peryx follows upstream redirects and credential controls. It also honors configured timeouts and
retries. A separate per-source concurrency pool reserves project-page slots while peryx fetches attestations. no-cache
forces revalidation; no-store clears any retained body and validators. After a transient refresh failure, peryx may
serve the previous accepted body within the repository's stale bound. peryx rejects an invalid replacement without
overwriting the previous body or affecting the distribution.
Security claim
peryx does not verify upstream provenance. It reports the configured source without claiming a publisher identity. It does not verify signatures or certificates and does not consult transparency logs.
- Visibility. The provenance is reachable only through the file's
provenanceURL, so it tracks the file: a yanked file keeps it, a trashed file drops it, a restore returns it. The upload rules cover the validation and limits.
Trailing-slash redirects
The Simple API canonical URLs end in a
slash. A request that drops the slash on the index or a project is redirected to the slashed form rather than answered
with a 404. {route} below is the index's route, for example root/pypi.
Rule
| Request | Response | Location |
|---|---|---|
GET /{route}/simple | 301 | /{route}/simple/ |
GET /{route}/simple/{project} | 301 | /{route}/simple/{normalized}/ |
GET /{route}/simple/ | 200 | served directly, not redirected |
GET /{route}/simple/{project}/ | 200 | served directly, not redirected |
The status is 301 Moved Permanently, the same status pypi.org (Warehouse) returns. {normalized} is {project} after
PEP 503 normalization.
Details
- Normalization. The project segment in the
Locationis normalized: lowercased, with every run of.,-, or_collapsed to a single-.Flask.Testredirects to/{route}/simple/flask-test/. An already-canonical name redirects to itself with the slash appended. - Query string. Any query string on the request is preserved on the
Locationunchanged.GET /{route}/simple/Flask.Test?extra=1redirects to/{route}/simple/flask-test/?extra=1. - Location form. The
Locationis a path (host-absolute), built from the request path with the route prefix intact, so the redirect stays on the same origin and works behind a proxy or under a nested route. - A project segment with a slash is not redirected. The redirect fires only for a single segment after
simple/. A path with a further slash, such as/{route}/simple/some/thing, is not a project name, is not redirected, and falls through to a404. - Already-slashed URLs are served, not redirected.
/{route}/simple/and/{route}/simple/{project}/are the canonical URLs; they return their content directly. Content negotiation, policy, and caching apply as normal. - Method. The redirect is defined for
GETon these two Simple read paths. It does not change the upload, yank, delete, files, inspect, or legacy JSON routes.
Operational checks
- Serving rationale: Simple API serving
- Diagnose a mirror stuck at
1.0, move a tool off the marker, or follow the redirect: diagnose Simple API serving - End-to-end examples: Simple API behavior
- The standards these implement: standards