Skip to content

Set up a complete Python project with uv, Ruff, ty, pytest, and pre-commit

Five tools turn an empty directory into a project that catches bugs before they reach version control. uv manages the project and its dependencies. Ruff lints and formats the code. ty checks types. pytest runs tests. pre-commit wires Ruff, ty, and pytest into a Git hook that fires on every commit.

This tutorial builds a small task tracker and layers each tool in one at a time: scaffold the project, write code, add linting, add type checking, add tests, then lock it all down with pre-commit. By the end, a single git commit runs three quality gates automatically.

Everything here runs on your machine. To carry the same uv, Ruff, ty, and pytest stack into GitHub Actions and publish to PyPI, see Build and Publish a Python Package.

Check your starting point

Install uv and Git. You do not need Python installed; uv downloads and manages the interpreter for you.

Scaffold the project

Create a new project called tasktrack:

$ uv init tasktrack
Initialized project `tasktrack` at `/path/to/tasktrack`
$ cd tasktrack

If you see Project is already initialized, a pyproject.toml already exists in that directory from a previous run. Pick a different name or remove the existing pyproject.toml.

uv created several files: pyproject.toml for project configuration, main.py as the entry point, .python-version pinning the interpreter, and .gitignore. Because Git is installed, uv also initialized a Git repository.

Open pyproject.toml:

[project]
name = "tasktrack"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13"
dependencies = []

The requires-python value depends on which Python versions uv finds on your system. Any version >=3.10 works for this tutorial.

Write the application code

Replace the contents of main.py with a task tracker that stores tasks with priorities:

main.py
from dataclasses import dataclass, field
from enum import Enum


class Priority(Enum):
    LOW = "low"
    MEDIUM = "medium"
    HIGH = "high"


@dataclass
class Task:
    title: str
    priority: Priority = Priority.MEDIUM
    done: bool = False

    def complete(self) -> None:
        self.done = True


@dataclass
class TaskList:
    tasks: list[Task] = field(default_factory=list)

    def add(self, title: str, priority: Priority = Priority.MEDIUM) -> Task:
        task = Task(title=title, priority=priority)
        self.tasks.append(task)
        return task

    def pending(self) -> list[Task]:
        return [t for t in self.tasks if not t.done]

    def by_priority(self, priority: Priority) -> list[Task]:
        return [t for t in self.tasks if t.priority == priority]

    def summary(self) -> str:
        total = len(self.tasks)
        done = sum(1 for t in self.tasks if t.done)
        return f"{done}/{total} tasks completed"


def main() -> None:
    tracker = TaskList()
    tracker.add("Write README", Priority.HIGH)
    tracker.add("Add tests", Priority.HIGH)
    tracker.add("Update changelog", Priority.LOW)

    tracker.tasks[0].complete()

    print(tracker.summary())
    print(f"Pending: {len(tracker.pending())}")
    for task in tracker.pending():
        print(f"  [{task.priority.value}] {task.title}")


if __name__ == "__main__":
    main()

Run it:

$ uv run main.py
1/3 tasks completed
Pending: 2
  [high] Add tests
  [low] Update changelog

uv run syncs any pending dependency changes, activates the project’s virtual environment, and runs the command. The first invocation creates the .venv/ directory and downloads the Python interpreter if needed.

Add Ruff for linting and formatting

Install Ruff as a development dependency:

$ uv add --dev ruff
Resolved 2 packages in 92ms
Installed 1 package in 3ms
 + ruff==0.15.14

The --dev flag places Ruff in a separate dependency group so it stays out of production installs.

Run the linter:

$ uv run ruff check .
All checks passed!

Run the formatter:

$ uv run ruff format --check .
1 file already formatted

Both pass on the fresh code. To see Ruff catch real problems, add a few rules to pyproject.toml that go beyond the defaults:

pyproject.toml
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM"]

These rule sets cover pycodestyle errors (E), Pyflakes (F), import sorting (I), Python upgrade suggestions (UP), bugbear (B), and code simplification (SIM). Run the linter again:

$ uv run ruff check .
All checks passed!

The code is already clean, but these rules will catch issues as the project grows. Try adding an unused import to main.py to see Ruff in action:

import os
$ uv run ruff check .
main.py:1:8: F401 [*] `os` imported but unused
Found 1 error.
[*] 1 fixable with the `--fix` option.

Ruff found the unused import and offers to fix it automatically. Run uv run ruff check --fix . to remove it, or delete the line yourself. For a deeper look at Ruff’s capabilities, see the Ruff formatting and linting tutorial.

Add ty for type checking

Install ty:

$ uv add --dev ty
Resolved 3 packages in 88ms
Installed 1 package in 4ms
 + ty==0.0.39

Run the type checker:

$ uv run ty check
All checks passed!

ty checks every Python file in the project. Unannotated parameters and return types are treated as Unknown rather than flagged as errors, so ty works on any codebase without requiring annotations up front. Adding type annotations to function signatures, as this code does, gives ty enough information to catch type mismatches.

To see ty catch a real bug, change the complete method in main.py to return the wrong type:

def complete(self) -> str:
    self.done = True
$ uv run ty check
error[invalid-return-type]: Function always implicitly returns `None`, which is not assignable to return type `str`
 --> main.py:17:27
   |
17 |     def complete(self) -> str:
   |                           ^^^
   |
info: Consider changing the return annotation to `-> None` or adding a `return` statement

Found 1 diagnostic

ty caught the mismatch: the method promises to return str but returns None. Revert the annotation back to -> None before continuing.

Add pytest for testing

Install pytest:

$ uv add --dev pytest
Resolved 9 packages in 98ms
Installed 5 packages in 9ms
 + iniconfig==2.3.0
 + packaging==26.2
 + pluggy==1.6.0
 + pygments==2.20.0
 + pytest==9.0.3

Create a test file:

test_main.py
from main import Priority, Task, TaskList


def test_add_task():
    tracker = TaskList()
    task = tracker.add("Write docs")
    assert task.title == "Write docs"
    assert task.priority == Priority.MEDIUM
    assert not task.done


def test_complete_task():
    task = Task(title="Ship it", priority=Priority.HIGH)
    task.complete()
    assert task.done


def test_pending_excludes_done():
    tracker = TaskList()
    tracker.add("Task A")
    tracker.add("Task B")
    tracker.tasks[0].complete()
    assert len(tracker.pending()) == 1
    assert tracker.pending()[0].title == "Task B"


def test_filter_by_priority():
    tracker = TaskList()
    tracker.add("Urgent", Priority.HIGH)
    tracker.add("Routine", Priority.LOW)
    tracker.add("Also urgent", Priority.HIGH)
    high = tracker.by_priority(Priority.HIGH)
    assert len(high) == 2


def test_summary():
    tracker = TaskList()
    tracker.add("A")
    tracker.add("B")
    tracker.tasks[0].complete()
    assert tracker.summary() == "1/2 tasks completed"

Run the tests:

$ uv run pytest
========================= test session starts =========================
collected 5 items

test_main.py .....                                                [100%]

========================== 5 passed in 0.01s ==========================

Five tests, all passing. For more on pytest configuration, fixtures, and coverage, see the pytest tutorial.

Wire everything together with pre-commit

The project now has three quality gates: Ruff (lint + format), ty (types), and pytest (tests). Running them manually before every commit is easy to forget. pre-commit automates this by running hooks every time you run git commit.

Create .pre-commit-config.yaml in the project root:

.pre-commit-config.yaml
repos:
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v6.0.0
    hooks:
      - id: trailing-whitespace
      - id: end-of-file-fixer
      - id: check-yaml

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

  - repo: local
    hooks:
      - id: ty
        name: ty
        entry: uvx ty check
        language: system
        types: [python]
        pass_filenames: false

      - id: pytest
        name: pytest
        entry: uv run pytest
        language: system
        types: [python]
        pass_filenames: false

The configuration runs seven hooks in order: trailing whitespace cleanup, end-of-file fixer, YAML validation, Ruff linting with auto-fix, Ruff formatting, type checking with ty, and tests with pytest. The ty and pytest hooks use local entries with pass_filenames: false so each tool discovers files on its own.

Install the hooks:

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

This writes a script to .git/hooks/pre-commit that runs automatically on git commit. The uvx command runs pre-commit without adding it as a project dependency.

Make your first commit

Stage all files and commit:

$ git add .
$ git commit -m "Initial project with Ruff, ty, pytest, and pre-commit"

pre-commit runs every hook against the staged files:

trim trailing whitespace.................................................Passed
fix end of files.........................................................Passed
check yaml...............................................................Passed
ruff check...............................................................Passed
ruff format..............................................................Passed
ty.......................................................................Passed
pytest...................................................................Passed

All seven hooks pass and the commit goes through. If any hook fails, pre-commit blocks the commit and prints what went wrong. Fix the issue, re-stage, and commit again.

Watch the hooks catch a mistake

Open main.py and introduce two problems: an unused import at the top of the file and a wrong return type on summary.

Add import json as the first line and change the summary return annotation from str to int:

def summary(self) -> int:
    total = len(self.tasks)
    done = sum(1 for t in self.tasks if t.done)
    return f"{done}/{total} tasks completed"

Stage and try to commit:

$ git add main.py
$ git commit -m "Add feature"
trim trailing whitespace.................................................Passed
fix end of files.........................................................Passed
check yaml...........................................(no files to check)Skipped
ruff-check...............................................................Failed
- hook id: ruff-check
- files were modified by this hook

Found 1 error (1 fixed, 0 remaining).

ruff format..............................................................Passed
ty.......................................................................Failed
- hook id: ty
- exit code: 1

error[invalid-return-type]: Return type does not match returned value
  --> main.py:36:26

Two hooks caught two different problems in one pass. Ruff removed the unused import json automatically (then marked the hook as failed because it modified the file). ty flagged the int return type on a method that returns a string.

Fix the summary annotation back to -> str, then re-stage and commit:

$ git add main.py
$ git commit -m "Add feature"

The commit succeeds only when every hook passes.

Inspect the finished project

The project directory now looks like this:

  • .gitignore
  • .pre-commit-config.yaml
  • .python-version
  • main.py
  • pyproject.toml
  • README.md
  • test_main.py
  • uv.lock

The final pyproject.toml contains the project metadata, dependencies, and Ruff configuration in one file:

pyproject.toml
[project]
name = "tasktrack"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13"
dependencies = []

[dependency-groups]
dev = [
    "pytest>=9.0.3",
    "ruff>=0.15.14",
    "ty>=0.0.39",
]

[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM"]

Three dev dependencies, six Ruff rule sets, and a pre-commit config that enforces all of them on every commit.

Run all checks manually

You can run each tool individually outside of Git commits:

uv run ruff check .          # lint
uv run ruff format --check . # check formatting without changing files
uv run ty check              # type check
uv run pytest                # run tests

To run all pre-commit hooks against every file (not just staged changes):

uvx pre-commit run --all-files

This is useful before pushing to a remote or when you want to verify the entire project at once.

Learn More

Last updated on