project setup#
consistent structure across projects: src/ layout, pyproject.toml as single source of truth, justfile for commands.
directory structure#
myproject/
├── src/myproject/
│ ├── __init__.py
│ ├── cli.py
│ ├── settings.py
│ └── _internal/ # private implementation
├── tests/
├── pyproject.toml
├── justfile
└── .pre-commit-config.yaml
the src/ layout prevents accidental imports from the working directory. your package is only importable when properly installed.
pyproject.toml#
[project]
name = "myproject"
description = "what it does"
readme = "README.md"
requires-python = ">=3.10"
dynamic = ["version"]
dependencies = [
"httpx>=0.27",
"pydantic>=2.0",
]
[project.scripts]
myproject = "myproject.cli:main"
[build-system]
requires = ["hatchling", "uv-dynamic-versioning"]
build-backend = "hatchling.build"
[tool.hatch.version]
source = "uv-dynamic-versioning"
[tool.uv-dynamic-versioning]
vcs = "git"
style = "pep440"
bump = true
fallback-version = "0.0.0"
[dependency-groups]
dev = [
"pytest>=8.0",
"ruff>=0.8",
"ty>=0.0.1a6",
]
key patterns:
dynamic = ["version"]- version comes from git tags, not manual editing[project.scripts]- CLI entry points
dependency groups vs optional dependencies#
these look similar but serve different purposes.
dependency groups (PEP 735) are local-only. they never appear in published package metadata. users who pip install your package won't see them:
[dependency-groups]
dev = ["pytest", "ruff"]
docs = ["mkdocs", "mkdocs-material"]
install with uv sync --group dev. CI can install only what it needs.
optional dependencies are published in package metadata. users can install them:
[project.optional-dependencies]
aws = ["prefect-aws"]
mcp = ["fastmcp>=2.0"]
install with pip install mypackage[aws] or uv add 'mypackage[mcp]'.
use groups for dev/test/CI. use optional deps for features consumers might want.
from switching a big python library from setup.py to pyproject.toml
versioning from git tags#
with uv-dynamic-versioning, your version is derived from git:
git tag v0.1.0
git push --tags
no more editing __version__ or pyproject.toml for releases.
justfile#
check-uv:
#!/usr/bin/env sh
if ! command -v uv >/dev/null 2>&1; then
echo "uv is not installed. Install: curl -LsSf https://astral.sh/uv/install.sh | sh"
exit 1
fi
install: check-uv
uv sync
test:
uv run pytest tests/ -xvs
lint:
uv run ruff format src/ tests/
uv run ruff check src/ tests/ --fix
check:
uv run ty check
run with just test, just lint, etc.
multiple entry points#
for projects with both CLI and MCP server:
[project.scripts]
myproject = "myproject.cli:main"
myproject-mcp = "myproject.mcp:main"
uv workspaces#
for multi-package repos (like plyr-python-client):
myproject/
├── packages/
│ ├── core/
│ │ ├── src/core/
│ │ └── pyproject.toml
│ └── mcp/
│ ├── src/mcp/
│ └── pyproject.toml
├── pyproject.toml # root workspace config
└── uv.lock
root pyproject.toml:
[tool.uv.workspace]
members = ["packages/*"]
[tool.uv.sources]
core = { workspace = true }
mcp = { workspace = true }
packages can depend on each other. one lockfile for the whole workspace.
simpler build backend#
for projects that don't need dynamic versioning, uv_build is lighter:
[build-system]
requires = ["uv_build>=0.9.2,<0.10.0"]
build-backend = "uv_build"
sources: