python-dev · v0.3.0 · 2026-09-09 · sha256 48cd51811c01635d
python-dev v0.3.0A
Immutable. This exact content is served forever at /api/v1/blob/48cd51811c01635d.
---
name: python-dev
description: Opinionated Python development setup with uv, ty, ruff, pytest, lefthook, and just. Use when creating a new Python project, writing or fixing pyproject.toml, or configuring linting, formatting, type checking, testing, git hooks, or CI.
metadata:
version: "0.3.0"
categories: "development"
topics: "python, uv, ruff, pytest, lefthook"
upstream: "uv@0.12.11, ty@0.0.79, ruff@0.16.6, pytest@9.1.1, pytest-asyncio@1.4.0, lefthook@2.1.12"
openclaw:
homepage: https://github.com/tenequm/skills/tree/main/skills/python-dev
emoji: "🐍"
---
# Python Development Setup
Opinionated, production-ready Python development stack. No choices to make - just use this.
## When to Use
- Starting a new Python project
- Modernizing an existing project (migrating from pip/poetry/mypy/black/flake8)
- Setting up linting, formatting, type checking, or testing
- Creating a Justfile for project commands
- Configuring pyproject.toml as the single source of truth
## The Stack
| Tool | Role | Replaces |
|------|------|----------|
| [uv](https://docs.astral.sh/uv/) 0.12+ | Package manager, Python versions, runner | pip, poetry, pyenv, virtualenv |
| [ty](https://docs.astral.sh/ty/) (beta) | Type checker (Astral, Rust) | mypy, pyright |
| [ruff](https://docs.astral.sh/ruff/) | Linter + formatter | flake8, black, isort, pyupgrade |
| [pytest](https://docs.pytest.org/) | Testing | unittest |
| [just](https://just.systems/) | Command runner | make |
| [lefthook](https://lefthook.dev/) 2.1+ | Git hooks (single binary, parallel) | pre-commit |
> **Note on ty**: ty is in beta (0.0.x) - no stable API, and inference can change between any
> two versions, so pin it. Pydantic is no longer a fair complaint: ty has shipped a dedicated
> library-support track for it since 0.0.57 (constructors, `model_config`, `BaseSettings`,
> `RootModel`, strict vs lax). Django and SQLAlchemy still have no such support and remain the
> likely source of false positives. Before swapping the whole checker, reach for
> `[tool.ty.analysis] replace-imports-with-any = ["sqlalchemy.**"]`, which silences one bad
> dependency instead of all of them. If you do need rock-solid checking today, swap `ty` for
> `pyright` and keep the rest of the stack unchanged.
## Quick Start: New Project
```bash
# 1. Create project with src layout (uv 0.12+ packages by default; --package is redundant)
uv init my-project
cd my-project
# 2. Pin Python version
uv python pin 3.13
# 3. Add dev dependencies
uv add --dev ruff ty pytest pytest-asyncio lefthook
# 4. Create Justfile and lefthook.yml (see templates below)
# 5. Configure pyproject.toml (see template below)
# 6. Install git hooks
uv run lefthook install
# 7. Run checks
just check
```
## pyproject.toml Template
This is the single config file. Copy this and adjust `[project]` fields.
```toml
[project]
name = "my-project"
version = "0.1.0"
description = "Project description"
readme = "README.md"
requires-python = ">=3.13"
license = {text = "MIT"}
dependencies = []
[project.scripts]
my-project = "my_project:main" # CLI: `uv run my-project` -> main() in src/my_project/__init__.py
[dependency-groups]
dev = [
"ruff>=0.16.6", # 0.16 changed the default rule set - see the lint note below
"ty>=0.0.79", # beta: pin tight, inference changes between minors
"pytest>=9.1.1", # 9.1 fixed addopts strictness being ignored
"pytest-asyncio>=1.4.0", # 1.4 added the loop-factories hook
"lefthook>=2.1.12",
]
# uv 0.12+ generates `uv_build` here instead. Keep that unless you need a hatchling
# plugin - this block is a deliberate override, not what `uv init` gives you.
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/my_project"]
# =============================================================================
# RUFF - Loose, helpful rules only
# =============================================================================
[tool.ruff]
target-version = "py313"
line-length = 100
[tool.ruff.lint]
# Ruff 0.16 enables 413 rules by default (up from 59). Do NOT write a `select`
# list here unless you mean to shrink that - `select = ["E","F","I","UP"]` now
# makes ruff weaker than no config at all. Narrow with `extend-select`/`ignore`.
ignore = [
"E501", # line too long - formatter handles it
"UP007", # X | Y unions - Optional[X] is more readable
]
exclude = [".git", ".venv", "__pycache__", "build", "dist"]
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
line-ending = "lf"
# Ruff 0.16 formats Python blocks inside Markdown by default. Drop this line
# only if you want README code fences reformatted too.
exclude = ["*.md"]
# =============================================================================
# TY - Type Checker
# =============================================================================
[tool.ty.environment]
python-version = "3.13"
[tool.ty.src]
include = ["src"]
# =============================================================================
# PYTEST
# =============================================================================
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
asyncio_mode = "auto"
# pytest 9.0 silently ignored --strict-markers/--strict-config passed via
# addopts. Use the ini keys, which work on 9.0 and 9.1 alike. `strict = true`
# is the shorthand and also covers strict_xfail + strict_parametrization_ids.
strict = true
addopts = ["-ra"]
```
`asyncio_default_fixture_loop_scope` is intentionally unset above; pytest-asyncio warns about
that on every run. Set it to `"function"` to silence the warning and lock the behavior in.
## Justfile Template
```just
# Check types, lint, and formatting (non-mutating; mirrors CI)
check:
uv run ty check
uv run ruff check
uv run ruff format --check
# Run tests
test *ARGS:
uv run pytest {{ARGS}}
# Run tests with coverage
test-cov:
uv run pytest --cov=src --cov-report=term-missing
# Auto-fix and format
fix:
uv run ruff check --fix
uv run ruff format
# Install/sync all dependencies
install:
uv sync --all-groups
uv run lefthook install
# Update all dependencies
update:
uv lock --upgrade
uv sync --all-groups
# Clean build artifacts
clean:
rm -rf dist/ build/ .pytest_cache/ .ruff_cache/ htmlcov/
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
```
## Lefthook Config
Lefthook replaces pre-commit here for the same reason go-dev uses it: one binary, hooks in
parallel, and - the reason that matters most in Python - the hook runs **your** ruff from
`uv.lock` instead of a second copy pinned separately in a hook config. It installs from PyPI as
a platform wheel, so `uv add --dev lefthook` is the whole install; no Go toolchain.
```bash
uv run lefthook install # writes .git/hooks/pre-commit and pre-push
```
```yaml
# lefthook.yml
assert_lefthook_installed: true
pre-commit:
piped: true # a failed job stops the rest; lint must fix before format runs
jobs:
- name: guards
group:
parallel: true
jobs:
- name: private-key
run: "! grep -lE 'BEGIN [A-Z ]*PRIVATE KEY' {staged_files}"
- name: merge-conflict
run: "! grep -lE '^(<<<<<<<|>>>>>>>) ' {staged_files}"
- name: large-files
exclude: ["uv.lock"]
run: "! find {staged_files} -type f -size +1000k | grep ."
- name: ruff-check
glob: "*.{py,pyi,ipynb}"
run: uv run ruff check --force-exclude --fix {staged_files}
stage_fixed: true
- name: ruff-format
glob: "*.{py,pyi,ipynb}"
run: uv run ruff format --force-exclude {staged_files}
stage_fixed: true
- name: ty
run: uv run ty check # no glob - see the glob trap below
pre-push:
jobs:
- name: test
run: uv run pytest
```
**`--force-exclude` is mandatory, not decoration.** Ruff ignores its own `exclude` config for
paths passed explicitly on the command line, and `{staged_files}` passes paths explicitly.
Without the flag a staged file under `[tool.ruff] exclude` gets linted anyway - verified: with
the flag `ruff check --force-exclude src/generated/gen.py` reports `All checks passed`, without
it the same call finds errors. pre-commit users never met this because `ruff-pre-commit` bakes
the flag into its hook entry; on lefthook it is yours to remember.
**`stage_fixed: true` is the ergonomic win over pre-commit.** pre-commit fails the commit when
a hook rewrites a file and makes you re-stage and re-run. lefthook re-runs `git add` on the
fixed files and the commit proceeds. Since 2.1.12 a failing `git add` fails the hook, so a fix
can never slip through unstaged.
**Ordering is why `piped: true` is set.** Ruff's own guidance is lint-with-fix before format,
because `--fix` emits code that then needs reformatting. Piped also means the guards run first
and a leaked key stops the commit before any tool burns time.
Notes worth knowing before editing this config:
- **A `glob` silently skips the whole job when nothing matches** - and that is a gate hole, not
a convenience. A job with a `glob` but no `{staged_files}` in its `run` is still filtered by
that glob, so `ty` with `glob: "*.{py,pyi}"` is skipped on a commit that changes only
`pyproject.toml` - exactly where `[tool.ty]` and your dependency pins live. Verified: such a
commit prints `ty (skip) no matching staged files` and records with no type check at all.
Any job that checks the *project* rather than the staged files must carry no glob. The ruff
jobs above keep theirs because they act on `{staged_files}` and nothing else.
- **`**` matches one or more directories, not zero or more.** `glob: "src/**/*.py"` does *not*
match `src/main.py`. Use `glob_matcher: doublestar` for the behavior every other tool has.
- **Config location is load-bearing.** lefthook auto-discovers only the repo root or `.config/`
(the latter since v1.11.12). Anywhere else and commits silently stop running hooks, because
git invokes the hook directly and no task-runner recipe can intercept that.
- **Never put a mutating job in `pre-push`.** A job that rewrites files there fails the push and
leaves you with uncommitted edits. `--fix` belongs in `pre-commit`, where `stage_fixed`
handles it; `pre-push` stays read-only, like the `pytest` job above.
- **`file_types` is available** when a glob is too blunt - `text`, `binary`, `executable`,
`symlink`, and MIME types including `text/x-python`.
- **Deleted files drop out of `{staged_files}`** and the job is skipped with
`no files for inspection`, so a deletion-only commit does not error.
- **Unstaged changes are hidden for the hook's duration and restored after**, so the gate judges
what you are actually committing, not your dirty worktree. Verified on 2.1.12: staging a clean
file while leaving a broken copy unstaged passes, commits the clean version, and gives the
unstaged edit back.
- **lefthook is dormant until installed.** `assert_lefthook_installed: true` turns a missing
binary into a failure instead of hooks silently not firing. Make `lefthook install` part of
onboarding.
- `lefthook validate` catches a malformed config in CI; `lefthook dump` prints the merged
effective config. A gitignored `lefthook-local.yml` lets one developer add or skip jobs
without imposing it on teammates.
**What you give up by leaving pre-commit.** The `pre-commit-hooks` library has no lefthook
equivalent. Three of its hooks were worth keeping and are hand-rolled in the `guards` group
above: `detect-private-key`, `check-merge-conflict` and `check-added-large-files`. Four are
**not** replicated - `check-yaml`, `check-toml`, `end-of-file-fixer`, `trailing-whitespace` and
`mixed-line-ending`: ruff's formatter already handles whitespace and final newlines for Python
files, and the rest only ever covered non-Python files. Add them back as shell jobs if your repo
carries a lot of hand-edited YAML. You also lose `pre-commit autoupdate` and hosted
`pre-commit.ci` - in exchange, `uv lock --upgrade` is now the one place tool versions move.
## Project Structure
Always use src layout:
```
my-project/
src/
my_project/
__init__.py
cli.py
models.py
tests/
conftest.py
test_models.py
pyproject.toml
Justfile
uv.lock
.python-version
lefthook.yml
.gitignore
```
## Daily Workflow
```bash
just check # Type check + lint + format
just test # Run tests
just test -x # Stop on first failure
just fix # Auto-fix lint issues
uv add httpx # Add a dependency
uv add --dev hypothesis # Add dev dependency
uv sync # main deps + dev (dev is in default-groups)
uv sync --all-groups # everything in [dependency-groups]
uv run python -m my_project # Run the project
```
## CI (GitHub Actions)
Mirror `just check` + `just test` in CI. Drop this in `.github/workflows/ci.yml`:
```yaml
name: CI
on: [push, pull_request]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v10.0.1 # pin the full version - see below
- run: uv sync --all-groups
- run: uv run ty check
- run: uv run ruff check --output-format github
- run: uv run ruff format --check
- run: uv run pytest
- run: uv run lefthook validate
```
`astral-sh/setup-uv` installs uv, manages the Python install requested by `.python-version`, and caches the resolver. No separate `setup-python` step needed.
Two things about that pin:
- **Pin the full version, not `@v10`.** From v8 setup-uv stopped publishing floating tags of
both kinds - *"To increase security even more we will stop publishing minor tags. You won't be
able to use `@v8` or `@v8.0` any longer."* Confirmed: the `v6` and `v7` refs resolve,
`v8`/`v9`/`v10` 404. Only exact patch versions exist, so pin one or a commit SHA.
- **Do not set `enable-cache: true`.** The default is `auto`, which since v10 deliberately
*disables* the cache on `pull_request_target`, `workflow_run` and `release` to block cache
poisoning. Forcing it on turns that protection off.
## Existing Project Migration
```bash
# 1. Install uv if not present
brew install uv
# 2. Convert requirements.txt to pyproject.toml deps
uv add -r requirements.txt
# 3. Replace mypy with ty
uv remove --dev mypy
uv add --dev ty
# 4. Replace black/flake8/isort with ruff
uv remove --dev black flake8 isort
uv add --dev ruff
# 5. Replace pre-commit with lefthook
uv run pre-commit uninstall # while it can still read its own config
uv remove --dev pre-commit
rm .pre-commit-config.yaml
uv add --dev lefthook
uv run lefthook install
# 6. Apply pyproject.toml config sections from template above
# 7. Create Justfile and lefthook.yml from templates above
# 8. Run: just check
```
`lefthook install` does not clobber an existing hook - it renames it to
`.git/hooks/pre-commit.old` and tells you so. Running `pre-commit uninstall` first just saves
you deleting that leftover.
## Reference Docs
Detailed guides for each tool in `references/`:
- **uv-reference.md** - Project init, dependencies, lock/sync, Python versions, build/publish
- **ty-reference.md** - Configuration, rules, CLI flags, known limitations
- **ruff-reference.md** - Rule sets, formatter options, per-file ignores, CI integration
- **pytest-reference.md** - Plugins, fixtures, async testing, conftest patterns
- **justfile-reference.md** - Syntax, variables, parameters, shebang recipes, settings
## Resources
- [uv docs](https://docs.astral.sh/uv/) | [uv GitHub](https://github.com/astral-sh/uv)
- [ty docs](https://docs.astral.sh/ty/) | [ty GitHub](https://github.com/astral-sh/ty)
- [ruff docs](https://docs.astral.sh/ruff/) | [ruff GitHub](https://github.com/astral-sh/ruff)
- [pytest docs](https://docs.pytest.org/en/stable/)
- [just manual](https://just.systems/man/en/)
- [lefthook docs](https://lefthook.dev/) | [lefthook GitHub](https://github.com/evilmartians/lefthook)