Table of Contents
Contents
How to Build and Publish Your Python Package
When you’re ready to share a Python library, whether that’s a tiny utility you hacked together on a weekend or a mission-critical internal SDK, you’ll want to package it properly. A package makes installation as simple as pip install yourlib
, gives users explicit version numbers to pin, and lets you publish updates without copy-pasting code between projects.
In this article, we will explain how to package your Python code and push it to a repository using pip, twine, and the setup.py file. We’ll also cover the modern pyproject.toml
approach, which is becoming the standard way to configure Python packages.
Python packaging has evolved over time:
- setup.py (traditional): The classic approach using Python code for configuration
- setup.cfg (declarative): An INI-style configuration file that works alongside setup.py
- pyproject.toml (modern): The newest standard (PEP 517/518/621) that works with any build backend
While setup.py
remains widely used, we’ll show you both approaches so you can choose what works best for your project or migrate existing packages to the newer standard. Directly running commands like python setup.py bdist_wheel
is now discouraged and officially deprecated in modern setuptools
; use python -m build
or pip install .
instead. Since pip 23.1, these tools invoke an isolated PEP 517 build environment automatically.
Table of Contents
- Project Structure
setup.py
(Legacy Approach)pyproject.toml
(Modern Approach)- Building Distributions
- Uploading to Test PyPI
- Uploading to a Private Index
- Uploading to the Public PyPI
- Versioning & Release Etiquette
- Conclusion
Project Structure
Here’s an example of how you can structure your Python package:
awesomepkg/
├── src/
│ └── awesomepkg/
│ ├── __init__.py
│ └── core.py
├── tests/
│ └── test_core.py
├── setup.py # Traditional approach
├── pyproject.toml # Modern approach (can replace setup.py)
├── LICENSE
├── README.md
└── .gitignore
This simple project structure separates your package code, tests, and documentation files to keep everything organized. You’ll typically use either setup.py
or pyproject.toml
, though during migration you might have both.
setup-py-legacy-approach
setup.py
is the traditional way to define your Python package metadata and build requirements. It provides flexibility through Python code, allowing you to dynamically customize your build process.
from setuptools import setup, find_packages
# Read the content of README.md
with open("README.md", "r", encoding="utf-8") as fh:
long_description = fh.read()
setup(
name="awesomepkg",
version="0.1.0",
author="Jane Doe",
author_email="jane@example.com",
description="Awesome helpers for doing X and Y",
long_description=long_description,
long_description_content_type="text/markdown",
url="https://github.com/janedoe/awesomepkg",
license="MIT",
classifiers=[
"Programming Language :: Python :: 3",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
],
keywords="awesome helpers utility",
package_dir={"": "src"},
packages=find_packages(where="src"),
python_requires=">=3.8",
install_requires=[
"requests>=2.31",
"numpy>=1.26",
],
extras_require={
"plot": ["matplotlib>=3.9"],
},
entry_points={
"console_scripts": [
"awesome-cli=awesomepkg.core:main",
],
},
)
install_requires
lists runtime dependencies.- Extras (
pip install awesomepkg[plot]
) are defined inextras_require
. - Entry points create a CLI wrapper after install.
The power of setup.py
comes from its flexibility - you can execute arbitrary Python code to configure your package. This allows for dynamic version detection, conditional dependencies, and custom build steps.
For full API reference, check the Python Packaging User Guide (“Distributing packages using setuptools”) (Python Packaging).
pyproject.toml
(Modern Approach)
While setup.py
has been the standard for years, the Python community has moved toward a more declarative and standardized approach. Enter pyproject.toml
- a modern configuration file introduced in PEP 518 and extended in PEP 621.
pyproject.toml
is the modern, standardized way to declare build requirements and package metadata. It lives in your project root and works with any PEP 517-compatible build backend (setuptools, Poetry, Flit, Hatch, etc.). Starting with pip 23.1, pyproject.toml
metadata is fully supported, making this approach future-proof.
Below is an example that uses setuptools as the build backend (closest to the setup.py
style you already know). The [project]
table (PEP 621) replaces most arguments previously passed to setup()
, while [build-system]
tells pip how to build the project.
[build-system]
requires = [
"setuptools>=65",
"wheel",
]
build-backend = "setuptools.build_meta"
[project]
name = "awesomepkg"
version = "0.1.0"
description = "Awesome helpers for doing X and Y"
readme = "README.md"
authors = [
{ name = "Jane Doe", email = "jane@example.com" },
]
license = { text = "MIT" }
requires-python = ">=3.8"
keywords = ["awesome", "helpers", "utility"]
classifiers = [
"Programming Language :: Python :: 3",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
]
dependencies = [
"requests>=2.31",
"numpy>=1.26",
]
[project.optional-dependencies]
plot = [
"matplotlib>=3.9",
]
[project.scripts]
awesome-cli = "awesomepkg.core:main"
# For dynamic version from your package's __init__.py
# [tool.setuptools.dynamic]
# version = {attr = "awesomepkg.__version__"}
Why switch to pyproject.toml
?
- Single-file configuration – no more mixing
setup.py
,setup.cfg
, andMANIFEST.in
for simple projects. - Backend-agnostic – swap
setuptools
forpoetry.core
,flit_core
, orhatchling
without changing your CLI workflow. - Isolation & Reproducibility – build requirements are pinned in
[build-system]
, so build environments are consistent. - Fully declarative – metadata is purely descriptive, making builds more predictable and safer.
Dynamic values with pyproject.toml
For projects that need dynamic configuration (like reading version from a file), you can use setuptools.dynamic
:
[project]
# ... other fields
dynamic = ["version"]
[tool.setuptools.dynamic]
version = {attr = "awesomepkg.__version__"} # Read from __init__.py
# Or from a text file:
# version = {file = "VERSION.txt"}
This allows you to maintain a fully declarative configuration while still handling dynamic values.
Building & Publishing remains identical
Once pyproject.toml
is present, the same commands you used earlier still apply:
python -m build # creates dist/ just like before
python -m twine upload dist/*
If you migrate from setup.py
, keep both files during the transition (setuptools will prefer pyproject.toml
when present). After successful migration and testing, you can safely delete setup.py
to avoid confusion.
For a deeper dive into the spec, see PEP 621 and the Packaging Guide – “Managing project metadata”.
Building Distributions
Regardless of whether you use setup.py
or pyproject.toml
, the build process remains the same thanks to the standardized build interface provided by PEP 517. The build
package takes care of the details for you.
Install the build tool once:
python -m pip install --upgrade build
From a clean working tree:
# (Optional) commit any pending changes
git add -A && git commit -m "feat: first public release"
# Build both sdist and wheel into dist/
python -m build
Note that while pip install .
still works for local development, it’s slower and bypasses build isolation. Using python -m build
followed by pip install dist/*.whl
is the recommended approach.
# Verify metadata
python -m pip install --upgrade twine
python -m twine check dist/*
After running the build command, dist/
will contain two types of distributions:
awesomepkg-0.1.0.tar.gz
(sdist): A source distribution containing your raw source code and build instructions. This allows users to build the package from source if needed.awesomepkg-0.1.0-py3-none-any.whl
(wheel): A pre-built distribution that can be installed directly without compilation, resulting in faster installations.
It’s best practice to upload both formats to PyPI. Source distributions ensure your package can be installed on platforms without pre-built wheels, while wheels provide faster installation for supported platforms.
Uploading to Test PyPI
1. Create a Test PyPI account
Head to https://test.pypi.org/manage/account/ and generate an API token.
2. Configure ~/.pypirc
[distutils]
index-servers =
pypi
testpypi
[testpypi]
repository = https://test.pypi.org/legacy/
username = __token__
password = <YOUR_TEST_PYPI_TOKEN>
[pypi]
repository = https://upload.pypi.org/legacy/
username = __token__
password =
(.pypirc
spec details (Python Packaging))
3. Upload and install back
python -m twine upload --repository testpypi dist/*
pip install -i https://test.pypi.org/simple/ awesomepkg==0.1.0
More in the “Using TestPyPI” guide (Python Packaging).
Uploading to a Private Index
Need to keep code in-house? Twine can target any index that speaks the PyPI API (Artifactory, Nexus, Cloudsmith, pypiserver, AWS CodeArtifact, etc.).
Example: Nexus Repository Manager
Nexus Repository Manager is a popular self-hosted repository solution used by many enterprises. To use it with your Python packages:
# Configure your .pypirc file for Nexus
cat > ~/.pypirc << EOF
[distutils]
index-servers =
pypi
nexus
[nexus]
repository = https://nexus.example.com/repository/pypi-internal/
username = your-username
password = your-password
EOF
# Upload your package to Nexus
python -m twine upload --repository nexus dist/*
To install packages from your Nexus repository:
# Add Nexus as a trusted source
pip config set global.index-url https://your-username:your-password@nexus.example.com/repository/pypi-internal/simple/
# Or for a one-time install
pip install --index-url https://your-username:your-password@nexus.example.com/repository/pypi-internal/simple/ awesomepkg
Uploading to the Public PyPI
-
Generate a production token in your PyPI account.
-
Paste the token into the
[pypi]
section of~/.pypirc
. -
Upload:
python -m twine upload dist/*
-
Double-check:
pip install awesomepkg==0.1.0
Versioning & Release Etiquette
Rule | Why it matters |
---|---|
Semantic Versioning (MAJOR.MINOR.PATCH ) | Signals compatibility expectations (breaking = MAJOR). |
Git tags (git tag v0.1.0 && git push --tags ) | Bind code snapshots to published artifacts. |
Changelog (CHANGELOG.md ) | Human-readable diffs help users upgrade confidently. |
Yanking over deleting | Yank bad releases (twine yank ) so pins break gracefully instead of 404s. |
Version-spec rules are formalised in PEP 440 (Python Packaging).
Conclusion
Packaging Python code doesn’t have to be complicated. We’ve walked through the entire process from structuring your project to publishing it on PyPI. Whether you choose the traditional setup.py
approach or the modern pyproject.toml
standard, the core workflow remains similar.
The Python packaging ecosystem continues to evolve, but the fundamentals we’ve covered here will serve you well for years to come. Now go build something awesome and share it with the world!