Files
2026-02-21 19:41:50 -05:00

733 lines
18 KiB
Markdown

# Python Style Guide
<!-- Canonical source: gitops/docs/standards/python-style-guide.md -->
<!-- Last synced: 2026-01-19 -->
> **Purpose**: Unified Python coding standards for this repository.
## Scope
This document covers: code formatting, complexity management, refactoring patterns, naming, type hints, error handling, logging, and testing.
**Related:**
- [Shell Script Standards](./shell-script-standards.md) - Bash scripting conventions
- [Tag Vocabulary](./tag-vocabulary.md) - Documentation standards
---
## Table of Contents
1. [Python Version](#python-version)
2. [Project Structure](#project-structure)
3. [Package Management](#package-management)
4. [Code Formatting](#code-formatting)
5. [Code Complexity](#code-complexity)
6. [Naming Conventions](#naming-conventions)
7. [Type Hints](#type-hints)
8. [Docstrings](#docstrings)
9. [Error Handling](#error-handling)
10. [Logging](#logging)
11. [Testing](#testing)
12. [CLI Script Template](#cli-script-template)
---
## Python Version
**Required:** Python 3.12+
All scripts MUST use Python 3.12 or later. This ensures:
- Modern syntax support (match statements, improved type hints)
- Performance improvements
- Consistency with container images
**pyproject.toml configuration:**
```toml
[project]
requires-python = ">=3.12"
```
---
## Project Structure
Standard Python structure:
```text
scripts/
├── my_script.py # Standalone CLI tools
├── lib/ # Shared libraries (if needed)
│ ├── __init__.py
│ └── helpers.py
└── tests/ # Test suite
├── __init__.py
├── conftest.py # Pytest fixtures
└── test_my_script.py
```
**Key Principles:**
- CLI scripts are standalone files in `scripts/`
- Shared code goes in `scripts/lib/`
- Tests mirror script structure
---
## Package Management
**Quick Reference:**
| Tool | Use For | Install |
|------|---------|---------|
| **uv** | Project dependencies | `brew install uv` |
| **pipx** | Global CLI tools | `brew install pipx` |
| **brew** | System tools | (macOS default) |
| ~~pip~~ | Avoid | Use uv instead |
### When to Use What
```
Need to... -> Use
---------------------------------------------
Install project dependencies -> uv sync
Add a new library to project -> uv add requests
Install a CLI tool globally -> pipx install ruff
Install a system tool -> brew install shellcheck
Quick one-off script -> uv run script.py
```
### Project Dependencies (uv)
Use `uv` for all project-level Python dependencies. It's 10-100x faster than pip and creates deterministic builds via lock files.
```bash
# Initialize a new project
uv init my-project
cd my-project
# Add dependencies
uv add requests pyyaml # Runtime deps
uv add --dev pytest ruff # Dev deps
# Install from existing pyproject.toml
uv sync # Creates/updates uv.lock
# Run a script with project deps
uv run python my_script.py
```
**Always commit `uv.lock`** - this ensures reproducible builds across machines.
### Global CLI Tools (pipx)
Use `pipx` for Python CLI tools you want available everywhere, isolated from projects.
```bash
# Install CLI tools globally
pipx install ruff # Linter/formatter
pipx install radon # Complexity analysis
pipx install xenon # Complexity enforcement
pipx install pre-commit # Git hooks
# Upgrade all
pipx upgrade-all
# Run without installing
pipx run cowsay "hello"
```
**Why not pip install globally?** Global pip installs pollute your system Python and cause version conflicts. pipx creates isolated venvs per tool.
### What NOT to Do
```bash
# Don't use pip globally
pip install requests # Pollutes system Python
sudo pip install anything # Even worse
# Don't mix package managers in a project
pip install requests # Now you have pip AND uv deps
uv add pyyaml # Conflicts likely
# Don't commit venv/
git add .venv/ # Use .gitignore
# Do this instead
uv add requests # Add to project
uv sync # Install everything
```
---
## Code Formatting
**Tool:** [ruff](https://github.com/astral-sh/ruff) (linter + formatter)
**Configuration in `pyproject.toml`:**
```toml
[tool.ruff]
line-length = 100
target-version = "py312"
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I", # isort
"N", # pep8-naming
"UP", # pyupgrade
"B", # flake8-bugbear
"C4", # flake8-comprehensions
"SIM", # flake8-simplify
]
ignore = [
"E501", # line-too-long (handled by formatter)
]
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
```
**Usage:**
```bash
# Check linting
ruff check scripts/
# Auto-fix issues
ruff check --fix scripts/
# Format code
ruff format scripts/
```
---
## Code Complexity
**Required:** Maximum cyclomatic complexity of 10 (Grade B) per function
**Why it matters:**
- Cyclomatic complexity = number of independent paths through code
- CC > 10 means exponentially more test cases needed for coverage
- High complexity correlates with defect density
- Humans (and LLMs) struggle to reason about deeply nested logic
### Complexity Grades
| Grade | CC Range | Meaning | Action |
|-------|----------|---------|--------|
| **A** | 1-5 | Simple, low risk | Ideal |
| **B** | 6-10 | Moderate, acceptable | Acceptable |
| **C** | 11-20 | Complex, hard to test | Refactor when touching |
| **D** | 21-30 | Very complex, high risk | Must refactor |
| **E** | 31-40 | Extremely complex | Urgent refactor |
| **F** | 41+ | Unmaintainable | Critical - block merges |
### Checking Complexity Locally
**Tools:** `radon` (analysis) + `xenon` (enforcement)
```bash
# Install
pipx install radon xenon
# Check specific file
radon cc scripts/my_script.py -s -a
# Fail if any function exceeds Grade B (CC > 10)
xenon scripts/ --max-absolute B
# Show only Grade C or worse
radon cc scripts/ -s -n C
```
### Reducing Complexity
#### Pattern 1: Dispatch Pattern (Handler Registry)
**When to use:** Functions with if/elif chains that dispatch based on mode or type.
```python
# Bad - if/elif chain (CC=18+)
def main():
if args.patch:
# 90 lines of patch logic
elif args.read:
# 20 lines of read logic
else:
# 100 lines of write logic
# Good - Dispatch pattern (CC=6)
def _handle_patch_mode(args, client):
"""Handle --patch mode."""
# Focused patch logic
def _handle_read_mode(args, client):
"""Handle --read mode."""
# Focused read logic
def main():
args = parse_args()
client = build_client()
if args.patch:
_handle_patch_mode(args, client)
elif args.read:
_handle_read_mode(args, client)
else:
_handle_write_mode(args, client)
```
#### Pattern 2: Early Returns (Guard Clauses)
```python
# Bad - Deep nesting (CC=8)
def validate_document(doc):
if doc:
if doc.content:
if len(doc.content) > 0:
if doc.tenant:
return True
return False
# Good - Guard clauses (CC=4)
def validate_document(doc):
if not doc:
return False
if not doc.content:
return False
if len(doc.content) == 0:
return False
if not doc.tenant:
return False
return True
```
#### Pattern 3: Lookup Tables
```python
# Bad - Each 'or' adds +1 CC
def normalize_field(key, value):
if key == "tls.crt" or key == "tls.key" or key == "ca":
return normalize_cert_field(value)
elif key == "config.json":
return normalize_pull_secret_json(value)
else:
return value
# Good - O(1) lookup
NORMALIZERS = {
"tls.crt": normalize_cert_field,
"tls.key": normalize_cert_field,
"ca": normalize_cert_field,
"config.json": normalize_pull_secret_json,
}
def normalize_field(key, value):
normalizer = NORMALIZERS.get(key)
return normalizer(value) if normalizer else value
```
### Helper Naming Convention
| Prefix | Meaning | Example |
|--------|---------|---------|
| `_handle_` | Mode/dispatch handler | `_handle_patch_mode()` |
| `_process_` | Processing helper | `_process_secret()` |
| `_validate_` | Validation helper | `_validate_cert()` |
| `_setup_` | Initialization helper | `_setup_mount_point()` |
| `_normalize_` | Data normalization | `_normalize_cert_field()` |
| `_build_` | Construction | `_build_audit_metadata()` |
---
## Naming Conventions
Follow PEP 8 naming conventions:
| Element | Convention | Example |
|---------|------------|---------|
| **Modules** | `snake_case.py` | `my_script.py` |
| **Classes** | `PascalCase` | `MyClient` |
| **Functions** | `snake_case()` | `get_secret()` |
| **Variables** | `snake_case` | `mount_point` |
| **Constants** | `UPPER_SNAKE_CASE` | `MAX_RETRIES` |
| **Private** | `_leading_underscore` | `_internal_helper()` |
---
## Type Hints
**Required:** Type hints for all public functions
**Preferred Style:**
- Use modern syntax (`list[str]` not `List[str]`)
- Use `|` for unions (`str | None` not `Optional[str]`)
- Add return type annotations
```python
from __future__ import annotations
# Good - Modern syntax
def process_secrets(
secrets: dict[str, dict],
only: set[str] | None = None,
) -> list[str]:
"""Process secrets and return names processed."""
results: list[str] = []
for name, payload in secrets.items():
if only and name not in only:
continue
results.append(name)
return results
# Bad - Old syntax
from typing import Dict, List, Optional
def process_secrets(
secrets: Dict[str, Dict],
only: Optional[List[str]] = None,
) -> List[str]:
pass
```
---
## Docstrings
**Required:** All public functions MUST have docstrings
**Style:** Google-style docstrings
```python
def verify_secret_after_write(
client: hvac.Client,
mount_point: str,
name: str,
expected_payload: dict[str, Any],
) -> bool:
"""Verify secret was written correctly.
Args:
client: Vault client
mount_point: KV v2 mount point
name: Secret name
expected_payload: Expected secret data
Returns:
True if verification passed, False if any check failed
Raises:
hvac.exceptions.InvalidPath: If secret path is invalid
"""
pass
```
---
## Error Handling
**Principles:**
1. **Use specific exception types** - Never catch bare `Exception` (unless re-raising)
2. **Fail fast** - Don't swallow errors silently
3. **Provide context** - Include relevant data in error messages
4. **Log warnings** - If catching exceptions, log what happened
### Good Patterns
```python
# Good - Specific exception, logged
try:
cert_info = validate_certificate(payload["tls.crt"])
except subprocess.CalledProcessError as exc:
logging.warning(f"Certificate validation failed: {exc}")
# Good - Specific types for format detection
try:
decoded = base64.b64decode(data)
except (UnicodeDecodeError, base64.binascii.Error, ValueError) as exc:
logging.debug(f"Not base64, assuming PEM format: {exc}")
decoded = data
# Good - Re-raise with context
try:
result = subprocess.run(cmd, check=True, capture_output=True)
except subprocess.CalledProcessError as exc:
raise RuntimeError(f"Command failed: {cmd}") from exc
```
### Bad Patterns
```python
# Bad - Bare exception, swallowed
try:
validate_something()
except Exception:
pass # Silent failure!
# Bad - Catching Exception without re-raising
try:
process_data()
except Exception as e:
logging.error(f"Error: {e}")
return None # Hides the problem
```
---
## Logging
**Library:** Python standard `logging` module
```python
import logging
logging.basicConfig(
format="%(asctime)s %(levelname)s %(message)s",
level=logging.INFO,
)
# Use module logger
log = logging.getLogger(__name__)
# Or use root logger for simple scripts
logging.info("Processing secret: %s", secret_name)
```
**Log Levels:**
- `DEBUG` - Detailed diagnostic (development only)
- `INFO` - Key events, progress
- `WARNING` - Recoverable issues
- `ERROR` - Operation failed
**Best Practices:**
```python
# Good - Context included
logging.info(f"Prepared {secret_name}: {preview}")
logging.warning(f"Security policy check failed for {key}: {exc}")
# Bad - No context
logging.info("Processing...")
logging.error(str(e))
```
---
## Testing
**Framework:** pytest
**Structure:**
```text
scripts/
├── my_script.py
└── tests/
├── conftest.py # Shared fixtures
├── test_my_script.py # Unit tests
└── e2e/ # End-to-end tests
├── conftest.py # Testcontainers fixtures
└── test_integration.py
```
**Running tests:**
```bash
# Run all tests
pytest scripts/tests/
# Run with coverage
pytest scripts/tests/ --cov=scripts --cov-report=term-missing
# Run only E2E tests
pytest scripts/tests/e2e/ -m e2e
```
**Naming:**
- Test files: `test_*.py`
- Test functions: `test_*`
### Testcontainers for E2E Tests
**What it is:** [Testcontainers](https://testcontainers.com/) is a library that spins up real Docker containers during test execution, then tears them down automatically.
**Why use it instead of mocks:**
| Approach | Problem |
|----------|---------|
| Mocks | Don't catch real DB behavior (SQL syntax, constraints) |
| SQLite substitute | Different SQL dialect, missing extensions |
| Shared test DB | Tests interfere with each other, flaky results |
| **Testcontainers** | Real DB, isolated per run, reproducible |
**When to use testcontainers:**
- Testing database queries
- Testing against message queues (Redis, RabbitMQ)
- Any E2E test that needs real infrastructure
**Installation:**
```bash
pip install testcontainers[postgres] # PostgreSQL
pip install testcontainers[redis] # Redis
```
**Example - PostgreSQL:**
```python
# tests/e2e/conftest.py
import pytest
from testcontainers.postgres import PostgresContainer
@pytest.fixture(scope="session")
def postgres_container():
"""Spin up PostgreSQL for E2E tests."""
with PostgresContainer("postgres:16") as postgres:
yield postgres
# Container automatically cleaned up
@pytest.fixture
def db_connection(postgres_container):
"""Get connection to test database."""
import psycopg
conn_str = postgres_container.get_connection_url()
with psycopg.connect(conn_str) as conn:
yield conn
```
**pytest marker configuration (pyproject.toml):**
```toml
[tool.pytest.ini_options]
markers = [
"e2e: marks tests as end-to-end (require Docker for testcontainers)",
]
```
---
## CLI Script Template
Standard template for standalone CLI scripts:
```python
#!/usr/bin/env python3
"""One-line description of what this script does.
Usage:
python3 script_name.py --config config.yaml --apply
Exit Codes:
0 - Success
1 - Argument/configuration error
2 - Runtime error
"""
from __future__ import annotations
import argparse
import logging
import sys
from pathlib import Path
from typing import Any
logging.basicConfig(
format="%(asctime)s %(levelname)s %(message)s",
level=logging.INFO,
)
def die(message: str) -> None:
"""Print error message and exit with code 1."""
logging.error(message)
sys.exit(1)
def parse_args() -> argparse.Namespace:
"""Parse command-line arguments."""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--config", default="config.yaml", help="Path to config file")
parser.add_argument("--apply", action="store_true", help="Apply changes")
parser.add_argument("--dry-run", action="store_true", help="Show what would be done")
return parser.parse_args()
def main() -> int:
"""Main entry point."""
args = parse_args()
if not args.apply:
args.dry_run = True
logging.info("Dry-run mode (use --apply to make changes)")
# Main logic here
try:
# ... implementation
pass
except Exception as exc:
logging.error(f"Failed: {exc}")
return 2
return 0
if __name__ == "__main__":
sys.exit(main())
```
---
## Common Errors
| Symptom | Cause | Fix |
|---------|-------|-----|
| `ruff check` fails | Code style violations | Run `ruff check --fix` |
| `xenon` reports Grade C+ | CC > 10 in function | Apply refactoring patterns above |
| `TypeError: NoneType` | Missing null check | Add guard clause or `if x is not None` |
| Import cycle error | Circular imports | Move imports inside function or restructure |
| `ModuleNotFoundError` | Missing dependency | Run `uv sync` or `uv add <package>` |
| Type hint error | Wrong type annotation | Use modern syntax: `list[str]` not `List[str]` |
| f-string in logging | Using `f"..."` with logging | Use `%s` formatting: `logging.info("x: %s", x)` |
---
## Anti-Patterns
| Name | Pattern | Why Bad | Instead |
|------|---------|---------|---------|
| God Function | Single function >100 lines | Untestable, high CC | Dispatch pattern, extract helpers |
| Bare Except | `except Exception: pass` | Hides bugs silently | Specific exceptions, log warnings |
| Print Debugging | `print("debug:", x)` | No levels, no timestamps | Use `logging.debug()` |
| Stringly Typed | Using strings for enums | No validation, typos | Use `enum.Enum` or Literal types |
| Deep Nesting | 4+ indent levels | Hard to follow, high CC | Early returns, extract functions |
| Global State | Module-level mutable state | Hidden dependencies, race conditions | Pass state explicitly |
| Any Typed | `def foo(x: Any)` | Defeats type checking | Use generics or specific types |
| Magic Strings | `if mode == "patch"` | Typo-prone, scattered | Define constants: `MODE_PATCH = "patch"` |
---
## AI Agent Guidelines
When AI agents write Python for this repo:
| Guideline | Rationale |
|-----------|-----------|
| ALWAYS run `ruff check` before committing | Catches style issues immediately |
| ALWAYS check complexity with `radon cc -s` | Prevents CC creep |
| NEVER add `# type: ignore` without comment | Explain why type check fails |
| NEVER use `subprocess.shell=True` | Security risk, use list args |
| PREFER `pathlib.Path` over `os.path` | Modern, cross-platform |
| PREFER dataclasses over dicts | Type safety, IDE support |
---
## Summary
**Key Takeaways:**
1. Python 3.12+ required
2. Use `ruff` for formatting and linting
3. **Cyclomatic complexity <= 10** per function (use `radon`/`xenon`)
4. Type hints required for public functions
5. Google-style docstrings for public APIs
6. Specific exception types, never bare `except Exception:`
7. Use `logging`, never `print()`
8. Follow CLI script template for consistency
9. Check Common Errors table for quick troubleshooting
10. Avoid named Anti-Patterns