Publishing with build & twine

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the Python Reference bibliography.

This page publishes the pip-flavoured birdtally with the standard tools: build makes the files, twine checks and uploads them. Every command below ran on 2026-10-22 against a local pypiserver 2.4.2 on localhost:8084 that requires HTTP basic authentication for uploads (pypi-server run -p 8084 -a update -P htpasswd packages), with build 1.6.1, twine 7.0.0 and pip 26.2.2 on CPython 3.14.6. The TestPyPI and PyPI commands at the end are not run here. The short introduction is on Virtual Environments & Packaging; running the index itself is on Private Indexes & Alternative Distribution.

Never use python setup.py upload: the upload and register commands were removed from setuptools long ago, and they sent credentials in ways twine replaced for good reasons.

The round trip

rm -rf dist/
python -m build
twine check --strict dist/*
export TWINE_USERNAME=birdwatch
export TWINE_PASSWORD=…              # from your secret store
twine upload --disable-progress-bar --repository-url http://localhost:8084/ dist/*
Successfully built birdtally-0.1.0.tar.gz and birdtally-0.1.0-py3-none-any.whl
Checking dist/birdtally-0.1.0-py3-none-any.whl: PASSED
Checking dist/birdtally-0.1.0.tar.gz: PASSED
Uploading distributions to http://localhost:8084/
Uploading birdtally-0.1.0-py3-none-any.whl
Uploading birdtally-0.1.0.tar.gz

Then, from a fresh environment, install the release from the index and run it:

python -m venv /tmp/from-index
/tmp/from-index/bin/python -m pip install --index-url http://localhost:8084/simple/ birdtally
/tmp/from-index/bin/birdtally examples/survey.csv
Collecting birdtally
  Downloading birdtally-0.1.1-py3-none-any.whl (3.7 kB)
Installing collected packages: birdtally
Successfully installed birdtally-0.1.1
{
  "lapwing": 7,
  "redshank": 2
}

(by then 0.1.1 had also been uploaded, see below, and pip picked the newest). Clean dist/ before each release build: twine upload dist/* uploads whatever it finds there, including last release’s files.

With a .pypirc repository

Name the index once in .pypirc (see PyPI & TestPyPI Accounts & Tokens) and refer to it with -r:

[distutils]
index-servers =
    birdwatch

[birdwatch]
repository = http://localhost:8084/
username = birdwatch
twine upload --disable-progress-bar --config-file pypirc-demo -r birdwatch dist/*
Uploading distributions to http://localhost:8084/
Uploading birdtally-0.1.1-py3-none-any.whl
Uploading birdtally-0.1.1.tar.gz

The password came from TWINE_PASSWORD; the default --config-file is ~/.pypirc.

When the upload fails

The file already exists

Uploading the same 0.1.0 files a second time:

Uploading distributions to http://localhost:8084/
Uploading birdtally-0.1.0-py3-none-any.whl
WARNING  Error during upload. Retry with the --verbose option for more details.
ERROR    HTTPError: 400 Bad Request from http://localhost:8084/
         Bad Request

--verbose shows the server’s reason, here pypiserver’s HTML page: Package 'birdtally-0.1.0-py3-none-any.whl' already exists! You may start server with `--overwrite` option.. PyPI answers a re-upload with Filename or contents already exists or Filename has been previously used, and there is no override: a filename can never be reused, even after the file, the release or the whole project is deleted. The fix is always a new version (a .post1 if only packaging changed).

--skip-existing makes twine skip files the index already has, but twine only supports it for PyPI:

ERROR    UnsupportedConfiguration: The configured repository
         'http://localhost:8084/' does not have support for the following
         features: --skip-existing and is an unsupported configuration

(twine 6.2.0 removed its workarounds for other indexes.)

Wrong credentials

Uploading distributions to http://localhost:8084/
Uploading birdtally-0.1.0-py3-none-any.whl
WARNING  Error during upload. Retry with the --verbose option for more details.
ERROR    HTTPError: 403 Forbidden from http://localhost:8084/
         Forbidden

PyPI’s equivalent is 403 Invalid or non-existent authentication information; its causes are on PyPI & TestPyPI Accounts & Tokens. --non-interactive (used here) fails instead of prompting, which is what CI needs.

PyPI-only errors (not run here)

  • Invalid distribution metadata: unrecognized or malformed field '…': a metadata field PyPI does not accept; twine 7.0.0 rejects metadata version "2.0" and accepts up to 2.5.

  • HTTPError: 400 Client Error: File too large. Limit for project 'sampleproject' is 100 MB.: the default limits are 100 MB per file and 10 GB per project; increases are requested through PyPI support.

  • Transient 5xx responses: twine retries, printing Received "503: Service Unavailable", Package upload appears to have failed. Retry 1 of 5. and so on (from twine’s source; the status varies).

  • Anyone writing their own uploader: form fields must use the upload API’s names; a field such as Description-Content-Type sent in its metadata spelling is silently ignored.

  • Since 2026-07-22 PyPI also refuses new files for a release that is more than 14 days old (see Managing Published Releases).

twine upload reference

Option Meaning

-r, --repository NAME

A section of the config file (default pypi); TWINE_REPOSITORY.

--repository-url URL

An upload URL, overriding -r; TWINE_REPOSITORY_URL.

-u, --username

TWINE_USERNAME. "Has no effect on PyPI or TestPyPI", where it is always __token__.

-p, --password

TWINE_PASSWORD. Prefer the variable or keyring: a command-line password lands in shell history and process lists.

--non-interactive

Fail instead of prompting for missing credentials; TWINE_NON_INTERACTIVE.

--config-file

Another .pypirc.

--skip-existing

Skip files that already exist (PyPI only).

--attestations

Upload each file’s PEP 740 attestation (<file>.publish.attestation) next to it; PyPI and TestPyPI only. See Trusted Publishing & Attestations.

-s, --sign, --sign-with, -i, --identity

GPG signatures. No longer useful on PyPI, which dropped support for them; attestations took their place.

-c, --comment

A comment stored with the file (legacy).

--cert, --client-cert

A CA bundle (TWINE_CERT) and a client certificate for the index.

--verbose, --disable-progress-bar

More detail; no progress bars in logs.

Keyring: twine looks up the password with keyring (installed with it) by repository URL and username. Where the keyring backend prompts unexpectedly (headless Linux, CI), the twine documentation’s fix is keyring --disable. Proxies: twine uses requests, so HTTPS_PROXY and NO_PROXY apply. twine register exists for other indexes; PyPI answers that registration "is not required with PyPI.org".

Recent twine releases: 5.0.0 (2024-02-10) requires __token__ for PyPI; 6.0.0 (2024-11-29) makes it the default username; 6.1.0 (2025-01-17) adds preliminary built-in Trusted Publishing and drops eggs, wininst and .tar.bz2; 6.2.0 (2025-09-04) refreshes Trusted Publishing tokens every 15 minutes; 7.0.0 (2026-07-27) accepts metadata 2.5, rejects "2.0" and reads .pypirc as UTF-8.

TestPyPI, then PyPI (not run here)

# rehearsal: needs a TestPyPI account and token
TWINE_PASSWORD="$TESTPYPI_TOKEN" twine upload --repository testpypi dist/*
python -m pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ birdtally

# the release: a project-scoped PyPI token, or better Trusted Publishing from CI
TWINE_PASSWORD="$PYPI_TOKEN" twine upload dist/*

Without -r, twine uploads to pypi (https://upload.pypi.org/legacy/). Each upload of a version needs fresh filenames on each index, so rehearse with dev versions on TestPyPI. From CI, publish with Trusted Publishing instead of a token: GitHub Actions & GitLab CI Release Workflows.