Documentation

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 sourceperyx servesWhy
hosted by peryx1.4peryx supplies every required field
upstream JSON declares 1.1, 1.2, 1.3, 1.4, 1.5, … (minor ≥ 1)1.4PEP 700 makes versions and per-file size mandatory here
upstream JSON declares 1.0, or omits api-version1.0PEP 691 mandates neither field
a PEP 503 HTML detail page, whatever version it declares1.0PEP 700 leaves the HTML form unchanged from 1.0
upstream declares a major other than 1 (2.0, …)rejectedperyx does not support the major version
upstream declares a version that does not parse (1.x, abc)rejectedperyx rejects the invalid version
upstream JSON declares 1.1+ and omits versions or a file sizerejectedthe 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 versions or size to reach 1.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 to 1.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:

SurfaceMarkerContent-addressed filePass-through file
PEP 691 JSONgpg-sigomittedpassed through
PEP 503 HTMLdata-gpg-sigomittedpassed through
Legacy PyPI JSONhas_sigfalsereflects 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.

SurfaceKey / attributeWith attestationsWithout
PEP 691 JSONprovenance/{route}/files/{sha256}/{filename}.provenanceomitted
PEP 503 HTMLdata-provenancethe same URL, as an escaped attributeomitted

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 provenance URL, 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

RequestResponseLocation
GET /{route}/simple301/{route}/simple/
GET /{route}/simple/{project}301/{route}/simple/{normalized}/
GET /{route}/simple/200served directly, not redirected
GET /{route}/simple/{project}/200served 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 Location is normalized: lowercased, with every run of ., -, or _ collapsed to a single -. Flask.Test redirects 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 Location unchanged. GET /{route}/simple/Flask.Test?extra=1 redirects to /{route}/simple/flask-test/?extra=1.
  • Location form. The Location is 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 a 404.
  • 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 GET on these two Simple read paths. It does not change the upload, yank, delete, files, inspect, or legacy JSON routes.

Operational checks

On this page