Claude Skill

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.

LLM Mart · 0 points · 16 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download tenequm-skills-skills_python-dev-1ff2284.zip · 29 KB
Part of tenequm/skills — 25 skills

Install

skills CLI npx skills add https://github.com/tenequm/skills/tree/main/skills/python-dev
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install tenequm-skills@llmmart
Git 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, swap ty for pyright and 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 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

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 @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

# 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.

No comments yet.

Reviews (0)

No reviews yet.

Related