Files
StarPunk/docs/standards/python-coding-standards.md
2025-11-18 19:21:31 -07:00

1009 lines
19 KiB
Markdown

# Python Coding Standards
## Purpose
This document establishes coding standards for all Python code in StarPunk. These standards ensure consistency, readability, and maintainability while adhering to the project philosophy: "Every line of code must justify its existence."
## Philosophy
Code standards should:
- **Enhance readability** without adding ceremony
- **Catch errors early** through consistent patterns
- **Enable automation** via tools (black, flake8, mypy)
- **Stay minimal** - no rules without clear benefit
## Base Standard
StarPunk follows **PEP 8** with specific clarifications and exceptions documented below.
**Reference**: [PEP 8 - Style Guide for Python Code](https://peps.python.org/pep-0008/)
---
## Code Formatting
### Automated Formatting with Black
**Decision**: All Python code MUST be formatted with [Black](https://black.readthedocs.io/).
**Configuration**: Use Black's defaults (88 character line length).
**Rationale**:
- Eliminates formatting debates
- Consistent across entire codebase
- Automated via pre-commit hook or CI
- Industry standard
**Usage**:
```bash
# Format all code
.venv/bin/black starpunk/ tests/
# Check formatting without changes
.venv/bin/black --check starpunk/ tests/
# Format specific file
.venv/bin/black starpunk/notes.py
```
**Configuration File** (optional `pyproject.toml`):
```toml
[tool.black]
line-length = 88
target-version = ['py311']
include = '\.pyi?$'
extend-exclude = '''
/(
\.venv
| data
)/
'''
```
---
## Code Style
### Line Length
**Standard**: 88 characters (Black default)
**Exception**: Long strings, URLs, or comments can exceed if breaking them reduces readability.
**Good**:
```python
# Fits in 88 characters, readable
def create_note(content: str, published: bool = False) -> Note:
"""Create a new note with the given content."""
...
```
**Acceptable Exception**:
```python
# URL shouldn't be broken
INDIELOGIN_ENDPOINT = "https://indielogin.com/auth?me={me}&client_id={client_id}&redirect_uri={redirect_uri}&state={state}"
```
---
### Indentation
**Standard**: 4 spaces per indentation level (PEP 8).
**Never use tabs** - Black enforces this.
**Good**:
```python
def example():
if condition:
do_something()
if nested:
do_nested()
```
---
### Blank Lines
**Rules**:
- 2 blank lines between top-level functions and classes
- 1 blank line between methods in a class
- Use blank lines sparingly within functions for logical grouping
**Good**:
```python
import os
def first_function():
"""First function."""
pass
def second_function():
"""Second function."""
pass
class MyClass:
"""Example class."""
def method_one(self):
"""First method."""
pass
def method_two(self):
"""Second method."""
pass
```
---
### Imports
**Order** (enforced by Black and flake8):
1. Standard library imports
2. Third-party imports
3. Local application imports
**Within each group**: Alphabetically sorted
**Style**: One import per line (except `from` imports)
**Good**:
```python
# Standard library
import os
import sqlite3
from datetime import datetime
from pathlib import Path
# Third-party
import httpx
import markdown
from flask import Flask, render_template, request
from feedgen.feed import FeedGenerator
# Local
from starpunk.config import load_config
from starpunk.database import get_db
from starpunk.models import Note
```
**Bad**:
```python
import os, sys # Multiple imports on one line
from starpunk.config import * # Star imports
from flask import Flask
import httpx # Wrong order (third-party before local)
from starpunk.models import Note
```
**Absolute vs Relative Imports**:
- Use absolute imports: `from starpunk.database import get_db`
- Avoid relative imports: `from .database import get_db`
---
### Quotes
**Standard**: Use double quotes `"` for strings (Black default).
**Exception**: Use single quotes to avoid escaping doubles inside string.
**Good**:
```python
name = "StarPunk"
description = "A minimal CMS"
html = '<a href="/note">Link</a>' # Single quotes to avoid escaping
```
**Bad**:
```python
name = 'StarPunk' # Black will change to double quotes
```
---
## Naming Conventions
### Files and Modules
**Pattern**: `lowercase_with_underscores.py`
**Good**:
```
starpunk/database.py
starpunk/notes.py
starpunk/utils.py
tests/test_auth.py
```
**Bad**:
```
starpunk/Database.py # CamelCase
starpunk/db.py # Abbreviation
starpunk/note-utils.py # Hyphens
```
---
### Classes
**Pattern**: `PascalCase` (CapWords)
**Good**:
```python
class Note:
pass
class SessionManager:
pass
class MicropubEndpoint:
pass
```
**Bad**:
```python
class note: # lowercase
class session_manager: # snake_case
class Micropub_Endpoint: # Mixed
```
---
### Functions and Methods
**Pattern**: `lowercase_with_underscores`
**Good**:
```python
def create_note(content):
pass
def get_note_by_slug(slug):
pass
def generate_rss_feed():
pass
```
**Bad**:
```python
def createNote(): # camelCase
def GetNoteBySlug(): # PascalCase
def generate_RSS_feed(): # Mixed case
```
---
### Variables
**Pattern**: `lowercase_with_underscores`
**Good**:
```python
note_count = 10
file_path = Path("data/notes")
session_token = generate_token()
```
**Bad**:
```python
noteCount = 10 # camelCase
FilePath = Path() # PascalCase
session_Token = "" # Mixed
```
---
### Constants
**Pattern**: `UPPERCASE_WITH_UNDERSCORES`
**Location**: Define at module level (top of file after imports)
**Good**:
```python
MAX_NOTE_LENGTH = 10000
DEFAULT_SESSION_LIFETIME = 30
INDIELOGIN_URL = "https://indielogin.com"
```
**Bad**:
```python
max_note_length = 10000 # lowercase
MaxNoteLength = 10000 # PascalCase
```
---
### Private Members
**Pattern**: Single leading underscore `_name` for internal use
**Good**:
```python
class Note:
def __init__(self, content):
self._content_hash = None # Internal attribute
def _calculate_hash(self): # Internal method
"""Calculate content hash (internal use)."""
pass
def save(self):
"""Public method."""
self._calculate_hash() # Can call private method
```
**Never use double underscore** `__name` (name mangling) unless you specifically need it (rare).
---
## Type Hints
### Standard
**Use type hints** for all function signatures in application code.
**Good**:
```python
from pathlib import Path
from typing import Optional, List
def create_note(content: str, published: bool = False) -> Note:
"""Create a new note."""
pass
def get_notes(limit: int = 10, offset: int = 0) -> List[Note]:
"""Get list of notes."""
pass
def find_note(slug: str) -> Optional[Note]:
"""Find note by slug, returns None if not found."""
pass
```
**Type Hint Guidelines**:
- Use built-in types when possible: `str`, `int`, `bool`, `list`, `dict`
- Use `typing` module for complex types: `Optional`, `List`, `Dict`, `Tuple`
- Python 3.11+ can use `list[Note]` instead of `List[Note]`
- Use `Optional[T]` for nullable types
- Use `->` for return type annotation
**Optional for Tests**: Type hints in tests are optional but encouraged.
---
## Documentation
### Docstrings
**Standard**: Use docstrings for all modules, classes, and functions.
**Format**: Google-style docstrings
**Module Docstring**:
```python
"""
Notes management module for StarPunk
This module handles creating, reading, updating, and deleting notes.
Notes are stored as markdown files with metadata in SQLite.
"""
```
**Function Docstring**:
```python
def create_note(content: str, slug: Optional[str] = None, published: bool = False) -> Note:
"""
Create a new note with the given content
Args:
content: Markdown content of the note
slug: Optional URL slug (generated if not provided)
published: Whether note is published (default: False)
Returns:
Created Note instance
Raises:
ValueError: If content is empty
FileExistsError: If slug already exists
Example:
>>> note = create_note("Hello world", slug="first-note")
>>> note.slug
'first-note'
"""
pass
```
**Class Docstring**:
```python
class Note:
"""
Represents a note with content and metadata
Attributes:
slug: URL-safe identifier
content: Markdown content
file_path: Path to markdown file
created_at: Creation timestamp
updated_at: Last modification timestamp
published: Publication status
"""
def __init__(self, slug: str, content: str):
"""
Initialize a new Note
Args:
slug: URL-safe identifier
content: Markdown content
"""
self.slug = slug
self.content = content
```
**Property Docstring**:
```python
@property
def html(self) -> str:
"""Rendered HTML content from markdown."""
return markdown.markdown(self.content)
```
**Short Functions**: One-line docstring is acceptable.
```python
def get_all_notes() -> List[Note]:
"""Get all notes from database."""
pass
```
---
### Comments
**Use sparingly** - code should be self-documenting via good naming.
**When to Comment**:
- Explain **why**, not **what**
- Document non-obvious business logic
- Clarify complex algorithms
- Note important gotchas or limitations
**Good Comment**:
```python
# IndieLogin requires state token to prevent CSRF attacks
state = generate_state_token()
# File operations must be atomic to prevent corruption
temp_path = file_path.with_suffix('.tmp')
temp_path.write_text(content)
temp_path.replace(file_path) # Atomic rename
```
**Bad Comment** (obvious from code):
```python
# Increment counter
counter += 1
# Create a note
note = Note(content)
```
**TODO Comments**: Acceptable for V1, should include context.
```python
# TODO: Add pagination when note count > 100
# TODO(username): Consider caching RSS feed (V2 feature)
```
---
## Error Handling
### Exceptions
**Prefer specific exceptions** over generic `Exception`.
**Good**:
```python
def get_note(slug: str) -> Note:
"""Get note by slug."""
if not slug:
raise ValueError("Slug cannot be empty")
note = db.query(slug)
if not note:
raise FileNotFoundError(f"Note not found: {slug}")
return note
```
**Bad**:
```python
def get_note(slug: str) -> Note:
if not slug:
raise Exception("Bad slug") # Too generic
# Silently return None instead of raising exception
note = db.query(slug)
return note # Could be None
```
### Error Messages
**Be specific and actionable**:
**Good**:
```python
raise ValueError(
f"Invalid slug '{slug}': must contain only lowercase letters, "
f"numbers, and hyphens"
)
raise FileNotFoundError(
f"Note file not found: {file_path}. "
f"Database may be out of sync with filesystem."
)
```
**Bad**:
```python
raise ValueError("Invalid input")
raise FileNotFoundError("File missing")
```
### Exception Handling
**Catch specific exceptions**, not bare `except:`.
**Good**:
```python
try:
note = create_note(content)
except ValueError as e:
logger.error(f"Invalid note content: {e}")
return {"error": str(e)}, 400
except FileExistsError as e:
logger.error(f"Duplicate slug: {e}")
return {"error": "Note already exists"}, 409
```
**Bad**:
```python
try:
note = create_note(content)
except: # Too broad, catches everything including KeyboardInterrupt
return {"error": "Something went wrong"}, 500
```
---
## Flask-Specific Patterns
### Route Decorators
**Pattern**: One route per function, clear HTTP methods.
**Good**:
```python
@app.route('/api/notes', methods=['GET'])
def list_notes():
"""List all published notes."""
notes = get_published_notes()
return jsonify([note.to_dict() for note in notes])
@app.route('/api/notes', methods=['POST'])
def create_note_endpoint():
"""Create a new note."""
data = request.get_json()
note = create_note(data['content'])
return jsonify(note.to_dict()), 201
```
**Bad**:
```python
@app.route('/api/notes', methods=['GET', 'POST']) # Multiple methods in one function
def notes():
if request.method == 'GET':
...
elif request.method == 'POST':
...
```
### Request Handling
**Validate input explicitly**:
**Good**:
```python
@app.route('/api/notes', methods=['POST'])
def create_note_endpoint():
"""Create a new note."""
data = request.get_json()
# Validate required fields
if not data or 'content' not in data:
return {"error": "Missing required field: content"}, 400
content = data['content'].strip()
if not content:
return {"error": "Content cannot be empty"}, 400
# Create note
try:
note = create_note(content)
return jsonify(note.to_dict()), 201
except ValueError as e:
return {"error": str(e)}, 400
```
---
## Testing Standards
### Test Naming
**Pattern**: `test_{function_name}_{scenario}`
**Good**:
```python
def test_create_note_success():
"""Test creating a note with valid content."""
pass
def test_create_note_empty_content():
"""Test creating a note with empty content raises ValueError."""
pass
def test_get_note_by_slug_not_found():
"""Test getting non-existent note raises FileNotFoundError."""
pass
```
### Test Structure
**Use Arrange-Act-Assert pattern**:
```python
def test_create_note_success():
"""Test creating a note with valid content."""
# Arrange
content = "This is a test note"
slug = "test-note"
# Act
note = create_note(content, slug=slug)
# Assert
assert note.slug == slug
assert note.content == content
assert note.published is False
```
### Fixtures
**Use pytest fixtures** for common setup:
```python
@pytest.fixture
def sample_note():
"""Create a sample note for testing."""
return create_note("Sample content", slug="sample")
def test_note_to_dict(sample_note):
"""Test converting note to dictionary."""
data = sample_note.to_dict()
assert data['slug'] == 'sample'
assert 'content' in data
```
---
## File Organization
### Module Structure
**Standard order within a Python file**:
1. Module docstring
2. Imports (standard → third-party → local)
3. Constants
4. Exception classes (if any)
5. Classes
6. Functions
7. Main execution block (if applicable)
**Example**:
```python
"""
Notes management module
This module handles note CRUD operations.
"""
# Standard library
import os
from datetime import datetime
from pathlib import Path
# Third-party
import markdown
from flask import current_app
# Local
from starpunk.database import get_db
from starpunk.utils import generate_slug
# Constants
MAX_NOTE_LENGTH = 10000
DEFAULT_PUBLISHED_STATUS = False
# Exception classes
class NoteError(Exception):
"""Base exception for note operations."""
pass
class DuplicateSlugError(NoteError):
"""Raised when note slug already exists."""
pass
# Classes
class Note:
"""Represents a note."""
pass
# Functions
def create_note(content: str) -> Note:
"""Create a new note."""
pass
def get_note(slug: str) -> Note:
"""Get note by slug."""
pass
```
---
## Anti-Patterns to Avoid
### Don't Repeat Yourself (DRY)
**Bad**:
```python
def get_note_as_html(slug):
note = db.query(slug)
html = markdown.markdown(note.content)
return html
def get_note_for_rss(slug):
note = db.query(slug)
html = markdown.markdown(note.content)
return html
```
**Good**:
```python
def get_note(slug: str) -> Note:
"""Get note by slug."""
return db.query(slug)
def render_markdown(content: str) -> str:
"""Render markdown to HTML."""
return markdown.markdown(content)
# Use in different contexts
def get_note_as_html(slug: str) -> str:
note = get_note(slug)
return render_markdown(note.content)
```
### Avoid Magic Numbers
**Bad**:
```python
if len(content) > 10000:
raise ValueError("Content too long")
time.sleep(86400) # What is 86400?
```
**Good**:
```python
MAX_NOTE_LENGTH = 10000
SECONDS_PER_DAY = 86400
if len(content) > MAX_NOTE_LENGTH:
raise ValueError(f"Content exceeds {MAX_NOTE_LENGTH} characters")
time.sleep(SECONDS_PER_DAY)
```
### Avoid Mutable Default Arguments
**Bad**:
```python
def create_note(content: str, tags: list = []): # Mutable default
tags.append('note') # Modifies shared default list
return Note(content, tags)
```
**Good**:
```python
def create_note(content: str, tags: Optional[List[str]] = None) -> Note:
if tags is None:
tags = []
tags.append('note')
return Note(content, tags)
```
### Avoid God Objects
**Bad**:
```python
class StarPunk:
"""Does everything."""
def create_note(self): pass
def render_html(self): pass
def generate_rss(self): pass
def authenticate(self): pass
def send_email(self): pass
# 50 more methods...
```
**Good**:
```python
# Separate concerns into focused modules
class Note:
"""Represents a note."""
pass
class NoteManager:
"""Manages note CRUD operations."""
pass
class FeedGenerator:
"""Generates RSS feeds."""
pass
```
---
## Code Quality Tools
### Black (Formatter)
**Required**: All code MUST be formatted with Black before commit.
```bash
# Format code
.venv/bin/black starpunk/ tests/
# Check formatting
.venv/bin/black --check starpunk/ tests/
```
### Flake8 (Linter)
**Required**: All code MUST pass flake8 checks.
```bash
# Lint code
.venv/bin/flake8 starpunk/ tests/
# Configuration in setup.cfg or pyproject.toml
```
**Configuration** (`setup.cfg`):
```ini
[flake8]
max-line-length = 88
extend-ignore = E203, W503
exclude =
.venv,
__pycache__,
data,
.git
```
### Mypy (Type Checker)
**Recommended**: Run mypy for type checking (optional for V1).
```bash
# Type check
.venv/bin/mypy starpunk/
```
**Configuration** (`pyproject.toml`):
```toml
[tool.mypy]
python_version = "3.11"
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = false # Set to true for strict mode
```
### Pytest (Testing)
**Required**: All code MUST have tests with >80% coverage.
```bash
# Run tests
.venv/bin/pytest
# With coverage
.venv/bin/pytest --cov=starpunk --cov-report=html tests/
```
---
## Pre-Commit Checklist
Before committing code, verify:
```bash
# 1. Format code
.venv/bin/black starpunk/ tests/
# 2. Lint code
.venv/bin/flake8 starpunk/ tests/
# 3. Run tests
.venv/bin/pytest
# 4. Check coverage
.venv/bin/pytest --cov=starpunk tests/
# 5. Type check (optional)
.venv/bin/mypy starpunk/
```
**All checks must pass** before code is committed.
---
## Summary
**Key Principles**:
1. Use Black for formatting (88 char lines)
2. Follow PEP 8 naming conventions
3. Add type hints to all function signatures
4. Write docstrings for all public APIs
5. Handle errors with specific exceptions
6. Keep functions focused and small
7. Test everything (>80% coverage)
8. Make code self-documenting
**Tools**:
- Black (required) - formatting
- Flake8 (required) - linting
- Pytest (required) - testing
- Mypy (recommended) - type checking
**Remember**: "Every line of code must justify its existence."
---
## References
- [PEP 8 - Style Guide](https://peps.python.org/pep-0008/)
- [PEP 257 - Docstring Conventions](https://peps.python.org/pep-0257/)
- [PEP 484 - Type Hints](https://peps.python.org/pep-0484/)
- [Black Documentation](https://black.readthedocs.io/)
- [Flake8 Documentation](https://flake8.pycqa.org/)
- [Mypy Documentation](https://mypy.readthedocs.io/)
- [Pytest Documentation](https://docs.pytest.org/)
- [Google Python Style Guide](https://google.github.io/styleguide/pyguide.html)