Skip to content
Back to skills

Python Project Rules

ASecurity

Team conventions for setting up and maintaining a Python project: the latest stable Python, src layout, uv commands, dev dependency groups and a committed lockfile, Ruff for format and lint, a GitHub Actions workflow with uv, a Makefile check target, dependency audit and trusted publishing. Use when creating a Python project, or editing pyproject.toml, CI or tooling. Background knowledge that agents preload; not a command.

  • 2 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added November 11, 2026
developmentpythonrustgobashfastapitestinggitapidatabasesecurity

Works with

  • cli
  • api

Security analysis

A92/100
  • mediumInstalls packages at runtime which could introduce malicious dependencies

Pro scans all 2 files and shows the line behind each finding

Scanned November 11, 2026

npx -y skills add JanSzewczyk/claude-plugins --skill python-project-rules --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Python Project Rules?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Python Project Rules
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/janszewczyk-python-project-rules/badge)](https://www.skillsdirectory.com/skills/janszewczyk-python-project-rules)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: python-project-rules
description: "Team conventions for setting up and maintaining a Python project: the latest stable Python, src layout, uv commands, dev dependency groups and a committed lockfile, Ruff for format and lint, a GitHub Actions workflow with uv, a Makefile check target, dependency audit and trusted publishing. Use when creating a Python project, or editing pyproject.toml, CI or tooling. Background knowledge that agents preload; not a command."
user-invocable: false
---

<!-- generated by knowledge.mjs from 24 note(s) (sha256 066c04b3f79b); edit the notes in knowledge/, then run build -->

# Python project rules

Apply these rules when you create a Python project or change its tooling: pyproject.toml, uv, Ruff, CI, the Makefile and publishing. The first section is always in force; open a rule from the table when the task touches it.

## Always apply (critical, high)

### Build a Python service's configuration as one typed Settings class fed by the environment

_high · python, fastapi, pydantic-settings_

**Decision.** Configure the service from environment variables, read once into a single `BaseSettings` class, and no other module reads `os.environ` or `.env`. Fields are typed, closed sets are `Literal` or an enum, secrets are `SecretStr`, and a setting that exposes something defaults to off or empty (one with no safe value has no default, so a missing value stops startup). The entry point builds `Settings()` once, so bad configuration stops startup; the app receives it as an argument and exposes it through one dependency. FastAPI's cached `get_settings()` is acceptable only if the entry point also calls it once at startup. `.env.example` documents the variables, the real `.env` is git-ignored, and tests build their own `Settings(_env_file=None, ...)`.

### Default a setting that exposes something to off or empty

_high · python, pydantic-settings_

**Rule.** Give every setting that exposes something (API docs, debug output, permissive CORS, admin routes) a default of off or empty, and let a developer turn it on in a local `.env`. The setting then feeds the code that exposes it, for example `docs_url=None` when `docs_enabled` is false.

**Why.** A production deployment that forgets one variable falls back to the default, and an exposing default silently publishes the docs, opens CORS or leaves debug on.

**Correct:**

```python
class Settings(BaseSettings):
    docs_enabled: bool = False
    cors_origins: list[str] = []     # from the environment as JSON: CORS_ORIGINS=["https://app.example.com"]
```

**Exceptions.** A value that has no safe default, such as a database URL, should have no default at all so that startup fails when it is missing.

### Keep secrets wrapped in SecretStr until the call that needs them

_high · python, pydantic-settings_

**Rule.** Type every password, token and API key setting as `SecretStr` (or `SecretBytes`) and unwrap it with `get_secret_value()` only in the call that needs the real value.

**Why.** A plain `str` secret is printed in full by `repr(settings)`, a logged settings object or a serialised dump, while a `SecretStr` shows `**********` and makes each real read easy to find in review.

**Correct:**

```python
class Settings(BaseSettings):
    api_key: SecretStr

log.info("started", settings=settings)   # api_key=SecretStr('**********')
client = Client(api_key=settings.api_key.get_secret_value())
```

**Exceptions.** `SecretStr` hides a value from output, it does not encrypt it, and a custom field serializer can reveal it. It complements the logger's redaction (`security/python/redact-secrets-in-the-logger-not-at-call-sites`), which still catches secrets that were never typed.

### Read all configuration through one typed Settings class

_high · python, pydantic-settings_

**Rule.** Declare every setting as a typed field of one `BaseSettings` class and read configuration only from an instance of it. No other module calls `os.environ`, `os.getenv` or reads `.env` itself.

**Why.** A variable read with `os.getenv` somewhere deep in the code is an untyped string that nobody validates, nobody documents and a test cannot replace, and a typo in its name surfaces only when that code path runs.

**Correct:**

```python
from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    smtp_timeout: int = 10

def send(message: str, settings: Settings) -> None:
    timeout = settings.smtp_timeout
    ...
```

**Exceptions.** The entry point builds the one `Settings` instance. A value that comes from elsewhere, such as a secrets manager, is loaded there and passed into `Settings`, so the class stays the only source.

### Audit dependencies in CI on every pull request and on a schedule

_high · python_

**Rule.** Run `pip-audit` in CI on every pull request and on a schedule against the locked dependencies: export the lockfile and pass it with `-r` and `--disable-pip`. Treat a finding as a known vulnerability to fix or consciously accept, and do not read a clean run as proof of safety.

**Why.** A pinned, hashed dependency can still carry a published CVE, and new advisories appear for versions you installed months ago, so only a recurring audit notices. A bare `uvx pip-audit` audits uvx's own isolated environment, not the project, so it reports clean whatever the lockfile holds.

**Correct:**

```yaml
# .github/workflows/ci.yml
on:
  pull_request:
  schedule:
    - cron: "0 6 * * 1"   # new advisories appear without a new commit
# ...
- run: uv sync --locked
- run: uv export --locked --no-emit-project -o requirements.txt
- run: uvx pip-audit -r requirements.txt --disable-pip   # a bare `uvx pip-audit` checks uvx's own environment
- run: uv run pytest
```

### Install from a lockfile that records hashes

_high · python_

**Rule.** Install from a lockfile that records a hash for every package: `uv.lock` with uv, or `pylock.toml` (PEP 751) with tools that read it. Outside uv, install with `pip install --require-hashes`. Commit the lockfile.

**Why.** An unlocked install resolves to whatever was published a minute ago, so a hijacked or tampered release reaches production unreviewed; a hashed lockfile makes the same bytes install everywhere.

**Correct:**

```bash
pip install --require-hashes -r requirements.lock
```

**Exceptions.** With uv, the stale-lockfile check in CI belongs to the uv workflow note and is not repeated here.

### Register every test directory in testpaths and in the type checker's files

_high · python, pytest, ruff, mypy_

**Rule.** List each test directory in pytest's `testpaths` and in mypy's `files`, so the bare `pytest` and `mypy` calls in `make check` cover it (a `check` recipe that passes explicit paths needs the directory there too). A directory outside the package also gets an `__init__.py` and a `known-first-party` entry in Ruff's isort settings, so `from tests_e2e.conftest import ...` resolves the same way for pytest, mypy and Ruff.

**Why.** A test directory missing from the gate passes only when someone runs it by hand: a whole second suite of 97 tests sat outside `make check` and CI, and nobody noticed.

**Correct:**

```toml
[tool.pytest.ini_options]
testpaths = ["tests", "tests_e2e"]

[tool.mypy]
files = ["src", "tests", "tests_e2e"]

[tool.ruff.lint.isort]
known-first-party = ["myapp", "tests", "tests_e2e"]   # each with an __init__.py
```

**Exceptions.** A suite that is too slow for every push (load tests, a nightly fuzz run) may live outside the gate; give it its own named make target and a scheduled CI job, so it is run on purpose. Do not open a second directory for ordinary tests: add them to the existing suite, see `testing/python/pytest/add-tests-to-the-existing-suite-by-topic`.

### Use only the latest stable Python release, for applications and libraries alike

_high · python_

**Rule.** Target only the latest stable Python release. Pin it in `.python-version`, set `requires-python` to `>=` that release, and write no code, CI matrix or conditional for older interpreters, so every other Python rule here applies without version caveats.

**Why.** Supporting several releases forces `TypeVar` boilerplate, quoted annotations, `typing_extensions` shims and CI matrices for syntax that the newest release makes unnecessary, and each older release is closer to losing security fixes.

**Correct:**

```toml
# .python-version: 3.15 (the latest stable release at the time of writing)

[project]
requires-python = ">=3.15"
```

**Exceptions.** Move to a new release only after the wheels of the dependencies you rely on exist for it; until then stay on the previous one and treat that as a temporary state, not a second supported version.

### Commit uv.lock and make CI fail when it is out of date

_high · python, uv_

**Rule.** Commit `uv.lock` to version control. In CI install with `uv sync --locked`, which raises an error instead of updating a lockfile that no longer matches `pyproject.toml`.

**Why.** Without the check, CI silently re-resolves dependencies, so the build tests versions nobody committed and a change to `pyproject.toml` can ship without its lockfile update.

**Correct:**

```yaml
- run: uv sync --locked
```

### Manage dependencies and run commands through uv, from pyproject.toml

_high · python, uv_

**Rule.** Keep project configuration in `pyproject.toml`. Add dependencies with `uv add`, recreate the environment with `uv sync`, and run project commands with `uv run`.

**Why.** Hand-edited requirements files and bare `python` calls make each machine resolve and run something different, which shows up as bugs that only reproduce on one laptop.

**Correct:**

```bash
uv add requests
uv run pytest
```

## Read when the task touches it

| Impact | Rule | Applies to |
| --- | --- | --- |
| medium | [Build Settings once at startup and inject them with Depends](references/rules.md#build-settings-once-at-startup-and-inject-them-with-depends) — Settings are created at startup so bad configuration stops the service, and tests pass their own instead of patching. | python, fastapi, pydantic-settings |
| medium | [Commit an .env.example that lists every variable and git-ignore the real .env](references/rules.md#commit-an-envexample-that-lists-every-variable-and-git-ignore-the-real-env) — The example file is the documented list of settings; the real .env holds secrets and stays out of git. | python, pydantic-settings |
| medium | [Type a setting with a fixed set of values as Literal or an enum](references/rules.md#type-a-setting-with-a-fixed-set-of-values-as-literal-or-an-enum) — A typo such as ENVIRONMENT=prod fails at startup instead of silently selecting the non-production branch. | python, pydantic-settings |
| medium | [Publish to PyPI with Trusted Publishing instead of a long-lived token](references/rules.md#publish-to-pypi-with-trusted-publishing-instead-of-a-long-lived-token) — OIDC credentials last minutes; a token stored in secrets can leak and stays valid. | python |
| medium | [Use Ruff as the only formatter and linter](references/rules.md#use-ruff-as-the-only-formatter-and-linter) — Ruff replaces Black, Flake8 and isort; run it from the check target and nothing next to it. | python, ruff |
| medium | [Add a CI workflow that installs from the lockfile and runs the check suite](references/rules.md#add-a-ci-workflow-that-installs-from-the-lockfile-and-runs-the-check-suite) — A workflow on push and pull_request runs uv sync --locked, then the one check command, so every change is checked. | python, uv |
| medium | [Use the src/ layout for libraries and CLI tools](references/rules.md#use-the-src-layout-for-libraries-and-cli-tools) — src/package_name/ stops tests and tools from importing raw sources instead of the installed package. | python |
| medium | [Keep developer tools in [dependency-groups], not in optional-dependencies extras](references/rules.md#keep-developer-tools-in-dependency-groups-not-in-optional-dependencies-extras) — PEP 735 groups are left out of built distributions; extras are published and become part of the public API. | python, uv |
| low | [Define the check commands once in a Makefile that wraps uv run](references/rules.md#define-the-check-commands-once-in-a-makefile-that-wraps-uv-run) — One check target runs format check, lint, type check and tests, so the README, CI and the developer run the same thing. | python, uv |
| low | [Enable Ruff rules that enforce logging conventions: G, LOG, T20, TRY400, TRY401](references/rules.md#enable-ruff-rules-that-enforce-logging-conventions-g-log-t20-try400-try401) — They flag f-strings in log calls, root-logger use, print() and exception logging that drops the traceback. | python, ruff |
| low | [Enable Ruff PT rules to keep pytest tests in one style](references/rules.md#enable-ruff-pt-rules-to-keep-pytest-tests-in-one-style) — PT011, PT012 and PT018 catch too-broad raises, multi-statement raises blocks and compound asserts. | python, ruff, pytest |
| low | [Enforce docstrings with Ruff D rules set to the Google convention](references/rules.md#enforce-docstrings-with-ruff-d-rules-set-to-the-google-convention) — convention = "google" switches off the D rules that contradict each other; tests are exempt. | python, ruff |
| low | [Set Ruff's line length to 120 once, in pyproject.toml](references/rules.md#set-ruffs-line-length-to-120-once-in-pyprojecttoml) — PEP 8 suggests 79 to 99; 120 is our deliberate choice, set once so editors and CI agree. | python, ruff |
| low | [Enable Ruff rule families gradually, starting from E, F, I, UP, B and S](references/rules.md#enable-ruff-rule-families-gradually-starting-from-e-f-i-up-b-and-s) — Begin with pycodestyle, Pyflakes, isort, pyupgrade, bugbear and security rules; add PTH, TC, FURB, TD one at a time. | python, ruff |

Files in this skill

  • SKILL.md13.5 KB
  • references/rules.md13.2 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

View all in development →

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.