| name | python-vibe-safe-coding-setup |
| description | Install and configure a "vibe-safe" Python toolchain (Ruff with the comprehensive rule set + mypy strict + pytest with coverage + coverage ratchet + ASCII enforcement + typed errors + validated config + Makefile verify pipeline) so AI-generated Python is caught by lints/types before it ships. TRIGGER when the user is starting a new Python service/script, asks to "set up linting", "add ruff/mypy", "make this project safe for vibe coding", "harden this Python project", or wants to mirror a known-good baseline. SKIP for one-off rule tweaks in an already-configured project. |
Python Vibe-Safe Coding Setup
A reusable, opinionated baseline that makes Ruff + mypy strict enough to catch the kinds of mistakes LLMs typically slip in: untyped boundaries, blind excepts, magic numbers, naive datetimes, blanket noqa/type:ignore, swallowed errors, f-strings in raise/logging, raw os.environ access, plain Exception raises.
When to apply
- New Python service, CLI, or notebook-supporting package with no Ruff/mypy config.
- Existing project where the user explicitly wants stricter guardrails.
Always confirm before overwriting an existing pyproject.toml, ruff.toml, or mypy.ini.
Step 1 - Pick the package manager
Prefer uv for new projects (uv init, uv add ...). If the project already uses poetry/pip-tools/hatch, match it.
Step 2 - Install dev tooling
uv add --dev ruff mypy pytest pytest-cov
uv add pydantic pydantic-settings
Step 3 - pyproject.toml configuration
Append to the project's pyproject.toml. Adjust requires-python to the actual minimum version.
[tool.ruff]
line-length = 100
[tool.ruff.lint]
select = [
"E", "F", "I", "W", "UP", "B", "SIM",
"A", "C4", "RUF", "PT", "T20", "TRY",
"RET", "SLF", "ARG", "ERA",
"PLC", "PLE", "PLR", "PLW",
"N",
"D",
"C90",
"S",
"FBT",
"TCH",
"PIE",
"PERF",
"FURB",
"LOG",
"BLE",
"EM",
"DTZ",
"ISC",
"RSE",
"G",
"PGH",
"FLY",
"YTT",
"T10",
"INP",
"FIX",
"TD",
"TID",
"COM",
]
ignore = [
"E501",
"D100",
"D104",
"D107",
"TD003",
"FIX002",
]
[tool.ruff.lint.mccabe]
max-complexity = 10
[tool.ruff.lint.pydocstyle]
convention = "google"
[tool.ruff.lint.pylint]
max-args = 4
max-branches = 10
max-returns = 4
max-statements = 50
[tool.ruff.lint.per-file-ignores]
"scripts/coverage_ratchet.py" = [
"T20",
"S603",
"S607",
]
"tests/**" = [
"S101",
"S310",
"D",
"ARG",
"PLR2004",
"FBT",
"C90",
"SLF",
"PT011",
"PT017",
"TRY003",
"TRY301",
"PERF",
"BLE",
"EM",
"DTZ",
"T10",
"TD",
"FIX",
"COM",
]
[tool.mypy]
python_version = "3.12"
strict = true
warn_return_any = true
warn_unused_configs = true
disallow_any_generics = true
disallow_untyped_defs = true
check_untyped_defs = true
no_implicit_optional = true
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "--cov=scripts --cov-report=term --cov-branch --cov-fail-under=95"
[tool.coverage.run]
omit = ["scripts/coverage_ratchet.py"]
Adjust --cov=scripts to point at the actual package directory. The --cov-fail-under=95 is a hard floor; the ratchet in Step 6 enforces "can only go up" beyond that. Use ruff format (not Black) - Ruff's formatter is Black-compatible and is one less tool to install.
Step 4 - ASCII enforcement (scripts/check-ascii.sh)
Ruff doesn't catch non-ASCII in comments, docstrings, or shell scripts. Add a repo-wide check (drop in at the project root, or skip if the parent monorepo already has one):
#!/usr/bin/env bash
set -euo pipefail
IN_SCOPE_REGEX='\.(py|md|json|ya?ml|sh|sql|toml|cfg|ini)$|(^|/)Dockerfile(\..+)?$|(^|/)Makefile$'
EXCLUDE_REGEX='(^|/)(\.venv|venv|__pycache__|\.git|build|dist|\.pytest_cache|\.ruff_cache|\.mypy_cache)(/|$)|(^|/)(uv\.lock|poetry\.lock)$'
collect_files() {
if [[ $# -gt 0 ]]; then printf '%s\n' "$@"
elif git rev-parse --git-dir >/dev/null 2>&1; then git ls-files
else find . -type f ! -path '*/.venv/*' ! -path '*/.git/*'
fi
}
violations=0
while IFS= read -r file; do
[[ -z "$file" || ! -f "$file" ]] && continue
if [[ ! "$file" =~ $IN_SCOPE_REGEX ]] || [[ "$file" =~ $EXCLUDE_REGEX ]]; then continue; fi
if matches=$(perl -ne 'print "$.: $_" if /[^\x00-\x7F]/' "$file") && [[ -n "$matches" ]]; then
echo "$file:"; echo "$matches" | sed 's/^/ /'; echo
violations=$((violations + 1))
fi
done < <(collect_files "$@")
if [[ $violations -gt 0 ]]; then
echo "check-ascii: $violations file(s) contain non-ASCII" >&2
exit 1
fi
echo "check-ascii: all files are pure ASCII"
chmod +x scripts/check-ascii.sh. Wire into pre-commit (Step 7) or call from Makefile.
Step 5 - Enabling abstractions (config.py, errors.py)
The T20 (no print) and BLE (no blind except) rules only feel right once the project has a structured logger and a typed exception hierarchy. Scaffold these:
scripts/config.py - the only module allowed to read environment variables:
"""Validated application configuration."""
from functools import lru_cache
from pydantic import field_validator
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
"""Application settings validated at startup."""
database_url: str | None = None
log_level: str = "INFO"
@field_validator("log_level")
@classmethod
def validate_log_level(cls, v: str) -> str:
"""Ensure log level is a recognized value."""
allowed = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
upper = v.upper()
if upper not in allowed:
msg = f"log_level must be one of {allowed}, got {v!r}"
raise ValueError(msg)
return upper
model_config = {"env_prefix": "", "case_sensitive": False}
@lru_cache(maxsize=1)
def get_settings() -> Settings:
"""Return cached application settings."""
return Settings()
def reset_settings() -> None:
"""Clear cached settings. Used in tests to pick up new env vars."""
get_settings.cache_clear()
scripts/errors.py - typed exceptions with HTTP status + blame attribution (parity with the TS skill):
"""Typed exception hierarchy with blame attribution."""
from typing import Literal
Blame = Literal["client", "server", "external"]
_DEFAULT_CLIENT_STATUS = 400
_DEFAULT_SERVER_STATUS = 500
_DEFAULT_EXTERNAL_STATUS = 502
class AppError(Exception):
"""Base error with HTTP status code and blame attribution."""
def __init__(
self,
message: str,
*,
status_code: int,
blame: Blame,
user_message: str | None = None,
) -> None:
"""Initialize with message, status code, blame, optional user message."""
super().__init__(message)
self.status_code = status_code
self.blame: Blame = blame
self.user_message = user_message or message
class ClientError(AppError):
"""Error caused by the caller (default 400)."""
def __init__(self, message: str, *, status_code: int = _DEFAULT_CLIENT_STATUS,
user_message: str | None = None) -> None:
"""Initialize client error."""
super().__init__(message, status_code=status_code, blame="client", user_message=user_message)
class ServerError(AppError):
"""Error caused by an internal server failure (default 500)."""
def __init__(self, message: str, *, status_code: int = _DEFAULT_SERVER_STATUS,
user_message: str | None = None) -> None:
"""Initialize server error."""
super().__init__(message, status_code=status_code, blame="server", user_message=user_message)
class ExternalServiceError(AppError):
"""Error caused by a third-party service (default 502)."""
def __init__(self, message: str, *, status_code: int = _DEFAULT_EXTERNAL_STATUS,
user_message: str | None = None) -> None:
"""Initialize external service error."""
super().__init__(message, status_code=status_code, blame="external", user_message=user_message)
Production code raises ClientError("bad request") etc. instead of bare Exception. The EM rule then forces these to be variables, not inline f-strings.
Step 6 - Coverage ratchet (scripts/coverage_ratchet.py)
Coverage that can only go up. Reads .coverage-baseline.json, fails on regression beyond tolerance.
"""Coverage ratchet - prevents coverage from decreasing between PRs."""
import json
import subprocess
import sys
from pathlib import Path
BASELINE = Path(".coverage-baseline.json")
TOLERANCE = 0.5
def parse_coverage() -> dict[str, float]:
"""Run pytest and extract per-metric coverage percentages."""
result = subprocess.run(
[sys.executable, "-m", "coverage", "json", "-o", "-"],
capture_output=True, text=True, check=True,
)
data = json.loads(result.stdout)
totals = data["totals"]
return {
"statements": totals["percent_covered"],
"branches": totals.get("percent_covered_branches", totals["percent_covered"]),
}
def main() -> int:
"""Compare current coverage against baseline; fail if regressed."""
current = parse_coverage()
if "--update" in sys.argv or not BASELINE.exists():
BASELINE.write_text(json.dumps(current, indent=2) + "\n")
print(f"Baseline updated: {current}")
return 0
baseline = json.loads(BASELINE.read_text())
failed = False
for metric, value in current.items():
prev = baseline.get(metric, value)
diff = value - prev
ok = diff >= -TOLERANCE
status = "PASS" if ok else "FAIL"
sign = "+" if diff >= 0 else ""
print(f"{metric}: {prev}% -> {value}% ({sign}{diff:.1f}%) {status}")
if not ok:
failed = True
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(main())
Step 7 - Pre-commit hook
If the project is standalone, .pre-commit-config.yaml:
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.8.0
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.14.0
hooks:
- id: mypy
- repo: local
hooks:
- id: check-ascii
name: check-ascii
entry: bash scripts/check-ascii.sh
language: system
pass_filenames: true
Then pre-commit install.
If the project lives in a JS monorepo with Husky, add to the parent .lintstagedrc:
{
"services/python/**/*.py": [
"bash -c 'cd services/python && .venv/bin/ruff check --fix . && .venv/bin/ruff format .'"
]
}
Step 8 - Makefile
.PHONY: lint typecheck test verify format format-check coverage-ratchet ascii
VENV := .venv/bin
lint:
$(VENV)/ruff check .
format:
$(VENV)/ruff format .
format-check:
$(VENV)/ruff format --check .
typecheck:
$(VENV)/mypy scripts/ tests/
test:
$(VENV)/pytest
coverage-ratchet:
$(VENV)/python3 scripts/coverage_ratchet.py
ascii:
bash scripts/check-ascii.sh
verify: format-check lint ascii typecheck test coverage-ratchet
Step 9 - Verify
Run make verify and resolve every issue. If existing code floods with violations, fix them or add per-file ignores with a comment - never strike rules from select.
What NOT to do
- Don't add
# noqa without a specific code: # noqa: E501. Blanket noqa is banned by PGH.
- Don't add
# type: ignore without a code or reason. Strict mypy plus PGH will flag this.
- Don't downgrade Ruff rules. Fix the code, or add a documented per-file ignore.
- Don't disable
strict in mypy. If a third-party library is untyped, add a targeted [[tool.mypy.overrides]] for that module only.
- Don't use
print in production code paths; use logging (the T20 rule enforces this).
- Don't read
os.environ outside config.py. Centralize env access so validation lives in one place.
- Don't initialize
--cov-fail-under=0 and call it done. Run real tests first, then ratchet up.