name: uv-python-style description: Opinionated Python project setup and tooling guide using Astral tools — uv for environments and dependencies, ruff for lint+format, ty for type checking — with src/ layouts, committed lockfiles, and CI-friendly commands. Use this skill whenever the user is creating, modifying, structuring, or reviewing a Python project, including questions about virtual environments, pyproject.toml, dependency management, locking and syncing, running scripts, linting, formatting, type checking, testing, or CI for Python. Trigger even when the user mentions only one piece of the stack — "how do I add a dev dependency", "set up pytest", "ruff is complaining about X", "how do I pin Python", "uv lock vs sync" — since the conventions are interconnected. Also use when migrating a repo from other tooling (Poetry, Hatch, pip-tools, Black, isort, flake8, mypy) to this stack.
uv Python style
Use this stack on every new or refactored Python project:
- uv for project, environment, and dependency management.
pyproject.tomlis the source of truth;uv.lockmakes installs reproducible. - ruff for lint and format, configured in
pyproject.toml. - ty for fast type checking (and as an LSP), configured in
pyproject.toml. ty is in preview — keep its config minimal and expect the rule set to evolve.
If a repo already uses different tooling, do not rewrite it blindly. See Migrating from other tooling at the bottom.
Core principles
Reproducible by default. Always invoke project commands through uv run. uv locks and syncs the environment automatically before running, so the lockfile, .venv, and the actual execution stay aligned with no manual steps. Commit uv.lock — yes, for libraries too. It controls your dev/CI environment, not what downstream consumers install.
One tool per job. ruff is the only formatter and linter. ty is the only type checker. uv is the only environment/dependency manager. Stacking Black, isort, or flake8 on top of ruff creates conflicting rules and slows everything down without adding value. Same for mypy + ty.
Configure in pyproject.toml. No setup.py, setup.cfg, requirements.txt, mypy.ini, .flake8, or tox.ini. Centralized config means one file to read, one file to diff.
Pin Python explicitly. Set requires-python in pyproject.toml and commit a .python-version file. The first is the contract; the second tells uv (and editors) which interpreter to use.
Bootstrap a new project
Pick the right uv init variant — they produce different layouts:
uv init my-app # flat layout, no build system (quick scripts, web servers)
uv init --package my-app # src/ layout WITH build system (CLIs, anything installable)
uv init --lib my-lib # src/ layout + py.typed marker (libraries; implies --package)
uv init --bare my-thing # just a pyproject.toml — no README, no .python-version, no src
Default to --package for any application with tests or entry points, and --lib for anything that will be published. The flat default is fine for short scripts and learning, but graduates poorly — moving from flat to src/ later requires touching imports and tests.
Pin the Python version:
uv python pin 3.13 # writes .python-version
And declare the range in pyproject.toml:
[project]
requires-python = ">=3.13"
.venv and uv.lock are created lazily on the first uv run, uv sync, or uv lock. No manual venv creation needed.
Manage dependencies
uv uses PEP 735 [dependency-groups] for dev/optional dependencies — not the legacy [tool.uv].dev-dependencies table. Use uv add / uv remove rather than editing pyproject.toml by hand; the CLI keeps tool.uv.sources and version constraints consistent.
uv add httpx # runtime dep → [project].dependencies
uv add --dev pytest # → [dependency-groups].dev
uv add --group lint ruff # → [dependency-groups].lint
uv add --group test pytest-cov # → [dependency-groups].test
uv add --optional plot matplotlib # → [project.optional-dependencies].plot (an extra)
uv remove httpx
uv remove --dev pytest
uv remove --group lint ruff
To change a constraint, just re-add with the new specifier: uv add "httpx>=0.27". To force an upgrade past the current lock: uv add "httpx>=0.27" --upgrade-package httpx.
The dev group is special: it syncs by default. For other groups, pass --group <name> on uv sync/uv run, or list them in [tool.uv].default-groups.
Run commands
uv run pytest # plain form, works for most cases
uv run -- pytest -k "auth and not slow" # use -- to forward args that look like uv flags
uv run python -c "import mypkg; print(mypkg.__version__)"
uv run script.py # also runs PEP 723 inline-metadata scripts
Manual activation works but is rarely worth it:
uv sync && source .venv/bin/activate
Prefer uv run because it re-syncs first — eliminates "but it works on my machine" caused by drift between the lockfile and the active venv.
Lock and sync
uv lock # refresh the lockfile
uv sync # match the env to the lockfile (removes extraneous packages)
uv lock --check # verify lock matches pyproject.toml (CI)
uv lock --upgrade # upgrade everything within constraints
uv lock --upgrade-package httpx # upgrade just one package
In CI, choose the right flag based on intent:
| Flag | When to use it |
|---|---|
uv run --locked … |
CI builds. Fails loudly if the lockfile is stale relative to pyproject.toml. |
uv run --frozen … |
Deploys / offline runs. Uses the lockfile as-is, skips the staleness check. |
uv run --no-sync … |
The env is already set up and you don't want uv to touch it at all. |
The same flags work on uv sync.
Export for tools that can't read uv.lock
uv export --format requirements.txt -o requirements.txt
uv export --format pylock.toml -o pylock.toml
Useful for Docker base images, Dependabot, or legacy deploy pipelines.
Ruff (lint + format)
ruff format .
ruff check .
ruff check . --fix # apply safe autofixes
ruff check . --fix --unsafe-fixes # opt into riskier ones
Baseline config in pyproject.toml:
[tool.ruff]
line-length = 100
target-version = "py313" # MUST match requires-python — UP rules depend on it
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
ignore = []
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
Why these defaults:
line-length = 100is a reasonable middle ground (88 if you want Black-defaults).target-versioncontrols whichUP(pyupgrade) rewrites are safe — set it wrong and ruff will suggest Python 3.13 syntax in a Python 3.10 codebase, or vice versa.- The
SIMandRUFrule packs catch real bugs and idiom issues without being noisy. Drop them for a more conservative baseline.
ty (type checking)
uv run ty check # preferred — uses the project env
ty check # only if .venv is activated
ty discovers the project via the active venv or a .venv at the project root, so uv run is the no-brainer.
Baseline config (start light, tighten over time):
[tool.ty]
# python = ".venv" # uncomment only if pointing at a non-default env
[tool.ty.rules]
# possibly-unresolved-reference = "warn"
# possibly-undefined-name = "error"
ty is moving fast. Don't over-invest in rule customization until the schema stabilizes; in the meantime, lean on whatever the defaults catch.
Testing
uv add --dev pytest
uv run pytest -q
uv run pytest -k "auth" --maxfail=1
Keep tests fast, deterministic (no real network — use respx, vcrpy, or fakes), and well-fixtured. For coverage, add it to a dedicated group:
uv add --group test pytest-cov
uv run --group test pytest --cov=src --cov-report=term-missing
Ad-hoc tooling with uvx
uvx (alias for uv tool run) runs a tool in a temporary, isolated environment — no dev-dep entry needed:
uvx ruff check . # run ruff in a repo that hasn't adopted it yet
uvx ruff@0.6.0 check . # pin a specific version
uvx ruff@latest check . # bypass cache, get the newest
uv tool install ruff # persistent install to PATH
Decision rule:
- Tool tied to the project env (pytest, ty, your own CLI) →
uv run. - Generic tool used across many repos and not in the lockfile (ad-hoc ruff audit,
httpie,yt-dlp) →uvx. - Tool you want on your
PATHpermanently →uv tool install.
CI example
GitHub Actions sketch — adapt as needed:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v3
with:
enable-cache: true
- run: uv python install
- run: uv sync --locked --all-groups
- run: uv run ruff format --check .
- run: uv run ruff check .
- run: uv run ty check
- run: uv run pytest -q
--locked is the load-bearing flag: the build fails if uv.lock is stale relative to pyproject.toml, which catches the common "forgot to commit the lockfile" mistake before it reaches main.
Logging and errors
Prefer structured logs (JSON) for services so log aggregators can parse them; plain logs are fine for one-off scripts. Catch specific exceptions:
try:
config = load_config(path)
except FileNotFoundError as e:
raise RuntimeError(f"config not found at {path}") from e
Avoid bare except Exception: unless you re-raise with context — silently swallowing errors makes debugging brutal.
Migrating from other tooling
If a repo already uses Poetry, Hatch, pip-tools, Black + flake8, or mypy, propose a minimal migration rather than a rewrite:
- Poetry → uv:
uv init --barein the repo, thenuv addeach dependency frompyproject.toml's[tool.poetry.dependencies]. Move dev deps to[dependency-groups]. The[build-system]table usually needs updating too. - pip-tools → uv:
uv add -r requirements.infor the input file;uv.lockreplacesrequirements.txt. Useuv exportif anything downstream still needs the.txt. - Black + isort + flake8 → ruff: install ruff, set
target-versionandline-lengthto match existing config, runruff formatonce to normalize, then remove the old tools. Expect a large initial diff — land it as a single commit. - mypy → ty: hold off until ty stabilizes for your use case. Running both in parallel for a transition period is fine.
Call out friction points up front (e.g., "this will change every file because ruff formats trailing commas differently from Black") so the user isn't surprised.