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
LICENSEfile 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 requestsRecaptchaRequiredMixin— requires a reCAPTCHA tokenHistoryMixin— adds historyStaffRequiredMixin— 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, notwiki_markdown_body_contentchanged_at, nottimestamp_of_changeis_manager_role, notmanager
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 history —
HistoryMixinon the model and a pairedAbstractHistorymodel 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
modelsorviewsfile 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 blockssections/— full-width page sections: a hero, a feature grid, an article with an imagecomponents/— self-contained pieces used inside sections: a form, a tile, a carouselwidgets/— interactive controls: a slot picker, a sort toggle, a formsetmacros/— reusable markup defined withdjango-macrosjs/— 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.htmlor a shipped layout, using the existing block names - Bootstrap 5 classes; no custom CSS where a utility class exists
{% load static %}and{% static %}— neverurl_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,
HistoryMixinandAbstractHistory,__str__ - [ ] Models and views files under 350 lines, or converted to a package
- [ ] Package
__init__.pyexposes the public names - [ ] Reusable template pieces extracted to a shared folder
- [ ] Template names in descriptive
snake_case - [ ] Components included with
only
Related
- Contributing — where code belongs
- Mixins — the mixin library
- History and Auditing — the required history pattern
- Architecture — how the core is organized
Contents
- Style Guide
- Python
- PEP 8
- Type hints
- Docstrings
- Comments
- File headers
- Naming
- Classes
- Models
- Properties and methods
- Private names
- Classes over functions
- Dunder methods
- Views
- Models
- File size and modules
- Converting a file to a package
- Templates
- Keep them small
- Folders
- Names
- Writing reusable templates
- Checklist
- Related
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 |