Python

How to Build and Publish Your Python Package

Zachary Carciu 11 min read

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

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 in extras_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, and MANIFEST.in for simple projects.
  • Backend-agnostic – swap setuptools for poetry.core, flit_core, or hatchling 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

  1. Generate a production token in your PyPI account.

  2. Paste the token into the [pypi] section of ~/.pypirc.

  3. Upload:

    python -m twine upload dist/*
    
  4. Double-check:

    pip install awesomepkg==0.1.0
    

Versioning & Release Etiquette

RuleWhy 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 deletingYank 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!