# Set up a Python project optimized for Claude Code


[Claude Code](https://claude.com/product/claude-code) writes Python fluently, but without project-specific configuration it falls back on [pip](https://pydevtools.com/handbook/reference/pip.md) and bare `python` instead of [uv](https://pydevtools.com/handbook/reference/uv.md). This tutorial builds a Python project with uv, [Ruff](https://pydevtools.com/handbook/reference/ruff.md), and [pytest](https://pydevtools.com/handbook/reference/pytest.md), 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](https://docs.astral.sh/uv/getting-started/installation/)
- [Claude Code installed](https://code.claude.com/docs/en/overview)
- [jq](https://jqlang.github.io/jq/) installed (used by one hook script)

## Create the project

Scaffold a new Python package:

```console
$ 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:

```console
$ 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](https://pydevtools.com/handbook/explanation/what-is-a-virtual-environment.md) for the project because this is the first dependency.

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

```toml
[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`:

```python {filename="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`:

```console
$ mkdir tests
```

```python {filename="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:

```console
$ 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`.

```console
$ 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:

```markdown {filename="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](https://pydevtools.com/handbook/how-to/how-to-use-the-pydevtools-claude-md-template.md) 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](https://pydevtools.com/handbook/how-to/how-to-write-claude-code-hooks-for-python-projects.md) 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:

```console
$ mkdir -p .claude/hooks
```
```powershell
New-Item -ItemType Directory -Path .claude\hooks -Force
```
### Block pip with a PreToolUse hook

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

```python {filename=".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`:

```bash {filename=".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`:

```json {filename=".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"
          }
        ]
      }
    ]
  }
}
```
```json {filename=".claude/settings.json"}
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "python \"${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\""
          }
        ]
      }
    ]
  }
}
```

> [!NOTE]
> Claude Code expands `${CLAUDE_PROJECT_DIR}` on all platforms and runs hook commands through a POSIX-compatible shell (Git Bash on Windows). The PostToolUse hook requires bash. Git for Windows includes it at `C:\Program Files\Git\bin\bash.exe`; ensure it is on your PATH, or replace `bash` with the full path.
Test the enforce-uv hook manually by piping sample JSON:

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

The output reads: `Blocked: 'pip install' detected. Use 'uv add' instead.`
The command exits with code 2 (blocked). Try a valid command:

```console
$ echo '{"tool_name":"Bash","tool_input":{"command":"uv add requests"}}' | python3 .claude/hooks/enforce-uv.py
```
```powershell
'{"tool_name":"Bash","tool_input":{"command":"uv add requests"}}' | python .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](https://pydevtools.com/handbook/how-to/how-to-use-python-skills-with-claude-code.md) 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 OpenAI's Astral plugin

[OpenAI's Astral plugin](https://pydevtools.com/handbook/how-to/how-to-install-astral-plugins-for-claude-code.md) provides skills for uv, Ruff, and [ty](https://pydevtools.com/handbook/reference/ty.md). 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

OpenAI's 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`.

```console
$ mkdir -p .claude/skills/release-check
```
```powershell
New-Item -ItemType Directory -Path .claude\skills\release-check -Force
```
```markdown {filename=".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](https://pydevtools.com/handbook/how-to/how-to-set-up-pre-commit-hooks-for-a-python-project.md) catch problems at `git commit` time regardless of how the code was written.

Create `.pre-commit-config.yaml`:

```yaml {filename=".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:

```console
$ 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](https://pydevtools.com/handbook/how-to/how-to-stop-ai-agents-from-bypassing-pre-commit-hooks.md), add a dedicated PreToolUse hook that blocks the flag.

## Review the final project

{{< /filetree/folder >}}
      {{< /filetree/folder >}}
      {{< /filetree/folder >}}
    {{< /filetree/folder >}}
    {{< /filetree/folder >}}
    {{< /filetree/folder >}}
    {{< /filetree/folder >}}
  {{< /filetree/folder >}}
{{< /filetree/container >}}

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

- [Claude Code complete guide](https://pydevtools.com/handbook/explanation/claude-code-complete-guide.md) covers the full range of Claude Code features for Python
- [How to write Claude Code hooks for Python projects](https://pydevtools.com/handbook/how-to/how-to-write-claude-code-hooks-for-python-projects.md) adds UserPromptSubmit and Stop hooks to the two covered here
- [How to configure Ruff with Claude Code](https://pydevtools.com/handbook/how-to/how-to-configure-ruff-with-claude-code.md) covers all four layers of Ruff integration
- [How to use Python skills with Claude Code](https://pydevtools.com/handbook/how-to/how-to-use-python-skills-with-claude-code.md) explains choosing between skills, hooks, and CLAUDE.md
- [How to stop AI agents from bypassing pre-commit hooks](https://pydevtools.com/handbook/how-to/how-to-stop-ai-agents-from-bypassing-pre-commit-hooks.md) adds defense-in-depth for team projects
