With v0.21 the project is documented, it survives errors, it has a history and eight tests that verify it in hundredths of a second. One last thing is missing, and it is what separates a program that works from one you are proud of: how it is written. Because a program is written once and read dozens of times: every time you go back to it, every time you hunt a bug, every time someone new joins the project. In EasyTask there are functions in interface.py that have grown to forty lines, the number 52 appears loose in three places, there are names inherited from when this was an exercise and blocks of code that repeat with two words changed. This lesson fixes all of that: Python's official style guide, the tools that apply it for you, the art of naming things, the code smells that give a problem away and a catalogue of refactorings that improve the code without changing what it does — leaning, precisely, on the tests you wrote in the previous lesson.

Contents

  1. PEP 8: Python's style guide
  2. Naming conventions
  3. The Zen of Python
  4. Tools: formatters and linters
  5. Naming things well
  6. Code smells
  7. What refactoring is (and what it is not)
  8. Catalogue of refactorings
  9. EasyTask v1.0: reviewed, formatted and refactored
  10. Common mistakes and tips
  11. Exercises
  12. Conclusion

  1. PEP 8: Python's style guide

PEP 8 is the official document that defines how Python is written. It changes nothing about behaviour: it defines the look, and its value lies in everybody writing the same way, so any piece of Python code feels familiar from the first minute. These are its practical rules:

Rule How it is done Example
Indentation 4 spaces, never tabs return total
Line length 79 characters maximum (many projects use 88) Break the line before you go over
Blank lines 2 between top-level functions or classes, 1 between methods Separate the ideas
Spaces around operators One on each side: a = b + c, not a=b+c total = days * rate
Spaces in arguments No space around the = of a default value def f(days=1):
Commas A space after, never before f(a, b), not f(a , b)
Imports One per line and at the top of the file import json
Import order Standard → third-party → your own, separated by a blank line See the example
# 1. Standard library
import json
import logging
from pathlib import Path

# 2. Third-party packages (none here: EasyTask uses none)
import pytest

# 3. The project's own modules
from .model import Task, PRIORITIES
from .agenda import Agenda

That order is not a whim: looking at a file's header tells you at a glance what depends on the outside world and what belongs to the project. And an important note about line length: the limit exists so you can read two files side by side and so Git's diff stays readable, not out of nostalgia for old screens.

  1. Naming conventions

The course has been using these conventions since Variables and data types; now we give them their official names:

Style Used for Examples from the project
snake_case Variables, functions and methods remaining_days, summary_by_assignee
PascalCase Classes Task, RecurringTask, Agenda
UPPERCASE Constants PRIORITIES, TEAM, WIDTH
_private Internal use; "do not touch this from outside" _tasks, _normalise
module.py Modules and packages: short and lowercase model.py, storage.py

The leading underscore deserves a clarification, because in Python it prevents nothing: agenda._tasks is perfectly accessible. It is an agreement between programmers, not a technical barrier: it means "this is an internal detail, it may change tomorrow and you should not depend on it". That agreement is exactly what let us change Agenda's internal list in 07-03 without breaking anyone.

  1. The Zen of Python

Type import this in the interpreter and nineteen aphorisms summing up the language's philosophy will appear. These five are the ones that will serve you best:

  • "Explicit is better than implicit". That is why import formatting beats from formatting import *, and why a function that returns Task | None says so in its signature instead of letting you find out when it fails.
  • "Simple is better than complex". A linear search over a list of 200 tasks is the right solution; the index and the binary tree are complexity nobody asked for.
  • "Readability counts". It is the whole reason this lesson exists: between two equally valid solutions, the one that reads better wins.
  • "Errors should never pass silently". Exactly what we said in 08-02 about except: pass.
  • "There should be one obvious way to do it". That is why the whole project uses a single docstring style and a single way of validating.

They are not mandatory rules but tie-breakers. When you hesitate between two ways of writing something, ask yourself which of the two is more explicit, simpler and more readable; it is almost always the same one.

  1. Tools: formatters and linters

The best part of all of the above is that hardly any of it has to be done by hand. There are two families of tools: formatters, which rewrite the code so it follows the style, and linters, which analyse it and warn about problems.

Tool Type What it does
black Formatter Reformats the whole file. Not configurable: no argument possible
ruff Linter (and formatter) Detects style and code problems. Extremely fast
flake8 Linter The classic: checks PEP 8 and common mistakes
pylint Linter The most exhaustive and the fussiest; it scores your code
mypy Type checker Verifies the annotations from 08-01 without running the program
pip install black ruff mypy
black easytask/              # reformats the whole package
black --check easytask/      # only says whether it would change things
ruff check easytask/         # lists the problems it found
ruff check --fix easytask/   # fixes the ones it can fix on its own
mypy easytask/               # checks that the types are consistent

The difference between the two types matters: black has no opinion about your code, only about its look; it removes arguments about quotes, spaces and line breaks forever. ruff does have opinions: it will tell you that you imported something you do not use, that a variable is defined and never read or that a function is too complex. In VS Code they plug in with two clicks: install the Python extension, choose black as the formatter and turn on "Format on Save"; from then on style stops being your problem.

  1. Naming things well

It is the only thing in this lesson no tool can do for you, and probably what most affects readability. A good name says what something is or what it does, with no cryptic abbreviations and no redundant information:

Before After Why
d, x, tmp days, task, pending A single letter is only fine in very short loops
list_of_team_tasks tasks If the context already says it, it is surplus
process(data) normalise_priority(text) "Process" means nothing
flag is_completed Booleans read like a question
get_t() find(title) The name must say what it returns
calculate() calculate_remaining_days() Calculate what?

Three criteria that sum it all up: booleans start with a state verb (is_, has_, there_is_), functions start with a verb that says what they do (find, save, normalise) and collections are plural (tasks, assignees). And if a name needs a comment beside it to be understood, the name is wrong.

  1. Code smells

A code smell is not an error: the program works. It is a sign that something is going to cause trouble soon. These are the seven you will meet over and over:

Smell How to spot it Fix
Function too long It does not fit on the screen; it does several things Extract function
Too many parameters Five or more; nobody remembers the order Group them in an object or use a dataclass
Duplicated code The same block with two words changed Extract a function with parameters
Magic numbers and strings 52, 0.21, "high" loose in the code Replace with a named constant
Deep nesting Three or more if statements inside each other Guard clause
Cryptic names d, tmp2, process Rename
Explanatory comment A comment that clarifies a confusing stretch Extract that stretch into a well-named function

The last one is the subtlest and the most useful to learn. A comment like # here we work out the surcharge and apply the discount is pointing out, without meaning to, that the block is a function that does not have a name yet. Extract it and call it apply_surcharge_and_discount(): the comment disappears because it is no longer needed, and the chunk becomes testable in isolation into the bargain.

  1. What refactoring is (and what it is not)

Refactoring is changing the code's internal structure without changing what it does at all. That second part is the complete definition, and two consequences follow from it that are worth being very clear about:

  • It is not refactoring to add a feature, fix a bug or improve performance. All of that changes the behaviour; refactoring does not.
  • You never do both things at once. If you refactor and add a function in the same commit and something breaks, you will not know what broke it. First one thing, then the other, each with its own commit (08-03).

And from there comes the golden rule: before refactoring you must have tests. The tests from 08-04 are what answer the only question that matters while you reorganise the code — "does it still do exactly the same thing?" — and what make the operation safe. The cycle is always this: tests green, one small change, run the tests, commit; and start again. If at some point they go red, you know with complete certainty what caused it, because you have just made a single change.

  1. Catalogue of refactorings

These six solve the vast majority of cases. Extract function breaks a long block into named pieces:

# BEFORE: one block that does three things
def show_card(task):
    print("=" * 52)
    print(f"{task.title.upper():^52}")
    print("=" * 52)
    bar = "#" * int(task.percentage() / 10) + "-" * (10 - int(task.percentage() / 10))
    print(f"Progress: [{bar}] {task.percentage():.0f}%")

# AFTER: each idea, with its own name
def header(text: str) -> str:
    """Return the text centred between separator lines."""
    return f"{'=' * WIDTH}\n{text.upper():^{WIDTH}}\n{'=' * WIDTH}"

def progress_bar(percentage: float) -> str:
    """Return a 10-cell bar for that percentage."""
    filled = int(percentage / 10)
    return "#" * filled + "-" * (10 - filled)

Extract explanatory variable turns an unreadable expression into something that explains itself, and replace the magic number with a constant does the same for loose values:

# BEFORE
if task.days > 20 and task.priority == "high" and not task.completed:
    total *= 0.9

# AFTER
LONG_PROJECT_DAYS = 20
LONG_DISCOUNT = 0.9

is_long_urgent_project = (task.days > LONG_PROJECT_DAYS
                          and task.priority == "high"
                          and not task.completed)
if is_long_urgent_project:
    total *= LONG_DISCOUNT

Invert the condition and use a guard clause flattens deep nesting, which is the smell that is hardest to read:

# BEFORE: three levels of nesting
def save(agenda, path):
    if agenda is not None:
        if len(agenda) > 0:
            if path.parent.exists():
                write_json(agenda, path)

# AFTER: the impossible cases are ruled out first and you leave
def save(agenda, path):
    if agenda is None or len(agenda) == 0:
        return
    if not path.parent.exists():
        raise FileNotFoundError(f"The folder {path.parent} does not exist")
    write_json(agenda, path)

Unify duplicates and replace a chain of if statements with a dispatch dictionary close the catalogue. The second is especially profitable in menus:

# BEFORE: a chain that grows with every new option
if option == "1":
    create_task(agenda)
elif option == "2":
    list_tasks(agenda)
elif option == "3":
    complete_task(agenda)

# AFTER: a dispatch dictionary (functions as values, 04-05)
ACTIONS = {"1": create_task, "2": list_tasks, "3": complete_task}

action = ACTIONS.get(option)
if action is not None:
    action(agenda)

Look at what the second version has gained: adding a new option is one line in the dictionary instead of two in the chain, the set of valid options lives in a single place and ACTIONS.keys() can be used directly to validate the user's input. It is the same idea of functions as values from 04-05, applied to the real problem.

  1. EasyTask v1.0: reviewed, formatted and refactored

First, the tools, with the tests green before we start:

$ pytest -q
8 passed in 0.05s

$ black easytask/ tests/
reformatted easytask/interface.py
reformatted easytask/agenda.py
All done! 2 files reformatted, 4 files left unchanged.

$ ruff check easytask/
easytask/interface.py:12:1: F401 [*] `csv` imported but unused
easytask/interface.py:48:5: C901 `handle_option` is too complex (14)
easytask/storage.py:7:1: E501 Line too long (92 > 88)
Found 3 errors (1 fixable with `--fix`).

ruff has pointed at exactly the two functions we knew were wrong: handle_option, with its chain of fourteen elif clauses, and show_card, with the formatting block. The first is fixed with the dispatch dictionary and the second by extracting header() and progress_bar(), exactly as you have just seen. After each change, the check that justifies everything:

$ pytest -q
8 passed in 0.05s

$ ruff check easytask/
All checks passed!

$ git commit -am "Refactor interface.py: dictionary dispatch and extraction"
$ git tag -a v1.0 -m "EasyTask 1.0: documented, robust, versioned and tested"

The eight tests are still green without a single one of them being touched, and that is the proof that the refactoring really was a refactoring: the behaviour is identical. The project thus reaches v1.0, the number that under 08-01's semantic versioning means "this is stable now". Look where it comes from:

Version What EasyTask was
v0.1 (mod. 2) Four variables and a print with f-strings
v0.4 (mod. 3) A menu in a while loop with conditionals
v0.8 (mod. 4) Functions, a main() and the logic separated from the interface
v0.11 (mod. 5) Lists and dictionaries, with saving to JSON and CSV
v0.13 (mod. 6) Searching, sorting and an awareness of cost
v0.17 (mod. 7) Classes Task and Agenda in the easytask/ package
v0.18 Documented: docstrings, type annotations and README.md
v0.19 Robust: try/except, InvalidTask and logging
v0.20 Versioned: Git repository with history and .gitignore
v0.21 Tested: a tests/ folder with eight green tests
v1.0 Formatted with black, reviewed with ruff and refactored

Common Mistakes and Tips

  • Arguing about style. It is wasted time: install black, turn it on at save time and spend that energy on what actually matters.
  • Refactoring without tests. That is rewriting blind. First the tests green, then the change.
  • Refactoring and changing the behaviour in the same commit. If something breaks, you will not know what it was. One commit, one intention.
  • The giant refactoring. Small changes with the tests run between one and the next; never a three-day rewrite.
  • Confusing "short" with "readable". A line of code you have to read three times is no better than three clear lines.
  • Following the linter blindly. It advises, it does not command. If you have a reason to leave something as it is, leave it (and note the why in a comment).
  • Tip: apply the campsite rule. Leave every file you touch a little better than you found it: a name, a constant, an extracted function. In a few months the whole project is clean without you ever having stopped to clean it.

Exercises

Exercise 1: Style and names

Rewrite this fragment applying PEP 8, the naming conventions and whatever constants are needed:

import json,csv
def PROC(l,f):
  r=[]
  for x in l:
    if x['p']=='high' and x['d']>20:r.append(x)
  return r

Exercise 2: Spotting smells

List this function's code smells, say which refactoring matches each one and rewrite it:

def report(tasks, kind):
    if kind == "short":
        for t in tasks:
            print(f"{t.title[:30]:<30} {t.assignee:<10}")
    elif kind == "long":
        for t in tasks:
            print(f"{t.title[:30]:<30} {t.assignee:<10} {t.days:>3} days")
    elif kind == "csv":
        for t in tasks:
            print(f"{t.title},{t.assignee},{t.days}")

Exercise 3: Guard clause and dispatch

Refactor this function using a guard clause and a dispatch dictionary, and explain why the result is easier to extend:

def apply_action(task, action):
    if task is not None:
        if not task.completed:
            if action == "complete":
                task.complete()
            elif action == "postpone":
                task.days += 1
            elif action == "urgent":
                task.priority = "high"

Solutions

Solution 1.

import csv
import json

LONG_PROJECT_DAYS = 20
URGENT_PRIORITY = "high"


def filter_long_urgent(tasks: list[dict]) -> list[dict]:
    """Return the high-priority tasks that go over 20 days."""
    return [
        task
        for task in tasks
        if task["priority"] == URGENT_PRIORITY
        and task["days"] > LONG_PROJECT_DAYS
    ]

There are seven changes: 4-space indentation, one import per line and in alphabetical order, a snake_case and descriptive function name (PROC says nothing and on top of that looked like a class), variables with full names, the 'p' and 'd' keys made readable, the magic numbers turned into constants and the one-line if unfolded. Oh, and the f parameter was not used: ruff would have caught it instantly.

Solution 2.

There are three smells: duplicated code (the loop is repeated three times identically), a chain of if statements over one value (a candidate for a dispatch dictionary) and magic strings ("short", "long", "csv" loose in the code). The matching refactoring is to extract the formatting of a line into functions and dispatch by dictionary:

FORMATS = {
    "short": lambda t: f"{t.title[:30]:<30} {t.assignee:<10}",
    "long": lambda t: f"{t.title[:30]:<30} {t.assignee:<10} {t.days:>3} days",
    "csv": lambda t: f"{t.title},{t.assignee},{t.days}",
}

def report(tasks: list[Task], kind: str = "short") -> None:
    """Print the task report in the given format."""
    format_line = FORMATS.get(kind)
    if format_line is None:
        raise ValueError(f"Invalid format: {kind!r}. Use {list(FORMATS)}.")
    for task in tasks:
        print(format_line(task))

The loop appears only once and the only thing that varies — the line's format — lives in the dictionary. Adding a new format is now one line, the valid formats are in a single place and the error message lists them by itself. It is exactly the idea of functions as values from 04-05 put to work.

Solution 3.

def apply_action(task: Task | None, action: str) -> None:
    """Apply an action to a pending task."""
    if task is None or task.completed:         # guard clause
        return
    ACTIONS[action](task)

ACTIONS = {
    "complete": lambda t: t.complete(),
    "postpone": lambda t: setattr(t, "days", t.days + 1),
    "urgent": lambda t: setattr(t, "priority", "high"),
}

The guard clause rules out at the start the cases where there is nothing to do, and the function's real body is left with no nesting: it reads top to bottom without holding conditions in your head. And extending it is trivial: a new action is one entry in ACTIONS, without touching the function. In a real project it is worth going one step further and replacing those lambdas with setattr by methods on Task (postpone(), mark_urgent()), because a task's behaviour belongs to the Task class: it is the lesson of 07-02 applied here.

Conclusion

Code is read many more times than it is written, and that is why style is not cosmetics. PEP 8 fixes the basics — 4 spaces of indentation, lines under 79 (or 88) characters, blank lines that separate ideas, spaces around operators and imports ordered as standard, third-party and your own — and the naming conventions give an official name to what the course had been doing since 02-01: snake_case for variables and functions, PascalCase for classes, UPPERCASE for constants and _private as an agreement between programmers that Python does not enforce. The Zen of Python (import this) supplies the tie-breakers: explicit is better than implicit, simple is better than complex, readability counts, errors should never pass silently and there should be one obvious way to do things. Nearly all of that is applied for you by the tools: black reformats without argument, ruff, flake8 and pylint detect problems, mypy checks the annotations from 08-01, and VS Code runs it all on save. The only thing that cannot be automated is names: verbs for functions, plural for collections, is_/has_ for booleans and never an abbreviation that needs a comment. Code smells — function too long, too many parameters, duplication, magic numbers and strings, deep nesting, cryptic names and the comment that explains a confusing stretch — point at where to act. And refactoring means improving the structure without changing the behaviour, never mixed with anything else in the same commit and always with green tests before you start: extract function, extract explanatory variable, replace magic number with a constant, invert the condition with a guard clause, unify duplicates and replace the chain of if statements with a dispatch dictionary.

EasyTask is v1.0: formatted with black, reviewed with ruff, with interface.py refactored and with the same eight green tests, which is the proof that nothing changed on the outside. And with that, module 8 closes and, in fact, so does the whole technical journey of the course. You started not knowing what a variable was and now you have the language (types, operators, input and output), the control structures, the functions, the data structures with their persistence in files, the search and sorting algorithms with their cost, object-oriented programming with modules and packages, and this module's five professional tools: documentation, debugging and error handling, version control, automated testing and refactoring. That is exactly the kit a programmer works with.

Only one thing is left to do, and it is the most important of all: building something whole from scratch, on your own. Until now every piece of EasyTask came proposed; in module 9 you choose the project, design it, plan it, implement it, test it and present it. We start in Project definition, deciding what is worth building and — just as importantly — where to draw the line so you can finish it.

© Copyright 2026. All rights reserved