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
- PEP 8: Python's style guide
- Naming conventions
- The Zen of Python
- Tools: formatters and linters
- Naming things well
- Code smells
- What refactoring is (and what it is not)
- Catalogue of refactorings
- EasyTask v1.0: reviewed, formatted and refactored
- Common mistakes and tips
- Exercises
- Conclusion
- 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 AgendaThat 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.
- 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.
- 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 formattingbeatsfrom formatting import *, and why a function that returnsTask | Nonesays 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.
- 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.
- 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.
- 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.
- 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.
- 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_DISCOUNTInvert 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.
- 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 rExercise 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.
Fundamentals of Programming
Module 1: Introduction to Programming
- What is programming?
- History of programming
- Programming languages
- Development environments
- From problem to algorithm
Module 2: Core Concepts
- Variables and data types
- Operators and expressions
- Input and output
- Type conversion and data validation
Module 3: Control Structures
Module 4: Functions and Procedures
- Defining and using functions
- Parameters and return values
- Variable scope
- Breaking a program down into functions
- Functions as values: lambda and higher order
Module 5: Data Structures
- Lists and arrays
- Strings
- Dictionaries and sets
- Tuples and nested structures
- Saving data to files: text, CSV and JSON
Module 6: Basic Algorithms
Module 7: Objects and Code Organisation
- From data to objects: classes and instances
- Attributes, methods and the constructor
- Collections of objects
- Modules, packages and imports
Module 8: Good Practices and Tools
- Documentation and comments
- Debugging and error handling
- Version control
- Automated testing
- Style, readability and refactoring
