Publish and manage a release
In this tutorial you drive four uploads that peryx once refused and now accepts, in one sitting against one running
server. You publish a historical wheel whose .dist-info directory predates PEP 503 normalization, publish another
wheel declaring only a legacy md5_digest, yank a release by a version spelling it was not uploaded with, and delete a
project whose name is the verb yank. It takes about half an hour, and shows that peryx takes the same uploads and
addresses releases the same way pypi.org and pip do.
Prerequisites
You need a peryx binary (installation lists the channels), Python 3 with
pip, build, twine, and
uv, and curl. Work in a scratch directory.
Start peryx with an upload token
Uploads are off until a hosted index has a token. Write a config that sets one and start peryx; every part below reuses it:
# peryx.toml
[[index]] # cached: read-through cache of pypi.org
name = "pypi"
cached = "https://pypi.org/simple/"
[[index]] # hosted: your own uploads, gated by the token
name = "hosted"
upload_token = "demo-secret"
[[index]] # virtual: uploads shadow upstream behind one URL
name = "root/pypi"
layers = ["hosted", "pypi"]
upload = "hosted"peryx serve --config peryx.toml
peryx listens on 127.0.0.1:4433. Leave it running and use a second terminal. peryx accepts any username on an upload;
the token is the password, matching the pypi.org __token__ convention.
Part 1: upload a historical wheel
Flask 0.12 shipped in 2016, before the ecosystem settled on normalized
.dist-info names. Download its wheel from pypi.org:
pip download Flask==0.12 --no-deps --only-binary :all: --dest dist
Look inside it and note the directory name:
unzip -l dist/Flask-0.12-py2.py3-none-any.whl | grep dist-info
The directory is Flask-0.12.dist-info, mixed case. The filename normalizes to flask, so the directory name and the
normalized filename are not byte-for-byte equal. pip installs this wheel every day; the question is whether peryx will
take it on upload. Publish it:
twine upload --repository-url http://127.0.0.1:4433/root/pypi/ \
-u __token__ -p demo-secret dist/Flask-0.12-py2.py3-none-any.whl
twine reports the upload succeeded. peryx matched Flask-0.12.dist-info to the flask-0.12 filename by normalizing the
name and parsing the version, rather than demanding the exact bytes, so the wheel passed validation. Before the change
that made peryx accept these, this same upload returned a 400.
Ask the index for the project page and find your file, then install it back through peryx to prove the round trip:
curl -s -H "Accept: application/vnd.pypi.simple.v1+json" \
http://127.0.0.1:4433/root/pypi/simple/flask/ | python3 -m json.tool | grep -A1 Flask-0.12
python -m venv check
check/bin/pip install --index-url http://127.0.0.1:4433/root/pypi/simple/ Flask==0.12
The wheel you published, un-normalized .dist-info and all, installed straight back out of peryx. A directory that
named a different project or version would still be rejected: peryx accepts a different spelling of the right identity,
never the wrong one.
Part 2: publish with an MD5-only client
twine always sends SHA-256, BLAKE2, and MD5 together, so to send MD5 alone you post the upload form yourself with
curl. Download a small pure-Python wheel and compute its MD5, the one digest you will declare:
pip download six==1.16.0 --no-deps --only-binary :all: --dest dist
MD5=$(python3 -c "import hashlib,sys;print(hashlib.md5(open(sys.argv[1],'rb').read()).hexdigest())" \
dist/six-1.16.0-py2.py3-none-any.whl)
echo "$MD5"
That value is what an MD5-only client would put in the md5_digest field. Send it and nothing stronger: the file in the
content part, the MD5 declared, and sha256_digest and blake2_256_digest left off entirely:
curl -sS -u __token__:demo-secret http://127.0.0.1:4433/root/pypi/ \
-F ":action=file_upload" \
-F "name=six" \
-F "version=1.16.0" \
-F "filetype=bdist_wheel" \
-F "md5_digest=$MD5" \
-F "content=@dist/six-1.16.0-py2.py3-none-any.whl"
The request returns 200 with no error body. peryx staged the wheel, saw that MD5 was the only digest you declared,
computed the MD5 of the bytes it received, found it equal to what you sent, and stored the file. Before the change that
made peryx accept this, the same MD5-only upload returned a 400.
Change one character of the digest and post again to watch the check fire:
curl -sS -u __token__:demo-secret http://127.0.0.1:4433/root/pypi/ \
-F ":action=file_upload" \
-F "name=six" \
-F "version=1.16.0" \
-F "filetype=bdist_wheel" \
-F "md5_digest=00000000000000000000000000000000" \
-F "content=@dist/six-1.16.0-py2.py3-none-any.whl"
peryx answers 400 with md5_digest mismatch. A declared MD5 is verified, not trusted: the right one passes and the
wrong one is refused, the same way SHA-256 is.
Ask the index for the project page and look at the hash on your file:
curl -s -H "Accept: application/vnd.pypi.simple.v1+json" \
http://127.0.0.1:4433/root/pypi/simple/six/ | python3 -m json.tool | grep -A3 1.16.0
The entry carries a sha256 hash and no md5. You declared MD5 on upload, but peryx content-addresses and serves the
file by SHA-256, so every installer verifies it the strong way. MD5 never appeared in what peryx published downstream.
Part 3: yank a release by an equivalent version
Now publish a release versioned exactly 1.0, then yank it by addressing 1.0.0, and watch the yank land even though
the two spellings are not byte-identical. Create a minimal project:
mkdir demo && cd demo# pyproject.toml
[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"
[project]
name = "demo-pkg"
version = "1.0"
[tool.setuptools]
py-modules = ["demo_pkg"]touch demo_pkg.py
python -m build
The build writes dist/demo_pkg-1.0.tar.gz and dist/demo_pkg-1.0-py3-none-any.whl. The version on both filenames, and
in the metadata twine records, is 1.0. Publish it and confirm the release is live and not yet yanked:
twine upload --repository-url http://127.0.0.1:4433/root/pypi/ \
-u __token__ -p demo-secret dist/*
curl -s -H "Accept: application/vnd.pypi.simple.v1+json" \
http://127.0.0.1:4433/root/pypi/simple/demo-pkg/ | python3 -m json.tool | grep -A2 filename
The files list "yanked": false. Now yank the release, but address it as 1.0.0 rather than the 1.0 it was published
with:
curl -X PUT -u __token__:demo-secret \
http://127.0.0.1:4433/root/pypi/demo-pkg/1.0.0/yank
peryx answers 200 with a non-zero count of files changed. 1.0.0 is the same release as 1.0 under
PEP 440, so the request reached both files. Before peryx compared versions this
way, the same request matched nothing, returned zero, and left the release live. Read the project page again:
curl -s -H "Accept: application/vnd.pypi.simple.v1+json" \
http://127.0.0.1:4433/root/pypi/simple/demo-pkg/ | python3 -m json.tool | grep yanked
Both files now report "yanked": true. A resolver skips them, while a build pinned to the exact version can still fetch
them, exactly as PEP 592 prescribes. A request for 1.0.0 would still never touch
1.0.1; equality is one release, not a range.
cd ..Part 4: delete a project named yank
yank is a real project name on pypi.org and a legal one under PEP 503, yet it is
also the verb peryx puts in the URL that yanks a file. Give a throwaway project that name and build a wheel:
mkdir yank-demo && cd yank-demo
cat > pyproject.toml <<'EOF'
[project]
name = "yank"
version = "1.0"
EOF
mkdir -p src/yank && touch src/yank/__init__.py
uv build
dist/ now holds yank-1.0-py3-none-any.whl and its sdist. Publish it and confirm it resolves:
uv publish --publish-url http://127.0.0.1:4433/root/pypi/ -u __token__ -p demo-secret dist/*
curl -s http://127.0.0.1:4433/root/pypi/simple/yank/ | grep yank
The project name and the action word are both yank, so the project segment comes first and the action second:
# yank every file of the project
curl -X PUT -u __token__:demo-secret http://127.0.0.1:4433/root/pypi/yank/yank
# un-yank it
curl -X DELETE -u __token__:demo-secret http://127.0.0.1:4433/root/pypi/yank/yank
Each answers 200 with the number of files affected. Now the request that used to fail. Deleting the whole project
addresses it with a trailing slash and no action word:
curl -X DELETE -u __token__:demo-secret http://127.0.0.1:4433/root/pypi/yank/
peryx returns 200 and the file count, and the project is gone:
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:4433/root/pypi/simple/yank/ # 404
Before the fix, peryx read the trailing yank/ as an un-yank of a project with no name and answered 400, leaving a
project named yank impossible to delete at the project level. The project segment in front of the action is what tells
peryx which one you mean.
What you saw
peryx accepted a historical wheel with an un-normalized .dist-info, an upload whose only declared digest was a legacy
MD5, a yank addressed by an equivalent version spelling, and the project-level delete of a project named after a
mutation verb. Each was once a 400 or a silent no-op; each now matches what pypi.org and pip do, and peryx served
every file addressed by SHA-256 regardless of what the uploader declared.
Where next
- Do this in your own upload flow: publish packages and yank and delete packages
- The exact rules, tables, and error strings: upload rules
- Why peryx accepts these inputs: what peryx accepts on upload