I recently published my first package to PyPI. Along the way, I made every mistake possible. Here's what I learned so you don't have to.
Start with pyproject.toml
Forget setup.py. The modern standard is pyproject.toml:
[project]
name = "my-awesome-tool"
version = "0.1.0"
description = "A tool that does awesome things"
readme = "README.md"
requires-python = ">=3.10"
license = {text = "MIT"}
authors = [
{name = "Your Name", email = "you@example.com"}
]
keywords = ["cli", "automation", "awesome"]
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
]
dependencies = [
"click>=8.0",
"httpx>=0.27",
]
[project.urls]
Homepage = "https://github.com/yourusername/my-awesome-tool"
Repository = "https://github.com/yourusername/my-awesome-tool"
Documentation = "https://github.com/yourusername/my-awesome-tool#readme"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"This single file replaces setup.py, setup.cfg, and MANIFEST.in. Everything lives in one place.
src Layout vs Flat Layout
You have two choices:
Flat Layout
my-awesome-tool/
├── my_awesome_tool/
│ ├── __init__.py
│ └── main.py
├── tests/
└── pyproject.toml
Simpler. Your package lives at the root.
src Layout (I recommend this)
my-awesome-tool/
├── src/
│ └── my_awesome_tool/
│ ├── __init__.py
│ └── main.py
├── tests/
└── pyproject.toml
Why add the extra src/ folder? Because it prevents a subtle bug.
With flat layout, when you run python from your project root, Python can import your package directly from the working directory—even if it's not installed. You might write code that works locally but breaks when users install it from PyPI.
The src/ layout forces you to install the package to import it. If it works locally, it'll work for users.
I learned this the hard way after publishing a broken package. Use src/.
Dependencies vs Dev Dependencies
Runtime dependencies go in [project.dependencies]:
[project]
dependencies = [
"click>=8.0",
"httpx>=0.27",
"pydantic>=2.0",
]These get installed when someone runs pip install my-awesome-tool.
Development dependencies go in optional dependencies:
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"ruff>=0.4",
"mypy>=1.10",
"build>=1.0",
"twine>=5.0",
]Install them with pip install -e ".[dev]".
Key rule: Don't put test frameworks or linters in runtime dependencies. Your users don't need pytest.
Version Constraints
For libraries, use minimum versions:
dependencies = ["click>=8.0"] # 8.0 or newerDon't pin exact versions (click==8.1.7) unless you have a specific reason. Exact pins cause dependency conflicts when your package is used alongside others.
For applications you deploy (not distribute), pinning is fine.
Entry Points: CLI Commands
Entry points let users run your code as a command:
[project.scripts]
awesome = "my_awesome_tool.cli:main"After pip install my-awesome-tool, users can run awesome from anywhere.
The format is command-name = "module.path:function".
Here's the function it points to:
# src/my_awesome_tool/cli.py
import click
@click.command()
@click.argument("name")
def main(name: str) -> None:
"""Say hello to NAME."""
click.echo(f"Hello, {name}!")
if __name__ == "__main__":
main()You can define multiple commands:
[project.scripts]
awesome = "my_awesome_tool.cli:main"
awesome-init = "my_awesome_tool.cli:init"
awesome-run = "my_awesome_tool.cli:run"Plugin Entry Points
If you're building something extensible, you can define plugin entry points:
[project.entry-points."my_awesome_tool.plugins"]
builtin = "my_awesome_tool.plugins.builtin:BuiltinPlugin"Other packages can register their own plugins under the same group. Your tool discovers them at runtime.
Building with build and Hatch
The Standard Way: python-build
# Install the build tool
pip install build
# Build your package
python -m buildThis creates two files in dist/:
my_awesome_tool-0.1.0.tar.gz— source distributionmy_awesome_tool-0.1.0-py3-none-any.whl— wheel (what pip actually installs)
Wheels are faster to install because they're pre-built. Always include both.
Using Hatch
Hatch is a modern project manager that handles building, versioning, and more:
pip install hatch
# Build
hatch build
# Run tests in isolated environment
hatch run test
# Bump version
hatch version minor # 0.1.0 -> 0.2.0To use Hatch for building, your pyproject.toml needs:
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"Hatchling is the build backend; Hatch is the CLI tool. You can use hatchling as your build backend without using the full Hatch workflow.
Dynamic Versioning
Instead of updating the version in multiple places, derive it from one source:
[project]
dynamic = ["version"]
[tool.hatch.version]
path = "src/my_awesome_tool/__init__.py"# src/my_awesome_tool/__init__.py
__version__ = "0.1.0"Or from git tags:
[tool.hatch.version]
source = "vcs"
[tool.hatch.build.hooks.vcs]
version-file = "src/my_awesome_tool/_version.py"Now git tag v1.0.0 && git push --tags sets your package version.
Publishing to PyPI
First: Test on TestPyPI
Don't publish directly to PyPI. Test first:
pip install twine
# Upload to TestPyPI
twine upload --repository testpypi dist/*Try installing it:
pip install --index-url https://test.pypi.org/simple/ my-awesome-toolDoes it work? Great. Does it crash? Fix it before real users see it.
Publish to PyPI
Once you're confident:
twine upload dist/*You'll need a PyPI account and API token. Create one at pypi.org, then configure:
# ~/.pypirc
[pypi]
username = __token__
password = pypi-your-api-token-hereOr pass credentials directly:
twine upload dist/* -u __token__ -p pypi-your-api-tokenThe Full Release Workflow
Here's my release checklist:
# 1. Make sure tests pass
pytest tests/ -v
# 2. Update version in __init__.py (if not using dynamic versioning)
# 3. Update CHANGELOG.md
# 4. Clean old builds
rm -rf dist/ build/ *.egg-info/
# 5. Build fresh
python -m build
# 6. Check the package
twine check dist/*
# 7. Test upload
twine upload --repository testpypi dist/*
# 8. Test install from TestPyPI
pip install --index-url https://test.pypi.org/simple/ my-awesome-tool
# 9. Real upload
twine upload dist/*
# 10. Tag the release
git tag v0.1.0
git push --tagsCommon Gotchas
Package name vs import name. Your PyPI package might be my-awesome-tool (with hyphens), but the import is my_awesome_tool (with underscores). They don't have to match, but it's less confusing if they're similar.
Forgetting py.typed. If you include type hints and want them available to users, add an empty py.typed file in your package root:
src/my_awesome_tool/
├── __init__.py
├── py.typed # Empty file
└── cli.py
Including test files. By default, hatchling includes everything not in .gitignore. Explicitly exclude tests:
[tool.hatch.build]
exclude = ["tests/", "docs/"]README not rendering. Make sure your readme = "README.md" actually points to an existing file, and that it's valid Markdown. PyPI shows raw text if parsing fails.
A Complete Example
Here's a minimal distributable package:
my-tool/
├── src/
│ └── my_tool/
│ ├── __init__.py
│ ├── py.typed
│ └── cli.py
├── tests/
│ └── test_cli.py
├── pyproject.toml
├── README.md
└── LICENSE
pyproject.toml:
[project]
name = "my-tool"
version = "0.1.0"
description = "Does a thing"
readme = "README.md"
requires-python = ">=3.10"
license = {text = "MIT"}
dependencies = ["click>=8.0"]
[project.scripts]
my-tool = "my_tool.cli:main"
[project.optional-dependencies]
dev = ["pytest>=8.0", "build", "twine"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"src/my_tool/init.py:
__version__ = "0.1.0"src/my_tool/cli.py:
import click
@click.command()
def main() -> None:
click.echo("Hello from my-tool!")That's it. Build it, upload it, share it with the world.
What I'd Do Differently
Looking back at my first package:
- Use src layout from the start. I wasted hours debugging import issues.
- Test on TestPyPI first. My first real PyPI upload had a broken README.
- Don't overcomplicate dependencies. Start minimal, add as needed.
- Write the README before publishing. An empty README looks unprofessional.
Packaging seems scary until you do it once. Then it's just a checklist.