# From requirements.txt to pyproject.toml: Python Evolution

> This article covers how uv, Ruff, and pyproject.toml replaced my entire Python toolchain. Together, they made Python feel more like TypeScript.

A few months ago, I wrote about entering Python territory as a Node.js developer. That article covered the culture shock of learning `requirements.txt`, virtual environments, and the Python trinity of Black, Flake8, and MyPy. This article is the sequel. I shipped multiple Python projects since then, and my setup changed a great deal.

Here is the short version: I no longer use `requirements.txt`, Black, or Flake8. Here is what changed.

## The Old Way vs. The New Way

Remember my original setup?

```bash
# The old way (what I wrote about before)
python -m venv venv
source venv/bin/activate
pip install -r requirements.txt -r requirements-dev.txt
```

The new way:

```bash
# The new way
uv sync
```

That one command replaces the entire virtual environment routine.

## uv: The Package Manager That Changed Everything

If you have used Bun in Node.js, uv is the Python equivalent. It brings the same speed improvement and the same simplicity.

Here is my actual setup from a recent project:

```toml
[project]
name = "load-tester"
version = "0.1.0"
description = "A high-performance async load testing tool"
requires-python = ">=3.12"
dependencies = [
    "aiohttp>=3.13.2",
    "pydantic>=2.12.5",
    "rich>=14.2.0",
    "typer>=0.20.0",
]

[dependency-groups]
dev = [
    "pytest>=8.0.0",
    "pytest-asyncio>=0.23.0",
    "pytest-cov>=4.1.0",
    "ruff>=0.1.0",
    "mypy>=1.8.0",
]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
```

I no longer need `requirements.txt` or `requirements-dev.txt`. Everything now lives in `pyproject.toml`, similar to `package.json` in Node.js, but with better structure.

The `[dependency-groups]` feature works well. Development dependencies stay separate from production dependencies, but both live in one file. When I run `uv sync`, it sets up everything I need. When I deploy, I can exclude the development dependencies.

## Ruff: One Tool to Rule Them All

I mentioned a "Python Development Trinity" before: Black, Flake8, and MyPy. I have now collapsed two of those tools into one:

```toml
[tool.ruff]
line-length = 88
target-version = "py312"
src = ["src", "tests"]

[tool.ruff.lint]
select = [
    "E",   # pycodestyle errors
    "W",   # pycodestyle warnings
    "F",   # Pyflakes
    "I",   # isort
    "B",   # flake8-bugbear
    "C4",  # flake8-comprehensions
    "UP",  # pyupgrade
    "ARG", # flake8-unused-arguments
    "SIM", # flake8-simplify
    "TCH", # flake8-type-checking
    "PTH", # flake8-use-pathlib
    "RUF", # Ruff-specific rules
]

[tool.ruff.format]
quote-style = "double"
indent-style = "space"
docstring-code-format = true
```

Ruff does what Black, Flake8, and isort did together, but as a single tool written in Rust that runs in milliseconds. My entire codebase now lints in the time Flake8 used to take just to start up.

Ruff's `select` system lets me pick exactly which rules I want. I am not stuck with one large configuration. I can enable flake8-bugbear to catch common bugs, flake8-simplify for code simplification suggestions, and pyupgrade to modernize my code automatically.

## Pydantic: TypeScript-Level Confidence

Coming from TypeScript, I missed knowing data shapes at compile time. Pydantic gives me that in Python:

```python
from pydantic import BaseModel, ConfigDict, Field, HttpUrl, model_validator
from typing import Annotated

class LoadTestConfig(BaseModel):
    """Configuration for a single load test execution."""

    model_config = ConfigDict(frozen=True)

    url: HttpUrl
    method: HttpMethod = HttpMethod.GET
    num_requests: Annotated[int, Field(ge=1)] = 100
    concurrency: Annotated[int, Field(ge=1)] = 10
    timeout: Annotated[float, Field(gt=0)] = 30.0
    headers: dict[str, str] = Field(default_factory=dict)

    @model_validator(mode="after")
    def validate_config(self) -> "LoadTestConfig":
        """Validate configuration constraints."""
        if self.concurrency > self.num_requests:
            raise ValueError("concurrency cannot exceed num_requests")
        return self
```

This goes beyond type hints. It gives you runtime validation with clear error messages. The `Annotated[int, Field(ge=1)]` code ensures the value is at least 1. The `@model_validator` decorator handles cross-field validation that TypeScript's type system cannot express.

The `frozen=True` setting makes the model immutable after creation. This prevents accidental mutations and removes the need to debug unexpected state changes.

## Typer + Rich: Beautiful CLIs Without the Boilerplate

CLI tools in Python used to mean argparse. Typer with Rich is a significant improvement:

```python
from typing import Annotated
import typer
from rich.console import Console

app = typer.Typer(
    name="load-tester",
    help="A high-performance async load testing tool.",
    no_args_is_help=True,
)

console = Console()

@app.command()
def run(
    url: Annotated[str, typer.Argument(help="Target URL for load testing.")],
    num_requests: Annotated[
        int,
        typer.Option(
            "-n", "--requests",
            help="Total number of requests to send.",
            min=1,
        ),
    ] = 100,
    verbose: Annotated[
        bool,
        typer.Option("-v", "--verbose", help="Enable verbose output."),
    ] = False,
) -> None:
    """Run a load test against a target URL."""
    console.print(f"[green]Testing {url}...[/green]")
```

Type hints become CLI arguments, help text generates automatically, and `min=1` enforces positive values without a line of validation code. Rich gives me colors and formatting without any extra work.

This setup needs less code than Click or argparse, and it produces better output.

## My Modern Python Project Structure

My current project structure:

```text
project/
├── src/
│   ├── __init__.py
│   ├── main.py              # CLI entry point
│   ├── models/              # Pydantic models
│   │   ├── __init__.py
│   │   ├── config.py
│   │   └── results.py
│   ├── engine/              # Core business logic
│   │   ├── __init__.py
│   │   └── runner.py
│   └── utils/
│       └── errors.py
├── tests/
│   ├── conftest.py          # Shared fixtures
│   ├── unit/
│   │   └── test_models.py
│   └── integration/
│       └── test_engine.py
├── pyproject.toml           # Single config file
├── Makefile                 # Common commands
└── uv.lock                  # Lock file (auto-generated)
```

This structure stays feature-based, the same as my Vue and React projects. Each feature owns its domain, and the tests mirror the source tree.

## The Makefile: npm Scripts for Python

I still use a Makefile for common tasks. It works like the npm scripts of Python:

```makefile
.PHONY: lint format typecheck test

lint:
 uv run ruff check .

format:
 uv run ruff format .

typecheck:
 uv run pyright

test:
 uv run pytest tests/ -v
```

`uv run` does the real work here. It uses the project's virtual environment automatically, so you never activate it by hand. This works like `npx` or `bunx`, but with more intelligence.

## Testing: pytest + pytest-asyncio

Async testing with pytest-asyncio:

```python
import pytest
from src.models import LoadTestConfig, HttpMethod

class TestLoadTestConfig:
    """Tests for LoadTestConfig model."""

    def test_valid_config(self) -> None:
        """Test creating a valid load test config."""
        config = LoadTestConfig(
            url="https://example.com/api", # type: ignore[arg-type]
            method=HttpMethod.POST,
            num_requests=1000,
            concurrency=100,
        )
        assert config.num_requests == 1000

    def test_concurrency_cannot_exceed_num_requests(self) -> None:
        """Test that concurrency cannot exceed num_requests."""
        with pytest.raises(ValidationError):
            LoadTestConfig(
                url="https://example.com", # type: ignore[arg-type]
                num_requests=10,
                concurrency=100,
            )
```

Pydantic validation errors tell you exactly what went wrong and where.

## What I Actually Build Now

Let me show you a real async function from production:

```python
async def run_load_test(
    config: LoadTestConfig,
    proxy_file: Path | None = None,
    show_progress: bool = True,
    output_format: str = "human",
) -> Statistics:
    """Run a complete load test."""
    if output_format == "human":
        print_test_header(config)

    proxy_manager: ProxyManager | None = None
    if proxy_file:
        proxy_manager = await ProxyManager.from_file(proxy_file)
        console.print(f"[green]Loaded {proxy_manager.proxy_count} proxies[/green]")

    runner = LoadTestRunner(config=config, proxy_manager=proxy_manager)
    stats = await runner.run(show_progress=show_progress)

    if output_format == "json":
        print_json_statistics(stats, config)
    else:
        print_statistics(stats, config)

    return stats
```

This function uses type hints everywhere. It keeps a clean separation of concerns. It reads almost like TypeScript.

## The Confidence Boost

Here is what strict typing and Pydantic validation give me:

```toml
[tool.mypy]
python_version = "3.12"
strict = true
warn_return_any = true
warn_unused_ignores = true
disallow_untyped_defs = true
disallow_incomplete_defs = true
check_untyped_defs = true
```

With `strict = true`, MyPy catches most issues on its own. Together with Pydantic's runtime validation, bugs now fail at import time or during validation, instead of in production.

## Before vs. After

| Before                                      | After                                       |
| ------------------------------------------- | ------------------------------------------- |
| `requirements.txt` + `requirements-dev.txt` | `pyproject.toml` with `[dependency-groups]` |
| `pip install` + manual venv                 | `uv sync`                                   |
| Black + Flake8 + isort                      | Ruff (all-in-one, Rust-powered)             |
| argparse / Click                            | Typer + Rich                                |
| Manual validation                           | Pydantic with `@model_validator`            |
| Makefiles with `source venv/bin/activate`   | Makefiles with `uv run`                     |

## What Comes Next

Modern Python with type hints, Pydantic, and uv feels like a different experience from the older tutorials. The ecosystem matured a great deal.

Start with `uv init`, add dependencies to `pyproject.toml`, and let Ruff handle the rest.
