python-dev
Opinionated Python development setup with uv, ty, ruff, pytest, and just. Use when creating a new Python project, writing or fixing pyproject.toml, or configuring linting, formatting, type checking, testing, pre-commit hooks, or build and CI tooling.
Install
npx skills add https://github.com/tenequm/skills/tree/main/skills/python-dev
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install tenequm-skills@llmmart
git clone https://github.com/tenequm/skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole tenequm/skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
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 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.
Quick Start: New Project
# 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.
[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
# 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.
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:
- A
globsilently skips the whole job when nothing matches - and that is a gate hole, not a convenience. A job with aglobbut no{staged_files}in itsrunis still filtered by that glob, sotywithglob: "*.{py,pyi}"is skipped on a commit that changes onlypyproject.toml- exactly where[tool.ty]and your dependency pins live. Verified: such a commit printsty (skip) no matching staged filesand 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 matchsrc/main.py. Useglob_matcher: doublestarfor 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.--fixbelongs inpre-commit, wherestage_fixedhandles it;pre-pushstays read-only, like thepytestjob above. file_typesis available when a glob is too blunt -text,binary,executable,symlink, and MIME types includingtext/x-python.- Deleted files drop out of
{staged_files}and the job is skipped withno 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: trueturns a missing binary into a failure instead of hooks silently not firing. Makelefthook installpart of onboarding. lefthook validatecatches a malformed config in CI;lefthook dumpprints the merged effective config. A gitignoredlefthook-local.ymllets 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
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:
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@v8or@v8.0any longer." Confirmed: thev6andv7refs resolve,v8/v9/v10404. Only exact patch versions exist, so pin one or a commit SHA. - Do not set
enable-cache: true. The default isauto, which since v10 deliberately disables the cache onpull_request_target,workflow_runandreleaseto block cache poisoning. Forcing it on turns that protection off.
Existing Project Migration
# 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
Files (skills)
-
references
-
justfile-reference.md 4.9 KB
# Justfile Reference `just` is a command runner for project-specific recipes. Replaces Makefile for non-build tasks. **Manual**: https://just.systems/man/en/ | **GitHub**: https://github.com/casey/just ## Installation ```bash brew install just # macOS cargo install just # Via Rust ``` ## Core Syntax ```just # This comment shows in `just --list` recipe-name: command1 command2 ``` Each line runs in a **separate shell** by default. Use `&&` to chain commands in one shell. Suppress command echoing with `@`: ```just @hello: echo "Hello" # Output: Hello (not: echo "Hello" \n Hello) ``` ## Variables ```just # Simple assignment version := "1.0.0" # Backtick (evaluated at parse time) git_hash := `git rev-parse --short HEAD` # Environment variable with default port := env("PORT", "8000") # Use in recipes build: @echo "Building {{version}} ({{git_hash}})" ``` ## Parameters ```just # Required build target: @echo "Building {{target}}" # With default test suite="unit": @echo "Running {{suite}} tests" # Variadic (one or more) backup +FILES: scp {{FILES}} server: # Variadic (zero or more) commit MESSAGE *FLAGS: git commit {{FLAGS}} -m "{{MESSAGE}}" # Exported as env var serve $PORT="8000": python -m http.server $PORT ``` ## Dependencies ```just # Run before (prior dependency) build: @echo "Building..." test: build @echo "Testing..." # Run after (subsequent dependency) deploy: build && notify cleanup @echo "Deploying..." # With arguments default: (test "unit") test suite: @echo "Running {{suite}} tests" ``` ## Settings ```just # Load .env file set dotenv-load # Export all variables as env vars set export # Suppress command echoing globally set quiet # Use bash set shell := ["bash", "-uc"] ``` ## Recipe Attributes ```just # Group in `just --list` [group('testing')] test: uv run pytest [group('quality')] check: uv run ty check # Require confirmation [confirm("Reset database?")] db-reset: dropdb myapp && createdb myapp # Platform-specific [linux] install: sudo apt install libfoo-dev [macos] install: brew install libfoo # Private (hidden from list) [private] _helper: echo "internal" # Documentation override [doc("Run type checking and linting")] check: uv run ty check uv run ruff check uv run ruff format --check ``` ## Shebang Recipes For multi-line scripts in any language: ```just analyze: #!/usr/bin/env python3 import json with open("data.json") as f: data = json.load(f) print(f"Found {len(data)} records") # With uv inline script deps check-api: #!/usr/bin/env -S uv run --script # /// script # dependencies = ["httpx"] # /// import httpx r = httpx.get("https://api.example.com/health") print(f"Status: {r.status_code}") # Bash with strict mode deploy: #!/usr/bin/env -S bash -euo pipefail echo "Deploying..." git push origin main ``` Use `#!/usr/bin/env -S` with `-S` flag when passing arguments to the interpreter. ## Conditional Logic ```just # Conditional assignment os := if os() == "macos" { "darwin" } else { "linux" } # Conditional in recipe test: uv run pytest {{ if env("CI", "") != "" { "--no-header -q" } else { "-v" } }} ``` ## Built-in Functions | Function | Returns | |----------|---------| | `os()` | "linux", "macos", "windows" | | `arch()` | "x86_64", "aarch64" | | `env("VAR", "default")` | Environment variable | | `justfile()` | Path to current Justfile | | `justfile_directory()` | Directory of Justfile | | `invocation_directory()` | Where `just` was called from | | `uuid()` | Random UUID | ## Python Project Justfile Template ```just set dotenv-load set quiet # Run type checking and linting [group('quality')] check: uv run ty check uv run ruff check uv run ruff format --check # Run tests [group('testing')] test *ARGS: uv run pytest {{ARGS}} # Run tests with coverage [group('testing')] test-cov: uv run pytest --cov=src --cov-report=term-missing # Auto-fix and format [group('quality')] fix: uv run ruff check --fix uv run ruff format # Install all dependencies and git hooks [group('dev')] install: uv sync --all-groups uv run lefthook install # Update all dependencies [group('dev')] update: uv lock --upgrade uv sync --all-groups # Build the package [group('build')] build: uv build # Clean build artifacts [group('dev')] clean: rm -rf dist/ build/ .pytest_cache/ .ruff_cache/ htmlcov/ find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true # Open Python REPL [group('dev')] repl: uv run python ``` ## Running ```bash just # Default recipe (first one, or [default]) just test # Named recipe just test -x -v # Pass args through just --list # List all recipes (grouped) just -l # Short form just --choose # Interactive (needs fzf) just --summary # One-line summary just -n test # Dry run (show what would run) ``` -
pytest-reference.md 7.5 KB
# pytest Reference Modern Python testing with pytest and key plugins. **Docs**: https://docs.pytest.org/en/stable/ | **GitHub**: https://github.com/pytest-dev/pytest | **Tracked line**: pytest 9.x > **Heads up (8 -> 9)**: pytest 9.0 adds native `[tool.pytest]` TOML configuration (alongside the still-supported `[tool.pytest.ini_options]`), built-in subtests, and stricter mode. It drops Python 3.9 and turns previously-deprecated behaviours into errors. pytest 8.4 already failed (instead of warn-skipping) async tests without an asyncio plugin and made `yield` in tests an error. pytest 9.0.3 fixes CVE-2025-71176 in temp-directory creation - upgrade if you are on 9.0.0/9.0.1/9.0.2. ## Installation ```bash uv add --dev pytest pytest-asyncio pytest-cov pytest-mock ``` ## Running Tests ```bash uv run pytest # Run all tests uv run pytest tests/test_models.py # Specific file uv run pytest tests/test_models.py::test_create # Specific test uv run pytest -x # Stop on first failure uv run pytest -x --pdb # Drop into debugger on failure uv run pytest -k "test_user" # Match test names uv run pytest -m "not slow" # Skip marked tests uv run pytest -v # Verbose output uv run pytest --tb=short # Short tracebacks uv run pytest -n auto # Parallel (needs pytest-xdist) ``` ## pyproject.toml Configuration ```toml [tool.pytest.ini_options] testpaths = ["tests"] python_files = ["test_*.py"] python_classes = ["Test*"] python_functions = ["test_*"] asyncio_mode = "auto" # pytest-asyncio: auto-detect async tests # pytest 9.0 silently ignored --strict-markers/--strict-config given via addopts. # The ini keys work on 9.0 and 9.1 alike; `strict` is the shorthand for all four # strictness axes (config, markers, xfail, parametrization ids). strict = true addopts = ["-ra"] # Show summary for all non-passing tests markers = [ "slow: marks tests as slow (deselect with '-m \"not slow\"')", "integration: integration tests", ] filterwarnings = [ "error", # Treat warnings as errors "ignore::DeprecationWarning:some_lib", # Selectively ignore ] ``` ### Coverage Config ```toml [tool.coverage.run] source = ["src"] branch = true omit = ["*/tests/*", "*/__pycache__/*"] [tool.coverage.report] exclude_lines = [ "pragma: no cover", "def __repr__", "raise NotImplementedError", "if __name__ == .__main__.:", "if TYPE_CHECKING:", ] fail_under = 80 ``` ## Key Plugins ### pytest-asyncio (1.0+) With `asyncio_mode = "auto"` in config, no decorators needed: ```python # Async tests - just write async def async def test_fetch_data(): result = await my_async_function() assert result == expected # Async fixtures @pytest.fixture async def async_client(): async with SomeAsyncClient() as client: yield client # For a custom event loop (e.g. uvloop), pytest-asyncio 1.4+ # conftest.py - maps factory names to loop factories import uvloop def pytest_asyncio_loop_factories(config, item): return { "uvloop": uvloop.new_event_loop, } ``` Select one per test with `@pytest.mark.asyncio(loop_factories=["uvloop"])`. Breaking changes in 1.0: the `event_loop` fixture was removed - use `asyncio.get_running_loop()` inside tests. 1.3 dropped Python 3.9 and added pytest 9 compatibility. **1.4.0 deprecated overriding the `event_loop_policy` fixture** in favour of the hook above, and raised the minimum pytest to 8.4.0. The deprecation is downstream of CPython: `asyncio.AbstractEventLoopPolicy` is deprecated as of Python 3.14 (removal planned for 3.16), and `uvloop.EventLoopPolicy` goes with it - so the old fixture form is on a clock, not merely out of fashion. ### pytest-cov ```bash uv run pytest --cov=src --cov-report=term-missing uv run pytest --cov=src --cov-report=html # HTML report in htmlcov/ ``` ### pytest-mock ```python def test_with_mock(mocker): mock_api = mocker.patch("myapp.services.external_api") mock_api.get_data.return_value = {"status": "ok"} result = my_function() mock_api.get_data.assert_called_once() ``` ### pytest-xdist (Parallel) ```bash uv add --dev pytest-xdist uv run pytest -n auto # Use all CPU cores uv run pytest -n 4 # Use 4 workers ``` ## Fixtures ### Scope Hierarchy 1. **function** (default) - per test 2. **class** - per test class 3. **module** - per .py file 4. **package** - per directory 5. **session** - once for entire run ### Common Patterns ```python # Yield fixture (setup + teardown) @pytest.fixture def db_session(database): database.execute("BEGIN") yield database database.execute("ROLLBACK") # Factory fixture @pytest.fixture def make_user(): created = [] def _make(name="test", email="[email protected]"): user = User(name=name, email=email) created.append(user) return user yield _make for u in created: u.delete() # Parametrized fixture @pytest.fixture(params=["sqlite", "postgres"]) def database(request): if request.param == "sqlite": return create_sqlite_db() return create_postgres_db() # Session-scoped resource @pytest.fixture(scope="session") def app(): app = create_app(testing=True) yield app ``` ### Built-in Fixtures | Fixture | Purpose | |---------|---------| | `tmp_path` | `pathlib.Path` to unique temp directory | | `monkeypatch` | Modify objects/env vars (auto-reverted) | | `capsys` | Capture stdout/stderr | | `request` | Test metadata (params, markers, etc.) | ### monkeypatch Examples ```python def test_env_var(monkeypatch): monkeypatch.setenv("APP_NAME", "test-app") result = get_config() assert result.app_name == "test-app" def test_patched_function(monkeypatch): monkeypatch.setattr("myapp.services.fetch_data", lambda: {"mock": True}) result = process() assert result["mock"] is True ``` ## conftest.py Fixtures in `conftest.py` are auto-discovered for all tests in the same directory and below. Never import from conftest files. ```python # tests/conftest.py import pytest @pytest.fixture(scope="session") def project_root(): from pathlib import Path return Path(__file__).parent.parent @pytest.fixture def mock_api(monkeypatch): monkeypatch.setattr("myapp.api.client.fetch", lambda url: {"data": []}) # Custom CLI option def pytest_addoption(parser): parser.addoption("--run-integration", action="store_true", default=False) def pytest_collection_modifyitems(config, items): if not config.getoption("--run-integration"): skip = pytest.mark.skip(reason="need --run-integration") for item in items: if "integration" in item.keywords: item.add_marker(skip) ``` ## Parametrize ```python @pytest.mark.parametrize("input,expected", [ ("hello", 5), ("", 0), ("world", 5), ]) def test_length(input, expected): assert len(input) == expected # Multiple parametrize = cartesian product @pytest.mark.parametrize("x", [1, 2]) @pytest.mark.parametrize("y", [10, 20]) def test_multiply(x, y): assert x * y > 0 ``` ## Project Structure ``` tests/ conftest.py # Shared fixtures test_models.py # Unit tests test_services.py integration/ conftest.py # Integration-specific fixtures test_api.py ``` ## Justfile Recipes ```just test *ARGS: uv run pytest {{ARGS}} test-cov: uv run pytest --cov=src --cov-report=term-missing test-watch: uv run pytest -x --watch # Needs pytest-watch plugin ``` -
ruff-reference.md 7.3 KB
# ruff Reference Extremely fast Python linter and formatter, written in Rust. Replaces flake8, black, isort, pyupgrade. **Docs**: https://docs.astral.sh/ruff/ | **GitHub**: https://github.com/astral-sh/ruff | **Tracked line**: ruff 0.16.x > **Heads up**: ruff **0.16 is the big one** - the default rule set went from 59 to 413 rules, 18 opinionated `E`/`F` rules were dropped from that default set, and `ruff format` now formats Python blocks inside Markdown by default. A `select` list replaces the defaults, so prefer `extend-select`/`ignore`. 0.16 also adds line-level `ruff: ignore` comments and `--add-ignore`, and `format --check` gained the linter's `--output-format github`. Earlier: 0.14 moved the default target Python to 3.14; 0.15 shipped the 2026 formatter style guide and block-level suppressions. Pinning `target-version` in `pyproject.toml` keeps formatter output reproducible across upgrades. ## Usage ```bash # Lint uv run ruff check . # Check for errors uv run ruff check --fix . # Auto-fix uv run ruff check --diff . # Show what would change # Format uv run ruff format . # Format code uv run ruff format --check . # Check without changing # Combined (lint + format in one go) uv run ruff check --fix && uv run ruff format # Info ruff rule E501 # Explain a rule ruff rule --all # List all rules ``` ## pyproject.toml Configuration ### Core Settings ```toml [tool.ruff] target-version = "py313" line-length = 100 indent-width = 4 exclude = [ ".git", ".venv", "__pycache__", "build", "dist", "*.egg-info", ] ``` ### Lint Rules ```toml [tool.ruff.lint] # Ruff 0.16 enables 413 rules by default (up from 59), and dropped 18 opinionated # E/F rules from that set. Writing a `select` list REPLACES the default set, so # `select = ["E","F","I","UP"]` now yields a weaker linter than no config at all. # Start from the defaults and narrow with `ignore`, or widen with `extend-select`. ignore = [ "E501", # line too long - formatter handles it "UP007", # X | Y unions - Optional[X] is more readable "UP006", # type vs Type - both valid ] # Allow auto-fix for all enabled rules fixable = ["ALL"] unfixable = [] ``` ### Extended Rule Sets (add when needed) Use `extend-select`, not `select` - it adds to ruff 0.16's default set instead of replacing it. Some of these are already on by default (a mutable-default arg raises `B006` with no config at all), while `S`, `T20` and `ERA` are not. ```toml [tool.ruff.lint] extend-select = [ "B", # flake8-bugbear - common bugs and design problems (partly default in 0.16) "SIM", # flake8-simplify - simplification suggestions "RUF", # ruff-specific rules "S", # flake8-bandit - security issues "PTH", # flake8-use-pathlib - prefer pathlib over os.path "T20", # flake8-print - no print() in production code "ERA", # eradicate - commented-out code detection ] ``` ### Per-File Ignores ```toml [tool.ruff.lint.per-file-ignores] "__init__.py" = ["F401"] # Allow unused imports "tests/*" = ["S101"] # Allow assert statements "scripts/*" = ["T20"] # Allow print() in scripts "conftest.py" = ["F401", "F811"] # Allow unused imports and redefined names ``` ### Import Sorting ```toml [tool.ruff.lint.isort] known-first-party = ["my_project"] known-third-party = ["fastapi", "pydantic"] force-single-line = false lines-after-imports = 2 ``` ### Formatter Settings ```toml [tool.ruff.format] quote-style = "double" # double (default) or single indent-style = "space" # space (default) or tab line-ending = "lf" # lf, cr-lf, cr, auto, native skip-magic-trailing-comma = false docstring-code-format = true # Format code in docstrings ``` ## Rule Categories Reference | Code | Name | What It Catches | |------|------|-----------------| | E | pycodestyle | Syntax errors, whitespace issues | | W | pycodestyle warnings | Whitespace warnings | | F | Pyflakes | Undefined names, unused imports, redefined names | | I | isort | Import order and grouping | | UP | pyupgrade | Outdated Python syntax (dict() vs {}, old-style formatting) | | B | flake8-bugbear | Common bugs (mutable default args, except Exception) | | SIM | flake8-simplify | Code simplification (if/else to ternary, dict.get) | | S | flake8-bandit | Security issues (hardcoded passwords, SQL injection) | | RUF | Ruff-specific | Ruff's own rules (unused noqa, mutable class default) | | T20 | flake8-print | print() statements (remove for production) | | PTH | flake8-use-pathlib | os.path vs pathlib suggestions | | ERA | eradicate | Commented-out code | | N | pep8-naming | Naming conventions | | D | pydocstyle | Docstring conventions | | ANN | flake8-annotations | Type annotation enforcement | | C4 | flake8-comprehensions | Unnecessary list/dict/set comprehension patterns | | PIE | flake8-pie | Miscellaneous lints | | RET | flake8-return | Return statement issues | ## Git Hook Integration ```yaml # lefthook.yml pre-commit: piped: true jobs: - 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 ``` `--force-exclude` is required whenever paths are passed explicitly, or `[tool.ruff] exclude` is ignored for them. Lint before format: `--fix` output may need reformatting. On pre-commit instead, the current hook ids are `ruff-check` and `ruff-format` - bare `ruff` is a legacy alias - and `rev` must be kept in step with the ruff pin in `pyproject.toml`: ```yaml # .pre-commit-config.yaml repos: - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.16.6 hooks: - id: ruff-check args: [--fix] - id: ruff-format ``` ## CI/CD ```yaml # GitHub Actions - name: Lint run: uv run ruff check --output-format github . - name: Format check run: uv run ruff format --check . ``` The `--output-format github` flag produces annotations that show inline in PRs. ## Editor Integration Ruff has first-party VS Code extension and LSP. With uv projects, the extension discovers ruff from the project's virtual environment automatically. ## Suppression Comments ```python x = 1 # noqa: E741 # Suppress specific rule x = 1 # noqa # Suppress all rules on this line # ruff: noqa: E741 # Suppress rule for entire file (top of file) ``` Clean up stale suppressions: ```bash uv run ruff check --extend-select RUF100 # Flag unused noqa comments ``` ## Migration from Other Tools ### From black Ruff format is compatible with black. Remove black, add ruff format config: ```toml [tool.ruff.format] quote-style = "double" # black default ``` ### From flake8 Map flake8 rules to ruff equivalents. Most common: `E`, `W`, `F` codes are identical. ### From isort Ruff `I` rules replace isort. Config maps: - `known_first_party` -> `[tool.ruff.lint.isort] known-first-party` - `known_third_party` -> `[tool.ruff.lint.isort] known-third-party` ## Troubleshooting ```bash ruff clean # Clear cache ruff check --show-settings # Show resolved config ruff check --show-files # Show files to be checked ruff check --statistics # Show rule violation counts ``` -
ty-reference.md 7.6 KB
# ty Reference (Beta) Astral's Python type checker - extremely fast, written in Rust. **GitHub**: https://github.com/astral-sh/ty | **Status**: Beta (0.0.x; current line 0.0.79) > **Heads up**: ty is still pre-1.0 and each minor bump can change inference. Notable: 0.0.31 introduced `--fix`; 0.0.33 prefers declared annotation over inferred RHS when assignable, removing many `cast(...)` workarounds; **0.0.52 made `error-on-warning` the default**, so warnings now fail your build; 0.0.57 onward added a dedicated Pydantic support track; 0.0.67 removed the deprecated `src.root` in favour of `environment.root`. Pin a concrete version in `[dependency-groups]` rather than tracking `@latest`. ## Installation ```bash # As dev dependency (recommended) uv add --dev ty uv run ty check # Quick run without installing uvx ty check # Global install - pin a version; ty is beta and inference moves between minors uv tool install ty@0.0.79 # Homebrew brew install ty ``` ## Basic Usage ```bash ty check # Check current project ty check src/ tests/ # Check specific paths ty check --watch # Watch mode (recheck on changes) ty server # Start language server (LSP) ty version # Print version ``` ## Configuration in pyproject.toml All config goes in `[tool.ty.*]` sections. Alternatively, use a standalone `ty.toml` file (omits `[tool.ty]` prefix). ### Environment Settings ```toml [tool.ty.environment] # Python version (default: inferred from requires-python minimum, fallback 3.14) python-version = "3.13" # Target platform: win32, darwin, linux, android, ios, all python-platform = "linux" # Path to Python environment (auto-discovered via VIRTUAL_ENV from uv run) python = ".venv" # Additional module resolution paths extra-paths = ["./shared/stubs"] # First-party module roots (priority order) root = ["./src", "./lib"] # Custom typeshed typeshed = "/path/to/typeshed" ``` ### Source Selection ```toml [tool.ty.src] # Files/dirs to include include = ["src", "tests"] # Files/dirs to exclude (gitignore patterns) exclude = [ "generated", "*.proto", "tests/fixtures/**", "!tests/fixtures/important.py", # negate to re-include ] # Respect .gitignore (default: true) respect-ignore-files = true ``` ### Rule Configuration ```toml [tool.ty.rules] # Set all rules to a severity all = "error" # or "warn" or "ignore" # Individual rule overrides possibly-unresolved-reference = "warn" division-by-zero = "ignore" unused-ignore-comment = "warn" possibly-missing-attribute = "error" possibly-missing-import = "error" empty-body = "error" ``` ### Analysis Settings ```toml [tool.ty.analysis] # Suppress unresolved-import for specific modules allowed-unresolved-imports = ["test.**", "!test.foo"] # Replace a module's types with `Any` - the targeted fix for a heavy-typing # dependency that produces false positives, instead of swapping type checker. # Import diagnostics are unconditionally suppressed for matching modules. replace-imports-with-any = ["sqlalchemy.**"] # Whether to respect `type: ignore` comments (default: true) # Set false to only use `ty: ignore` comments respect-type-ignore-comments = true ``` ### Output Settings ```toml [tool.ty.terminal] # Output format: full, concise, github, gitlab, junit output-format = "full" # Exit code 1 on warnings. NOTE: the default is `true` since 0.0.52 - # set this to false only if you deliberately want warnings to pass. error-on-warning = false ``` ### Per-File Overrides ```toml [[tool.ty.overrides]] include = ["tests/**", "**/test_*.py"] [tool.ty.overrides.rules] possibly-unresolved-reference = "warn" empty-body = "ignore" [[tool.ty.overrides]] include = ["generated/**"] [tool.ty.overrides.rules] all = "ignore" ``` ## CLI Flags ```bash ty check [OPTIONS] [PATH]... # Rule severity (repeatable, use 'all' for all rules) --error <rule> # Treat as error --warn <rule> # Treat as warning --ignore <rule> # Disable rule # Environment --python-version <ver> # 3.7-3.15 accepted; 3.10 is the real support floor --python-platform <plat> # win32, darwin, linux, all --python <path> # Path to environment/interpreter --extra-search-path <path> # Additional module path --typeshed <path> # Custom typeshed # Source --exclude <pattern> # Gitignore-style exclude --force-exclude # Enforce on direct paths too # Config -c <key=value> # TOML override (e.g. 'environment.python-version="3.12"') --config-file <path> # Path to ty.toml --project <dir> # Project directory # Output --output-format <fmt> # full, concise, github, gitlab, junit --error-on-warning # Exit 1 on warnings --exit-zero # Always exit 0 -q / -v # Quiet / verbose # Special --watch, -W # Watch mode --fix # Apply auto-fixes for diagnostics that support them (ty 0.0.31+) --add-ignore # Auto-add ty: ignore comments for all diagnostics ``` ## Suppression Comments ```python # Suppress specific rule x: int = "hello" # ty: ignore[invalid-assignment] # Suppress all ty diagnostics on a line x: int = "hello" # ty: ignore # mypy-compatible (respected by default) x: int = "hello" # type: ignore[assignment] x: int = "hello" # type: ignore ``` To disable `type: ignore` support: ```toml [tool.ty.analysis] respect-type-ignore-comments = false ``` ## Integration with uv Always run ty through uv to ensure proper environment discovery: ```bash uv run ty check ``` `uv run` sets the `VIRTUAL_ENV` environment variable, which ty uses to find installed packages. Without it, ty may not resolve your project's dependencies. ### Update ty ```bash uv lock --upgrade-package ty ``` ## Key Type System Features ### Intersection Types After isinstance checks, ty narrows to intersection types: ```python class Serializable: ... class Versioned: ... def process(obj: Serializable): if isinstance(obj, Versioned): # ty: type is Serializable & Versioned reveal_type(obj) ``` ### Gradual Types For untyped code, ty uses `Unknown` instead of `Any` to avoid false positives: ```python max_retries = None # ty infers: Unknown | None (not just None) max_retries = 3 # no error - Unknown allows this ``` ### Redeclarations ty allows reusing a symbol with a different type: ```python def split_paths(paths: str) -> list[Path]: paths: list[str] = paths.split(":") # ty allows this return [Path(p) for p in paths] ``` ## Known Limitations 1. **Beta status** - expect bugs and missing features. Version 0.0.x, targeting stable in 2026. 2. **Incomplete typing spec** - long tail of Python typing features still being added. 3. **Third-party libraries** - Django and SQLAlchemy support is not yet complete. Pydantic has had a dedicated support track since 0.0.57 (constructors, `model_config`, `BaseSettings`, `RootModel`, strict vs lax) and is no longer the usual culprit. For a dependency that still misbehaves, silence just that one with `[tool.ty.analysis] replace-imports-with-any = ["sqlalchemy.**"]`. 4. **No plugin system** - unlike mypy, no plugin API for custom type inference. 5. **Some rules off by default** - `possibly-unresolved-reference`, `possibly-missing-import`, `division-by-zero` produce false positives. ## Recommended Setup ```toml # Minimal, practical config [tool.ty.environment] python-version = "3.13" [tool.ty.src] include = ["src"] # Keep default rules - they catch real issues without noise # Add stricter rules incrementally as ty matures ``` For the Justfile: ```just check: uv run ty check uv run ruff check uv run ruff format --check ``` -
uv-reference.md 11.3 KB
# uv Reference (0.12.x) Complete guide to uv - the Python package manager, version manager, and project runner. > **Heads up (0.11 -> 0.12)**: `uv init` now **packages by default** - it writes a `[build-system]` using `uv_build`, uses src layout, and adds a `[project.scripts]` entry; `--no-package` restores the old flat layout. The default pre-release mode is now `if-necessary`, and `if-necessary-or-explicit` survives only as a deprecated alias. `uv venv --clear` now refuses to clear a directory that is not a virtualenv (use `--force`). `uv lock --upgrade-group <name>` validates the group and errors if it does not exist. > > **Earlier (0.10 -> 0.11)**: `uv venv` requires explicit `--clear` to remove an existing environment. `--native-tls` is deprecated in favor of `--system-certs`. `uv python upgrade`, `--upgrade-group`, and workspace commands `uv workspace dir` / `uv workspace list` are stable. Always run a recent uv (>= 0.11.6) to pick up the wheel-uninstall path-traversal fix (GHSA-pjjw-68hj-v9mw). **Docs**: https://docs.astral.sh/uv/ | **GitHub**: https://github.com/astral-sh/uv ## Installation ```bash # macOS/Linux (Homebrew) brew install uv # Or via pip pip install uv # Verify uv version ``` ## Project Initialization ### `uv init` Templates As of uv 0.12.0 `uv init` **packages by default**: it writes a `[build-system]` using `uv_build`, uses src layout, and adds a `[project.scripts]` entry. `--package` is now redundant; `--no-package` gets the old flat, build-systemless layout. ```bash # Packaged application (src layout, uv_build, entry point) - the default since 0.12 uv init my-project # Flat application, no build system (the pre-0.12 default) uv init --no-package my-app # Library (src layout, py.typed marker) uv init --lib my-lib # Minimal (only pyproject.toml) uv init --bare my-project # With specific build backend (overriding the uv_build default) uv init --build-backend hatchling my-project # With author from git config uv init --author-from git my-project ``` ### Key `uv init` Flags | Flag | Effect | |------|--------| | `--app` | Application template | | `--package` | Packaged app with build system and src layout (default since 0.12) | | `--no-package` | Flat layout with no build system (the pre-0.12 default) | | `--lib` | Library (implies --package, adds py.typed) | | `--bare` | Only pyproject.toml, no other files | | `--build-backend <name>` | hatchling, flit-core, pdm-backend, setuptools, uv_build, maturin | | `--python <ver>` | Set requires-python | | `--author-from git` | Pull author from git config | | `--vcs git` | Init git repo | | `--python-pin` | Create .python-version file | ### Generated Structure (--package) ``` my-project/ .python-version README.md pyproject.toml src/ my_project/ __init__.py ``` ## Dependency Management ### Adding Dependencies ```bash # Project dependencies uv add httpx uv add "httpx>=0.20" uv add "httpx==0.27.0" # Dev dependencies (goes to [dependency-groups] dev) uv add --dev pytest ruff ty # Named dependency group uv add --group lint ruff uv add --group test coverage # Optional dependencies (extras) uv add --optional network httpx # From requirements file uv add -r requirements.txt # From git uv add git+https://github.com/encode/httpx uv add git+https://github.com/encode/httpx --tag 0.27.0 uv add git+https://github.com/encode/httpx --branch main # From local path (editable) uv add --editable ../packages/foo/ # With platform markers uv add "jax; sys_platform == 'linux'" # Control version bound style uv add httpx --bounds exact # ==x.y.z uv add httpx --bounds minor # >=x.y.z, <x.y+1.0 uv add httpx --bounds major # >=x.y.z, <x+1.0.0 uv add httpx --bounds lower # >=x.y.z (default) ``` ### Removing Dependencies ```bash uv remove httpx uv remove --dev pytest uv remove --group lint ruff ``` ### Lockfile ```bash uv lock # Create/update uv.lock uv lock --check # Check if up-to-date (no changes) uv lock --upgrade # Upgrade all to latest allowed uv lock --upgrade-package httpx # Upgrade specific package uv lock --upgrade-group lint # Upgrade everything in a dependency-group (uv 0.11.4+) ``` ### Syncing Environment ```bash uv sync # Exact sync (default - removes extraneous) uv sync --inexact # Don't remove extraneous packages uv sync --no-dev # Exclude dev group uv sync --all-groups # Include all groups uv sync --group docs # Include specific group uv sync --all-extras # Include all extras uv sync --locked # Error if lockfile outdated uv sync --frozen # Don't check lockfile uv sync --no-install-project # Skip installing the project itself ``` #### What `uv sync` installs uv ships with `dev` in `default-groups`, so a bare `uv sync` already pulls dev deps. Other named groups only install when requested. | Command | Installs | |---|---| | `uv sync` | main deps + `dev` | | `uv sync --no-dev` | main deps only | | `uv sync --group lint` | main deps + `dev` + `lint` | | `uv sync --all-groups` | everything in `[dependency-groups]` | To change the default set, declare it explicitly: ```toml [tool.uv] default-groups = ["dev", "test"] # uv sync now includes both ``` ### Exporting ```bash uv export --format requirements.txt -o requirements.txt uv export --format pylock.toml # PEP 751 uv export --format cyclonedx1.5 # SBOM ``` ## Running Code ### `uv run` ```bash # Run Python files uv run main.py uv run python -c "import example" # Run entry points uv run my-cli-command # Run with extra temporary dependencies uv run --with httpx python -c "import httpx" # Run with specific Python version uv run --python 3.12 main.py # Run without syncing uv run --no-sync pytest # Stdin echo 'print("hello")' | uv run - ``` ### Inline Script Metadata (PEP 723) ```python # /// script # requires-python = ">=3.12" # dependencies = [ # "requests<3", # "rich", # ] # /// import requests from rich.pretty import pprint resp = requests.get("https://peps.python.org/api/peps.json") pprint(resp.json()) ``` Run with: `uv run script.py` - dependencies install automatically. ### `uvx` (Tool Runner) ```bash uvx ruff check . # Run tool without installing uvx ruff@0.6.0 --version # Specific version uvx --with mkdocs-material mkdocs build # Install persistently uv tool install ruff uv tool upgrade ruff uv tool list uv tool uninstall ruff ``` ## Python Version Management ```bash # Install uv python install 3.13 uv python install 3.12 3.13 # Multiple uv python install pypy # Alternative implementation # Pin (creates .python-version) uv python pin 3.13 uv python pin --global 3.13 # Global default # List uv python list uv python list --only-installed # Upgrade patch versions uv python upgrade 3.13 # 3.13.x -> latest 3.13.y uv python upgrade # All installed # Find uv python find '>=3.12' # Remove uv python uninstall 3.12 ``` ## pyproject.toml Configuration ### `[tool.uv]` Settings ```toml [tool.uv] managed = true # uv manages this project required-version = ">=0.10.0" # Enforce minimum uv version default-groups = ["dev"] # Groups to sync by default # Resolution resolution = "highest" # highest, lowest, lowest-direct prerelease = "if-necessary-or-explicit" # Performance compile-bytecode = false link-mode = "clone" # clone (macOS), hardlink (Linux) ``` ### `[tool.uv.sources]` - Custom Dependency Sources ```toml [tool.uv.sources] # From specific index torch = { index = "pytorch" } # From git httpx = { git = "https://github.com/encode/httpx", tag = "0.27.0" } # Local editable my-lib = { path = "../my-lib", editable = true } # Workspace member my-pkg = { workspace = true } ``` ### `[[tool.uv.index]]` - Package Indexes ```toml [[tool.uv.index]] name = "pytorch" url = "https://download.pytorch.org/whl/cpu" explicit = true # Only used when referenced in sources [[tool.uv.index]] name = "private" url = "https://private.example.com/simple/" default = true # Replaces PyPI ``` ### `[dependency-groups]` (PEP 735) ```toml [dependency-groups] dev = [ "ruff>=0.16.6", "ty>=0.0.79", "pytest>=9.1.1", {include-group = "lint"}, ] lint = ["ruff"] test = ["pytest", "coverage"] docs = ["sphinx", "furo"] ``` `[dependency-groups]` is PEP 735 and supported by uv (and modern pip 25+). For libraries published to PyPI that need broader tooling support, `[project.optional-dependencies]` (extras) remains the portable choice. ## CLI Entry Points (`[project.scripts]`) Declare console scripts in `pyproject.toml`: ```toml [project.scripts] my-project = "my_project:main" my-project-admin = "my_project.cli:admin" ``` Format is `<script-name> = "<module>:<callable>"`. Two requirements: 1. **A build system.** Editable installs (which `uv sync` performs for the current project) need `[build-system]`. Without it, `uv sync` errors with "project requires a build system to install". ```toml [build-system] requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["src/my_project"] ``` 2. **A matching callable.** `my_project:main` resolves to the `main` attribute of `src/my_project/__init__.py`. `my_project.cli:admin` resolves to `admin` in `src/my_project/cli.py`. ```python # src/my_project/__init__.py def main() -> None: print("hello from my-project") ``` After `uv sync`, run with `uv run my-project`. uv installs the project editable, so edits under `src/` take effect on the next invocation - no reinstall. ## Workspaces (Monorepos) Root `pyproject.toml`: ```toml [tool.uv.workspace] members = ["packages/*"] exclude = ["packages/experimental"] ``` ```bash uv workspace list # List all members uv workspace dir # Show workspace root uv run --package foo # Run in specific member's context ``` Key: single `uv.lock` for entire workspace, single `requires-python` intersection. ## Build and Publish ```bash # Build uv build # sdist + wheel into dist/ uv build --wheel # Wheel only uv build --no-sources # Ignore tool.uv.sources (before publishing) # Version management uv version # Read current uv version 1.0.0 # Set exact uv version --bump patch # 1.2.3 -> 1.2.4 uv version --bump minor # 1.2.3 -> 1.3.0 uv version --bump major # 1.2.3 -> 2.0.0 # Publish uv publish # To PyPI uv publish --token <TOKEN> uv publish --index testpypi # Prevent accidental PyPI publish # Add to classifiers: "Private :: Do Not Upload" ``` ## CI/CD (GitHub Actions) ```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 # full version: floating majors stop at v7 - 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 ``` ## Troubleshooting ```bash uv cache clean # Clear package cache uv lock --upgrade # Regenerate lockfile rm -rf .venv && uv sync # Reset environment uv python list --only-installed # Check Python installations ```
-
-
CHANGELOG.md 6.9 KB
# Changelog All notable changes to this skill will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/2.0.0/), and this skill adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] ## [0.3.0] - 2026-09-09 ### Changed - **Breaking:** git hooks move from **pre-commit to lefthook**, matching `go-dev`. `lefthook` installs from PyPI as a platform wheel (`uv add --dev lefthook`), so the hook runs the project's own ruff from `uv.lock` instead of a second copy pinned separately by `.pre-commit-config.yaml` `rev:`. Existing projects: see the migration steps in SKILL.md. - **Breaking:** the `[tool.ruff.lint]` template no longer sets `select`. Ruff 0.16 raised the default rule set from 59 to 413 rules, so `select = ["E","F","I","UP"]` now produces a *weaker* linter than no config at all. Narrow with `ignore`, widen with `extend-select`. `E741` left the ignore list with the `E` group it belonged to. - `[tool.pytest.ini_options]` uses `strict = true` instead of passing `--strict-markers` / `--strict-config` through `addopts`. Verified on both 9.0.3 and 9.1.1. - CI pins `astral-sh/setup-uv@v10.0.1` and `actions/checkout@v7`, and no longer sets `enable-cache: true`. - `uv init` is documented as packaging by default with the `uv_build` backend (uv 0.12); the hatchling block is now labelled as a deliberate override. - ty's beta caveat no longer blames Pydantic, and points at `[tool.ty.analysis] replace-imports-with-any` before recommending a wholesale pyright swap. - Dev-dependency floors raised to the versions the guidance actually requires: `ruff>=0.16.6`, `ty>=0.0.79`, `pytest>=9.1.1`, `pytest-asyncio>=1.4.0`. - `references/pytest-reference.md` replaces the deprecated `event_loop_policy` uvloop recipe with the `pytest_asyncio_loop_factories` hook. - The non-mutating `check` contract is now consistent: three reference recipes still ran `ruff check --fix && ruff format`. ### Added - `lefthook.yml` template with a parallel `guards` group (`private-key`, `merge-conflict`, `large-files`), ruff check/format with `stage_fixed`, and `ty` on `pre-commit`; `pytest` on `pre-push`. - `--force-exclude` on both ruff hook jobs, with the reason: ruff ignores `[tool.ruff] exclude` for paths passed explicitly, which is exactly what `{staged_files}` does. - `[tool.ruff.format] exclude = ["*.md"]`, because ruff 0.16 formats Markdown code fences by default and would otherwise fail `just check` on READMEs. - `uv run lefthook validate` in CI; `uv run lefthook install` in the Justfile `install` recipe and the setup and migration steps. - lefthook added to The Stack table; docs linked in Resources. ### Fixed - The `ty` hook job carried `glob: "*.{py,pyi}"`, and lefthook applies a glob even to a job whose `run` never uses `{staged_files}`. A commit touching only `pyproject.toml` - where `[tool.ty]` and the dependency pins live - was recorded with **no type checking at all**. The glob is gone. - `references/ruff-reference.md` prescribed the same `select` list SKILL.md now warns against, and its "Extended Rule Sets" block used `select` where it must use `extend-select`. - `references/ty-reference.md` implied `error-on-warning` defaults to `false`; it has defaulted to `true` since 0.0.52. It also recommended `uv tool install ty@latest` while telling readers to pin. - `references/uv-reference.md` CI block drifted from SKILL.md (bare `ruff check`, no `lefthook validate`, `actions/checkout@v4`). Verified against: 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 ## [0.2.5] - 2026-08-21 ### Changed - Declared ClawHub browse categories (`development`) and topics in `metadata`, so the release pipeline publishes them instead of leaving the skill in the `other` category. ### Removed - `skill-card.md`. The ClawHub CLI strips a root `skill-card.md` from every publish and the registry generates its own card, so the authored file never reached ClawHub. ## [0.2.4] - 2026-08-07 ### Changed - Trimmed the frontmatter description to what-plus-when; dropped the trailing 10-item trigger-keyword list. ### Fixed - The Justfile `check` recipe ran `ruff check --fix && ruff format`, mutating files and duplicating `fix`. It is now non-mutating (`ruff check`, `ruff format --check`) and mirrors the CI block. - CI example used `actions/checkout@v4`; bumped to `@v6`. ## [0.2.3] - 2026-07-22 ### Added - skill-card.md release record following NVIDIA's skill-card format - metadata.openclaw block (emoji, homepage) for ClawHub display ## [0.2.2] - 2026-07-10 ### Changed - CHANGELOG preamble pinned to Keep a Changelog 2.0.0 (format unchanged; KaC 2.0.0 keeps existing changelogs valid). ## [0.2.0] - 2026-04-28 ### Added - `metadata.upstream` field tracking uv, ty, ruff, ruff-pre-commit, pytest, pytest-asyncio, pre-commit, pre-commit-hooks at concrete pinned versions. - CHANGELOG.md (this file) seeded as the canonical "last verified" signal. - "Note on ty" beta caveat in SKILL.md so users know to swap in pyright for type-heavy stacks. - "CI (GitHub Actions)" section in SKILL.md mirroring `just check` + `just test`, using `astral-sh/setup-uv@v6` with caching. - SKILL.md `[project.scripts]` template now annotated with the call path; Daily Workflow gains two `uv sync` lines clarifying default vs `--all-groups`. - references/uv-reference.md: new "CLI Entry Points (`[project.scripts]`)" section covering build-system requirement, `module:callable` semantics, and editable-install behavior. - references/uv-reference.md: "What `uv sync` installs" sub-table under Syncing Environment with a `default-groups` example. - references/uv-reference.md: PEP 735 / extras trade-off note. - references/ty-reference.md: `--fix` CLI flag (ty 0.0.31+). - references/uv-reference.md: `uv lock --upgrade-group <name>` (uv 0.11.4+). ### Changed - Pinned dev-group versions in SKILL.md template: ruff `>=0.15.0`, ty `>=0.0.30`, pytest `>=9.0.0`, pytest-asyncio `>=1.3.0`, pre-commit `>=4.0.0`. - Pre-commit hooks: `pre-commit-hooks` rev v5.0.0 -> v6.0.0; `ruff-pre-commit` rev v0.8.4 -> v0.15.12. - Stack table now lists "uv 0.11+" and labels ty as "(beta)". - references/uv-reference.md: header bumped to 0.11.x with migration heads-up (`uv venv --clear` requirement, `--native-tls` deprecation, GHSA-pjjw-68hj-v9mw fix). - references/pytest-reference.md: heads-up summarising 8 -> 9 changes (native `[tool.pytest]` TOML, dropped 3.9, stricter mode, CVE-2025-71176 fix). - references/ruff-reference.md: heads-up summarising 0.14 (default target py3.14) and 0.15 (2026 style guide, block suppression comments). - references/pytest-reference.md: pytest-asyncio note expanded to call out 1.3 dropping Python 3.9 and adding pytest 9 compatibility. Verified against: uv@0.11.8, ty@0.0.33, ruff@0.15.12, ruff-pre-commit@0.15.12, pytest@9.0.3, pytest-asyncio@1.3.0, pre-commit@4.6.0, pre-commit-hooks@6.0.0 ## [0.1.2] - 2026-04-09 - Initial CHANGELOG; tracking established. -
LICENSE.txt 8.9 KB
Apache License Version 2.0, January 2004 https://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS -
SKILL.md 15.8 KB
--- 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)
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.