Test Simple API behavior
Use curl to test upstream-derived meta.api-version values, gpg-sig filtering on content-addressed files, marker
retention on pass-through files, and canonical redirects for slashless URLs. peryx advertises only metadata supported by
the served bytes.
Prerequisites
You need a peryx binary (installation lists the channels), Python 3, and
curl. Work in a scratch directory. Each part below writes its own peryx.toml; stop the previous
peryx before starting the next.
Part 1: derive the advertised version
Serve two upstreams through peryx: pypi.org, which declares PEP 700 1.1, and a
bare PEP 503 HTML index with no version. Their served meta.api-version values are
1.4 and 1.0. A virtual index containing both serves 1.0. The pypi.org request needs network access.
A PEP 503 index is a directory of HTML pages, one per project, with no version metadata. Create one project page:
mkdir -p up/simple/sampleproject<!-- up/simple/sampleproject/index.html -->
<!DOCTYPE html>
<html>
<head>
<title>
Links for sampleproject
</title>
</head>
<body>
<a href="sampleproject-1.0.0-py3-none-any.whl">
sampleproject-1.0.0-py3-none-any.whl
</a>
</body>
</html>
There is no pypi:repository-version meta tag, so this index promises neither versions nor size. Serve the tree:
python3 -m http.server 8000 --directory up
Leave it running and use another terminal. Write a config with two cached indexes, pypi.org and the local tree:
# peryx.toml
data_dir = "peryx-data"
[[index]] # declares api-version 1.1 or newer
ecosystem = "pypi"
name = "pypi"
[[index.upstream]]
name = "primary"
url = "https://pypi.org/simple/"
[[index]] # a bare PEP 503 HTML tree: no version declared
ecosystem = "pypi"
name = "local"
[[index.upstream]]
name = "primary"
url = "http://127.0.0.1:8000/simple/"peryx serve --config peryx.toml
peryx listens on 127.0.0.1:4433. Use a third terminal for the requests. Fetch sampleproject through the
pypi.org-backed route and print only the advertised version:
curl -s -H "Accept: application/vnd.pypi.simple.v1+json" \
http://127.0.0.1:4433/pypi/simple/sampleproject/ \
| python3 -c 'import sys, json; print(json.load(sys.stdin)["meta"]["api-version"])'
It prints 1.4. pypi.org declares 1.1 or newer, so its pages carry PEP 700's versions and size. peryx passes them
through and keeps its 1.4 ceiling. Now fetch the same project through the local route:
curl -s -H "Accept: application/vnd.pypi.simple.v1+json" \
http://127.0.0.1:4433/local/simple/sampleproject/ \
| python3 -c 'import sys, json; print(json.load(sys.stdin)["meta"]["api-version"])'
It prints 1.0. The bare HTML page declared no version, so it promises neither field. peryx serves 1.0 rather than
labelling the page 1.4 and implying fields it cannot guarantee. Add a virtual index that stacks both layers, and
restart peryx:
[[index]] # uploads-free stack: hosted-style precedence, both upstreams
ecosystem = "pypi"
name = "both"
layers = ["local", "pypi"]curl -s -H "Accept: application/vnd.pypi.simple.v1+json" \
http://127.0.0.1:4433/both/simple/sampleproject/ \
| python3 -c 'import sys, json; print(json.load(sys.stdin)["meta"]["api-version"])'
It prints 1.0. Both layers carry sampleproject, and the local layer serves 1.0, so the merged page takes the
lower version. A virtual index is only as capable as its weakest layer: one pre-PEP 700 layer caps the whole page.
Part 2: filter the gpg-sig marker
Front a small static index whose files both advertise a GPG signature. peryx drops gpg-sig from a file moved to its
content-addressed route and retains the marker on a pass-through file. Build one project page listing two files marked
data-gpg-sig="true": one anchor carries a #sha256= fragment and the other carries no hash.
mkdir -p static/demo
: > static/demo-1.0-py3-none-any.whl
: > static/demo-1.0.post1-py3-none-any.whl
sha=$(python3 -c "import hashlib; print(hashlib.sha256(open('static/demo-1.0-py3-none-any.whl','rb').read()).hexdigest())")
cat > static/demo/index.html <<EOF
<!DOCTYPE html>
<html><body>
<a href="../demo-1.0-py3-none-any.whl#sha256=$sha" data-gpg-sig="true">demo-1.0-py3-none-any.whl</a>
<a href="../demo-1.0.post1-py3-none-any.whl" data-gpg-sig="true">demo-1.0.post1-py3-none-any.whl</a>
</body></html>
EOF
Serve the directory on port 8000 and leave it running:
python3 -m http.server 8000 --directory static
demo-1.0 has a sha256, so peryx can content-address it. demo-1.0.post1 has none, so peryx cannot, and will leave
its URL alone. In a second terminal, point a cached index at the static server and start peryx:
# peryx.toml
[[index]] # cached: read-through cache of the static index
ecosystem = "pypi"
name = "static"
[[index.upstream]]
name = "primary"
url = "http://127.0.0.1:8000/"peryx serve --config peryx.toml
peryx listens on 127.0.0.1:4433. Ask peryx for the project page as JSON, the form pip and uv read:
curl -s -H "Accept: application/vnd.pypi.simple.v1+json" \
http://127.0.0.1:4433/static/simple/demo/ | python3 -m json.tool
Look at the two file objects. The content-addressed file and the pass-through file diverge on both url and gpg-sig:
{
"filename": "demo-1.0-py3-none-any.whl",
"url": "/static/files/e3b0c442.../demo-1.0-py3-none-any.whl"
}{
"filename": "demo-1.0.post1-py3-none-any.whl",
"url": "http://127.0.0.1:8000/demo-1.0.post1-py3-none-any.whl",
"gpg-sig": true
}
demo-1.0 had a sha256, so peryx rewrote its url to its own /static/files/... route and dropped the gpg-sig
field: the field is gone, not false. demo-1.0.post1 had no hash, so peryx left its url pointing upstream and kept
gpg-sig: true. The upstream .asc is still next to that upstream URL, so the marker is still true there.
The same split shows in the PEP 503 HTML page. Fetch it and read the two anchors:
curl -s http://127.0.0.1:4433/static/simple/demo/
The demo-1.0 anchor points at /static/files/... and carries no data-gpg-sig; the demo-1.0.post1 anchor keeps its
upstream href and its data-gpg-sig="true". Both serving surfaces agree, because both clear the marker on the same
condition. The marker follows the file URL peryx hands out, nothing else.
Part 3: test a slashless URL redirect
Send Simple API requests without trailing slashes and inspect the canonical redirects. The read path needs no
configuration. Start the server on its default route root/pypi:
peryx serve
It listens on http://127.0.0.1:4433. Use curl -i so you see the status line and headers, and ask for the index
without the slash:
curl -i http://127.0.0.1:4433/root/pypi/simple
peryx answers with a 301, not a page:
HTTP/1.1 301 Moved Permanently
location: /root/pypi/simple/
The Location header carries the canonical URL: the same path with the trailing slash restored. Now request a project,
again without the slash, and use a mixed-case name with a dot in it:
curl -i http://127.0.0.1:4433/root/pypi/simple/Flask.TestHTTP/1.1 301 Moved Permanently
location: /root/pypi/simple/flask-test/
Two things happened at once. The trailing slash was restored, and the name was normalized: Flask.Test became
flask-test. PEP 503 folds a name to lowercase and collapses any run of ., -, or _ to a single -, so the
redirect lands on the one canonical spelling of the project rather than a variant. Add -L and curl follows the
Location to the real page:
curl -iL http://127.0.0.1:4433/root/pypi/simple/flask
You see the 301 first, then the 200 with the project detail. Any client that follows redirects lands on the page in
one extra round trip. Finally, append a query string and it survives the redirect intact:
curl -i "http://127.0.0.1:4433/root/pypi/simple/Flask.Test?extra=1"HTTP/1.1 301 Moved Permanently
location: /root/pypi/simple/flask-test/?extra=1
The ?extra=1 rides along to the canonical URL, so a request that carried parameters does not lose them.
Results
The advertised version came back 1.4 from pypi.org, 1.0 from a bare index, and 1.0 from a stack that included the
bare index; the gpg-sig marker was dropped for the file peryx content-addressed and kept for the one it passed
through; and a slashless URL returned a 301 to the slashed, normalized form. In every case peryx advertised what its
bytes guarantee: the version the payload satisfies, a signature only where an .asc is reachable, and the one canonical
URL for a project.
Next steps
- The exact rules across JSON, HTML, and legacy JSON: Simple API serving
- Diagnose a real mirror or move a tool off the marker: diagnose Simple API serving
- Why peryx serves this way: what peryx serves on the Simple API