Install
openclaw skills install @tenequm/python-devOpinionated 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.
openclaw skills install @tenequm/python-devOpinionated, production-ready Python development stack. No choices to make - just use this.
| Tool | Role | Replaces |
|---|---|---|
| uv 0.12+ | Package manager, Python versions, runner | pip, poetry, pyenv, virtualenv |
| ty (beta) | Type checker (Astral, Rust) | mypy, pyright |
| ruff | Linter + formatter | flake8, black, isort, pyupgrade |
| pytest | Testing | unittest |
| just | Command runner | make |
| lefthook 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, swaptyforpyrightand keep the rest of the stack unchanged.
# 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
This is the single config file. Copy this and adjust [project] fields.
[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.
# 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 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.
uv run lefthook install # writes .git/hooks/pre-commit and pre-push
# 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:
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/
(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.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.{staged_files} and the job is skipped with
no files for inspection, so a deletion-only commit does not error.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.
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
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
Mirror just check + just test in CI. Drop this in .github/workflows/ci.yml:
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:
@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.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.# 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.
Detailed guides for each tool in references/: