Set up a Python project optimized for Claude Code
Claude Code needs the same project commands your teammates use. Record uv commands explicitly so the agent can install dependencies and run checks in the project environment. This tutorial builds a Python project with uv, Ruff, and pytest, then layers on Claude Code integration: a CLAUDE.md file with your conventions, a hook that formats Python edits, and on-demand skills for tool guidance.
If the project already exists and only needs command guardrails, read Claude Code Hooks for uv Projects for a focused hook configuration.
Prerequisites
Create the project
ScaffoldGenerate the initial file and folder structure for a new project. a new Python package:
$ uv init wordtools
Initialized project `wordtools` at `/path/to/wordtools`
$ cd wordtools
If you see error: project already exists, a directory named wordtools already exists in the current location; choose a different name.
This creates a src/wordtools/ layout with an installable package, which is what lets a separate tests/ directory import the package by name.
The --dev flag marks Ruff and pytest as development dependenciesA package needed during development (testing, linting, formatting) that is not shipped to users of your project. Installed with the --dev flag.
, keeping them out of production installs:
$ 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 environmentAn isolated folder where Python installs packages for one project, keeping them separate from other projects and your system Python.
Learn more →
for the project because this is the first dependencyAn external package your project needs, listed in pyproject.toml so tools can install it automatically.
.
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
The tests/ directory sits at the project root alongside src/. pytest discovers it there automatically because pyproject.toml is also at the root.
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 executes the command inside the project’s virtual environment, ensuring all dependencies are installed first:
$ 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 ===============================
If pytest reports ModuleNotFoundError: No module named 'wordtools.counter', check that counter.py is at src/wordtools/counter.py, inside the package directory uv init created.
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. Use it to name the project commands instead of relying on the agent to infer them.
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 provides context, not access controls. Check the proposed commands and use /permissions to inspect what the session can run. The next layer runs formatting automatically after supported file edits.
Format Python edits with a hook
Hooks run commands on session events. A PostToolUse hook runs after a successful tool call, so it can format an edit but cannot prevent or undo that edit.
Create .claude/hooks/ in your project, then save this script:
import json
import os
from pathlib import Path
import subprocess
import sys
payload = json.load(sys.stdin)
root = Path(payload.get("cwd") or os.environ.get("CLAUDE_PROJECT_DIR") or ".")
path = Path(payload.get("tool_input", {}).get("file_path", ""))
if not path.is_absolute():
path = root / path
if path.suffix not in {".py", ".pyi"} or not path.is_file():
sys.exit(0)
failed = False
for args in (["check", "--fix", "--force-exclude"], ["format", "--force-exclude"]):
result = subprocess.run(
["uv", "run", "ruff", *args, str(path)],
cwd=root, capture_output=True, text=True,
)
if result.returncode:
print(result.stdout + result.stderr, file=sys.stderr)
failed = True
sys.exit(2 if failed else 0)The script reads the edited path and active directory from Claude Code’s JSON input, so Ruff uses the current worktree’s environment and configuration. It applies safe Ruff fixes and formatting, respects Ruff’s exclusions even for an explicit path, and sends remaining errors back to Claude with exit code 2. It ignores non-Python files.
Create .claude/settings.json with this configuration, or merge PostToolUse into the existing hooks object:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "uv",
"args": [
"run",
"--no-project",
"python",
"${CLAUDE_PROJECT_DIR}/.claude/hooks/ruff-after-edit.py"
],
"timeout": 30
}
]
}
]
}
}The command and args fields run uv directly without a shell. ${CLAUDE_PROJECT_DIR} locates the handler in the original checkout, including when the path contains spaces. --no-project launches the standard-library handler; its internal uv run ruff commands use the active project’s dependencies. Open /hooks in Claude Code to confirm the hook is registered.
To test it, ask Claude Code to change a Python file, then inspect the resulting file and run uv run ruff check . and uv run ruff format --check .. Both should pass. An edit made through a shell command does not match Write|Edit, so keep project checks in pre-commit and CI too.
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 Astral’s plugin
Astral’s 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 usage patterns, configuration examples, and links to official documentation. Type /astral:ruff which bugbear rules should I enable? to load the Ruff guidance. Verify the proposed configuration with Ruff; a skill does not guarantee accuracy.
Claude Code also matches skills by description, not just slash command. A request such as “how do I configure Ruff’s import sorting?” can trigger the Ruff skill. Invoke /astral:ruff directly when you want to ensure the skill is loaded.
Write a custom skill
Astral’s 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.17.2
hooks:
- id: ruff-check
args: [--fix]
- id: ruff-formatInstall the hooks:
$ uvx pre-commit install
pre-commit installed at .git/hooks/pre-commit
If uvx pre-commit install reports An error occurred: git directory not found, the project is not a git repository yet. Run git init first, then re-run the install.
Every git commit now runs Ruff. A normal git commit runs the installed hooks. For team projects where you want to reduce hook bypasses, combine local guardrails with required CI checks.
Review the final project
-
- CLAUDE.md
- .pre-commit-config.yaml
- pyproject.toml
- README.md
-
- settings.json
-
- ruff-after-edit.py
-
-
- 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/formats supported Python file edits and reports lint failures.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 a command guardrail and a bounded type-check hook
- 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