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: