Python coding standards and toolchain — uv is the only entrypoint (never pip, python, venv, conda, poetry, or requirements.txt) and every fact has exactly one home. Applies to all Python work in any repository, to changing existing code as much as to writing new code. This skill should be used whenever creating, editing, running, testing, debugging, refactoring, reviewing, or packaging Python; touching any .py file, pyproject.toml, uv.lock, conftest.py, or a Python Dockerfile; fixing type errors, ty or mypy errors, lint failures, or failing pytest tests; adding, removing, or upgrading a dependency; writing a one-off or standalone script; or setting up CI, GitHub Actions, or a build for a Python project. Covers uv, ruff, and ty commands, PEP 723 inline script metadata, dependency groups, the single-source-of-truth rules for versions and schemas, idempotent convergence, and making illegal states unrepresentable with Literal, StrEnum, NewType, assert_never, and pydantic.
Python coding standards and toolchain — uv is the only entrypoint (never pip, python, venv, conda, poetry, or requirements.txt) and every fact has exactly one home. Applies to all Python work in any repository, to changing existing code as much as to writing new code. This skill should be used whenever creating, editing, running, testing, debugging, refactoring, reviewing, or packaging Python; touching any .py file, pyproject.toml, uv.lock, conftest.py, or a Python Dockerfile; fixing type errors, ty or mypy errors, lint failures, or failing pytest tests; adding, removing, or upgrading a dependency; writing a one-off or standalone script; or setting up CI, GitHub Actions, or a build for a Python project. Covers uv, ruff, and ty commands, PEP 723 inline script metadata, dependency groups, the single-source-of-truth rules for versions and schemas, idempotent convergence, and making illegal states unrepresentable with Literal, StrEnum, NewType, assert_never, and pydantic.
Python
Two laws. Everything else follows.
uv runs everything. There is no other way to invoke Python.
Every fact has exactly one home. Copies drift silently; derive instead.
Law 2 is the single-source-of-truth philosophy. Each rule below names the duty it moves
off human memory and onto a mechanism that cannot forget — compiler, type, lock, or CI gate.
Every claim below was verified against uv 0.11.32, ruff, and ty on 2026-07-26.
0. The uv mandate
Requires uv ≥ 0.11 (uv check and uv audit are recent). Pin it — see §7.
Not installed? brew install uv, or curl -LsSf https://astral.sh/uv/install.sh | sh.
uv also manages interpreters (uv python install) — never install Python via brew or
pyenv for a project; that's a second home for "which Python".
Never
Always
Why
pip install x
uv add x
pip mutates an env with no record; uv add writes pyproject and uv.lock — the change has a home
python foo.py
uv run foo.py
bare python is whatever's on PATH; uv run resolves the declared interpreter + deps
python -m venv / virtualenv
uv sync
.venv is a derived artifact, never hand-built
source .venv/bin/activate
uv run <cmd>
activation is ambient state a later command can silently miss
requirements.txt, requirements-dev.txt
[project.dependencies], [dependency-groups]
a second dep list is a drift bomb
setup.py, setup.cfg
pyproject.toml
one home for metadata
pip freeze > requirements.txt
uv.lock (committed)
freeze snapshots one machine's env; the lock is cross-platform and derived from the declaration
conda, poetry, pipenv, pdm
uv
one resolver, one lock format
pipx install x
uvx x / uv tool install x
tools stay out of the project env
os.system("pip ...") in code
declare the dep
runtime installs are imperative steps (violates §3)
uv run self-heals. It locks and syncs before executing, so the environment
converges to the declaration on every invocation. You never need to remember to
install anything — that duty is gone (§4).
The verbs
Task
Command
Run code
uv run <script.py> / uv run -m pkg / uv run pytest
Add / remove dep
uv add x / uv add --group dev x / uv remove x
Converge env
uv sync (exact: prunes extras)
Reproducible install
uv sync --locked ← CI uses this
Format
uv run ruff format
Lint
uv run ruff check --fix
Type check
uv run ty check
Test
uv run pytest
Vulnerability audit
uv audit
Bump version
uv version --bump patch|minor|major
Throwaway tool
uvx <tool>
uv format and uv check exist as zero-config shortcuts (they wrap ruff and ty),
but prefer uv run ruff … / uv run ty …: that way the tool version lives in
uv.lock alongside everything else, so your editor, your terminal, and CI cannot
disagree. A tool version resolved outside the lock is a second home (§1).
Standalone scripts — PEP 723, never a stray venv
A script that needs deps declares them in the script:
Run it with uv run fetch.py. Add deps with uv add --script fetch.py httpx.
Never hand-edit the block — uv add --script owns it.
The dependency lives next to the code that imports it (§6). No README line saying
"first pip install httpx" — that instruction is a duty parked on a human, and it rots.
Landing in a project that isn't on uv yet
Check this before writing code. Migrate first, then work.
Never bolt uv on beside a live Poetry or pip setup. Two dependency lists is precisely the
failure this skill exists to prevent — and the migration is a two-minute command.
If migrating is genuinely out of scope right now, say so explicitly and follow the
project's existing tooling consistently. Do not half-convert.
Precedence
These rules govern new code. When editing an existing file, match its surrounding
style — do not reformat, re-type, or restructure a module as a drive-by; that buries the
actual change in noise.
But never add a new violation to an old file: no fresh pip install, no new
requirements.txt entry, no bare str where the set is closed. Leave existing ones alone
unless you were asked to clean them up.
1. One home
pyproject.toml is the only config file. Delete setup.py, setup.cfg, .flake8,
.isort.cfg, pytest.ini, tox.ini, mypy.ini, requirements*.txt.
Ruff, ty, and pytest all read pyproject.toml.
Dev deps go in [dependency-groups] (PEP 735), not [project.optional-dependencies].
Groups are never shipped in the built wheel; extras are.
[dependency-groups]dev = ["pytest>=8", "ruff>=0.14", "ty>=0.0.1a1"]
test = ["pytest-cov"]
uv sync installs dev by default. uv sync --no-dev for production images.
Config values get one home too. Never scatter os.environ.get(...) across modules —
each call site is a place the default can disagree.
# ✅ one home; parsed once, at the boundaryfrom pydantic_settings import BaseSettings
classSettings(BaseSettings):
database_url: str
timeout_seconds: int = 30
settings = Settings() # type: ignore[call-arg]
# ❌ three homes for one fact, three defaults that can drift
timeout = int(os.environ.get("TIMEOUT", "30"))
2. Derive, don't copy
Version — the canonical Python drift bug.pyproject.toml holds the version.
__version__ derives from it:
# ✅ src/mypkg/__init__.pyfrom importlib.metadata import version
__version__ = version("mypkg")
# ❌ now bump it in two places forever
__version__ = "1.2.3"
Bump with uv version --bump minor, never by hand-editing.
Python version — set it once.[project] requires-python is the one home.
Both Ruff and ty infer their target version from it. So:
[project]requires-python = ">=3.12"
and do not set [tool.ruff] target-version or [tool.ty.environment] python-version —
each would be a second home that silently wins over the first.
.python-version is not a duplicate — it's a different fact (which interpreter dev
and CI actually use, a choice inside the supported range). Commit it.
Derive collections from the enum, never a parallel list:
# ✅ one homeclassStatus(StrEnum):
PENDING = "pending"
DONE = "done"
ALL_STATUSES = [s.value for s in Status]
# ❌ add a member, forget the list
ALL_STATUSES = ["pending", "done"]
Other derivations: JSON Schema from Model.model_json_schema(), not hand-written.
CLI flags from type hints (Typer/argparse off a dataclass), not a parser mirroring a
config class. API clients from OpenAPI via datamodel-code-generator. uv.lock from
pyproject.toml — never hand-edit the lock.
3. Declare state, not steps
pyproject.toml + uv.lockdeclare the environment; uv sync finds the path there
from whatever state the machine is in. A setup.sh full of pip install lines assumes a
starting state and corrupts anything else.
Schema changes go through Alembic migrations, never Base.metadata.create_all() against a
real database — create_all is a no-op on an existing table, so it silently skips your
column change.
Prefer declarative shapes in code too: @dataclass / pydantic models over
__init__ bodies that assign twelve attributes.
4. Idempotent — re-run changes nothing
uv sync converges. Running it twice is a no-op. Hold your own code to that bar.
Never branch on "did this already run?" — that asks the process to remember across
crashes, which it cannot.
# ❌ read-modify-write: a retry double-applies
row = db.get(id); row.count += 1; db.save(row)
# ✅ atomic upsert — converge to the declared state
db.execute(
insert(Counter).values(id=id, count=1)
.on_conflict_do_update(index_elements=["id"], set_={"count": Counter.count + 1})
)
For anything that crosses a network, retries are guaranteed (at-least-once is all the world
offers). Buy exactly-once effect with an idempotency key, not exactly-once delivery:
Codegen must be idempotent and gated. Any generator you write: running it twice
produces a byte-identical tree. CI proves it (§7).
functools.cache and any materialized table are derived views — the world is never
rebuilt from them.
5. Make illegal states unrepresentable
A runtime if trusts every future caller to remember the check. A type trusts no one.
Never a bare str for a closed set:
# ✅ the set is the typeclassStatus(StrEnum):
PENDING = "pending"
DONE = "done"# also fine for small closed sets
Mode = Literal["fast", "safe"]
Exhaustiveness — the compiler patrols the call sites, not you. Add a member, and every
unhandled match becomes a type error:
from typing import assert_never
deflabel(s: Status) -> str:
match s:
case Status.PENDING: return"waiting"case Status.DONE: return"finished"case _ as unreachable: assert_never(unreachable)
Parse, don't validate. Untyped data is parsed once at the boundary and never again:
# ✅ dict[str, Any] dies at the doordefhandle(raw: bytes) -> Response:
req = CreateUser.model_validate_json(raw) # parse oncereturn create(req) # everything downstream is typed
# ❌ every function re-checks, and one of them forgetsdefcreate(data: dict[str, Any]) -> dict[str, Any]:
if"email"notin data: raise ValueError(...)
Distinguish IDs by type, so you can't pass the wrong one:
Model states as a union, not a pile of Optionals.Optional[str] × 3 = 8 states, 5 of
them nonsense. A discriminated union has exactly the states that exist:
@dataclass(frozen=True)classPending: submitted_at: datetime
@dataclass(frozen=True)classFailed: submitted_at: datetime; error: str
Job = Pending | Failed # "failed with no error" is now unwriteable
Default to @dataclass(frozen=True, slots=True) and Final for module constants.
When a refactor makes a bug class impossible, delete its guard test and say so in the
commit message. The structure holds the line now; the test is dead weight implying the risk
is still live.
Suppress narrowly and never blanket-ignore: # ty: ignore[unresolved-attribute].
6. Colocate truth with the thing
A side dict keyed by name is a join you maintain by hand — it has two failure modes a field
can't have (orphan key, missing key) plus a typo-able string key.
# ❌ two structures to keep in stepclassColor(Enum): RED = "red"; BLUE = "blue"
LABELS = {"red": "Red", "blue": "Blue"} # typo-able, silently incomplete
# ✅ the fact lives on the memberclassColor(Enum):
RED = ("red", "Red")
BLUE = ("blue", "Blue")
def__init__(self, code: str, label: str) -> None:
self.code = code
self.label = label
Same move elsewhere: constraints as Annotated[int, Field(ge=0)] on the field, not a
validator function far away; fixtures in the conftest.py nearest the tests that use them;
@property on the model instead of compute_x(model) in a utils module.
7. Hand-sync unavoidable? Gate it
Some copies can't be removed — a README example, a committed generated file. The copy is
forced, so its freshness must not rest on memory. Make the build red instead.
-run:uvsync--locked# lock matches pyproject, or fail-run:uvrunruffformat--diff# formatting is committed, or fail-run:uvrunruffcheck-run:uvruntycheck-run:uvrunpytest-run:uvaudit-run:uvrunpython-mtools.codegen&&gitdiff--exit-code# generated files current
uv sync --locked is the whole point: it errors if uv.lock is stale rather than
quietly updating it. Never use --frozen in CI (it skips the check entirely).
Test the docs. README code blocks rot invisibly. Execute them:
Pin the toolchain, so "works on my machine" is structural rather than hoped-for:
[tool.uv]required-version = ">=0.11"
Commit .python-version and uv.lock. In GitHub Actions use astral-sh/setup-uv pinned
to a SHA, and uv python install to honour .python-version.
8. Extract a package — but earn it
Rule 1 pushed across a repo boundary. Copying logic into a second repo is the worst drift:
the person who'd "remember to update the other copy" doesn't know it exists.
But this rule has a brake. Extraction buys a dependency edge (version skew, breaking
changes rippling outward). Duplicate until the third use reveals the real shape, then
extract. A wrong abstraction costs more than the duplication it replaced.
Inside one repo, use a uv workspace rather than a published package:
One lockfile for the whole workspace, edits picked up immediately, no publish step, no
version skew. Publish to an index only when a genuinely external consumer needs it.
Canonical pyproject.toml
[project]name = "mypkg"version = "0.1.0"requires-python = ">=3.12"# ONE home — ruff and ty both infer from thisdependencies = ["httpx>=0.27"]
[dependency-groups]dev = ["pytest>=8", "pytest-cov", "ruff>=0.14", "ty"]
[tool.uv]required-version = ">=0.11"[tool.ruff]line-length = 88# no target-version — inferred from requires-python[tool.ruff.lint]select = ["E", "F", "I", "B", "UP", "SIM", "RUF", "ANN", "PTH"]
ignore = ["E501"] # the formatter owns line length[tool.ty.rules]all = "error"[tool.pytest.ini_options]addopts = "--strict-markers --strict-config"testpaths = ["tests"]
[build-system]requires = ["uv_build>=0.11"]
build-backend = "uv_build"
Libraries: keep runtime constraints loose (>=), never pin — pinning in a library forces
conflicts on consumers. uv.lock still gets committed; it governs your dev/CI env, not
your consumers'.
Definition of done
Nothing is finished until all of these pass:
uv sync --locked
uv run ruff format --check
uv run ruff check
uv run ty check
uv run pytest