Style Guide

How code in Pengin-Pi-3 is written. These rules apply to the core and to any contribution submitted to it. Pull requests that don't follow them will be sent back for changes.

The short version: PEP 8, type hints everywhere, docstrings on everything public, classes over loose functions, small files, small templates, and names that say exactly what a thing is.


Python

PEP 8

Follow PEP 8. Four-space indents, snake_case functions and variables, PascalCase classes, UPPER_CASE constants, imports grouped standard library, then third party, then project.

A linter catches most of it before review does. ruff check or flake8 both work.

Type hints

Annotate every function and method — every parameter and every return value:

def display_title_for_user(user: User, department: Group | None = None) -> str:
    ...

def get_managed_groups(user: User) -> QuerySet[Group]:
    ...

Use modern syntax: str | None, not Optional[str]; list[str], not List[str]. Return None explicitly in the annotation when a function returns nothing.

Annotate properties too:

@property
def is_wiki_root(self) -> bool:
    return self.wiki_root_id is not None and self.wiki_root_id == self.id

Docstrings

Every public module, class, method, and function gets a docstring, per PEP 257. One-line summary first, in the imperative, ending with a period. A blank line, then detail when it's needed:

def save_history(self, user: User) -> None:
    """Snapshot this object's current database state into its history table.

    Re-fetches the row by primary key rather than reading self, because a
    ModelForm has already applied cleaned_data to the instance by the
    time most callers reach this.
    """

Say what it does and anything a caller would get wrong without being told. Don't restate the signature.

Comments

The codebase explains why, not what. A comment that says # loop over users adds nothing. A comment that says what went wrong without this line, or why the obvious approach doesn't work, saves the next person from undoing it. See main/models/mixins.py and util/slug_dynamic_data.py for the style.


File headers

Every Python file opens with the project banner, the file's path, a short description, the license, and its authors:

# Pengin-Pi-3 — Pengin Open Source
# main/views/wiki.py
#
# Wiki pages are plain Slugs rendered through a wiki layout. SlugView
# delegates here once it resolves a Slug with wiki_root set.
#
# Copyright (C) 2026 Tobu Pengin, L.L.C. and contributors
# Authors:
#   Jane Doe <jane@example.com>
#
# SPDX-License-Identifier: GPL-3.0-or-later
  • Path — so the file identifies itself when pasted, excerpted, or read outside the repo
  • Description — what the module is for, and anything a reader needs before the first line of code
  • Copyright — the year the file was created
  • Authors — add yourself when you make a substantive change to a file. Fixing a typo doesn't count; rewriting a function does.
  • SPDX identifier — a machine-readable license tag. It survives when code is copied out of the repository in fragments, which the LICENSE file at the root doesn't.

Headers are being added across the codebase over time. Any file you touch should leave with one.


Naming

Names say what a thing is or does, as close to its purpose as possible, without being verbose.

Classes

A class is named for what it controls or provides. A mixin is a mixin and is named as one:

  • RateLimitedPostMixin — rate limits POST requests
  • RecaptchaRequiredMixin — requires a reCAPTCHA token
  • HistoryMixin — adds history
  • StaffRequiredMixin — requires staff

Mixins end in Mixin. Views end in View. Forms end in Form. Abstract bases start with Abstract. A reader should know what a class is from its name without opening the file.

Models

The model is named for what it represents: Slug, TeamRole, Subscription. Singular, never plural.

Fields describe the data they hold, close to purpose and short:

  • wiki_body, not wiki_markdown_body_content
  • changed_at, not timestamp_of_change
  • is_manager_role, not manager

Booleans read as yes/no questions: is_dynamic, requires_recaptcha, is_blog_author_role.

Properties and methods

Short and self-explanatory. is_wiki_root, get_absolute_url(), save_history().

Private names

Anything not meant for use outside its module gets a leading underscore — _resolve_slug_path(), _BLOCK_ROOTS, _WikiLinkRenderer. That applies to functions, variables, constants, and classes alike.

A leading underscore is a contract: other modules don't import it, and it can change without notice.


Classes over functions

Classes are how this project organizes code. Class inheritance and dunder methods are expected, not optional.

  • Group related behavior into a class rather than a set of loose functions
  • Share behavior through inheritance and mixins rather than copying it
  • Build on the project's existing bases — HistoryMixin, AbstractHistory, SitemapEntry, the auth and security mixins — rather than reimplementing what they do

Dunder methods

Implement the dunder methods that make an object behave naturally.

Every model defines __str__, returning something a person can read in the admin and in logs:

def __str__(self) -> str:
    return self.name

Add __repr__ where debugging output matters, __eq__ and __hash__ for value-like objects, __iter__ and __len__ for collections, __call__ for objects that act like functions. If you're writing a method called equals, to_string, or length, there's a dunder for it.

Standalone helper functions are fine in util/ where a class would add nothing — a pure function that takes a value and returns a value. Anything with state, configuration, or more than one related operation becomes a class.


Views

Class-based views only. Function views are not accepted.

class ReportDetailView(StaffRequiredMixin, RateLimitedGetMixin, View):
    """Show a single report to staff."""

    def get(self, request: HttpRequest, pk: uuid.UUID) -> HttpResponse:
        ...

Compose behavior from mixins, with View last in the bases. See Mixins.

A plain helper that takes a request and returns a response, called from a view rather than routed to directly, is not a view. Mark it private with a leading underscore.


Models

Every model:

  • Uses a UUID primary key:

    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    
  • Carries historyHistoryMixin on the model and a paired AbstractHistory model beside it. Required by default. Don't submit a model without them. A better auditing system is planned; until it exists, this is the system. See History and Auditing.

  • Defines __str__

  • Has a docstring saying what it represents

  • Annotates every method and property

Field declarations are already typed by their field class, so annotating the fields themselves is optional.

Opt into the sitemap with SitemapEntry if the model has public pages. See SEO and Sitemaps.


File size and modules

Keep Python files under 350 lines.

  • Models and views: mandatory. A models or views file that reaches 350 lines must be converted into a package.
  • Everything else: strongly recommended. Past 350 lines, consider whether the file is doing more than one job.

Converting a file to a package

main/views/reports.py            →   main/views/reports/
                                       __init__.py
                                       detail.py
                                       list.py
                                       _helpers.py

Split by responsibility, not arbitrarily at line 350. Then expose the public names from __init__.py, so imports elsewhere don't change:

# main/views/reports/__init__.py
from .detail import ReportDetailView
from .list import ReportListView

__all__ = ["ReportDetailView", "ReportListView"]

Existing code that did from main.views.reports import ReportDetailView keeps working without modification — that's the point of re-exporting.

Anything deliberately internal stays unexposed and keeps its leading underscore — _helpers.py, _resolve_slug_path(). Don't re-export private names.

main/models/ and main/models/users/ are both examples already in the codebase.


Templates

The rule for templates is one question: can another template use this doohickey?

If yes, it belongs in its own file in one of the shared folders, not inline in the page that happened to need it first.

Keep them small

A template does one thing. A page template assembles components; it doesn't contain them. A 300-line template is usually five templates that haven't been separated yet.

Folders

  • layout/, layouts/ — page skeletons that define blocks
  • sections/ — full-width page sections: a hero, a feature grid, an article with an image
  • components/ — self-contained pieces used inside sections: a form, a tile, a carousel
  • widgets/ — interactive controls: a slot picker, a sort toggle, a formset
  • macros/ — reusable markup defined with django-macros
  • js/ — script includes

Feature-specific templates live in a folder named for the feature — wiki/, staff/, slug/ — and should be built from the shared pieces.

A new shared folder is fine when a real group of templates emerges. Don't create one for a single file.

Names

Template names describe what they render, in snake_case, in the order a person sees it on the page:

left_column_article_right_column_image_section.html
ui_box_nav_bar_edit_button.html
hero_video_centered_title.html

A long name that says exactly what's in the file beats a short one that needs opening. The goal is a library someone can browse by filename.

Use full words — column, not col; image, not img.

Writing reusable templates

Pass what a component needs explicitly, and isolate it from the caller's context with only:

{% include "components/tile.html" with title=product.name image=product.cover only %}

Without only, a component silently depends on variables from whichever page included it first, and breaks when included anywhere else.

Document what a template expects at the top:

{# components/tile.html                                     #}
{# Expects: title (str), image (path), url (optional str)   #}
  • Extend layout.html or a shipped layout, using the existing block names
  • Bootstrap 5 classes; no custom CSS where a utility class exists
  • {% load static %} and {% static %} — never url_for

See Template Reference.


Checklist

  • [ ] PEP 8, linter clean
  • [ ] Every function and method annotated, parameters and return
  • [ ] Docstrings on public modules, classes, methods, and functions
  • [ ] File header with banner, path, description, license, and authors
  • [ ] Names describe purpose; mixins end in Mixin
  • [ ] Private names start with _
  • [ ] Class-based views only
  • [ ] Models have UUID keys, HistoryMixin and AbstractHistory, __str__
  • [ ] Models and views files under 350 lines, or converted to a package
  • [ ] Package __init__.py exposes the public names
  • [ ] Reusable template pieces extracted to a shared folder
  • [ ] Template names in descriptive snake_case
  • [ ] Components included with only

Pages Here

No sub-pages yet.

Page Info

Wiki: Docs

Created on Sep 21, 2026 by Tobu Pengin, L.L.C.

Maintainers

Editor Last Activity
Tobu Pengin, L.L.C. creator Sep 21, 2026