# Project Structure and Tooling ## Overview **Core Principle:** Project setup is infrastructure. Good infrastructure is invisible when working, painful when missing. Set it up once, benefit forever. Modern Python projects use `pyproject.toml` for all configuration, `ruff` for linting and formatting, `mypy` for type checking, and `pre-commit` for automated quality gates. The choice between src layout and flat layout determines import patterns and package discoverability. This skill covers SETUP of tooling. For FIXING lint warnings systematically, see `systematic-delinting`. ## When to Use **Use this skill when:** - Starting a new Python project - "How should I structure my project?" - Setting up pyproject.toml - Configuring ruff, mypy, or pre-commit - "What dependency manager should I use?" - Packaging Python projects for distribution **Don't use when:** - Fixing existing lint warnings (use systematic-delinting) - Writing type hints (use modern-syntax-and-types) - Setting up tests (use testing-and-quality) **Symptoms triggering this skill:** - "New Python project setup" - "Configure ruff/black/mypy" - "src layout vs flat layout" - "Poetry vs pip-tools" - "Package my project" ## Project Layout Decisions ### Src Layout vs Flat Layout **Decision tree:** ``` Distributing as package? → src layout Testing import behavior? → src layout Simple script/app? → flat layout Learning project? → flat layout Production library? → src layout ``` ### Flat Layout ``` my_project/ ├── pyproject.toml ├── README.md ├── my_package/ │ ├── __init__.py │ ├── module1.py │ └── module2.py └── tests/ ├── __init__.py ├── test_module1.py └── test_module2.py ``` **Pros:** - Simpler structure - Easier to understand for beginners - Fewer directories **Cons:** - Can accidentally import from source instead of installed package - Harder to test actual install behavior - Package and project root mixed **Use when:** - Simple applications - Learning projects - Not distributing as package ### Src Layout (Recommended for Libraries) ``` my_project/ ├── pyproject.toml ├── README.md ├── src/ │ └── my_package/ │ ├── __init__.py │ ├── module1.py │ └── module2.py └── tests/ ├── __init__.py ├── test_module1.py └── test_module2.py ``` **Pros:** - Forces testing against installed package - Clear separation: src/ is package, tests/ is tests - Prevents accidental imports from source - Industry standard for libraries **Cons:** - One extra directory level - Slightly more complex **Use when:** - Creating a library - Distributing on PyPI - Want production-quality setup **Why this matters**: Src layout forces you to install your package in editable mode (`pip install -e .`), ensuring tests run against the installed package, not loose Python files. Catches import issues early. ## pyproject.toml Fundamentals ### Basic Structure **File:** `pyproject.toml` ```toml [build-system] requires = ["hatchling"] build-backend = "hatchling.build" [project] name = "my-package" version = "0.1.0" description = "A short description" authors = [ {name = "Your Name", email = "your.email@example.com"} ] readme = "README.md" requires-python = ">=3.12" classifiers = [ "Development Status :: 3 - Alpha", "Intended Audience :: Developers", "Programming Language :: Python :: 3.12", ] dependencies = [ "requests>=2.31.0", "pydantic>=2.0.0", ] [project.optional-dependencies] dev = [ "pytest>=7.4.0", "mypy>=1.5.0", "ruff>=0.1.0", ] [project.urls] Homepage = "https://github.com/username/my-package" Documentation = "https://my-package.readthedocs.io" Repository = "https://github.com/username/my-package" [tool.ruff] target-version = "py312" line-length = 140 [tool.mypy] python_version = "3.12" strict = true ``` **Why this matters**: Single file for all configuration. No setup.py, setup.cfg, or scattered config files. Modern standard (PEP 621). ### Build System Selection **hatchling (recommended for most projects):** ```toml [build-system] requires = ["hatchling"] build-backend = "hatchling.build" ``` **setuptools (traditional, still common):** ```toml [build-system] requires = ["setuptools>=68.0.0", "wheel"] build-backend = "setuptools.build_meta" ``` **poetry (if using Poetry for dependencies):** ```toml [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" ``` **Decision tree:** ``` Using Poetry for deps? → poetry-core Need advanced features? → setuptools Simple project? → hatchling ``` **Why hatchling?** - Modern, fast, minimal configuration - Good defaults - Works with standard tools - No legacy baggage ### Version Management **Static version:** ```toml [project] version = "0.1.0" ``` **Dynamic version from file:** ```toml [project] dynamic = ["version"] [tool.hatch.version] path = "src/my_package/__init__.py" ``` **File:** `src/my_package/__init__.py` ```python __version__ = "0.1.0" ``` **Dynamic version from git tag:** ```toml [build-system] requires = ["hatchling", "hatch-vcs"] [tool.hatch.version] source = "vcs" ``` **Recommendation**: Start with static version. Add dynamic versioning when you need it. ## Ruff Configuration ### Core Configuration **File:** `pyproject.toml` ```toml [tool.ruff] target-version = "py312" line-length = 140 # Note: 140, not default 88 # Exclude patterns exclude = [ ".git", ".venv", "__pycache__", "build", "dist", "*.egg-info", ] [tool.ruff.lint] # Enable rule sets select = [ "E", # pycodestyle errors "W", # pycodestyle warnings "F", # pyflakes "I", # isort "N", # pep8-naming "UP", # pyupgrade "B", # flake8-bugbear "C4", # flake8-comprehensions "SIM", # flake8-simplify "RUF", # ruff-specific ] # Ignore specific rules ignore = [ "E501", # Line too long (handled by formatter) ] # Per-file ignores [tool.ruff.lint.per-file-ignores] "tests/**/*.py" = [ "S101", # Allow assert in tests ] [tool.ruff.format] quote-style = "double" indent-style = "space" line-ending = "auto" ``` **Why line-length = 140?** - Modern screens are wide - Default 88 is too restrictive for complex type hints - 140 balances readability and fitting multiple windows - Industry trend toward 100-140 **Rule set breakdown:** | Set | Purpose | Example Rules | |-----|---------|---------------| | E/W | PEP 8 style | Whitespace, indentation | | F | Logical errors | Undefined names, unused imports | | I | Import sorting | isort compatibility | | N | Naming | PEP 8 naming conventions | | UP | Python upgrades | Use Python 3.10+ features | | B | Bug detection | Likely bugs (mutable defaults) | | C4 | Comprehensions | Better list/dict comprehensions | | SIM | Simplification | Simplify complex code | | RUF | Ruff-specific | Ruff's custom checks | ### Import Sorting (isort compatibility) ```toml [tool.ruff.lint.isort] known-first-party = ["my_package"] known-third-party = ["numpy", "pandas"] section-order = ["future", "standard-library", "third-party", "first-party", "local-folder"] lines-after-imports = 2 ``` **Expected import order:** ```python # Future imports from __future__ import annotations # Standard library import json import sys from pathlib import Path # Third-party import numpy as np import pandas as pd import requests # First-party from my_package import utils from my_package.core import Engine def my_function(): ... ``` **Why this matters**: Consistent import ordering improves readability and prevents merge conflicts. ### Advanced Configuration ```toml [tool.ruff.lint.flake8-bugbear] # Extend immutable calls (prevent mutation) extend-immutable-calls = ["fastapi.Depends", "fastapi.Query"] [tool.ruff.lint.flake8-quotes] docstring-quotes = "double" inline-quotes = "double" [tool.ruff.lint.mccabe] max-complexity = 10 [tool.ruff.lint.pydocstyle] convention = "google" # or "numpy", "pep257" ``` **Complexity limit explanation:** - Complexity < 10: Good - 10-15: Acceptable, monitor - 15+: Refactor ## Type Checking with mypy ### Strict Configuration **File:** `pyproject.toml` ```toml [tool.mypy] python_version = "3.12" strict = true # Strict mode includes: # - warn_return_any # - warn_unused_configs # - disallow_untyped_defs # - disallow_any_generics # - disallow_subclassing_any # - disallow_untyped_calls # - disallow_untyped_decorators # - disallow_incomplete_defs # - check_untyped_defs # - warn_redundant_casts # - warn_unused_ignores # - warn_no_return # - warn_unreachable # - strict_equality # Exclude patterns exclude = [ "^build/", "^dist/", ] # Per-module overrides [[tool.mypy.overrides]] module = "tests.*" disallow_untyped_defs = false # Tests can be less strict [[tool.mypy.overrides]] module = "third_party.*" ignore_missing_imports = true [[tool.mypy.overrides]] module = "untyped_library" ignore_missing_imports = true ``` ### Incremental Adoption **Start lenient, get stricter:** ```toml # Phase 1: Basic type checking [tool.mypy] python_version = "3.12" warn_return_any = true warn_unused_configs = true # Phase 2: Add more checks check_untyped_defs = true warn_redundant_casts = true warn_unused_ignores = true # Phase 3: Require types disallow_untyped_defs = true disallow_incomplete_defs = true # Phase 4: Full strict strict = true ``` **Per-module migration:** ```toml [tool.mypy] python_version = "3.12" # Default: lenient [[tool.mypy.overrides]] module = "my_package.new_module" strict = true # New code is strict [[tool.mypy.overrides]] module = "my_package.legacy" ignore_errors = true # TODO: Fix legacy code ``` **Why this matters**: Incremental adoption prevents overwhelming backlog of type errors. Strict mode for new code, lenient for legacy. ## Dependency Management ### pip-tools **Recommended for most projects. Simple, standard, no lock-in.** **Setup:** ```bash pip install pip-tools ``` **File:** `requirements.in` (high-level dependencies) ``` requests>=2.31.0 pydantic>=2.0.0 ``` **Generate locked requirements:** ```bash pip-compile requirements.in # Creates requirements.txt with exact versions ``` **File:** `requirements.txt` (auto-generated) ``` certifi==2023.7.22 # via requests charset-normalizer==3.2.0 # via requests idna==3.4 # via requests pydantic==2.3.0 # via -r requirements.in pydantic-core==2.6.3 # via pydantic requests==2.31.0 # via -r requirements.in urllib3==2.0.4 # via requests ``` **Development dependencies:** **File:** `requirements-dev.in` ``` -c requirements.txt # Constrain to production versions pytest>=7.4.0 mypy>=1.5.0 ruff>=0.1.0 ``` **Compile:** ```bash pip-compile requirements-dev.in ``` **Sync environment:** ```bash pip-sync requirements.txt requirements-dev.txt ``` **Why pip-tools?** - Uses standard requirements.txt format - No proprietary lock file - Simple mental model - Works everywhere - No lock-in ### Poetry **Better for libraries, more features, heavier.** **Setup:** ```bash curl -sSL https://install.python-poetry.org | python3 - ``` **File:** `pyproject.toml` ```toml [tool.poetry] name = "my-package" version = "0.1.0" description = "" authors = ["Your Name "] [tool.poetry.dependencies] python = "^3.12" requests = "^2.31.0" pydantic = "^2.0.0" [tool.poetry.group.dev.dependencies] pytest = "^7.4.0" mypy = "^1.5.0" ruff = "^0.1.0" [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" ``` **Commands:** ```bash poetry install # Install dependencies poetry add requests # Add dependency poetry add --group dev pytest # Add dev dependency poetry update # Update dependencies poetry lock # Update lock file poetry build # Build package poetry publish # Publish to PyPI ``` **Why Poetry?** - Manages dependencies AND build system - Better dependency resolution - Built-in virtual environment management - Integrated publishing **Why NOT Poetry?** - Heavier tool - Proprietary lock format - Slower than pip-tools - Lock-in to Poetry workflow ### Comparison Decision Tree ``` Publishing to PyPI? → Poetry (integrated workflow) Simple project? → pip-tools (minimal) Need reproducible builds? → Either (both lock) Team unfamiliar with tools? → pip-tools (simpler) Complex dependency constraints? → Poetry (better resolver) CI/CD integration? → pip-tools (faster) ``` ## Pre-commit Hooks ### Setup **Install:** ```bash pip install pre-commit ``` **File:** `.pre-commit-config.yaml` ```yaml repos: # Ruff for linting and formatting - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.1.6 hooks: # Run linter - id: ruff args: [--fix, --exit-non-zero-on-fix] # Run formatter - id: ruff-format # mypy for type checking - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.7.0 hooks: - id: mypy additional_dependencies: [types-requests] # Standard pre-commit hooks - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.5.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer - id: check-yaml - id: check-toml - id: check-added-large-files args: [--maxkb=1000] - id: check-merge-conflict - id: check-case-conflict # Python-specific - repo: https://github.com/pycqa/isort rev: 5.12.0 hooks: - id: isort name: isort (python) ``` **Install hooks:** ```bash pre-commit install ``` **Run manually:** ```bash pre-commit run --all-files ``` **Update hooks:** ```bash pre-commit autoupdate ``` ### Hook Selection Strategy **Essential hooks (always use):** - `ruff` - Linting and formatting - `trailing-whitespace` - Clean files - `end-of-file-fixer` - Proper file endings - `check-yaml` - YAML syntax - `check-merge-conflict` - Prevent merge markers **Recommended hooks:** - `mypy` - Type checking - `check-toml` - pyproject.toml syntax - `check-added-large-files` - Prevent large files **Optional hooks:** - `pytest` - Run tests (slow!) - `bandit` - Security checks - `interrogate` - Docstring coverage **Why NOT include slow hooks:** ```yaml # ❌ WRONG: Tests in pre-commit (too slow) - repo: local hooks: - id: pytest name: pytest entry: pytest language: system pass_filenames: false ``` **Why this matters**: Pre-commit hooks run on EVERY commit. Keep them fast (<5 seconds total). Run tests in CI, not pre-commit. ### Skipping Hooks **Skip all hooks (use sparingly):** ```bash git commit --no-verify -m "Quick fix" ``` **Skip specific hook:** ```bash SKIP=mypy git commit -m "WIP: type errors to fix" ``` **When to skip:** - WIP commits on feature branch (will fix before PR) - Emergency hotfixes (fix hooks after) - Known false positives (fix hook config instead) **When NOT to skip:** - Merging to main - Creating PR - "Too lazy to fix" ← Never valid reason ## Formatting and Linting Workflow ### Ruff as Formatter and Linter **Ruff replaces: black, isort, flake8, pyupgrade, and more.** **Format code:** ```bash ruff format . ``` **Check linting:** ```bash ruff check . ``` **Fix auto-fixable issues:** ```bash ruff check --fix . ``` **Show what would fix without changing:** ```bash ruff check --fix --diff . ``` ### IDE Integration **VS Code** (`.vscode/settings.json`): ```json { "python.linting.enabled": true, "python.linting.ruffEnabled": true, "python.formatting.provider": "none", "editor.formatOnSave": true, "editor.defaultFormatter": "charliermarsh.ruff", "[python]": { "editor.formatOnSave": true, "editor.codeActionsOnSave": { "source.fixAll": true, "source.organizeImports": true }, "editor.defaultFormatter": "charliermarsh.ruff" } } ``` **PyCharm:** - Install Ruff plugin - Settings → Tools → Ruff → Enable - Settings → Tools → Actions on Save → Ruff format **Why this matters**: Format on save prevents formatting commits. Linting in IDE catches issues before commit. ## Packaging and Distribution ### Minimal Package **File structure:** ``` my_package/ ├── pyproject.toml ├── README.md ├── LICENSE └── src/ └── my_package/ ├── __init__.py └── main.py ``` **File:** `pyproject.toml` ```toml [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.12" license = {text = "MIT"} authors = [ {name = "Your Name", email = "your.email@example.com"} ] classifiers = [ "Programming Language :: Python :: 3.12", ] dependencies = [] [project.urls] Homepage = "https://github.com/username/my-package" ``` **Build:** ```bash pip install build python -m build ``` **Creates:** ``` dist/ ├── my_package-0.1.0-py3-none-any.whl └── my_package-0.1.0.tar.gz ``` ### Publishing to PyPI **Test on TestPyPI first:** ```bash pip install twine # Upload to TestPyPI twine upload --repository testpypi dist/* # Test install pip install --index-url https://test.pypi.org/simple/ my-package ``` **Publish to real PyPI:** ```bash twine upload dist/* ``` **Better: Use GitHub Actions** **File:** `.github/workflows/publish.yml` ```yaml name: Publish to PyPI on: release: types: [published] jobs: publish: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v4 with: python-version: "3.12" - name: Install build run: pip install build twine - name: Build package run: python -m build - name: Publish to PyPI env: TWINE_USERNAME: __token__ TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} run: twine upload dist/* ``` **Why this matters**: Automated publishing on GitHub release. Consistent process, no manual uploads. ### Entry Points **Console scripts:** ```toml [project.scripts] my-cli = "my_package.cli:main" my-tool = "my_package.tools:run" ``` **Creates command-line tools:** ```bash pip install my-package my-cli --help # Runs my_package.cli:main() ``` **File:** `src/my_package/cli.py` ```python def main() -> None: print("Hello from my-cli!") if __name__ == "__main__": main() ``` ## Complete Example: Production Project ### Project Structure ``` awesome_project/ ├── .github/ │ └── workflows/ │ ├── ci.yml │ └── publish.yml ├── .pre-commit-config.yaml ├── pyproject.toml ├── README.md ├── LICENSE ├── .gitignore ├── src/ │ └── awesome_project/ │ ├── __init__.py │ ├── core.py │ ├── utils.py │ └── py.typed └── tests/ ├── __init__.py ├── test_core.py └── test_utils.py ``` ### pyproject.toml (Complete) ```toml [build-system] requires = ["hatchling"] build-backend = "hatchling.build" [project] name = "awesome-project" version = "0.1.0" description = "An awesome Python project" readme = "README.md" requires-python = ">=3.12" license = {text = "MIT"} authors = [ {name = "Your Name", email = "your.email@example.com"} ] classifiers = [ "Development Status :: 3 - Alpha", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3 :: Only", "Typing :: Typed", ] dependencies = [ "requests>=2.31.0", "pydantic>=2.0.0", ] [project.optional-dependencies] dev = [ "pytest>=7.4.0", "pytest-cov>=4.1.0", "mypy>=1.5.0", "ruff>=0.1.0", "pre-commit>=3.5.0", "types-requests>=2.31.0", ] [project.urls] Homepage = "https://github.com/username/awesome-project" Documentation = "https://awesome-project.readthedocs.io" Repository = "https://github.com/username/awesome-project" Issues = "https://github.com/username/awesome-project/issues" [project.scripts] awesome = "awesome_project.cli:main" # Ruff configuration [tool.ruff] target-version = "py312" line-length = 140 exclude = [ ".git", ".venv", "__pycache__", "build", "dist", "*.egg-info", ] [tool.ruff.lint] select = [ "E", # pycodestyle errors "W", # pycodestyle warnings "F", # pyflakes "I", # isort "N", # pep8-naming "UP", # pyupgrade "B", # flake8-bugbear "C4", # flake8-comprehensions "SIM", # flake8-simplify "RUF", # ruff-specific ] ignore = [ "E501", # Line too long (handled by formatter) ] [tool.ruff.lint.per-file-ignores] "tests/**/*.py" = [ "S101", # Allow assert in tests ] [tool.ruff.lint.isort] known-first-party = ["awesome_project"] [tool.ruff.format] quote-style = "double" indent-style = "space" # mypy configuration [tool.mypy] python_version = "3.12" strict = true warn_return_any = true warn_unused_configs = true disallow_untyped_defs = true [[tool.mypy.overrides]] module = "tests.*" disallow_untyped_defs = false # pytest configuration [tool.pytest.ini_options] testpaths = ["tests"] python_files = ["test_*.py"] python_functions = ["test_*"] addopts = [ "--strict-markers", "--strict-config", "--cov=awesome_project", "--cov-report=term-missing", ] # Coverage configuration [tool.coverage.run] source = ["src"] omit = ["tests/*"] [tool.coverage.report] exclude_lines = [ "pragma: no cover", "def __repr__", "raise AssertionError", "raise NotImplementedError", "if __name__ == .__main__.:", "if TYPE_CHECKING:", ] ``` ### .pre-commit-config.yaml (Complete) ```yaml repos: - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.1.6 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] - id: ruff-format - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.7.0 hooks: - id: mypy additional_dependencies: [types-requests, pydantic] - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.5.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer - id: check-yaml - id: check-toml - id: check-added-large-files - id: check-merge-conflict ``` ### .gitignore (Complete) ```gitignore # Python __pycache__/ *.py[cod] *$py.class *.so .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ *.egg-info/ .installed.cfg *.egg # Virtual environments venv/ env/ ENV/ .venv # IDEs .vscode/ .idea/ *.swp *.swo *~ # Testing .pytest_cache/ .coverage htmlcov/ .tox/ # mypy .mypy_cache/ .dmypy.json dmypy.json # Ruff .ruff_cache/ # OS .DS_Store Thumbs.db ``` ### CI Workflow **File:** `.github/workflows/ci.yml` ```yaml name: CI on: push: branches: [main] pull_request: branches: [main] jobs: test: runs-on: ${{ matrix.os }} strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] python-version: ["3.12"] steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | pip install -e ".[dev]" - name: Run ruff (lint) run: ruff check . - name: Run ruff (format check) run: ruff format --check . - name: Run mypy run: mypy src/ - name: Run pytest run: pytest --cov --cov-report=xml - name: Upload coverage uses: codecov/codecov-action@v3 if: matrix.os == 'ubuntu-latest' ``` ## Anti-Patterns ### Scattered Configuration Files ``` # ❌ WRONG: Configuration in multiple files setup.py setup.cfg requirements.txt requirements-dev.txt .flake8 mypy.ini pytest.ini .isort.cfg ``` ```toml # ✅ CORRECT: Single pyproject.toml # All configuration in one place [tool.ruff] ... [tool.mypy] ... [tool.pytest.ini_options] ... ``` **Why this matters**: Single source of truth. Easier to maintain, version control, and share. ### Not Using Src Layout for Libraries ``` # ❌ WRONG: Flat layout for distributed package my_package/ ├── my_package/ │ └── __init__.py └── tests/ ``` **Problem**: Tests might pass locally but fail when installed: ```bash # Works locally (imports from source) pytest # PASS # Fails when installed (package not installed correctly) pip install . python -c "import my_package" # ImportError ``` ``` # ✅ CORRECT: Src layout forces proper install my_package/ ├── src/ │ └── my_package/ │ └── __init__.py └── tests/ ``` **Why this matters**: Src layout catches packaging issues early by forcing editable install. ### Too Many Dependencies ```toml # ❌ WRONG: Kitchen sink approach dependencies = [ "requests", "httpx", # Both requests and httpx? "urllib3", # Already included with requests "pandas", "polars", # Both pandas and polars? "numpy", # Included with pandas # ... 50 more ] ``` ```toml # ✅ CORRECT: Minimal direct dependencies dependencies = [ "requests>=2.31.0", # Only what YOU directly use "pydantic>=2.0.0", ] # Transitive deps (requests → urllib3) handled automatically ``` **Why this matters**: More dependencies = more conflict risk, slower installs, larger attack surface. ### Ignoring Lock Files ```bash # ❌ WRONG: Install from requirements.in pip install -r requirements.in ``` **Problem**: Gets different versions each time, breaks reproducibility. ```bash # ✅ CORRECT: Install from locked requirements pip install -r requirements.txt ``` **Why this matters**: Locked dependencies ensure reproducible builds and deployments. ### Pre-commit Hooks Too Slow ```yaml # ❌ WRONG: Run full test suite on every commit repos: - repo: local hooks: - id: pytest name: pytest entry: pytest tests/ language: system pass_filenames: false ``` **Problem**: 5-minute test suite blocks every commit. Developers will skip hooks. ```yaml # ✅ CORRECT: Fast checks only repos: - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.1.6 hooks: - id: ruff - id: ruff-format ``` **Why this matters**: Pre-commit must be fast (<5s total). Run tests in CI, not pre-commit. ## Decision Trees ### Choosing Project Layout ``` ├─ Distributing as package? │ ├─ Yes → src layout │ └─ No │ ├─ Complex project? → src layout (future-proof) │ └─ Simple script? → flat layout ``` ### Choosing Dependency Manager ``` ├─ Publishing to PyPI? │ ├─ Yes → Poetry (integrated workflow) │ └─ No │ ├─ Need simple workflow? → pip-tools │ ├─ Complex constraints? → Poetry │ └─ Existing requirements.txt? → pip-tools ``` ### Choosing Build Backend ``` ├─ Using Poetry? → poetry-core ├─ Need setuptools features? → setuptools └─ Simple project? → hatchling ``` ### Line Length Configuration ``` ├─ Team preference for 88? → 88 ├─ Complex type hints? → 120-140 ├─ Modern screens? → 120-140 └─ No strong opinion? → 120 ``` ## Common Workflows ### New Project from Scratch ```bash # 1. Create structure mkdir my_project cd my_project git init # 2. Create directory structure mkdir -p src/my_project tests # 3. Create pyproject.toml (see example above) # 4. Create .pre-commit-config.yaml (see example above) # 5. Create .gitignore (see example above) # 6. Initialize package cat > src/my_project/__init__.py << 'EOF' """My awesome project.""" __version__ = "0.1.0" EOF # 7. Create py.typed marker for type checking touch src/my_project/py.typed # 8. Install in editable mode pip install -e ".[dev]" # 9. Install pre-commit hooks pre-commit install # 10. First commit git add . git commit -m "feat: Initial project structure" ``` ### Adding Ruff to Existing Project ```bash # 1. Install ruff pip install ruff # 2. Add to pyproject.toml cat >> pyproject.toml << 'EOF' [tool.ruff] target-version = "py312" line-length = 140 [tool.ruff.lint] select = ["E", "W", "F", "I", "N", "UP", "B", "C4", "SIM", "RUF"] ignore = ["E501"] EOF # 3. Check what would change ruff check --diff . # 4. Apply fixes ruff check --fix . # 5. Format code ruff format . # 6. Add to pre-commit cat >> .pre-commit-config.yaml << 'EOF' repos: - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.1.6 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] - id: ruff-format EOF pre-commit install ``` ### Migrating from Black/Flake8 to Ruff ```bash # 1. Install ruff pip install ruff # 2. Remove old tools pip uninstall black flake8 isort pyupgrade # 3. Convert black config to ruff # Old .flake8: # [flake8] # max-line-length = 88 # ignore = E203, W503 # New pyproject.toml: [tool.ruff] line-length = 88 [tool.ruff.lint] ignore = ["E203", "W503"] # 4. Remove old config files rm .flake8 .isort.cfg # 5. Update pre-commit # Replace black, isort, flake8 hooks with ruff # 6. Reformat everything ruff format . ``` ## Integration with Other Skills **Before using this skill:** - No prerequisites (start here for new projects) **After using this skill:** - Fix lint warnings → See `systematic-delinting` - Add type hints → See `modern-syntax-and-types` - Setup testing → See `testing-and-quality` - Add CI/CD → (Future skill) **Cross-references:** - Type checking setup → `modern-syntax-and-types` for type hint patterns - Delinting process → `systematic-delinting` for fixing warnings - Testing setup → `testing-and-quality` for pytest configuration ## Quick Reference ### Essential Commands ```bash # Project setup pip install -e ".[dev]" # Editable install with dev deps pre-commit install # Install git hooks # Daily workflow ruff check . # Lint ruff check --fix . # Lint and auto-fix ruff format . # Format mypy src/ # Type check pytest # Run tests # Pre-commit pre-commit run --all-files # Run all hooks manually pre-commit autoupdate # Update hook versions # Dependency management (pip-tools) pip-compile requirements.in # Lock dependencies pip-compile requirements-dev.in # Lock dev dependencies pip-sync requirements.txt requirements-dev.txt # Sync environment # Building and publishing python -m build # Build package twine upload dist/* # Upload to PyPI ``` ### Configuration Checklist **Minimum viable pyproject.toml:** - [x] `[build-system]` - hatchling or setuptools - [x] `[project]` - name, version, dependencies - [x] `[tool.ruff]` - target-version, line-length - [x] `[tool.mypy]` - python_version, strict **Production-ready additions:** - [x] `[project.optional-dependencies]` - dev dependencies - [x] `[project.scripts]` - console scripts - [x] `[tool.ruff.lint]` - rule selection - [x] `[tool.pytest.ini_options]` - test configuration - [x] `.pre-commit-config.yaml` - automated checks - [x] `.gitignore` - ignore build artifacts - [x] `src/package/py.typed` - typed package marker ### Ruff Rule Sets Quick Reference | Code | Name | Purpose | |------|------|---------| | E/W | pycodestyle | PEP 8 style | | F | Pyflakes | Logical errors | | I | isort | Import ordering | | N | pep8-naming | Naming conventions | | UP | pyupgrade | Modern syntax | | B | flake8-bugbear | Bug detection | | C4 | flake8-comprehensions | Better comprehensions | | SIM | flake8-simplify | Code simplification | | RUF | Ruff | Ruff-specific | **Enable progressively:** 1. Start: `["E", "W", "F"]` - Core errors 2. Add: `["I", "N", "UP"]` - Style and modernization 3. Add: `["B", "C4", "SIM"]` - Quality improvements 4. Add: `["RUF"]` - Ruff-specific checks ## Why This Matters: Real-World Impact **Good tooling setup prevents:** - ❌ "Works on my machine" - Locked dependencies ensure consistency - ❌ Import errors in production - Src layout catches packaging issues - ❌ Style arguments in PRs - Automated formatting ends debates - ❌ Type errors in production - mypy catches before deploy - ❌ Breaking dependencies - Lock files ensure reproducibility - ❌ Manual quality checks - Pre-commit automates enforcement **Good tooling setup enables:** - ✅ Fast onboarding - `pip install -e ".[dev]"` gets developers running - ✅ Consistent code style - Ruff format ensures uniformity - ✅ Early bug detection - Type checking and linting catch issues - ✅ Confident refactoring - Types and tests enable safe changes - ✅ Automated publishing - CI/CD handles releases - ✅ Professional polish - Well-configured projects attract contributors **Time investment:** - Initial setup: 1-2 hours - Saved per month: 10+ hours (no style debates, fewer bugs, faster onboarding) - ROI: Positive after first month, compounds over project lifetime