diff --git a/plan/requirements/requirements_v3.md b/plan/requirements/requirements_v3.md index 9656320..07e76dd 100644 --- a/plan/requirements/requirements_v3.md +++ b/plan/requirements/requirements_v3.md @@ -216,4 +216,11 @@ adapters: - Python, typed as strictly as practical (Pydantic, TypedDict, enums, Literal). - Pyright for type checking; pytest for unit tests; Alembic for schema migrations. +- **All configuration enters through pydantic-settings** (`BaseSettings`) — environment + variables are read in exactly one place, as typed, validated settings objects. **Direct + `os.environ` / `os.getenv` access is banned** (enforced by lint — ruff `TID251`); an + exception requires a per-line ignore with a reason. +- **Secrets are typed `SecretStr`/`SecretBytes`** in settings — never plain `str` — so they + cannot leak into logs, reprs, or tracebacks; unwrap (`.get_secret_value()`) only at the + call site that actually needs the value, never store the unwrapped form. - Docstrings, comments, well-structured modules. diff --git a/pyproject.toml b/pyproject.toml index 7f2e007..54689e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,7 +63,15 @@ line-length = 88 skip-magic-trailing-comma = true [tool.ruff.lint] -extend-select = ["I", "B", "A", "ERA"] +extend-select = ["I", "B", "A", "ERA", "TID251"] + +# All configuration goes through pydantic-settings; secrets are SecretStr/SecretBytes +# (requirements_v3.md §Code). Env vars are read in exactly one place — a BaseSettings +# class — never ad hoc. Tests set env via pytest's monkeypatch.setenv, which is not banned. +[tool.ruff.lint.flake8-tidy-imports.banned-api] +"os.environ".msg = "Read config via pydantic-settings (BaseSettings), not os.environ (requirements §Code)." +"os.getenv".msg = "Read config via pydantic-settings (BaseSettings), not os.getenv (requirements §Code)." +"os.putenv".msg = "Do not mutate the process environment; pass typed settings instead (requirements §Code)." [tool.ruff.lint.isort] case-sensitive = false