Lab 1.1 — Publish a Python Library to PyPI using UV#
?> What you’ll build
?> 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.0And 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-YOURNAMEInspect 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?> src-layout matters
?> --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.0Step 4 — Add a test
uv add --dev pytestCreate 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 -vAll 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 = "[email protected]" }
]
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"]?> Why 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-YOURNAMEUsage#
from tds_hello_YOURNAME import greet
print(greet("TDS")) # Hello, TDS! — from tds-hello v0.1.0License#
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'))" > LICENSEStep 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/*.whlVerify 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 --pushGo 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.
?> Did it fail?
?> Common errors:
?> - 403 Forbidden — name is already taken; change it.
?> - 400 Bad metadata — fix pyproject.toml and rerun uv 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.
?> What is Trusted Publishing? ?> 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/*?> Why two stages? ?> 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 -vCommit and push:
git add .github/
git commit -m "ci: add release and test workflows"
git pushGo 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.0Go 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:def shout(name: str = "world") -> str: return greet(name).upper() - Update the test.
- Bump version in
pyproject.tomlfrom0.1.0to0.2.0. - Commit:
git add -A git commit -m "feat: add shout()" git push git 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.
Knowledge Check#
Q1. Why do we use a src/ layout when building a Python library?
- A) It is required by PyPI for all new packages
- B) It forces tests to run against the installed version of the package, preventing import bugs
- C) It makes the package download size smaller
- D) It is a requirement for using the UV package manager
Answer
B — The src/ layout ensures that your code is not importable directly from the project root. This forces pytest to use the installed package (just like your users will), catching issues where files are missing from the build.
Q2. What is the primary benefit of PyPI Trusted Publishing (OIDC) over traditional API tokens?
- A) It allows publishing directly from your local terminal without a password
- B) It eliminates the need to manage and store long-lived secrets in GitHub Actions
- C) It makes the upload speed to PyPI significantly faster
- D) It bypasses the need to have a PyPI account
Answer
B — Trusted Publishing uses short-lived tokens generated on the fly. You don’t need to save a PyPI password or token in GitHub Secrets, eliminating the risk of a leaked token.
Q3. Why should you always test your release on TestPyPI before the real PyPI?
- A) TestPyPI automatically fixes broken Python code
- B) TestPyPI is required by GitHub Actions before deploying to PyPI
- C) PyPI does not allow you to re-upload or reuse a version number if you make a mistake
- D) TestPyPI gives you free compute resources to run your unit tests
Answer
C — PyPI’s strict immutability means if you upload a broken v0.1.0, you can never upload a fixed v0.1.0. TestPyPI lets you catch packaging errors before you burn a version number on the real index.
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
References#
- UV publishing guide
- UV in GitHub Actions
- PyPI Trusted Publishing
- Sample repo: astral-sh/trusted-publishing-examples