# 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 = 'Link' # 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)