FreeCampus Python

Write Python People Can Follow

Improve names, layout, expressions, and function structure so a reader can verify a change without translating the program first.
python-foundations code-quality-maintainability readability pep8
Open in Colab
  • Level: Beginner project maintainer
  • Estimated time: 3–4 hours
  • You will learn: Make readability choices that expose represented values, units, control flow, and the next likely change while preserving behavior.
  • Practice in: The Midnight Museum project, a notebook for comparisons, and a local test runner

1. Readability reduces the work before the real work

A Midnight Museum team earns 30 points per clue, 25 points for a bonus clue, and loses 2 points for every minute after minute 45. You need to change the bonus from 25 to 40. Start with this valid implementation:

def calc(x):
    a = x[0] * 30
    b = 25 if x[2] else 0
    c = max(0, x[1] - 45) * 2
    return a + b - c


assert calc((6, 42, True)) == 205

Before editing, answer four questions:

  1. Which position contains elapsed minutes?
  2. Does b represent the presence of a bonus, the clue, or the awarded points?
  3. Can the result become negative?
  4. Which line must change, and how will you prove that ordinary attempts did not change accidentally?

The interpreter has enough information. A maintainer does not. The tuple positions and short names force the reader to reconstruct meaning before reasoning about the change.

Rewrite only the representation and names:

def score_attempt(clues_found, elapsed_minutes, found_bonus_clue):
    clue_points = clues_found * 30
    bonus_points = 25 if found_bonus_clue else 0
    overtime_minutes = max(0, elapsed_minutes - 45)
    overtime_penalty = overtime_minutes * 2
    return clue_points + bonus_points - overtime_penalty


assert score_attempt(6, 42, True) == 205

The second function is longer, but each line answers a domain question. A future change to overtime has a visible location. Length is not the goal; reducing unnecessary reconstruction is.

Name the value and its unit

A strong name usually combines the represented idea with information needed to use it safely:

Name What the reader still has to discover
t Everything
time Elapsed time, start time, deadline, or clock time? Which unit?
elapsed Which unit?
elapsed_minutes The represented duration and unit are visible

Units matter whenever two values of the same Python type are not interchangeable. elapsed_minutes, distance_km, and price_cents make a wrong calculation easier to spot even before the type lesson.

WarningLonger is not automatically clearer

number_of_clues_found_by_the_current_team repeats context already supplied by score_attempt. Prefer the shortest name that stays unambiguous in its scope.

2. Let conventions carry familiar information

PEP 8 gives Python projects a shared set of reading conventions. It is guidance for consistency, not a law that overrides clarity. PEP 8 itself says consistency within a project and within a function matters greatly, and that a recommendation can be ignored when following it would reduce readability.

Common naming shapes communicate a role before the reader opens the definition:

MAX_CLUES = 8
OVERTIME_START_MINUTES = 45


def score_attempt(clues_found, elapsed_minutes, found_bonus_clue):
    ...


class QuestResult:
    ...


def _validate_attempt(clues_found, elapsed_minutes):
    ...
  • lowercase_with_underscores is conventional for functions and variables.
  • CapitalizedWords identifies classes.
  • UPPER_CASE_WITH_UNDERSCORES signals a module-level value treated as a constant by the project.
  • One leading underscore marks an implementation detail such as _validate_attempt; it does not create security or runtime privacy.
  • Avoid inventing __double_underscore__ names. Python reserves that shape for documented language protocols.

A trailing underscore avoids a keyword collision without an obscure abbreviation:

def group_by_class(class_):
    return f"Gallery class: {class_}"


assert group_by_class("clockwork") == "Gallery class: clockwork"

Make booleans read as questions

Compare the branch conditions:

def bonus_points(bonus):
    return 25 if bonus else 0


def bonus_points(found_bonus_clue):
    return 25 if found_bonus_clue else 0

if found_bonus_clue can be read aloud as a yes/no statement. Names beginning with is_, has_, can_, or a past-tense action often help, but choose the wording that matches the domain. Avoid double negatives:

def can_enter_gallery(has_ticket, is_gallery_closed):
    return has_ticket and not is_gallery_closed


assert can_enter_gallery(True, False) is True

not is_not_open would make the reader perform an avoidable logic conversion.

Checkpoint: names and conventions

3. Shape the file so its groups are visible

Whitespace does not repair poor design, but consistent layout helps readers see where one idea ends and another begins. Put imports at the top, group standard library, third-party, and local imports, and keep one import per line unless a from import is clearer:

from dataclasses import dataclass
from pathlib import Path

import pytest

from midnight_museum.scoring import score_attempt

In an ordinary module, separate top-level classes and functions with two blank lines. Within a function, use a blank line to separate meaningful stages—not after every statement.

Long expressions are safest to wrap inside parentheses, brackets, or braces. The delimiters make continuation explicit and let every indentation level remain four spaces:

leaderboard_line = (
    f"{position}. {team_name} — "
    f"{score} points in {elapsed_minutes} minutes"
)

A trailing comma makes multi-line collections and calls easier to extend and produces smaller diffs:

quality_commands = [
    "ruff check src tests",
    "mypy src",
    "pytest -q",
]

Do not compress independent actions onto one physical line:

def record_score(scores, team, score):
    scores[team] = score
    return scores[team]


scores = {}
assert record_score(scores, "Moon Moths", 205) == 205

The equivalent scores[team] = score; return scores[team] is valid Python, but it hides two actions on a line and produces a worse location when one action fails.

Formatting exposes structure; it does not choose structure

A formatter can normalize spaces, wrapping, quotes, and trailing commas. It cannot decide that x[1] should become elapsed_minutes, that a tuple should be a structured record, or that one function owns too many responsibilities. Those choices require domain knowledge. Lesson 4 will show exactly what Ruff can and cannot automate.

4. Keep the main path easy to find

Nested branches make a reader hold unfinished conditions in memory:

def admission_message(has_ticket, is_gallery_open, age):
    if has_ticket:
        if is_gallery_open:
            if age >= 12:
                return "Enter the puzzle gallery"
            return "Enter with an adult"
        return "The gallery is closed"
    return "A ticket is required"

Guard clauses handle rejected cases first and leave the ordinary successful path at the lowest indentation level:

def admission_message(has_ticket, is_gallery_open, age):
    if not has_ticket:
        return "A ticket is required"
    if not is_gallery_open:
        return "The gallery is closed"
    if age < 12:
        return "Enter with an adult"
    return "Enter the puzzle gallery"

Both functions can be correct. The second is helpful when the rejected cases are independent and the final path is the main action. Guard clauses are not a rule to apply blindly: a small two-sided if/else can remain clearer when both branches have equal importance.

The guard-clause version closes rejected paths before the main result.

flowchart TD
  A[Admission request] --> B{Has ticket?}
  B -- No --> C[Require ticket]
  B -- Yes --> D{Gallery open?}
  D -- No --> E[Report closed]
  D -- Yes --> F{Age at least 12?}
  F -- No --> G[Enter with adult]
  F -- Yes --> H[Enter gallery]

Keep one level of detail in view

This function mixes scoring rules, sorting, and text presentation:

def leaderboard(attempts):
    rows = []
    for attempt in attempts:
        score = attempt["clues_found"] * 30
        if attempt["bonus_clue"]:
            score += 25
        score -= max(0, attempt["elapsed_minutes"] - 45) * 2
        rows.append((score, attempt["team"]))
    rows.sort(reverse=True)
    return "\n".join(f"{team}: {score}" for score, team in rows)

Do not split it merely to maximize the number of functions. Split when the parts have distinct reasons to change and useful names:

def score_attempt(attempt):
    clue_points = attempt["clues_found"] * 30
    bonus_points = 25 if attempt["bonus_clue"] else 0
    overtime_penalty = max(0, attempt["elapsed_minutes"] - 45) * 2
    return clue_points + bonus_points - overtime_penalty


def rank_attempts(attempts):
    scored = [(score_attempt(attempt), attempt["team"]) for attempt in attempts]
    return sorted(scored, reverse=True)


def render_leaderboard(attempts):
    return "\n".join(
        f"{team}: {score}" for score, team in rank_attempts(attempts)
    )

Now a scoring change has a named home, and presentation can change without editing the calculation. Later lessons will refine the record types and tie rule.

Checkpoint: layout and control flow

5. Prefer clarity over clever compression

A concise expression can be excellent when its shape is familiar:

active_teams = [
    attempt["team"]
    for attempt in attempts
    if attempt["clues_found"] > 0
]

Compression becomes expensive when it hides several rules:

scores = {
    attempt["team"]: max(
        0,
        attempt["clues_found"] * 30
        + (25 if attempt["bonus_clue"] else 0)
        - max(0, attempt["elapsed_minutes"] - 45) * 2,
    )
    for attempt in attempts
    if attempt["team"].strip()
}

The expression is valid, but validation, scoring, filtering, and construction compete for attention. A loop with named stages makes debugging easier:

scores = {}
for attempt in attempts:
    team_name = attempt["team"].strip()
    if not team_name:
        continue

    score = score_attempt(attempt)
    scores[team_name] = max(0, score)

Choose based on what the next reader must verify. A comprehension with one mapping and one filter is often clear. Several nested conditions, side effects, or independent domain rules usually deserve statements and names.

Comments should add missing reasons

This comment merely translates syntax:

# Add 25 to score.
score += 25

This one preserves information that the expression cannot show:

# The paper-map clue stays worth 25 points to match the printed rule cards.
score += 25

A stale comment is worse than no comment because it sends the maintainer toward a false contract. Keep the rule in tests or project documentation as well when it is public behavior. Unit 15 will teach documentation design in depth.

6. Review a change by behavior and readability

Suppose the requirement changes to “overtime begins after 50 minutes and costs 3 points per minute.” A controlled patch should:

  1. update or add behavior examples at minutes 50 and 51;
  2. rename a misleading threshold if necessary;
  3. change the threshold and rate in one visible calculation;
  4. rerun the focused tests; and
  5. inspect the diff for an unrelated style rewrite.
OVERTIME_START_MINUTES = 50
OVERTIME_PENALTY_PER_MINUTE = 3


def overtime_penalty(elapsed_minutes):
    overtime_minutes = max(0, elapsed_minutes - OVERTIME_START_MINUTES)
    return overtime_minutes * OVERTIME_PENALTY_PER_MINUTE


assert overtime_penalty(50) == 0
assert overtime_penalty(51) == 3

The names make both axes of the change explicit. The assertions establish the threshold boundary. A later type annotation will show that the values are integers, but only these names show that they are minutes and points.

Checkpoint: readable change evidence

7. Lab: make the museum scorer reviewable

Start from this behavior-correct but difficult function:

def make_board(xs):
    out = []
    for x in xs:
        n = x["c"] * 30 + (25 if x["b"] else 0) - max(0, x["m"] - 45) * 2
        out.append((max(0, n), x["t"]))
    out.sort(reverse=True)
    return "\n".join(f"{t}: {n}" for n, t in out)

Use these acceptance checks before and after every controlled change:

attempts = [
    {"t": "Moon Moths", "c": 6, "m": 42, "b": True},
    {"t": "Brass Bats", "c": 7, "m": 50, "b": False},
]

expected = "Moon Moths: 205\nBrass Bats: 200"
assert make_board(attempts) == expected
assert make_board([]) == ""

Complete these stages:

  1. Write a table explaining every current key and local name.
  2. Replace vague names with domain names without changing the record keys yet.
  3. Extract a scoring function whose intermediate values reveal the rule.
  4. Extract rendering only if its responsibility becomes clearer.
  5. Decide whether reverse=True expresses the tie behavior you want. Do not change the public behavior during this lab; record the concern for a future requirement.
  6. Run the assertions after each stage and keep one before/after diff.
  7. Explain three decisions that a formatter could not make for you.
Hint A: map the current keys

t is the team name, c is clues found, m is elapsed minutes, and b records whether the team found the bonus clue. Rename locals first; changing keys and structure simultaneously makes a failure harder to locate.

Hint B: extract the calculation

A useful extracted function can name clue_points, bonus_points, overtime_minutes, and overtime_penalty. Keep the final max(0, ...) because that is current observable behavior.

Hint C: inspect the tie rule

Sorting (score, team) tuples with reverse=True orders both score and team in descending order. Preserve it for this behavior-preserving lab, but write down whether an alphabetical ascending tie rule would be more understandable as a future behavior change.

Show one complete behavior-preserving refactor
def score_attempt(attempt):
    clue_points = attempt["c"] * 30
    bonus_points = 25 if attempt["b"] else 0
    overtime_minutes = max(0, attempt["m"] - 45)
    overtime_penalty = overtime_minutes * 2
    return max(0, clue_points + bonus_points - overtime_penalty)


def make_board(attempts):
    ranked_scores = []
    for attempt in attempts:
        team_name = attempt["t"]
        score = score_attempt(attempt)
        ranked_scores.append((score, team_name))

    ranked_scores.sort(reverse=True)
    return "\n".join(
        f"{team_name}: {score}" for score, team_name in ranked_scores
    )


attempts = [
    {"t": "Moon Moths", "c": 6, "m": 42, "b": True},
    {"t": "Brass Bats", "c": 7, "m": 50, "b": False},
]
assert make_board(attempts) == "Moon Moths: 205\nBrass Bats: 200"
assert make_board([]) == ""

This version keeps the public function name, input keys, output, zero floor, and descending tuple tie rule. Changing the data shape or tie policy would be a separate behavior/interface change with its own tests.

Key points

  • Readability is the effort required to verify meaning and make a safe change, not the fewest lines.
  • Names should expose domain roles and units without repeating obvious scope.
  • PEP 8 supplies shared conventions, but project consistency and clarity take priority over mechanical obedience.
  • Layout, guard clauses, and cohesive functions can reveal structure; a formatter cannot choose the right domain structure for you.
  • Prefer a clear loop when compression hides several rules or debugging points.
  • Protect behavior before renaming or restructuring, and keep each diff focused.

References

Continue to Lesson 2: Make Contracts Visible with Type Annotations

Back to top