Python packaging was confusing for years (setup.py, setup.cfg, requirements.txt, MANIFEST.in...). In 2026, it's converging on pyproject.toml + src/ layout + uv.
The recommended project structure (2026)
my-package/
├── pyproject.toml # single source of metadata + deps + tool config
├── README.md
├── LICENSE
├── .python-version # for pyenv / uv
├── src/
│ └── my_package/ # actual package code
│ ├── __init__.py
│ ├── __main__.py # if it's a runnable module
│ ├── core.py
│ └── utils.py
├── tests/
│ ├── conftest.py
│ ├── test_core.py
│ └── test_utils.py
├── docs/
└── .github/
└── workflows/
└── ci.yml
Why src/ layout
Without src/, your package directory is in the project root:
my-package/
├── my_package/
│ └── ...
└── tests/
This causes a subtle bug: tests can import my_package even without installing it (because the cwd is on sys.path). You might be testing your local files instead of the installed package — bugs lurk.
With src/:
- Tests must use the installed version (forced by Python import rules).
- Catches packaging bugs early.
- Industry standard.
Trade-off: slightly more verbose. Worth it.
pyproject.toml — the single source of truth
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "my-package"
version = "0.1.0"
description = "A short description"
readme = "README.md"
requires-python = ">=3.11"
license = "MIT"
authors = [
{ name = "Your Name", email = "you@example.com" }
]
dependencies = [
"requests>=2.31",
"pydantic>=2.0",
]
[project.optional-dependencies]
dev = [
"pytest>=7.0",
"pyright",
"ruff",
]
[project.scripts]
my-cli = "my_package.cli:main" # creates `my-cli` command on install
[project.urls]
Homepage = "https://github.com/you/my-package"
[tool.hatch.build.targets.wheel]
packages = ["src/my_package"]
[tool.pytest.ini_options]
testpaths = ["tests"]
[tool.pyright]
include = ["src", "tests"]
typeCheckingMode = "strict"
[tool.ruff]
line-length = 100
target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP"]
One file: project metadata + dependencies + tool configurations. Replaces setup.py, setup.cfg, requirements.txt, separate config files for ruff, pytest, etc.
The build system
build-system specifies how to build the package:
- hatchling (modern, fast, recommended).
- setuptools (legacy but works).
- poetry-core (if using Poetry).
- flit-core (minimal).
For most new projects: hatchling.
Installing for development
# Editable install: changes in src/ reflect immediately
pip install -e .
# With dev dependencies
pip install -e ".[dev]"
Editable install creates a link, not a copy. Edits to your source code take effect without reinstall. Standard for development.
Dependency management tools (2026 landscape)
uv (recommended in 2026)
# Create project
uv init my-package
# Add dependency
uv add requests
# Add dev dependency
uv add --dev pytest
# Install everything
uv sync
# Run a script in the env
uv run python script.py
Fast (Rust-based; 10-100x faster than pip), correct (PEP 621 + lockfile), and replaces virtualenv + pip + pip-tools.
In 2026, uv is the modern default. Use it for new projects.
Poetry
poetry add requests
poetry install
poetry run python script.py
Mature, widely used. Slower than uv but feature-rich. Use if your team is already on it.
pip + pip-tools (the simple stack)
# requirements.in (you write this)
requests>=2.31
pydantic>=2.0
# Compile to requirements.txt with pinned versions
pip-compile requirements.in
pip install -r requirements.txt
Works everywhere; minimal magic. Use for simple projects or CI environments.
Conda (data science world)
Conda is its own ecosystem; used heavily in DS/ML. Not really compatible with the pip world. Use if your team / domain depends on it (PyData stack).
Lockfiles
A lockfile pins exact versions of all dependencies (including transitive):
uv.lock(uv)poetry.lock(poetry)requirements.txtfrom pip-compile (pip-tools)
Why: reproducible builds. Same code on different machines = same dependencies.
Commit lockfiles to git. They're not "human-edited"; tools update them.
Versioning
Use semver (Major.Minor.Patch):
- Major: breaking changes.
- Minor: new features, backward compatible.
- Patch: bug fixes.
0.x.y # pre-1.0; anything can change
1.0.0 # first stable release; commit to API
1.1.0 # new features
1.1.1 # bug fix
2.0.0 # breaking changes
In pyproject.toml:
[project]
version = "0.1.0"
For automated versioning: hatch-vcs reads version from git tags.
Publishing to PyPI
# Build wheel + sdist
uv build # or: python -m build
# Upload (need PyPI API token)
uv publish # or: twine upload dist/*
Need a PyPI account; configure API token in ~/.pypirc or env.
Internal packages
For private packages (company-internal):
- GitHub Packages: install via
pip install git+https://.... - Private PyPI server: pypiserver, devpi, or AWS CodeArtifact.
- Direct git URLs in dependencies:
dependencies = [
"internal-utils @ git+ssh://git@github.com/company/utils.git@v1.2.0",
]
Common project-structure mistakes
- Code in project root (no src/). Tests can import without install.
- Multiple requirements.txt files (dev, prod, test). Use
[project.optional-dependencies]instead. - No lockfile. Builds break on dependency updates.
setup.pyin new projects. Usepyproject.tomlexclusively.- Hardcoded version in code. Read from package metadata or git tag.
Takeaway
Modern Python projects in 2026: src/ layout + pyproject.toml + uv (or Poetry/pip-tools). Single config file for metadata, deps, and tool settings. Editable install (pip install -e ".[dev]") for development. Lockfile committed to git for reproducibility. Hatchling for builds. uv for new projects — fast and correct.
Production Note: Including Non-Python Package Data in pyproject.toml
Non-Python files (such as .json schemas, .sql queries, or .yaml files) inside package folders are excluded from wheels by default unless declared as package data.
In pyproject.toml using modern build backends:
# Hatchling backend:
[tool.hatch.build.targets.wheel.force-include]
"src/mypackage/data" = "mypackage/data"
# Setuptools backend:
[tool.setuptools.package-data]
"mypackage" = ["*.json", "queries/*.sql"]
To load package data reliably at runtime without relying on filesystem paths, use importlib.resources:
import importlib.resources as pkg_resources
schema_text = (
pkg_resources.files("mypackage.data")
.joinpath("schema.json")
.read_text(encoding="utf-8")
)