tooling #
project layout and pyproject.toml as single source of truth; ruff for linting and formatting, ty for type checking, pre-commit to enforce both.
project layout #
myproject/
├── src/myproject/
│ ├── __init__.py
│ ├── cli.py
│ └── _internal/ # private implementation
├── tests/
├── pyproject.toml
├── justfile
└── .pre-commit-config.yaml
the src/ layout prevents accidental imports from the working directory — the package is only importable when properly installed.
pyproject.toml #
[project]
name = "myproject"
requires-python = ">=3.10"
dynamic = ["version"]
dependencies = ["httpx>=0.27", "pydantic>=2.0"]
[project.scripts]
myproject = "myproject.cli:main"
myproject-mcp = "myproject.mcp:main"
[build-system]
requires = ["hatchling", "uv-dynamic-versioning"]
build-backend = "hatchling.build"
[tool.hatch.version]
source = "uv-dynamic-versioning"
[dependency-groups]
dev = ["pytest>=8.0", "ruff>=0.8", "ty>=0.0.1a6"]
dynamic = ["version"]withuv-dynamic-versioningderives the version from git tags — no manual version edits, release bygit tag v0.1.0 && git push --tags[project.scripts]declares CLI entry points; multiple are fine (CLI + MCP server)- projects that don't need dynamic versioning can use the lighter
uv_buildbackend
dependency groups vs optional dependencies #
dependency groups (PEP 735) are local-only and never appear in published metadata:
[dependency-groups]
dev = ["pytest", "ruff"]
docs = ["mkdocs", "mkdocs-material"]
install with uv sync --group dev; CI installs only what it needs.
optional dependencies are published, installable by consumers as pip install mypackage[aws]:
[project.optional-dependencies]
aws = ["prefect-aws"]
use groups for dev/test/CI, optional deps for features consumers might want.
PyPI rejects a distribution whose Requires-Dist contains a direct reference
(pkg @ git+https://...), and an optional dependency is only a Requires-Dist
with an extra == "..." marker, so a git dependency cannot hide in an extra.
twine check passes that metadata; the upload fails. a git pin in a dependency
group is fine because groups are never published. to offer a fork through an
extra, publish the fork's package under its own name.
exclude-newer in [tool.uv] also holds back a dependency released inside the
window, including one you just published; exempt it with
exclude-newer-package.
ruff #
replaces black, isort, flake8, and dozens of plugins. one tool, fast.
uv run ruff format src/ tests/ # format
uv run ruff check --fix # lint and auto-fix
[tool.ruff]
line-length = 88
[tool.ruff.lint]
fixable = ["ALL"]
extend-select = ["I", "B", "C4", "UP", "SIM", "RUF"]
ignore = ["COM812"] # conflicts with formatter
[tool.ruff.lint.per-file-ignores]
"__init__.py" = ["F401", "I001"] # unused imports ok in __init__
"tests/**/*.py" = ["S101"] # assert ok in tests
ty #
astral's new type checker. still early but fast and improving.
uv run ty check
[tool.ty.src]
include = ["src", "tests"]
[tool.ty.environment]
python-version = "3.10"
[tool.ty.rules]
# start permissive, tighten over time
unknown-argument = "ignore"
no-matching-overload = "ignore"
pre-commit #
enforce standards before commits reach the repo. install with uv run pre-commit install; never skip hooks with --no-verify — fix the issue instead.
repos:
- repo: https://github.com/abravalheri/validate-pyproject
rev: v0.24.1
hooks:
- id: validate-pyproject
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.8.0
hooks:
- id: ruff-check
args: [--fix, --exit-non-zero-on-fix]
- id: ruff-format
- repo: local
hooks:
- id: type-check
name: type check
entry: uv run ty check
language: system
types: [python]
pass_filenames: false
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: no-commit-to-branch
args: [--branch, main]
pytest #
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
testpaths = ["tests"]
asyncio_mode = "auto" means async tests just work — no @pytest.mark.asyncio needed.
justfile #
a justfile names the project's commands so every repo runs the same way:
install:
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
uv workspaces #
for multi-package repos, [tool.uv.workspace] in the root pyproject.toml shares one lockfile:
[tool.uv.workspace]
members = ["packages/*"]
[tool.uv.sources]
core = { workspace = true }
packages can depend on each other; uv sync resolves the whole workspace together.
alternatives #
sources #
- pdsx/pyproject.toml (pdsx is an atproto record CLI + MCP server)
- pdsx/.pre-commit-config.yaml
- plyr-python-client — workspace layout
- switching a big python library from setup.py to pyproject.toml
- pypi/warehouse
forklift/metadata.py— "Can't have direct dependency" check onrequires_dist, read 2026-09-26; pypa/twine#726