Create and Publish a Python Package to PyPI: Complete Guide
Publishing a package to PyPI (Python Package Index) lets anyone install it with pip install your-package. This tutorial covers the modern approach with pyproject.toml (PEP 517/518).
1. Modern package structure
my_package/
├── src/
│ └── my_package/
│ ├── __init__.py
│ ├── core.py
│ ├── utils.py
│ └── py.typed # Marks package as typed
├── tests/
│ ├── __init__.py
│ ├── test_core.py
│ └── test_utils.py
├── docs/
│ └── index.md
├── pyproject.toml
├── README.md
├── LICENSE
└── .gitignore
2. pyproject.toml
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "my-package"
version = "0.1.0"
description = "A sample Python package"
readme = "README.md"
license = { file = "LICENSE" }
authors = [{ name = "Your Name", email = "you@example.com" }]
requires-python = ">=3.9"
keywords = ["python", "conversion", "files"]
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
]
dependencies = [
"requests>=2.28.0",
"Pillow>=10.0.0",
]
[project.urls]
Homepage = "https://github.com/youruser/my-package"
Documentation = "https://my-package.readthedocs.io"
Repository = "https://github.com/youruser/my-package"
"Bug Tracker" = "https://github.com/youruser/my-package/issues"
[project.optional-dependencies]
dev = [
"pytest>=7.0",
"pytest-cov>=4.0",
"mypy>=1.0",
"ruff>=0.1.0",
]
excel = ["openpyxl>=3.1"]
[project.scripts]
my-tool = "my_package.cli:main"
3. Package code
# src/my_package/__init__.py
"""My sample package."""
__version__ = "0.1.0"
__author__ = "Your Name"
__all__ = ["convert", "validate"]
from .core import convert, validate
# src/my_package/core.py
from pathlib import Path
from typing import Optional
def convert(
source: str | Path,
dest: str | Path,
*,
quality: int = 85,
overwrite: bool = False,
) -> Path:
"""
Convert a file to another format.
Args:
source: Input file path.
dest: Output file path.
quality: Compression quality (1-100). Default 85.
overwrite: If True, overwrite existing destination.
Returns:
Path to the converted file.
Raises:
FileNotFoundError: If source file does not exist.
FileExistsError: If destination exists and overwrite=False.
"""
source = Path(source)
dest = Path(dest)
if not source.exists():
raise FileNotFoundError(f"File not found: {source}")
if dest.exists() and not overwrite:
raise FileExistsError(f"File exists: {dest}. Use overwrite=True.")
# ... conversion logic ...
return dest
def validate(path: str | Path, extensions: Optional[list[str]] = None) -> bool:
"""Validate that a file has an allowed extension."""
path = Path(path)
if extensions is None:
return path.exists()
return path.suffix.lower() in {ext.lower() for ext in extensions}
# src/my_package/cli.py
import argparse, sys
from .core import convert
def main():
parser = argparse.ArgumentParser(prog="my-tool", description="Convert files.")
parser.add_argument("source", help="Input file")
parser.add_argument("dest", help="Output file")
parser.add_argument("--quality", type=int, default=85)
parser.add_argument("--force", action="store_true")
args = parser.parse_args()
try:
result = convert(args.source, args.dest, quality=args.quality, overwrite=args.force)
print(f"Converted: {result}")
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
4. Tests
# tests/test_core.py
import pytest
from pathlib import Path
from my_package.core import convert, validate
def test_validate_valid_extension(tmp_path):
f = tmp_path / "photo.jpg"
f.write_bytes(b"FAKE")
assert validate(f, [".jpg", ".png"])
def test_validate_invalid_extension(tmp_path):
f = tmp_path / "photo.jpg"
f.write_bytes(b"FAKE")
assert not validate(f, [".png", ".webp"])
def test_convert_source_not_found(tmp_path):
with pytest.raises(FileNotFoundError):
convert(tmp_path / "missing.jpg", tmp_path / "out.png")
def test_convert_dest_exists_no_overwrite(tmp_path):
src = tmp_path / "input.txt"
dst = tmp_path / "output.txt"
src.write_text("hello")
dst.write_text("already here")
with pytest.raises(FileExistsError):
convert(src, dst, overwrite=False)
5. Semantic versioning
MAJOR.MINOR.PATCH
0.1.0 → First alpha release
0.2.0 → New feature (backward compatible)
0.2.1 → Bug fix
1.0.0 → First stable release (public API defined)
1.1.0 → New stable feature
2.0.0 → Breaking changes
6. Build and publish to PyPI
pip install build twine
# Build distributions
python -m build
# Produces: dist/my_package-0.1.0-py3-none-any.whl
# dist/my_package-0.1.0.tar.gz
# Test on TestPyPI first
twine upload --repository testpypi dist/*
pip install --index-url https://test.pypi.org/simple/ my-package
# Publish to real PyPI
twine upload dist/*
# (requires pypi.org account and API token)
# After publishing
pip install my-package
7. Configure PyPI token
# ~/.pypirc
[distutils]
index-servers = pypi testpypi
[pypi]
username = __token__
password = pypi-AgENdGVzdC...
[testpypi]
repository = https://test.pypi.org/legacy/
username = __token__
password = pypi-AgENdGVzdC...
Or via environment variable:
TWINE_USERNAME=__token__ TWINE_PASSWORD=pypi-your-token twine upload dist/*
8. GitHub Actions: auto-publish on release
# .github/workflows/publish.yml
name: Publish to PyPI
on:
release:
types: [published]
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- run: pip install build twine
- run: python -m build
- run: twine upload dist/*
env:
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
9. Best practices
- Use
src/layout: prevents accidental imports from root during tests. - Strict semver: breaking public API changes increment MAJOR.
- Document with docstrings: Google, NumPy or reStructuredText format.
py.typed: empty file in the package indicating type annotation support.- Never commit API keys: use
.gitignoreand keep.pypircoutside the repo. - Test on TestPyPI before publishing to real PyPI.
- Single
__version__: define in__init__.pyor read fromimportlib.metadata.
from importlib.metadata import version, PackageNotFoundError
try:
__version__ = version("my-package")
except PackageNotFoundError:
__version__ = "0.0.0" # In development
Related conversions
Frequent conversions across the catalogue: