Skip to content

Set up a Python project optimized for Claude Code

Claude Code writes Python fluently, but without project-specific configuration it falls back on pip and bare python instead of uv. This tutorial builds a Python project with uv, Ruff, and pytest, then layers on Claude Code integration: a CLAUDE.md file that teaches Claude your conventions and hooks that enforce them, plus on-demand skills for tool expertise.

Prerequisites

Create the project

Scaffold a new Python package:

$ uv init wordtools --package
Initialized project `wordtools` at `/path/to/wordtools`
$ cd wordtools

The --package flag creates a src/wordtools/ layout with an installable package. Without it, uv creates a flat main.py script suitable for quick experiments but not for a project with a separate tests/ directory that imports the package by name.

Add Ruff and pytest as development dependencies:

$ uv add --dev ruff pytest
Using CPython 3.14.4
Creating virtual environment at: .venv
Resolved 8 packages in 120ms
Installed 7 packages in 18ms
 + iniconfig==2.3.0
 + packaging==26.2
 + pluggy==1.6.0
 + pygments==2.20.0
 + pytest==9.0.3
 + ruff==0.15.14
 + wordtools==0.1.0 (from file:///path/to/wordtools)

Your version numbers may differ. Notice the .venv/ directory that appeared: uv created a virtual environment for the project because this is the first dependency.

Open pyproject.toml and add Ruff’s recommended lint extensions at the bottom:

[tool.ruff.lint]
extend-select = ["B", "I", "UP"]

B catches common Python bugs, I enforces sorted imports, and UP suggests modern Python idioms.

Add a module and tests

Create a module at src/wordtools/counter.py:

src/wordtools/counter.py
def count_words(text: str) -> int:
    return len(text.split())


def count_characters(text: str, include_spaces: bool = True) -> int:
    if include_spaces:
        return len(text)
    return len(text.replace(" ", ""))

Create a tests/ directory and add tests/test_counter.py:

$ mkdir tests
tests/test_counter.py
from wordtools.counter import count_characters, count_words


def test_count_words():
    assert count_words("hello world") == 2
    assert count_words("") == 0


def test_count_characters_with_spaces():
    assert count_characters("hello world") == 11


def test_count_characters_without_spaces():
    assert count_characters("hello world", include_spaces=False) == 10

Verify everything passes:

$ uv run pytest -v
============================= test session starts ==============================
platform darwin -- Python 3.14.4, pytest-9.0.3, pluggy-1.6.0
rootdir: /path/to/wordtools
configfile: pyproject.toml
collected 3 items

tests/test_counter.py::test_count_words PASSED                           [ 33%]
tests/test_counter.py::test_count_characters_with_spaces PASSED          [ 66%]
tests/test_counter.py::test_count_characters_without_spaces PASSED       [100%]

============================== 3 passed in 0.01s ===============================

The platform line shows linux or win32 on those systems instead of darwin.

$ uv run ruff check .
All checks passed!

The project runs and passes lint checks. Now configure Claude Code to work with it.

Teach Claude your conventions with CLAUDE.md

CLAUDE.md is a markdown file in the project root that Claude Code reads at the start of every session. Without one, Claude defaults to pip install when you ask it to add a dependency and runs python script.py instead of uv run.

Create CLAUDE.md in the project root:

CLAUDE.md
# wordtools

## Package management

Use uv for all Python operations in this project.

- Install dependencies: `uv add <package>`
- Remove dependencies: `uv remove <package>`
- Run scripts and tools: `uv run <command>`
- Sync environment: `uv sync`
- Never use pip, pip install, or bare python/pytest/ruff

## Testing

- Run tests: `uv run pytest`
- Write tests in `tests/` following pytest conventions
- Use `src/` layout imports: `from wordtools.counter import count_words`

## Code quality

- Format: `uv run ruff format .`
- Lint: `uv run ruff check --fix .`
- Ruff config lives in pyproject.toml

Open Claude Code in the project directory and ask it to “add the httpx library.” It should run uv add httpx, not pip install httpx. Ask it to “run the tests.” It should use uv run pytest, not bare pytest.

Tip

The handbook maintains a ready-made CLAUDE.md template that covers uv, Ruff, pytest, and type checking. Download it with curl -o CLAUDE.md https://pydevtools.com/configs/CLAUDE.md for a more comprehensive starting point.

CLAUDE.md works because Claude reads and follows it. But it is a suggestion, not a constraint. During long sessions or complex tasks, Claude occasionally reverts to pip install or runs python without uv run. The next layer prevents that.

Enforce rules with hooks

Hooks are scripts that fire at specific points during a Claude Code session. A PreToolUse hook runs before Claude executes a command and can block it with exit code 2. A PostToolUse hook runs after a tool executes and can apply fixes on top of what Claude wrote.

Create the hooks directory:

$ mkdir -p .claude/hooks

Block pip with a PreToolUse hook

Create .claude/hooks/enforce-uv.py:

.claude/hooks/enforce-uv.py
#!/usr/bin/env python3
import json
import sys

data = json.load(sys.stdin)
command = data.get("tool_input", {}).get("command", "")

redirects = {
    "pip install": "uv add",
    "pip3 install": "uv add",
    "pip uninstall": "uv remove",
    "pip3 uninstall": "uv remove",
    "python -m pip": "uv add / uv remove",
    "python3 -m pip": "uv add / uv remove",
    "python -m pytest": "uv run pytest",
    "python3 -m pytest": "uv run pytest",
}

for pattern, replacement in redirects.items():
    if pattern in command:
        print(f"Blocked: '{pattern}' detected. Use '{replacement}' instead.", file=sys.stderr)
        sys.exit(2)

bare_prefixes = ("pip ", "pip3 ", "python ", "python3 ", "pytest ", "ruff ")
for bare in bare_prefixes:
    if command.startswith(bare) and not command.startswith("uv run"):
        print(f"Blocked: bare '{bare.strip()}' command. Use uv (e.g. 'uv add', 'uv run').", file=sys.stderr)
        sys.exit(2)

bare_exact = {"pip", "pip3", "python", "python3", "pytest", "ruff"}
if command.strip() in bare_exact:
    print(f"Blocked: use 'uv run {command.strip()}' instead.", file=sys.stderr)
    sys.exit(2)

The hook reads the command Claude is about to execute from stdin JSON. Exit code 2 blocks the command and sends the stderr message back to Claude so it can adjust its approach.

Auto-format Python files after edits

Create .claude/hooks/ruff-after-edit.sh:

.claude/hooks/ruff-after-edit.sh
#!/usr/bin/env bash
input=$(cat)
file_path=$(echo "$input" | jq -r '.tool_input.file_path')

if [[ "$file_path" == *.py ]] && [[ -f "$file_path" ]]; then
  uv run ruff check --fix --quiet "$file_path" 2>/dev/null
  uv run ruff format --quiet "$file_path" 2>/dev/null
fi
exit 0

Note

This script requires bash. On Windows, Git for Windows includes bash at C:\Program Files\Git\bin\bash.exe, or use WSL.

Every time Claude writes or edits a Python file, this hook auto-formats the result and applies safe lint fixes. Not all lint violations have auto-fixes, so the hook catches formatting and import order issues automatically while flagging remaining violations for Claude to address.

Register the hooks

Create .claude/settings.json to wire up both hooks. The commands differ by platform because macOS and Linux use python3 while Windows uses python:

.claude/settings.json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "python3 \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/enforce-uv.py"
          }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "bash \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/ruff-after-edit.sh"
          }
        ]
      }
    ]
  }
}

Test the enforce-uv hook manually by piping sample JSON:

$ echo '{"tool_name":"Bash","tool_input":{"command":"pip install requests"}}' | python3 .claude/hooks/enforce-uv.py
Blocked: 'pip install' detected. Use 'uv add' instead.

The command exits with code 2 (blocked). Try a valid command:

$ echo '{"tool_name":"Bash","tool_input":{"command":"uv add requests"}}' | python3 .claude/hooks/enforce-uv.py

No output and exit code 0 (allowed). Claude Code applies the same logic during sessions.

CLAUDE.md says “don’t use pip.” Hooks enforce the rule for the common cases: pip, pip3, bare python, pytest, and ruff invocations. A determined user or edge-case command could still slip past (shell aliases, subprocess calls inside Python scripts), so treat hooks as a strong guardrail rather than an airtight sandbox. Neither layer gives Claude current expertise about your tools. When you ask “what Ruff rules should I enable for data science code?” Claude falls back on its training data. Skills fill that gap.

Add expertise with skills

Skills are instruction files that Claude loads when a prompt matches the skill’s description or when you type a slash command. They give Claude current knowledge about a tool without occupying context in every session.

Install the Astral plugin

The Astral plugin provides skills for uv, Ruff, and ty. Install it from within Claude Code with two slash commands:

/plugin marketplace add astral-sh/claude-code-plugins
/plugin install astral@astral-sh

After installing, activate the plugin in your current session:

/reload-plugins

This adds three skills: /astral:uv, /astral:ruff, and /astral:ty. Each one provides a curated guide with usage patterns, configuration examples, and links to the official documentation. Type /astral:ruff which bugbear rules should I enable? and Claude answers with accurate rule names instead of guessing from training data.

Claude Code also matches skills by description, not just slash command. Asking “how do I configure Ruff’s import sorting?” loads the Ruff skill automatically.

Write a custom skill

The Astral plugin covers generic uv, Ruff, and ty workflows. Custom skills bottle up procedures specific to your project. Create a release-check skill that runs the full quality gate before tagging a release: lint, format, test, and verify the version in pyproject.toml.

$ mkdir -p .claude/skills/release-check
.claude/skills/release-check/SKILL.md
---
name: release-check
description: Run the full quality gate before a release. Use when the user wants to prepare a release, check if the project is ready to tag, or verify everything passes.
---

Run these checks in order and report the results:

1. `uv run ruff check .` (lint)
2. `uv run ruff format --check .` (format verification)
3. `uv run pytest` (tests)
4. Read the `version` field from `pyproject.toml` and confirm it matches the intended release version.

If any check fails, fix the issue and rerun that check before continuing. Report a summary: which checks passed, which failed, and the current version.

Invoke it in Claude Code by typing /release-check, or describe the task naturally (“check if everything passes before I tag a release”). Claude matches the prompt against the skill’s description field and loads the procedure either way.

Wire up pre-commit as a safety net

CLAUDE.md and hooks handle Claude Code sessions, but code can also be committed manually or by other tools. Pre-commit hooks catch problems at git commit time regardless of how the code was written.

Create .pre-commit-config.yaml:

.pre-commit-config.yaml
repos:
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.15.14
    hooks:
      - id: ruff-check
        args: [--fix]
      - id: ruff-format

Install the hooks:

$ uvx pre-commit install
pre-commit installed at .git/hooks/pre-commit

Every git commit now runs Ruff. Claude Code respects pre-commit hooks by default. For team projects where you want to prevent --no-verify bypasses, add a dedicated PreToolUse hook that blocks the flag.

Review the final project

    • CLAUDE.md
    • .pre-commit-config.yaml
    • pyproject.toml
    • README.md
      • settings.json
        • enforce-uv.py
        • ruff-after-edit.sh
          • SKILL.md
        • __init__.py
        • counter.py
      • test_counter.py

Each file serves a distinct role:

  • CLAUDE.md tells Claude what tools to use and how
  • .claude/hooks/ blocks wrong commands and auto-formats code
  • .claude/skills/ provides on-demand expertise about your workflow
  • .pre-commit-config.yaml catches lint issues at commit time regardless of editor

Next Steps

Last updated on