Languages No. 115

Modern Python skill for Claude Code

uv, ruff, strict types, one pyproject.toml. Claude builds Python projects the 2025 way: uv manages interpreters, venvs and the lockfile, dev tools live in dependency groups, ruff replaces black, isort and flake8, and new code ships with strict type hints using 3.12 generics. It migrates requirements.txt, poetry or setup.py projects without breaking installs and knows when to reach for dataclasses versus Pydantic.

  • namepython-modern
  • size220 lines · ≈2,150 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Sets up and writes modern Python 3.12+ projects — uv for environments and locking, pyproject.toml with dependency groups, ruff for lint and format, strict type checking with PEP 695 generics, dataclasses vs Pydantic, src layout, and packaging to PyPI. Use when starting a Python project, migrating from pip/requirements.txt/poetry/setup.py, adding type hints, configuring ruff or mypy, or writing scripts with inline dependencies.

Try asking

  • Migrate this requirements.txt and setup.py project to uv and pyproject.toml
  • Set up ruff and mypy strict for this repo and fix what they flag
  • Write a one-file script that fetches this API and needs httpx, runnable with no venv setup

Use it when

  • Starting a new Python package, CLI, or service
  • Moving off pip, poetry, pipenv, or setup.py
  • Adding type hints or a type checker to existing code
  • Configuring lint, format, and test tooling in pyproject.toml

Skip it when

How to check the result

On a fresh clone, uv sync --locked, ruff check, ruff format --check, the type checker, and pytest all pass with no manual venv activation.

The common mistake it prevents

Putting pytest, ruff and mypy in optional-dependencies so they ship in published metadata, instead of PEP 735 dependency groups.

~/.claude/skills/python-modern/SKILL.md

--- name: python-modern description: Sets up and writes modern Python 3.12+ projects — uv for environments and locking, pyproject.toml with dependency groups, ruff for lint and… license: MIT ---

Done well means: one pyproject.toml describes the project, uv.lock pins it, uv run works on a fresh clone, ruff check and ruff format --check pass, the type checker runs in strict mode on new code, and nobody activates a virtualenv by hand.

Ground rules

  • Use uv for Python versions, venvs, dependencies, locking, tools, and builds. Do not mix pip installs into a uv-managed .venv.
  • Commit uv.lock for applications and libraries alike; CI installs with --locked.
  • Target the oldest Python you support in requires-python, then let ruff’s UP rules and the type checker enforce syntax for that version.
  • Types on every public function signature; validate untrusted data at the edge with Pydantic (or msgspec), use plain dataclasses inside.
  • Never edit uv.lock by hand. Never pin with == in a library’s dependencies.

Inspect before changing

ls pyproject.toml setup.py setup.cfg requirements*.txt poetry.lock Pipfile uv.lock 2>/dev/null
python3 --version; uv --version
grep -nE "requires-python|build-backend|\[tool\." pyproject.toml
FoundAction
requirements.txt onlyuv init --bare, then uv add -r requirements.txt, move test/lint deps with uv add --dev
poetry.lock / [tool.poetry]Convert metadata to PEP 621 [project]; uvx migrate-to-uv automates most of it, review the diff
setup.pyMove metadata to [project]; keep setup.py only if a C extension build needs it
uv.lock presentUse uv sync / uv add; never pip install

New project workflow

uv init --package myapp          # src/ layout, build backend, console script
cd myapp
uv python pin 3.13               # writes .python-version
uv add httpx pydantic
uv add --dev pytest ruff mypy    # goes to [dependency-groups] dev
uv run pytest
uv run python -m myapp
  • Library: uv init --lib. App that is never installed as a package: uv init --app.
  • Global CLI tools: uv tool install ruff or one-off uvx ruff check.
  • Lock-only changes: uv lock --upgrade-package httpx; everything: uv lock --upgrade.
  • CI install: uv sync --locked (fails if the lock is stale) — never a bare uv sync.
  • Export for platforms that need it: uv export --format requirements-txt --no-hashes > requirements.txt.

For a full annotated pyproject.toml (ruff, mypy, pytest, coverage settings), read references/pyproject-template.md when creating or overhauling the config.

Single-file scripts (PEP 723)

# /// script
# requires-python = ">=3.12"
# dependencies = ["httpx>=0.27", "rich"]
# ///
import httpx
from rich import print
print(httpx.get("https://example.org").status_code)

Run with uv run fetch.py; add deps with uv add --script fetch.py typer. Prefer this over a throwaway venv for utilities and one-off data jobs.

Typing: modern syntax

from collections.abc import Callable, Iterable, Mapping, Sequence
from typing import Self, override, TypedDict, NotRequired

type JSON = dict[str, "JSON"] | list["JSON"] | str | int | float | bool | None   # 3.12 alias

def first[T](items: Sequence[T], default: T | None = None) -> T | None:   # 3.12 generics
    return items[0] if items else default

class Repo[ModelT]:
    def get(self, id: int) -> ModelT | None: ...

class Builder:
    def with_name(self, name: str) -> Self: ...

class Child(Base):
    @override                       # type checker errors if Base has no such method
    def save(self) -> None: ...

class Event(TypedDict):
    id: str
    payload: NotRequired[dict[str, object]]

Rules:

  • X | None, list[int], dict[str, int] — never Optional, List, Dict.
  • Accept abstract (Iterable, Mapping, Sequence), return concrete (list, dict).
  • Callbacks: Callable[[int, str], bool]; for complex signatures use a Protocol with __call__.
  • Structural typing with typing.Protocol instead of ABCs for dependency seams.
  • TypeIs (3.13, or typing_extensions) for narrowing helpers; prefer it to TypeGuard.
  • Only add from __future__ import annotations on 3.12/3.13 if you need forward refs everywhere; 3.14 evaluates annotations lazily by default.

Type checker choice: mypy --strict is the conservative default; pyright (strict mode) is faster and stricter on inference. Pick one per repo and run it in CI. Astral’s ty is promising but check its release status before relying on it as the only gate.

Data modeling decision

NeedUse
Internal value object, trusted data@dataclass(slots=True, frozen=True, kw_only=True)
Parse/validate untrusted JSON, env, API bodiesPydantic v2 BaseModel / pydantic-settings
Very hot decode path, simple schemasmsgspec Struct
Dict shape for a JSON payload you only pass throughTypedDict
Fixed set of valuesenum.StrEnum / enum.IntEnum
from dataclasses import dataclass, field
from datetime import datetime, UTC

@dataclass(slots=True, frozen=True, kw_only=True)
class Invoice:
    id: str
    amount_cents: int
    tags: tuple[str, ...] = ()
    created_at: datetime = field(default_factory=lambda: datetime.now(UTC))

Never use a mutable default (tags: list[str] = []); dataclasses reject it, plain functions silently share it.

Language features worth reaching for (3.11+)

  • match for structural dispatch on tagged dicts/dataclasses, not as a long if chain over plain values.
  • asyncio.TaskGroup instead of bare gather for structured concurrency; failures cancel siblings and surface as ExceptionGroup (except* ValueError:).
  • tomllib (read-only TOML) in the stdlib; zoneinfo instead of pytz.
  • datetime.now(UTC) — never naive datetime.utcnow() (deprecated in 3.12).
  • pathlib.Path over os.path; Path.read_text(encoding="utf-8") always with encoding.
  • f-string debugging f"{value=}"; 3.12 allows nested quotes in f-strings.

Ruff configuration essentials

[tool.ruff]
line-length = 100
target-version = "py312"     # inferred from requires-python if omitted

[tool.ruff.lint]
select = ["E", "W", "F", "I", "B", "UP", "SIM", "RUF", "C4", "PTH", "DTZ", "S", "N", "PL", "TID"]
ignore = ["PLR0913", "S101"]  # many args ok; assert ok (tests)

[tool.ruff.lint.per-file-ignores]
"tests/**" = ["S", "PLR2004"]

Commands: uv run ruff check --fix ., uv run ruff format .. Ruff replaces flake8, isort, pyupgrade, and black; remove their configs when adopting it.

Packaging and publishing

uv build                          # sdist + wheel in dist/
uvx twine check dist/*            # metadata sanity
uv publish                        # uses trusted publishing in GitHub Actions, or UV_PUBLISH_TOKEN
  • Build backend: hatchling is the safe default; uv_build is fine for pure-Python projects on current uv.
  • Version single source: [project] version or a dynamic version from VCS (hatch-vcs); not both.
  • Ship a py.typed marker in the package for typed libraries.
  • Prefer PyPI trusted publishing (OIDC) over long-lived tokens.

Gotchas

  • A src/ layout prevents tests from importing the working tree accidentally; keep it.
  • uv run syncs the environment first; in CI use uv run --locked or uv sync --locked then uv run --no-sync.
  • requires-python = ">=3.12" without an upper bound is correct; <4 caps cause resolver pain for downstream users.
  • Dev tools in [project.optional-dependencies] leak into published metadata; use [dependency-groups] (PEP 735) instead.
  • except Exception: pass is never acceptable; ruff’s S110/BLE rules catch it.
  • Free-threaded (no-GIL) builds exist (3.13t, supported in 3.14); C extensions must declare support or the GIL is re-enabled at import. Do not promise speedups untested.

Verification

uv sync --locked
uv run ruff check . && uv run ruff format --check .
uv run mypy src        # or: uv run pyright
uv run pytest -q
uv build               # for packages: wheel builds cleanly

All five must pass on a fresh clone. If uv sync --locked fails, run uv lock, review the lock diff, and commit it separately from code changes.

Output for a migration

**From:** requirements.txt + setup.py  **To:** pyproject.toml + uv.lock
**Python:** requires-python >=3.12, pinned 3.13 for dev
**Tooling:** ruff (replaced black, isort, flake8), mypy --strict on src/
**Commands:** `uv sync`, `uv run pytest`, `uv run ruff check --fix .`
**Follow-ups:** 41 mypy errors in legacy/ excluded via per-module override

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

references/pyproject-template.md 121 lines · 3.0 KB
# Annotated pyproject.toml template

Use as a starting point; delete sections the project does not need. Values assume a
`src/` layout package named `myapp`.

```toml
[project]
name = "myapp"
version = "0.1.0"
description = "One sentence that says what this does."
readme = "README.md"
requires-python = ">=3.12"
license = "MIT"                     # PEP 639 SPDX expression
authors = [{ name = "Your Name", email = "[email protected]" }]
dependencies = [
  "httpx>=0.27",                    # lower bounds only for libraries
  "pydantic>=2.7",
]

[project.scripts]
myapp = "myapp.cli:main"            # console entry point

[project.urls]
Homepage = "https://github.com/you/myapp"

[dependency-groups]                 # PEP 735: never published
dev = ["pytest>=8", "pytest-cov", "ruff", "mypy"]
docs = ["mkdocs-material"]

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

# ---------------- uv ----------------
[tool.uv]
default-groups = ["dev"]            # groups synced by default

# ---------------- ruff ----------------
[tool.ruff]
line-length = 100
src = ["src", "tests"]

[tool.ruff.lint]
select = ["E", "W", "F", "I", "B", "UP", "SIM", "RUF", "C4", "PTH", "DTZ", "S", "N", "PL", "TID", "PT"]
ignore = ["PLR0913"]

[tool.ruff.lint.per-file-ignores]
"tests/**" = ["S101", "PLR2004"]

[tool.ruff.lint.isort]
known-first-party = ["myapp"]

[tool.ruff.format]
docstring-code-format = true

# ---------------- mypy ----------------
[tool.mypy]
strict = true
python_version = "3.12"
warn_unreachable = true
plugins = ["pydantic.mypy"]

[[tool.mypy.overrides]]
module = ["legacy.*"]               # quarantine, shrink over time
ignore_errors = true

[[tool.mypy.overrides]]
module = ["some_untyped_lib.*"]
ignore_missing_imports = true

# ---------------- pytest ----------------
[tool.pytest.ini_options]
minversion = "8.0"
testpaths = ["tests"]
addopts = ["-ra", "--strict-markers", "--strict-config", "--import-mode=importlib"]
filterwarnings = ["error"]          # warnings fail tests; add targeted ignores

# ---------------- coverage ----------------
[tool.coverage.run]
source = ["myapp"]
branch = true

[tool.coverage.report]
show_missing = true
skip_covered = true
exclude_also = ["if TYPE_CHECKING:", "raise NotImplementedError", "\\.\\.\\."]
```

## pyright alternative to mypy

```toml
[tool.pyright]
include = ["src", "tests"]
typeCheckingMode = "strict"
pythonVersion = "3.12"
reportMissingTypeStubs = false
```

## GitHub Actions job

```yaml
jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python: ["3.12", "3.13"]
    steps:
      - uses: actions/checkout@v4
      - uses: astral-sh/setup-uv@v6
        with:
          python-version: ${{ matrix.python }}
          enable-cache: true
      - run: uv sync --locked
      - run: uv run ruff check . && uv run ruff format --check .
      - run: uv run mypy src
      - run: uv run pytest --cov
```

Check the latest major tag of `astral-sh/setup-uv` before pinning; pin to a commit SHA
in security-sensitive repos.

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc