# 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 ```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"]` with `uv-dynamic-versioning` derives the version from git tags — no manual version edits, release by `git 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_build` backend ## dependency groups vs optional dependencies **dependency groups** (PEP 735) are local-only and never appear in published metadata: ```toml [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]`: ```toml [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. ```bash uv run ruff format src/ tests/ # format uv run ruff check --fix # lint and auto-fix ``` ```toml [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. ```bash uv run ty check ``` ```toml [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. ```yaml 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 ```toml [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: ```makefile 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: ```toml [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 - [typsht](https://github.com/zzstoatzz/typsht) - parallel type checking across multiple checkers - [prek](https://github.com/zzstoatzz/prek) - pre-commit reimplemented in rust ## sources - [pdsx/pyproject.toml](https://github.com/zzstoatzz/pdsx/blob/main/pyproject.toml) (pdsx is an atproto record CLI + MCP server) - [pdsx/.pre-commit-config.yaml](https://github.com/zzstoatzz/pdsx/blob/main/.pre-commit-config.yaml) - [plyr-python-client](https://github.com/zzstoatzz/plyr-python-client) — workspace layout - [switching a big python library from setup.py to pyproject.toml](https://blog.zzstoatzz.io/switching-a-big-python-library-from-setuppy-to-pyprojecttoml/) - [pypi/warehouse `forklift/metadata.py`](https://github.com/pypi/warehouse/blob/main/warehouse/forklift/metadata.py) — "Can't have direct dependency" check on `requires_dist`, read 2026-09-26; [pypa/twine#726](https://github.com/pypa/twine/issues/726)