19 KiB
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
Code Formatting
Automated Formatting with Black
Decision: All Python code MUST be formatted with Black.
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:
# 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):
[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:
# Fits in 88 characters, readable
def create_note(content: str, published: bool = False) -> Note:
"""Create a new note with the given content."""
...
Acceptable Exception:
# 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:
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:
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):
- Standard library imports
- Third-party imports
- Local application imports
Within each group: Alphabetically sorted
Style: One import per line (except from imports)
Good:
# 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:
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:
name = "StarPunk"
description = "A minimal CMS"
html = '<a href="/note">Link</a>' # Single quotes to avoid escaping
Bad:
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:
class Note:
pass
class SessionManager:
pass
class MicropubEndpoint:
pass
Bad:
class note: # lowercase
class session_manager: # snake_case
class Micropub_Endpoint: # Mixed
Functions and Methods
Pattern: lowercase_with_underscores
Good:
def create_note(content):
pass
def get_note_by_slug(slug):
pass
def generate_rss_feed():
pass
Bad:
def createNote(): # camelCase
def GetNoteBySlug(): # PascalCase
def generate_RSS_feed(): # Mixed case
Variables
Pattern: lowercase_with_underscores
Good:
note_count = 10
file_path = Path("data/notes")
session_token = generate_token()
Bad:
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:
MAX_NOTE_LENGTH = 10000
DEFAULT_SESSION_LIFETIME = 30
INDIELOGIN_URL = "https://indielogin.com"
Bad:
max_note_length = 10000 # lowercase
MaxNoteLength = 10000 # PascalCase
Private Members
Pattern: Single leading underscore _name for internal use
Good:
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:
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
typingmodule for complex types:Optional,List,Dict,Tuple - Python 3.11+ can use
list[Note]instead ofList[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:
"""
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:
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:
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:
@property
def html(self) -> str:
"""Rendered HTML content from markdown."""
return markdown.markdown(self.content)
Short Functions: One-line docstring is acceptable.
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:
# 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):
# Increment counter
counter += 1
# Create a note
note = Note(content)
TODO Comments: Acceptable for V1, should include context.
# 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:
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:
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:
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:
raise ValueError("Invalid input")
raise FileNotFoundError("File missing")
Exception Handling
Catch specific exceptions, not bare except:.
Good:
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:
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:
@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:
@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:
@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:
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:
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:
@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:
- Module docstring
- Imports (standard → third-party → local)
- Constants
- Exception classes (if any)
- Classes
- Functions
- Main execution block (if applicable)
Example:
"""
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:
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:
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:
if len(content) > 10000:
raise ValueError("Content too long")
time.sleep(86400) # What is 86400?
Good:
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:
def create_note(content: str, tags: list = []): # Mutable default
tags.append('note') # Modifies shared default list
return Note(content, tags)
Good:
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:
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:
# 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.
# Format code
.venv/bin/black starpunk/ tests/
# Check formatting
.venv/bin/black --check starpunk/ tests/
Flake8 (Linter)
Required: All code MUST pass flake8 checks.
# Lint code
.venv/bin/flake8 starpunk/ tests/
# Configuration in setup.cfg or pyproject.toml
Configuration (setup.cfg):
[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).
# Type check
.venv/bin/mypy starpunk/
Configuration (pyproject.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.
# Run tests
.venv/bin/pytest
# With coverage
.venv/bin/pytest --cov=starpunk --cov-report=html tests/
Pre-Commit Checklist
Before committing code, verify:
# 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:
- Use Black for formatting (88 char lines)
- Follow PEP 8 naming conventions
- Add type hints to all function signatures
- Write docstrings for all public APIs
- Handle errors with specific exceptions
- Keep functions focused and small
- Test everything (>80% coverage)
- 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."