Lab 1.1 — Publish a Python Library to PyPI using UV
A real Python package, installable by the world via pip install your-package-name, published to PyPI from GitHub Actions using Trusted Publishing (no API tokens, no secrets in your repo). We'll use UV for every step.
Time: 60–90 minutes. Difficulty: ⭐⭐☆☆☆. Ship: your name on pypi.org.
What the Finished Thing Looks Like
By the end:
pip install tds-hello-<yourname>
python -c "from tds_hello import greet; print(greet('World'))"
# Hello, World! — from tds-hello v0.1.0
And every git tag v* push auto-publishes a new version.
Prerequisites
- UV installed (see uv.mdx)
- GitHub account +
ghCLI authenticated - PyPI account with 2FA enabled (required)
- TestPyPI account (separate) with 2FA
- Python 3.11+ via
uv python install 3.13
The Steps
Each step below is collapsed by default. Click to expand, run the commands, then move to the next step.
Step 1 — Pick a unique package name
Your package name must be globally unique on PyPI and on TestPyPI. Use a prefix like tds-hello-<yourname> to guarantee uniqueness.
Check availability:
# If this returns 404, the name is free.
curl -s -o /dev/null -w "%{http_code}\n" https://pypi.org/project/tds-hello-yourname/
Pick a name that:
- Lowercase letters, numbers, hyphens only
- Starts with a letter
- Isn't confusingly similar to a famous project
For the rest of this lab, I'll use tds-hello-YOURNAME. Substitute your actual name every time you see it.
Step 2 — Scaffold the project with UV
# Pick a Python version and scaffold a library (src layout)
uv init --lib --python 3.13 tds-hello-YOURNAME
cd tds-hello-YOURNAME
Inspect what UV created:
tree -a -I '.git|.venv'
You should see:
tds-hello-YOURNAME/
├── .git/
├── .gitignore
├── .python-version
├── README.md
├── pyproject.toml
└── src/
└── tds_hello_YOURNAME/
├── __init__.py
└── py.typed
--lib gives you a src/ layout. This is best practice because it forces tests to run against the installed version, not the source directory. You'll avoid a whole class of import bugs.
Step 3 — Write the library code
Open src/tds_hello_YOURNAME/__init__.py and replace the contents:
"""tds-hello — a tiny greeter from the TDS 2026 course."""
from importlib.metadata import version as _v
__version__ = _v("tds-hello-YOURNAME")
def greet(name: str = "world") -> str:
"""Return a friendly greeting with the package version."""
if not isinstance(name, str):
raise TypeError("name must be a str")
return f"Hello, {name}! — from tds-hello v{__version__}"
Quick sanity-check:
uv run python -c "from tds_hello_YOURNAME import greet; print(greet('TDS'))"
# Hello, TDS! — from tds-hello v0.1.0
Step 4 — Add a test
uv add --dev pytest
Create tests/test_greet.py:
import pytest
from tds_hello_YOURNAME import greet
def test_default():
assert greet() == "Hello, world! — from tds-hello v0.1.0"
def test_custom_name():
assert "Alice" in greet("Alice")
def test_invalid_type():
with pytest.raises(TypeError):
greet(42) # type: ignore[arg-type]
Run:
uv run pytest -v
All three tests should pass.
Step 5 — Polish the pyproject.toml
Open pyproject.toml and fill in the metadata:
[project]
name = "tds-hello-YOURNAME"
version = "0.1.0"
description = "A tiny greeter library from TDS 2026 at IIT Madras."
readme = "README.md"
license = "MIT"
requires-python = ">=3.11"
authors = [
{ name = "Your Name", email = "you@example.com" }
]
keywords = ["tds", "iit-madras", "greeter"]
classifiers = [
"Development Status :: 3 - Alpha",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Education",
]
dependencies = []
[project.urls]
Homepage = "https://github.com/YOUR-USERNAME/tds-hello-YOURNAME"
Issues = "https://github.com/YOUR-USERNAME/tds-hello-YOURNAME/issues"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[dependency-groups]
dev = ["pytest>=8"]
hatchling?UV uses hatchling as the default build backend — it's PyPA-maintained, fast, and configuration-free for most projects. Leave this section alone unless you know what you're doing.
Write a proper README:
# tds-hello-YOURNAME
A tiny Python greeter, published as part of **Tools in Data Science** at IIT Madras (May 2026).
## Install
```bash
pip install tds-hello-YOURNAME
Usage
from tds_hello_YOURNAME import greet
print(greet("TDS")) # Hello, TDS! — from tds-hello v0.1.0
License
MIT
Add a LICENSE file:
```bash
curl -s https://api.github.com/licenses/mit | uv run python -c "import json, sys; print(json.load(sys.stdin)['body'].replace('[year]', '2026').replace('[fullname]', 'Your Name'))" > LICENSE
Step 6 — Build locally and inspect the artifact
uv build
ls -la dist/
You should see two files:
tds_hello_YOURNAME-0.1.0-py3-none-any.whl— the wheel (binary installable)tds_hello_YOURNAME-0.1.0.tar.gz— the source distribution (sdist)
Peek inside the wheel:
unzip -l dist/*.whl
Verify it installs correctly in an isolated env:
uv run --isolated --no-project --with dist/*.whl python -c "from tds_hello_YOURNAME import greet; print(greet())"
If this prints the greeting, your wheel is good.
Step 7 — Push to GitHub
git add .
git commit -m "feat: initial release v0.1.0"
# Create a GitHub repo and push
gh repo create tds-hello-YOURNAME --public --source=. --remote=origin --push
Go to the repo in your browser — you should see all your files.
Step 8 — Reserve the name on TestPyPI (first-publish-only step)
Before trusted publishing can work, you need to tell PyPI/TestPyPI what GitHub workflow is allowed to publish.
First, do a one-time manual upload to TestPyPI to claim the name.
Create a TestPyPI API token:
- Go to test.pypi.org/manage/account/ → API tokens.
- Create a new token, scope: "Entire account" (we'll delete it after first upload).
- Copy the
pypi-Ag...token.
Upload to TestPyPI:
# Configure UV to know about TestPyPI
export UV_PUBLISH_URL=https://test.pypi.org/legacy/
export UV_PUBLISH_TOKEN=pypi-Ag... # paste the token
uv publish dist/*
Visit https://test.pypi.org/project/tds-hello-YOURNAME/ — you should see your package.
Common errors:
403 Forbidden— name is already taken; change it.400 Bad metadata— fixpyproject.tomland rerunuv build.The user YOURNAME isn't allowed to upload to project ...— token scope wrong.
Now delete that token from TestPyPI (we'll use Trusted Publishing from now on).
Step 9 — Configure Trusted Publishing on TestPyPI
- Go to your TestPyPI project →
Manage→Publishing. - Under Add a new trusted publisher → GitHub, enter:
- PyPI Project Name:
tds-hello-YOURNAME - Owner: your GitHub username
- Repository name:
tds-hello-YOURNAME - Workflow name:
release.yml - Environment name:
testpypi
- PyPI Project Name:
- Click Add.
Now repeat the same on the real PyPI — except you use the pending publisher flow (since you haven't uploaded to PyPI yet):
- Go to pypi.org/manage/account/publishing/ → Add a new pending publisher.
- Fill in the same details, with Environment name:
pypi. - Save.
Trusted Publishing (a.k.a. OIDC publishing) lets PyPI verify that a publish request came from a specific GitHub Actions workflow using short-lived OIDC tokens — no long-lived secrets to manage or leak. This is now the recommended way to publish.
Step 10 — Create GitHub environments
On GitHub → your repo → Settings → Environments:
- Create environment
testpypi. Optionally add Required reviewers for extra safety. - Create environment
pypi. Definitely add Required reviewers (yourself) — this means every prod release requires a manual click.
Step 11 — Write the publish workflow
Create .github/workflows/release.yml:
name: Release
on:
push:
tags:
- 'v*' # v0.1.0, v1.2.3, ...
jobs:
build:
name: Build distribution
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
- name: Set up Python
run: uv python install 3.13
- name: Build
run: uv build
- name: Smoke test (wheel)
run: uv run --isolated --no-project --with dist/*.whl python -c "from tds_hello_YOURNAME import greet; print(greet('ci'))"
- name: Upload dist/
uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
publish-testpypi:
name: Publish to TestPyPI
needs: build
runs-on: ubuntu-latest
environment:
name: testpypi
url: https://test.pypi.org/project/tds-hello-YOURNAME/
permissions:
id-token: write
steps:
- name: Download dist/
uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- name: Install uv
uses: astral-sh/setup-uv@v7
- name: Publish
run: uv publish --index testpypi dist/*
env:
UV_PUBLISH_URL: https://test.pypi.org/legacy/
publish-pypi:
name: Publish to PyPI
needs: publish-testpypi
runs-on: ubuntu-latest
environment:
name: pypi
url: https://pypi.org/project/tds-hello-YOURNAME/
permissions:
id-token: write
steps:
- name: Download dist/
uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- name: Install uv
uses: astral-sh/setup-uv@v7
- name: Publish
run: uv publish dist/*
TestPyPI is your staging environment — catch bad metadata or missing files before they hit the real PyPI (which you cannot re-upload to with the same version number).
Step 12 — Also add a CI workflow for tests
.github/workflows/ci.yml:
name: CI
on:
push:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.11', '3.12', '3.13']
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
- name: Install Python
run: uv python install ${{ matrix.python-version }}
- name: Install dependencies
run: uv sync --all-groups
- name: Run tests
run: uv run pytest -v
Commit and push:
git add .github/
git commit -m "ci: add release and test workflows"
git push
Go to Actions tab on GitHub — the CI workflow should run and pass.
Step 13 — Tag and release
# Make sure everything is committed and pushed
git status
git push
# Create an annotated tag
git tag -a v0.1.0 -m "v0.1.0 — first release"
git push origin v0.1.0
Go to Actions tab. You'll see the Release workflow running. It will:
- Build + smoke-test
- Publish to TestPyPI
- Pause waiting for your approval on the
pypienvironment - After you click Review → Approve, publish to the real PyPI
Watch the jobs run. When publish-pypi goes green, visit https://pypi.org/project/tds-hello-YOURNAME/.
Your package is live on PyPI!
Step 14 — Install your own package from PyPI
# In a fresh directory:
mkdir /tmp/install-test && cd /tmp/install-test
uvx --from tds-hello-YOURNAME python -c "from tds_hello_YOURNAME import greet; print(greet())"
If that prints your greeting — you have shipped a Python library to the world.
Step 15 — Ship a v0.2.0 to confirm the workflow
- Edit
src/tds_hello_YOURNAME/__init__.py, add ashout()function:pythondef shout(name: str = "world") -> str:return greet(name).upper() - Update the test.
- Bump version in
pyproject.tomlfrom0.1.0to0.2.0. - Commit:
bashgit add -Agit commit -m "feat: add shout()"git pushgit tag -a v0.2.0 -m "v0.2.0 — add shout()"git push origin v0.2.0
- Watch the release workflow run again. Approve. Installed users can now
pip install --upgrade tds-hello-YOURNAME.
Troubleshooting
"403 Forbidden" on uv publish
- The package name on PyPI is already taken. Choose a different name (you'll need to update
pyproject.toml, the GitHub environments, and the trusted-publisher config on PyPI). - Your GitHub environment name doesn't match what you entered on PyPI. Fix the mismatch.
- You forgot
permissions: id-token: writein the workflow.
"Trusted publishing exchange failure"
This is almost always a config mismatch between GitHub and PyPI. Double-check:
- Owner matches your GitHub username/org exactly (case-sensitive).
- Repository name matches exactly.
- Workflow filename is just
release.yml, not.github/workflows/release.yml. - Environment name matches the one in your workflow.
"The name X is already in use."
Someone else already claimed this name. Rename your package.
Version conflict: "File already exists"
PyPI does not allow re-uploading the same version. Bump the version in pyproject.toml, commit, and tag again.
What You've Learned
- Scaffolding a proper Python library with
src/layout using UV. - Writing
pyproject.tomlmetadata the PyPA way. - Building sdist + wheel with
uv build. - Publishing with
uv publish— locally with tokens, then from CI with Trusted Publishing. - A proper two-stage (TestPyPI → PyPI) release workflow with manual approval.
- Matrix CI testing across Python versions.
Write a Blog Post
Publish a Discourse blog post covering:
- What "Trusted Publishing" is and why it's more secure than API tokens.
- The two-stage release workflow pattern.
- One gotcha you hit and how you solved it.
Next Lab
Lab 1.2 — UV CLI tool + LaTeX docs PDF on GitHub Pages