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
- uv installed
- Claude Code installed
- jq installed (used by one hook script)
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:
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
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) == 10Verify 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:
# 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.tomlOpen 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:
#!/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:
#!/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 0Note
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:
{
"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-shAfter installing, activate the plugin in your current session:
/reload-pluginsThis 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
---
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:
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.14
hooks:
- id: ruff-check
args: [--fix]
- id: ruff-formatInstall 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.yamlcatches lint issues at commit time regardless of editor
Next Steps
- Claude Code complete guide covers the full range of Claude Code features for Python
- How to write Claude Code hooks for Python projects adds UserPromptSubmit and Stop hooks to the two covered here
- How to configure Ruff with Claude Code covers all four layers of Ruff integration
- How to use Python skills with Claude Code explains choosing between skills, hooks, and CLAUDE.md
- How to stop AI agents from bypassing pre-commit hooks adds defense-in-depth for team projects