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

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):

  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:

# 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 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:

"""
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:

  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:

"""
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:

  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